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:
thandClaude Opus 4.8 committed 2026-06-06 20:41:25 +02:00
1 parent 46f9f32aef
commit 518d43855b
41 files changed
+3907 -86

No files matched your search

+26 -6
View File
@@ -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) {
+53
View File
@@ -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
}
}
+4 -2
View File
@@ -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)))
+65
View File
@@ -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)
}
}
+4
View File
@@ -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
+16
View File
@@ -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
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.15'
export const APP_VERSION = '1.3.0'
export const APP_NAME = 'RUWIO'