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' } } } } if (scene.camera != null) { const camera = scene.camera if (typeof camera !== 'object') return { ok: false, error: 'camera must be an object' } if (camera.enabled) { const cw = Number(camera.base?.width) const ch = Number(camera.base?.height) if (!Number.isFinite(cw) || cw <= 0 || !Number.isFinite(ch) || ch <= 0) { return { ok: false, error: 'enabled camera must have positive width and height' } } } if (camera.keyframes != null) { if (!Array.isArray(camera.keyframes)) { return { ok: false, error: 'camera keyframes must be an array' } } keyframes += camera.keyframes.length if (keyframes > limits.maxKeyframes) { return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` } } for (const kf of camera.keyframes) { if (!Number.isFinite(Number(kf?.time))) { return { ok: false, error: 'keyframe times must be finite numbers' } } const w = kf?.props?.width const h = kf?.props?.height if (w != null && (!Number.isFinite(Number(w)) || Number(w) <= 0)) { return { ok: false, error: 'camera keyframe width must be positive' } } if (h != null && (!Number.isFinite(Number(h)) || Number(h) <= 0)) { return { ok: false, error: 'camera keyframe height must be positive' } } } } } 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, }