154 lines
5.5 KiB
JavaScript
154 lines
5.5 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' }
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
}
|