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

+30
View File
@@ -0,0 +1,30 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
export function resolveFfmpegPath(): string {
const envPath = process.env.FFMPEG_PATH?.trim()
if (envPath) return envPath
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const bundledPath = path.join(process.resourcesPath, 'bin', process.platform, exe)
if (isUsableBinary(bundledPath)) return bundledPath
return 'ffmpeg'
}
export function missingFfmpegMessage(): string {
if (app.isPackaged) {
return 'ffmpeg is not bundled with this RUWIO build, or it cannot be executed. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
return 'ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
function isUsableBinary(file: string): boolean {
try {
fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
return true
} catch {
return false
}
}
+125 -2
View File
@@ -1,7 +1,13 @@
import { app, BrowserWindow, Menu, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { renderMp4Job } from './renderWorker.cjs'
const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
validateRenderRequest(scene: unknown, options: unknown): RenderValidation
}
const isDev = !app.isPackaged
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
@@ -11,12 +17,127 @@ const appIconPath = isDev
: path.join(process.resourcesPath, 'build', 'icon.png')
let mainWindow: BrowserWindowInstance | null = null
const renderJobs = new Map<string, AbortController>()
// Serialize desktop renders: one active export at a time, mirroring the
// standalone server's MAX_ACTIVE_JOBS=1 (finding #6). The UI also guards this,
// but IPC is directly invokable, so enforce it in main too.
let renderActive = false
interface RenderValidation {
ok: boolean
error?: string
scene?: Parameters<typeof renderMp4Job>[0]['scene']
options?: Parameters<typeof renderMp4Job>[0]['options']
}
interface RenderMp4Payload {
scene?: unknown
fps?: unknown
crf?: unknown
preset?: unknown
suggestedName?: unknown
}
function sendAppCommand(command: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send('ruwio:menu-command', command)
}
function sendRenderProgress(
target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string },
): void {
if (!target.isDestroyed()) target.send('ruwio:render-progress', event)
}
function sanitizeMp4Name(value: unknown): string {
const raw = typeof value === 'string' && value.trim() ? value.trim() : 'animation.mp4'
const noPath = raw.replace(/[\\/]+/g, '-')
const safe = noPath.replace(/[^a-z0-9äöüß._-]+/gi, '-').replace(/^-+|-+$/g, '')
const named = safe || 'animation.mp4'
return named.toLowerCase().endsWith('.mp4') ? named : `${named}.mp4`
}
async function handleRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
if (renderActive) {
throw new Error('A render is already in progress')
}
renderActive = true
try {
return await runRenderMp4(event, payload)
} finally {
renderActive = false
}
}
async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
const validation = validateRenderRequest(payload?.scene, {
fps: payload?.fps,
crf: payload?.crf,
preset: payload?.preset,
})
if (!validation.ok || !validation.scene || !validation.options) {
throw new Error(validation.error || 'invalid render request')
}
// The desktop path captures a hidden, on-screen BrowserWindow, which Chromium
// can't composite/capture beyond the physical display bounds — a canvas larger
// than the screen would silently yield truncated frames. Fail fast with a clear
// message instead (finding #11). The web/server path uses a headless viewport
// and has no such limit, so this is desktop-only.
const { width: canvasWidth, height: canvasHeight } = validation.scene.canvas
const { width: displayWidth, height: displayHeight } = screen.getPrimaryDisplay().size
if (canvasWidth > displayWidth || canvasHeight > displayHeight) {
throw new Error(
`Canvas ${canvasWidth}×${canvasHeight} is larger than this display ` +
`(${displayWidth}×${displayHeight}). Desktop export can't capture a canvas ` +
`bigger than the screen — reduce the canvas size and try again.`,
)
}
const window = BrowserWindow.fromWebContents(event.sender)
const saveOptions = {
title: 'Export MP4',
defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
}
const saveResult = window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null
const jobId = randomUUID()
const controller = new AbortController()
renderJobs.set(jobId, controller)
sendRenderProgress(event.sender, { jobId, progress: 0, done: false })
try {
await renderMp4Job({
jobId,
scene: validation.scene,
options: validation.options,
outputPath: saveResult.filePath,
signal: controller.signal,
onProgress: (progress) => {
sendRenderProgress(event.sender, { jobId, progress, done: false })
},
})
sendRenderProgress(event.sender, { jobId, progress: 1, done: true })
return { path: saveResult.filePath }
} catch (err) {
const message = (err as Error).message || String(err)
sendRenderProgress(event.sender, { jobId, progress: 0, done: true, error: message })
throw err
} finally {
renderJobs.delete(jobId)
}
}
async function handleRenderCancel(_event: IpcMainInvokeEvent, jobId: unknown): Promise<void> {
if (typeof jobId !== 'string' || !jobId) return
renderJobs.get(jobId)?.abort()
}
function parseUrl(raw: string): URL | null {
try {
return new URL(raw)
@@ -256,6 +377,8 @@ function createMenu(): void {
app.setName('RUWIO')
app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
createMenu()
createMainWindow()
+29
View File
@@ -14,4 +14,33 @@ contextBridge.exposeInMainWorld('ruwioDesktop', {
ipcRenderer.removeListener('ruwio:menu-command', listener)
}
},
renderMp4(options: {
scene: unknown
fps?: number
crf?: number
preset?: string
suggestedName?: string
}) {
return ipcRenderer.invoke('ruwio:render-mp4', options)
},
onRenderProgress(callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void) {
const listener = (_event: Electron.IpcRendererEvent, payload: {
jobId: string
progress: number
done: boolean
error?: string
}) => callback(payload)
ipcRenderer.on('ruwio:render-progress', listener)
return () => {
ipcRenderer.removeListener('ruwio:render-progress', listener)
}
},
cancelRender(jobId: string) {
return ipcRenderer.invoke('ruwio:render-cancel', jobId)
},
})
+309
View File
@@ -0,0 +1,309 @@
import { BrowserWindow, app } from 'electron'
import type { Rectangle } from 'electron'
import { spawn } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { missingFfmpegMessage, resolveFfmpegPath } from './ffmpegPath.cjs'
const validation = require('../shared/renderValidation.cjs') as {
playbackRange(scene: RenderScene): { start: number; end: number }
}
// Inactivity watchdog: abort a job if no frame makes progress for this long.
// The timer is re-armed on every onProgress, so steady renders never trip it;
// only a wedged executeJavaScript/capturePage does. Mirrors the standalone
// server's RENDER_JOB_TIMEOUT_MS safety net (finding #3).
function readTimeoutMs(): number {
const raw = Number(process.env.RUWIO_RENDER_TIMEOUT_MS)
return Number.isFinite(raw) && raw >= 1000 ? raw : 2 * 60 * 1000
}
const RENDER_INACTIVITY_TIMEOUT_MS = readTimeoutMs()
// Isolated, in-memory session for the hidden render window so its webRequest
// filter can't affect the main app session (finding #5).
const RENDER_PARTITION = 'ruwio-render'
function abortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new Error('aborted')
}
// Reject as soon as `signal` aborts, otherwise settle with `promise`. Lets a
// timeout/cancel interrupt an await that would otherwise hang (finding #3).
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(abortReason(signal))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener('abort', onAbort)
reject(err)
},
)
})
}
export interface RenderScene {
canvas: { width: number; height: number }
duration: number
layers: Array<{ keyframes: Array<{ time: number }> }>
assets?: unknown[]
[key: string]: unknown
}
export interface RenderOptions {
fps: number
duration: number
totalFrames: number
crf: string
preset: string
}
export interface RenderJobInput {
jobId: string
scene: RenderScene
options: RenderOptions
outputPath: string
signal: AbortSignal
onProgress: (progress: number) => void
}
export async function renderMp4Job(input: RenderJobInput): Promise<void> {
const renderWindow = createRenderWindow(input.scene)
const ffmpegPath = resolveFfmpegPath()
let ffmpegErr = ''
let settled = false
let started = false
// Render into a sibling temp file and promote it on success, so a cancelled
// or failed render never truncates a pre-existing file the user chose to
// overwrite (finding #7). Same directory => rename stays on one filesystem.
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// Combined abort: trips on user cancel (input.signal) or the inactivity
// watchdog. Used to kill ffmpeg and to interrupt per-frame awaits.
const jobAbort = new AbortController()
const onExternalAbort = () => jobAbort.abort(new Error('aborted'))
if (input.signal.aborted) onExternalAbort()
else input.signal.addEventListener('abort', onExternalAbort, { once: true })
let watchdog: ReturnType<typeof setTimeout> | undefined
const armWatchdog = () => {
if (watchdog) clearTimeout(watchdog)
watchdog = setTimeout(
() => jobAbort.abort(new Error('render timed out')),
RENDER_INACTIVITY_TIMEOUT_MS,
)
}
const ffmpeg = spawn(ffmpegPath, [
'-y',
'-f', 'image2pipe',
'-framerate', String(input.options.fps),
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions, but validation allows any
// canvas in 1..4096 — an odd width/height would otherwise make ffmpeg exit
// with a confusing "height not divisible by 2" error. Pad up to the next
// even size (≤1px edge column/row) so odd canvases encode (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', input.options.preset,
'-crf', input.options.crf,
'-movflags', '+faststart',
// Force the muxer explicitly: the temp filename has no .mp4 extension, and
// the final path may lack one too, so don't rely on extension inference.
'-f', 'mp4',
tmpPath,
])
// If ffmpeg exits early (encode error, disk full, ...), the next stdin write
// emits an async 'error' (EPIPE). Without a listener that becomes an
// uncaughtException and crashes the app; the real failure still surfaces via
// the 'exit'/ffmpegDone path below (finding #8).
ffmpeg.stdin.on('error', () => {
/* noop: surfaced via ffmpegDone */
})
const abort = () => {
try {
ffmpeg.kill('SIGKILL')
} catch {
/* noop */
}
}
jobAbort.signal.addEventListener('abort', abort, { once: true })
const ffmpegReady = new Promise<void>((resolve, reject) => {
ffmpeg.once('spawn', () => resolve())
ffmpeg.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
})
const ffmpegDone = new Promise<void>((resolve, reject) => {
ffmpeg.stderr.on('data', (d: Buffer) => {
ffmpegErr += d.toString()
})
ffmpeg.on('spawn', () => {
started = true
})
ffmpeg.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
ffmpeg.on('exit', (code) => {
settled = true
if (code === 0) resolve()
else if (jobAbort.signal.aborted) reject(abortReason(jobAbort.signal))
else if (!started) reject(new Error(missingFfmpegMessage()))
else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
})
})
// ffmpeg can fail mid-render, so ffmpegDone may reject long before the
// `await ffmpegDone` after the loop attaches a handler. Keep a handler on it
// from creation so that gap can't trip Node's unhandled-rejection process
// termination; real handling still flows through `await ffmpegDone` (#8).
void ffmpegDone.catch(() => {})
const signal = jobAbort.signal
try {
armWatchdog()
await raceAbort(ffmpegReady, signal)
await raceAbort(loadRenderPage(renderWindow), signal)
await raceAbort(
renderWindow.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(input.scene)})`,
true,
),
signal,
)
const { start: rangeIn } = validation.playbackRange(input.scene)
const captureRect: Rectangle = {
x: 0,
y: 0,
width: input.scene.canvas.width,
height: input.scene.canvas.height,
}
for (let i = 0; i < input.options.totalFrames; i += 1) {
if (signal.aborted) throw abortReason(signal)
// ffmpeg already exited (e.g. encode error): stop feeding it so the real
// error surfaces via `await ffmpegDone` below instead of an EPIPE (#8).
if (settled) break
const time = rangeIn + Math.min(i / input.options.fps, input.options.duration)
await raceAbort(
renderWindow.webContents.executeJavaScript(`window.__animatrSeek(${time})`, true),
signal,
)
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
// capturePage returns a NativeImage at the display's device scale factor,
// so on a HiDPI screen the bitmap is N× the requested canvas and the MP4
// comes out at the wrong resolution. Normalize every frame back to the
// canvas dimensions — the standalone server pins deviceScaleFactor:1 for
// the same reason. No-op on scale-factor-1 displays (finding #10).
const frameSize = frame.getSize()
const png = (frameSize.width === captureRect.width && frameSize.height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
if (!ffmpeg.stdin.write(png)) {
// Wait for backpressure to clear — but don't hang forever if ffmpeg has
// already died (no 'drain' will ever fire) or the job is aborted. Racing
// ffmpegDone surfaces an early ffmpeg exit here instead of wedging (#8).
await raceAbort(
Promise.race([
new Promise<void>((resolve) => ffmpeg.stdin.once('drain', resolve)),
ffmpegDone,
]),
signal,
)
}
input.onProgress((i + 1) / input.options.totalFrames)
armWatchdog()
}
ffmpeg.stdin.end()
await ffmpegDone
// Render succeeded and tmpPath is complete: promote it into place. Try a
// plain rename first (atomic replace on POSIX); Windows won't overwrite an
// existing destination, so fall back to remove-then-rename there (#7).
try {
await fs.rename(tmpPath, finalPath)
} catch {
await fs.rm(finalPath, { force: true })
await fs.rename(tmpPath, finalPath)
}
} catch (err) {
if (!settled) abort()
try {
ffmpeg.stdin.destroy()
} catch {
/* noop */
}
// Only ever remove our temp file, never the user's chosen target (#7).
await fs.rm(tmpPath, { force: true }).catch(() => {})
await ffmpegDone.catch(() => {})
throw err
} finally {
if (watchdog) clearTimeout(watchdog)
input.signal.removeEventListener('abort', onExternalAbort)
jobAbort.signal.removeEventListener('abort', abort)
if (!renderWindow.isDestroyed()) renderWindow.close()
}
}
function createRenderWindow(scene: RenderScene): BrowserWindow {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
// Opaque white background so transparent regions (e.g. backgroundVideo
// scenes) flatten onto white under yuv420p — matching the standalone
// server's Puppeteer screenshot (omitBackground:false), not black (#4).
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
// The window is never shown, so Chromium would throttle/pause rAF and the
// per-frame paint settle in __animatrSeek would hit its 1s fallback every
// frame (~1fps renders). Keep timers/rAF running while hidden (finding #12).
backgroundThrottling: false,
},
})
// Defense-in-depth: the render page loads only local (file:/data:/blob:)
// assets, so block any networked request the captured project might carry
// (finding #5). The url filter only matches http/https, leaving local
// schemes untouched. Scoped to this window's isolated session partition.
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
return win
}
async function loadRenderPage(win: BrowserWindow): Promise<void> {
const renderHtml = app.isPackaged
? path.join(process.resourcesPath, 'server', 'render.html')
: path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
}