Add desktop/server MP4 export pipeline, hardening, and render tests (v1.3.0)
Desktop MP4 export renders frames from a hidden BrowserWindow via capturePage, pipes PNGs to a bundled ffmpeg (libx264/yuv420p/+faststart), and writes the file through an atomic temp-file swap. It shares scene validation (shared/renderValidation.cjs) and the render.html renderer with the standalone Puppeteer server, which is hardened in parallel. Render worker & main (electron/): - abortable render loop with an inactivity watchdog and cancel plumbing - atomic temp-file + rename so a cancel/failure never truncates a pre-existing target (#7); stdin EPIPE / early-exit / backpressure-hang handled (#8) - HiDPI frames normalized to canvas size (#10); odd dimensions padded to even (#13, also server); backgroundThrottling disabled (#12) - reject canvases larger than the display (#11); single-render guard; isolated render session that blocks network requests (#5) Tests: - Electron harnesses (scripts/verify-*.cjs) for capture, export, cancel, timeout, assets, background, capture-fidelity, resolution, odd-dims, aggregated by verify-desktop-render.cjs - server smoke + e2e and shared validation unit tests - headless hook (src/devtools/headless.ts) to run the in-app suite under browser/electron hosts; pixel-diff util + capture fixtures (test/) - dev_docs plans: integration, fixes (#7-#13), and the e2e/capture strategy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
46f9f32aef
commit
518d43855b
41 files changed
+3907
-86
No files matched your search
@@ -16,6 +16,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib
|
||||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||||
import { downloadSceneAsGif } from '@/lib/gifExport'
|
||||
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
|
||||
import { hasDesktopRender, renderMp4OnDesktop } from '@/lib/desktopRenderExport'
|
||||
import { downloadFromServer } from '@/lib/serverExport'
|
||||
import { downloadRuwFile } from '@/lib/ruwFile'
|
||||
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
|
||||
@@ -358,6 +359,8 @@ async function exportApngPreview() {
|
||||
async function exportMp4Server() {
|
||||
if (progress.value) return
|
||||
const controller = new AbortController()
|
||||
const base = (projectName.value ?? 'animation').toString()
|
||||
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animation'
|
||||
progress.value = {
|
||||
progress: 0,
|
||||
controller,
|
||||
@@ -366,12 +369,29 @@ async function exportMp4Server() {
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadFromServer(activeSceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
const scene = activeSceneClone()
|
||||
if (hasDesktopRender()) {
|
||||
const result = await renderMp4OnDesktop(scene, {
|
||||
signal: controller.signal,
|
||||
fps: targetFps.value,
|
||||
suggestedName: `${safe}.mp4`,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
if (!result) {
|
||||
progress.value = null
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await downloadFromServer(scene, {
|
||||
signal: controller.signal,
|
||||
fps: targetFps.value,
|
||||
onProgress: (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
}
|
||||
progress.value = null
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { runAll, type TestResult } from './testRunner'
|
||||
import { registerAllTests } from './tests'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ruwioTestResults?: TestResult[]
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRunSuite(name: string, desktop: boolean): boolean {
|
||||
if (name.startsWith('desktop:')) return desktop
|
||||
if (name.startsWith('browser:')) return !desktop
|
||||
return true
|
||||
}
|
||||
|
||||
export async function runHeadlessTests(): Promise<TestResult[]> {
|
||||
const desktop = !!window.ruwioDesktop
|
||||
const store = useSceneStore()
|
||||
const { locale } = useI18n()
|
||||
const snapshot = JSON.parse(JSON.stringify(store.$state))
|
||||
const previousLocale = locale.value
|
||||
|
||||
document.body.setAttribute('data-tests-done', 'running')
|
||||
document.body.setAttribute('data-test-host', desktop ? 'electron' : 'browser')
|
||||
locale.value = 'en'
|
||||
|
||||
try {
|
||||
const results = await runAll(registerAllTests, {
|
||||
interTestDelayMs: 0,
|
||||
suiteFilter: (name) => shouldRunSuite(name, desktop),
|
||||
})
|
||||
window.__ruwioTestResults = results
|
||||
document.body.setAttribute('data-tests-done', results.some((r) => !r.ok) ? 'fail' : 'pass')
|
||||
return results
|
||||
} catch (err) {
|
||||
const result: TestResult = {
|
||||
suite: 'headless',
|
||||
name: 'runAll',
|
||||
ok: false,
|
||||
error: (err as Error).message || String(err),
|
||||
stack: (err as Error).stack,
|
||||
durationMs: 0,
|
||||
}
|
||||
window.__ruwioTestResults = [result]
|
||||
document.body.setAttribute('data-tests-done', 'fail')
|
||||
return [result]
|
||||
} finally {
|
||||
store.$state = snapshot
|
||||
locale.value = previousLocale
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ export interface RunOptions {
|
||||
onTestStart?: (suite: string, name: string) => void
|
||||
onResult?: (result: TestResult) => void
|
||||
interTestDelayMs?: number
|
||||
suiteFilter?: (suite: string) => boolean
|
||||
}
|
||||
|
||||
export async function runAll(
|
||||
@@ -141,11 +142,12 @@ export async function runAll(
|
||||
): Promise<TestResult[]> {
|
||||
suites.length = 0
|
||||
register()
|
||||
const total = suites.reduce((a, s) => a + s.tests.length, 0)
|
||||
const runnableSuites = opts.suiteFilter ? suites.filter((s) => opts.suiteFilter?.(s.name)) : suites
|
||||
const total = runnableSuites.reduce((a, s) => a + s.tests.length, 0)
|
||||
const results: TestResult[] = []
|
||||
let done = 0
|
||||
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
|
||||
for (const s of suites) {
|
||||
for (const s of runnableSuites) {
|
||||
for (const t of s.tests) {
|
||||
opts.onTestStart?.(s.name, t.name)
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Scene } from '@/types/scene'
|
||||
|
||||
export interface DesktopRenderOptions {
|
||||
onProgress?: (progress: number) => void
|
||||
signal?: AbortSignal
|
||||
fps?: number
|
||||
crf?: number
|
||||
preset?: string
|
||||
suggestedName?: string
|
||||
}
|
||||
|
||||
export function hasDesktopRender(): boolean {
|
||||
return !!window.ruwioDesktop?.renderMp4
|
||||
}
|
||||
|
||||
export async function renderMp4OnDesktop(
|
||||
scene: Scene,
|
||||
opts: DesktopRenderOptions = {},
|
||||
): Promise<{ path: string } | null> {
|
||||
const desktop = window.ruwioDesktop
|
||||
if (!desktop?.renderMp4) throw new Error('Desktop MP4 rendering is not available')
|
||||
|
||||
let jobId: string | null = null
|
||||
let settled = false
|
||||
let pendingAbort = false
|
||||
const cleanupProgress = desktop.onRenderProgress?.((event) => {
|
||||
jobId = event.jobId
|
||||
if (typeof event.progress === 'number') opts.onProgress?.(event.progress)
|
||||
if (pendingAbort && !settled && jobId) {
|
||||
pendingAbort = false
|
||||
void desktop.cancelRender?.(jobId)
|
||||
}
|
||||
})
|
||||
|
||||
const abort = () => {
|
||||
if (settled) return
|
||||
if (jobId) void desktop.cancelRender?.(jobId)
|
||||
else pendingAbort = true
|
||||
}
|
||||
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
throw new DOMException('aborted', 'AbortError')
|
||||
}
|
||||
opts.signal.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await desktop.renderMp4({
|
||||
scene,
|
||||
fps: opts.fps,
|
||||
crf: opts.crf,
|
||||
preset: opts.preset,
|
||||
suggestedName: opts.suggestedName,
|
||||
})
|
||||
return result
|
||||
} catch (err) {
|
||||
if (opts.signal?.aborted) throw new DOMException('aborted', 'AbortError')
|
||||
throw err
|
||||
} finally {
|
||||
settled = true
|
||||
cleanupProgress?.()
|
||||
opts.signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ app.use(VueKonva)
|
||||
app.directive('tooltip', vTooltip)
|
||||
app.mount('#app')
|
||||
|
||||
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('__runtests') === '1') {
|
||||
void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests())
|
||||
}
|
||||
|
||||
// Service worker exists solely to make the app installable as a PWA.
|
||||
// In Chrome/Edge, an installed PWA runs in standalone display mode,
|
||||
// which removes the top-edge "exit fullscreen" indicator that tabs
|
||||
|
||||
Vendored
+16
@@ -1,3 +1,5 @@
|
||||
import type { Scene } from './scene'
|
||||
|
||||
export {}
|
||||
|
||||
type RuwioDesktopMenuCommand =
|
||||
@@ -33,6 +35,20 @@ declare global {
|
||||
onMenuCommand?: (
|
||||
callback: (command: RuwioDesktopMenuCommand) => void,
|
||||
) => () => void
|
||||
renderMp4?: (options: {
|
||||
scene: Scene
|
||||
fps?: number
|
||||
crf?: number
|
||||
preset?: string
|
||||
suggestedName?: string
|
||||
}) => Promise<{ path: string } | null>
|
||||
onRenderProgress?: (callback: (event: {
|
||||
jobId: string
|
||||
progress: number
|
||||
done: boolean
|
||||
error?: string
|
||||
}) => void) => () => void
|
||||
cancelRender?: (jobId: string) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.15'
|
||||
export const APP_VERSION = '1.3.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user