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,120 @@
|
||||
const DEFAULT_LIMITS = Object.freeze({
|
||||
maxCanvasWidth: 4096,
|
||||
maxCanvasHeight: 4096,
|
||||
maxFps: 60,
|
||||
maxDuration: 60,
|
||||
maxFrames: 3600,
|
||||
maxLayers: 500,
|
||||
maxKeyframes: 5000,
|
||||
maxAssets: 500,
|
||||
})
|
||||
|
||||
const ALLOWED_FFMPEG_PRESETS = Object.freeze([
|
||||
'ultrafast',
|
||||
'superfast',
|
||||
'veryfast',
|
||||
'faster',
|
||||
'fast',
|
||||
'medium',
|
||||
'slow',
|
||||
'slower',
|
||||
'veryslow',
|
||||
])
|
||||
|
||||
function effectiveEnd(scene) {
|
||||
let max = 0
|
||||
for (const layer of scene.layers) {
|
||||
for (const kf of layer.keyframes) {
|
||||
if (kf.time > max) max = kf.time
|
||||
}
|
||||
}
|
||||
if (max <= 0) return scene.duration
|
||||
return Math.min(max, scene.duration)
|
||||
}
|
||||
|
||||
function playbackRange(scene) {
|
||||
const autoEnd = effectiveEnd(scene)
|
||||
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
|
||||
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
|
||||
const end = Math.max(start, Math.min(scene.duration, rawEnd))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function validateRenderRequest(scene, options = {}, limits = DEFAULT_LIMITS) {
|
||||
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
|
||||
if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' }
|
||||
const width = toBoundedInt(scene.canvas.width, 1, limits.maxCanvasWidth)
|
||||
const height = toBoundedInt(scene.canvas.height, 1, limits.maxCanvasHeight)
|
||||
if (!width || !height) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `canvas must be between 1 and ${limits.maxCanvasWidth}x${limits.maxCanvasHeight}`,
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' }
|
||||
if (scene.layers.length > limits.maxLayers) {
|
||||
return { ok: false, error: `scene has more than ${limits.maxLayers} layers` }
|
||||
}
|
||||
if (Array.isArray(scene.assets) && scene.assets.length > limits.maxAssets) {
|
||||
return { ok: false, error: `scene has more than ${limits.maxAssets} assets` }
|
||||
}
|
||||
let keyframes = 0
|
||||
for (const layer of scene.layers) {
|
||||
if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' }
|
||||
keyframes += layer.keyframes.length
|
||||
if (keyframes > limits.maxKeyframes) {
|
||||
return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` }
|
||||
}
|
||||
for (const kf of layer.keyframes) {
|
||||
if (!Number.isFinite(Number(kf?.time))) {
|
||||
return { ok: false, error: 'keyframe times must be finite numbers' }
|
||||
}
|
||||
}
|
||||
}
|
||||
const duration = Number(scene.duration)
|
||||
if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) {
|
||||
return { ok: false, error: `duration must be between 0 and ${limits.maxDuration} seconds` }
|
||||
}
|
||||
const fps = toBoundedInt(options.fps ?? scene.fps ?? 60, 1, limits.maxFps)
|
||||
if (!fps) return { ok: false, error: `fps must be between 1 and ${limits.maxFps}` }
|
||||
const normalizedScene = { ...scene, canvas: { ...scene.canvas, width, height }, duration }
|
||||
const range = playbackRange(normalizedScene)
|
||||
const requestedDuration = Number(options.duration ?? Math.max(0, range.end - range.start))
|
||||
if (
|
||||
!Number.isFinite(requestedDuration) ||
|
||||
requestedDuration <= 0 ||
|
||||
requestedDuration > limits.maxDuration
|
||||
) {
|
||||
return { ok: false, error: `render duration must be between 0 and ${limits.maxDuration} seconds` }
|
||||
}
|
||||
const totalFrames = Math.max(1, Math.round(requestedDuration * fps))
|
||||
if (totalFrames > limits.maxFrames) {
|
||||
return { ok: false, error: `render has more than ${limits.maxFrames} frames` }
|
||||
}
|
||||
const crf = toBoundedInt(options.crf ?? 18, 0, 51)
|
||||
if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' }
|
||||
const preset = String(options.preset ?? 'fast')
|
||||
if (!ALLOWED_FFMPEG_PRESETS.includes(preset)) {
|
||||
return { ok: false, error: 'invalid ffmpeg preset' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
scene: normalizedScene,
|
||||
options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset },
|
||||
}
|
||||
}
|
||||
|
||||
function toBoundedInt(value, min, max) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return null
|
||||
const i = Math.round(n)
|
||||
if (i < min || i > max) return null
|
||||
return i
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_FFMPEG_PRESETS,
|
||||
DEFAULT_LIMITS,
|
||||
playbackRange,
|
||||
validateRenderRequest,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { validateRenderRequest, playbackRange } = require('./renderValidation.cjs')
|
||||
|
||||
function scene(overrides = {}) {
|
||||
return {
|
||||
canvas: { width: 640, height: 360 },
|
||||
duration: 2,
|
||||
fps: 30,
|
||||
layers: [
|
||||
{
|
||||
keyframes: [
|
||||
{ time: 0, props: {} },
|
||||
{ time: 1, props: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('accepts a normal render request', () => {
|
||||
const result = validateRenderRequest(scene(), { fps: 30, crf: 18, preset: 'fast' })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.options.totalFrames, 30)
|
||||
})
|
||||
|
||||
test('rejects invalid dimensions', () => {
|
||||
const result = validateRenderRequest(scene({ canvas: { width: 0, height: 360 } }))
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /canvas/)
|
||||
})
|
||||
|
||||
test('rejects invalid fps and frame count', () => {
|
||||
assert.equal(validateRenderRequest(scene(), { fps: 0 }).ok, false)
|
||||
assert.equal(validateRenderRequest(scene({ duration: 61 }), { fps: 60 }).ok, false)
|
||||
})
|
||||
|
||||
test('rejects invalid duration', () => {
|
||||
const result = validateRenderRequest(scene({ duration: 0 }))
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /duration/)
|
||||
})
|
||||
|
||||
test('rejects too many layers and keyframes', () => {
|
||||
const layers = Array.from({ length: 501 }, () => ({ keyframes: [] }))
|
||||
assert.equal(validateRenderRequest(scene({ layers })).ok, false)
|
||||
|
||||
const keyframes = Array.from({ length: 5001 }, (_, i) => ({ time: i / 60, props: {} }))
|
||||
assert.equal(validateRenderRequest(scene({ layers: [{ keyframes }] })).ok, false)
|
||||
})
|
||||
|
||||
test('rejects invalid crf and preset', () => {
|
||||
assert.equal(validateRenderRequest(scene(), { crf: 52 }).ok, false)
|
||||
assert.equal(validateRenderRequest(scene(), { preset: 'turbo' }).ok, false)
|
||||
})
|
||||
|
||||
test('computes playback range from in/out or last keyframe', () => {
|
||||
assert.deepEqual(playbackRange(scene({ inTime: 0.25, outTime: 1.5 })), { start: 0.25, end: 1.5 })
|
||||
assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 })
|
||||
})
|
||||
Reference in new issue
Block a user