Files
RUWIO/shared/renderValidation.cjs
T
thandClaude Opus 4.8 518d43855b 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>
2026-06-06 20:41:25 +02:00

121 lines
4.1 KiB
JavaScript

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,
}