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
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user