From 518d43855b66c424d3b03ff72523cfb63fdbc41a Mon Sep 17 00:00:00 2001 From: th Date: Sat, 6 Jun 2026 20:41:25 +0200 Subject: [PATCH] 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) --- .gitignore | 5 + README.md | 22 ++ bin/README.md | 16 + .../electron-e2e-and-capture-test-plan.md | 366 ++++++++++++++++++ dev_docs/electron-rendering-fix-plan.md | 356 +++++++++++++++++ .../electron-rendering-integration-plan.md | 291 ++++++++++++++ .../electron-rendering-integration-plan_2.md | 308 +++++++++++++++ dev_docs/electron-rendering-manual-checks.md | 121 ++++++ electron/ffmpegPath.cts | 30 ++ electron/main.cts | 127 +++++- electron/preload.cts | 29 ++ electron/renderWorker.cts | 309 +++++++++++++++ package-lock.json | 4 +- package.json | 27 +- scripts/stage-ffmpeg.cjs | 65 ++++ scripts/verify-assets.cjs | 109 ++++++ scripts/verify-bg.cjs | 89 +++++ scripts/verify-cancel.cjs | 100 +++++ scripts/verify-capture-fidelity.cjs | 120 ++++++ scripts/verify-capture.cjs | 138 +++++++ scripts/verify-desktop-render.cjs | 39 ++ scripts/verify-export.cjs | 125 ++++++ scripts/verify-odd-dims.cjs | 66 ++++ scripts/verify-resolution.cjs | 58 +++ scripts/verify-timeout.cjs | 88 +++++ server/package.json | 4 +- server/render.e2e.test.cjs | 310 +++++++++++++++ server/render.html | 15 +- server/server.js | 83 +--- server/server.smoke.test.cjs | 139 +++++++ shared/renderValidation.cjs | 120 ++++++ shared/renderValidation.test.cjs | 62 +++ src/components/ExportDialog.vue | 32 +- src/devtools/headless.ts | 53 +++ src/devtools/testRunner.ts | 6 +- src/lib/desktopRenderExport.ts | 65 ++++ src/main.ts | 4 + src/types/desktop.d.ts | 16 + src/version.ts | 2 +- test/fixtures/scenes/capture-basic.json | 33 ++ test/lib/pixelDiff.cjs | 41 ++ 41 files changed, 3907 insertions(+), 86 deletions(-) create mode 100644 bin/README.md create mode 100644 dev_docs/electron-e2e-and-capture-test-plan.md create mode 100644 dev_docs/electron-rendering-fix-plan.md create mode 100644 dev_docs/electron-rendering-integration-plan.md create mode 100644 dev_docs/electron-rendering-integration-plan_2.md create mode 100644 dev_docs/electron-rendering-manual-checks.md create mode 100644 electron/ffmpegPath.cts create mode 100644 electron/renderWorker.cts create mode 100644 scripts/stage-ffmpeg.cjs create mode 100644 scripts/verify-assets.cjs create mode 100644 scripts/verify-bg.cjs create mode 100644 scripts/verify-cancel.cjs create mode 100644 scripts/verify-capture-fidelity.cjs create mode 100644 scripts/verify-capture.cjs create mode 100644 scripts/verify-desktop-render.cjs create mode 100644 scripts/verify-export.cjs create mode 100644 scripts/verify-odd-dims.cjs create mode 100644 scripts/verify-resolution.cjs create mode 100644 scripts/verify-timeout.cjs create mode 100644 server/render.e2e.test.cjs create mode 100644 server/server.smoke.test.cjs create mode 100644 shared/renderValidation.cjs create mode 100644 shared/renderValidation.test.cjs create mode 100644 src/devtools/headless.ts create mode 100644 src/lib/desktopRenderExport.ts create mode 100644 test/fixtures/scenes/capture-basic.json create mode 100644 test/lib/pixelDiff.cjs diff --git a/.gitignore b/.gitignore index 486841b..fd6b08e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ release /ruwio.png .codex misc/ + +# Locally staged ffmpeg binaries. Release-managed binaries can be force-added. +/bin/linux/ffmpeg +/bin/darwin/ffmpeg +/bin/win32/ffmpeg.exe diff --git a/README.md b/README.md index b4dab7f..8875b60 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,18 @@ npm run desktop:build # build platform installer/package artifacts into release app without creating an installer. `desktop:build` uses `electron-builder` and targets AppImage on Linux, NSIS on Windows, and DMG on macOS. +Desktop MP4 export runs inside Electron and uses ffmpeg from +`resources/bin//` in packaged builds. The build scripts run +`npm run stage:ffmpeg -- --optional`, which copies `FFMPEG_PATH` or `ffmpeg` on +`PATH` into `bin//` when available. For release artifacts, replace +that local copy with redistributable static ffmpeg binaries for Linux, Windows, +and macOS. + +If no bundled binary is present, MP4 export still falls back to `FFMPEG_PATH` +or system `ffmpeg` and reports a clear error when neither can be executed. +Before shipping bundled ffmpeg, document the binary source, version, configured +codecs, and GPL/LGPL obligations for that build. + ## MP4 render server MP4 export is handled by the optional render server in `server/`. It binds to @@ -96,6 +108,16 @@ The included `docker-compose.yml` keeps the published host port on `127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will start. +Render-server checks: + +```bash +npm run test:render-server # health/auth/validation route smoke +npm run test:render-server:e2e # renders a tiny MP4 and verifies it with ffprobe +``` + +The E2E check requires Chromium, ffmpeg, and ffprobe. Set +`PUPPETEER_EXECUTABLE_PATH` when Chromium is not at `/usr/bin/chromium`. + ## Stack - Vue 3 (``, +) +process.chdir(tmpDir) + +const { renderMp4Job } = require('../dist-electron/renderWorker.cjs') + +const fps = 12 +const duration = 2 +const scene = { + canvas: { width: 160, height: 120, background: '#202030' }, + duration, + assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }], + layers: [ + { + id: 'l1', + assetId: 'a1', + visible: true, + base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }], + }, + ], +} +const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' } + +app.on('window-all-closed', () => {}) + +app.whenReady().then(async () => { + const outPath = path.join(tmpDir, 'out.mp4') + const controller = new AbortController() + const startedAt = process.hrtime.bigint() + + let rejected = false + let message = '' + try { + await renderMp4Job({ + jobId: 'timeout', + scene, + options, + outputPath: outPath, + signal: controller.signal, + onProgress: () => {}, + }) + } catch (err) { + rejected = true + message = err.message + } + const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6 + await new Promise((r) => setTimeout(r, 200)) + + const notes = [] + let pass = true + if (!rejected) { pass = false; notes.push('job did not reject') } + if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) } + if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) } + if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') } + + console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`) + console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none') + console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL') + + fs.rmSync(tmpDir, { recursive: true, force: true }) + app.exit(pass ? 0 : 1) +}) diff --git a/server/package.json b/server/package.json index 080f372..b92f873 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,9 @@ "main": "server.js", "scripts": { "start": "node server.js", - "dev": "node server.js" + "dev": "node server.js", + "test:smoke": "node --test server.smoke.test.cjs", + "test:e2e": "node --test render.e2e.test.cjs" }, "dependencies": { "express": "^4.21.2", diff --git a/server/render.e2e.test.cjs b/server/render.e2e.test.cjs new file mode 100644 index 0000000..7a2612e --- /dev/null +++ b/server/render.e2e.test.cjs @@ -0,0 +1,310 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const { mkdtemp, readFile, rm } = require('node:fs/promises') +const net = require('node:net') +const os = require('node:os') +const path = require('node:path') +const { test } = require('node:test') + +const HOST = '127.0.0.1' +const TOKEN = 'server-e2e-token' +const CHROME = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' + +test('render server produces a playable MP4 for a tiny scene', async (t) => { + const ffmpeg = await commandPath('ffmpeg') + const ffprobe = await commandPath('ffprobe') + if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) { + t.skip('requires ffmpeg, ffprobe, and Chromium') + return + } + + const port = await freePort() + const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-')) + const server = spawn(process.execPath, ['server.js'], { + cwd: __dirname, + env: { + ...process.env, + HOST, + PORT: String(port), + RENDER_TOKEN: TOKEN, + RENDER_JOB_TTL_MS: '1000', + RENDER_JOB_TIMEOUT_MS: '30000', + PUPPETEER_EXECUTABLE_PATH: CHROME, + CHROME_NO_SANDBOX: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let output = '' + server.stdout.on('data', (chunk) => { + output += chunk.toString() + }) + server.stderr.on('data', (chunk) => { + output += chunk.toString() + }) + + t.after(async () => { + if (server.exitCode === null) { + server.kill('SIGTERM') + await waitForExit(server) + } + await rm(tmp, { recursive: true, force: true }) + }) + + await waitForServer(server, port, () => output) + + const base = `http://${HOST}:${port}` + const start = await fetch(`${base}/render`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ scene: tinyScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }), + }) + const startText = await start.text() + assert.equal(start.status, 200, startText) + const { jobId, accessToken } = JSON.parse(startText) + assert.equal(typeof jobId, 'string') + assert.equal(typeof accessToken, 'string') + + const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`) + assert.equal(done.done, true) + assert.equal(done.error, null) + assert.equal(done.progress, 1) + assert.equal(done.url, `/render/${jobId}/file?token=${encodeURIComponent(accessToken)}`) + + const file = await fetch(`${base}${done.url}`) + assert.equal(file.status, 200) + const outPath = path.join(tmp, 'tiny.mp4') + await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer())) + + const metadata = await ffprobeJson(outPath) + const video = metadata.streams.find((stream) => stream.codec_type === 'video') + assert.ok(video, 'expected a video stream') + assert.equal(video.codec_name, 'h264') + assert.equal(video.width, 64) + assert.equal(video.height, 64) +}) + +test('render server pads odd canvas dimensions to an even MP4', async (t) => { + const ffmpeg = await commandPath('ffmpeg') + const ffprobe = await commandPath('ffprobe') + if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) { + t.skip('requires ffmpeg, ffprobe, and Chromium') + return + } + + const port = await freePort() + const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-')) + const server = spawn(process.execPath, ['server.js'], { + cwd: __dirname, + env: { + ...process.env, + HOST, + PORT: String(port), + RENDER_TOKEN: TOKEN, + RENDER_JOB_TTL_MS: '1000', + RENDER_JOB_TIMEOUT_MS: '30000', + PUPPETEER_EXECUTABLE_PATH: CHROME, + CHROME_NO_SANDBOX: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let output = '' + server.stdout.on('data', (chunk) => { + output += chunk.toString() + }) + server.stderr.on('data', (chunk) => { + output += chunk.toString() + }) + + t.after(async () => { + if (server.exitCode === null) { + server.kill('SIGTERM') + await waitForExit(server) + } + await rm(tmp, { recursive: true, force: true }) + }) + + await waitForServer(server, port, () => output) + + const base = `http://${HOST}:${port}` + const start = await fetch(`${base}/render`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ scene: oddScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }), + }) + const startText = await start.text() + assert.equal(start.status, 200, startText) + const { jobId, accessToken } = JSON.parse(startText) + + const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`) + assert.equal(done.done, true) + assert.equal(done.error, null) + assert.equal(done.progress, 1) + + const file = await fetch(`${base}${done.url}`) + assert.equal(file.status, 200) + const outPath = path.join(tmp, 'odd.mp4') + await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer())) + + const metadata = await ffprobeJson(outPath) + const video = metadata.streams.find((stream) => stream.codec_type === 'video') + assert.ok(video, 'expected a video stream') + assert.equal(video.codec_name, 'h264') + assert.equal(video.width, 102) + assert.equal(video.height, 102) +}) + +function tinyScene() { + return { + canvas: { width: 64, height: 64, background: '#ffffff' }, + duration: 1, + fps: 2, + assets: [ + { id: 'asset-rect', kind: 'rect', name: 'Box', width: 24, height: 24, fill: '#22cc88' }, + ], + layers: [ + { + id: 'layer-rect', + name: 'Box', + assetId: 'asset-rect', + visible: true, + base: { x: 16, y: 32, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + keyframes: [ + { id: 'kf-0', time: 0, easing: 'linear', props: { x: 16 } }, + { id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 48 } }, + ], + }, + ], + } +} + +function oddScene() { + const s = tinyScene() + s.canvas.width = 101 + s.canvas.height = 101 + s.layers[0].base.x = 50 + s.layers[0].base.y = 50 + s.layers[0].keyframes = [ + { id: 'kf-0', time: 0, easing: 'linear', props: { x: 42 } }, + { id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 58 } }, + ] + return s +} + +async function waitForDone(url) { + const res = await fetch(url) + assert.equal(res.status, 200) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + const deadline = Date.now() + 30000 + + while (Date.now() < deadline) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const messages = buffer.split('\n\n') + buffer = messages.pop() || '' + for (const message of messages) { + const line = message.split('\n').find((entry) => entry.startsWith('data: ')) + if (!line) continue + const payload = JSON.parse(line.slice(6)) + if (payload.done) return payload + } + } + + throw new Error('render did not finish before timeout') +} + +function ffprobeJson(file) { + return new Promise((resolve, reject) => { + const child = spawn('ffprobe', [ + '-v', 'error', + '-show_streams', + '-of', 'json', + file, + ]) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', reject) + child.on('exit', (code) => { + if (code === 0) resolve(JSON.parse(stdout)) + else reject(new Error(`ffprobe exited with code ${code}: ${stderr}`)) + }) + }) +} + +function commandPath(command) { + return new Promise((resolve) => { + const probe = spawn(process.platform === 'win32' ? 'where' : 'which', [command]) + probe.on('error', () => resolve('')) + probe.on('exit', (code) => resolve(code === 0 ? command : '')) + }) +} + +async function canAccess(file) { + try { + await readFile(file) + return true + } catch { + return false + } +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, HOST, () => { + const address = server.address() + server.close(() => { + if (!address || typeof address === 'string') reject(new Error('could not allocate test port')) + else resolve(address.port) + }) + }) + }) +} + +async function waitForServer(server, port, output) { + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`server exited early with code ${server.exitCode}\n${output()}`) + } + try { + const res = await fetch(`http://${HOST}:${port}/health`) + if (res.ok) return + } catch { + /* retry */ + } + await delay(50) + } + throw new Error(`server did not start\n${output()}`) +} + +function waitForExit(child) { + return new Promise((resolve) => { + child.once('exit', () => resolve()) + setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL') + resolve() + }, 1000).unref() + }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/server/render.html b/server/render.html index 9e4ed4c..cf88f2e 100644 --- a/server/render.html +++ b/server/render.html @@ -386,7 +386,20 @@ return true; }); }; - window.__animatrSeek=function(time){ drawFrame(time); }; + window.__animatrSeek=function(time){ + drawFrame(time); + // Resolve only after the compositor has had a chance to paint, so a + // subsequent capturePage()/screenshot() sees THIS frame and not the + // previous one. Without this wait, Electron's capturePage on a hidden + // window intermittently returns the prior frame (stale/off-by-one). + // setTimeout is an anti-hang fallback in case rAF is throttled while hidden. + return new Promise(function(res){ + var done=false; + function finish(){ if(!done){ done=true; res(true); } } + requestAnimationFrame(function(){ requestAnimationFrame(finish); }); + setTimeout(finish, 1000); + }); + }; })(); diff --git a/server/server.js b/server/server.js index c69f691..8d76072 100644 --- a/server/server.js +++ b/server/server.js @@ -6,6 +6,7 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' import puppeteer from 'puppeteer-core' +import renderValidation from '../shared/renderValidation.cjs' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -36,6 +37,17 @@ const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000) const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000) const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000) const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000) +const RENDER_LIMITS = { + maxCanvasWidth: MAX_CANVAS_WIDTH, + maxCanvasHeight: MAX_CANVAS_HEIGHT, + maxFps: MAX_FPS, + maxDuration: MAX_DURATION, + maxFrames: MAX_FRAMES, + maxLayers: MAX_LAYERS, + maxKeyframes: MAX_KEYFRAMES, + maxAssets: MAX_ASSETS, +} +const { playbackRange, validateRenderRequest: validateSharedRenderRequest } = renderValidation const app = express() app.disable('x-powered-by') @@ -214,25 +226,6 @@ function notify(job) { } } -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 } -} - async function runJob(job, scene, options) { const { start: rangeIn } = playbackRange(scene) const { fps, duration, totalFrames, crf, preset } = options @@ -260,6 +253,9 @@ async function runJob(job, scene, options) { '-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', + // libx264 + yuv420p require even dimensions; pad an odd canvas up to the + // next even size so it encodes instead of crashing ffmpeg (finding #13). + '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-preset', preset, '-crf', crf, '-movflags', '+faststart', @@ -327,54 +323,7 @@ async function cleanupJob(jobId) { } function validateRenderRequest(scene, options) { - 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, MAX_CANVAS_WIDTH) - const height = toBoundedInt(scene.canvas.height, 1, MAX_CANVAS_HEIGHT) - if (!width || !height) return { ok: false, error: `canvas must be between 1 and ${MAX_CANVAS_WIDTH}x${MAX_CANVAS_HEIGHT}` } - if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' } - if (scene.layers.length > MAX_LAYERS) return { ok: false, error: `scene has more than ${MAX_LAYERS} layers` } - if (Array.isArray(scene.assets) && scene.assets.length > MAX_ASSETS) return { ok: false, error: `scene has more than ${MAX_ASSETS} 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 > MAX_KEYFRAMES) return { ok: false, error: `scene has more than ${MAX_KEYFRAMES} 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 > MAX_DURATION) { - return { ok: false, error: `duration must be between 0 and ${MAX_DURATION} seconds` } - } - const fps = toBoundedInt(options.fps || scene.fps || 60, 1, MAX_FPS) - if (!fps) return { ok: false, error: `fps must be between 1 and ${MAX_FPS}` } - const range = playbackRange({ ...scene, canvas: { ...scene.canvas, width, height }, duration }) - const requestedDuration = Number(options.duration || Math.max(0, range.end - range.start)) - if (!Number.isFinite(requestedDuration) || requestedDuration <= 0 || requestedDuration > MAX_DURATION) { - return { ok: false, error: `render duration must be between 0 and ${MAX_DURATION} seconds` } - } - const totalFrames = Math.max(1, Math.round(requestedDuration * fps)) - if (totalFrames > MAX_FRAMES) return { ok: false, error: `render has more than ${MAX_FRAMES} 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') - const allowedPresets = new Set(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow']) - if (!allowedPresets.has(preset)) return { ok: false, error: 'invalid ffmpeg preset' } - return { - ok: true, - scene: { ...scene, canvas: { ...scene.canvas, width, height }, duration }, - 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 + return validateSharedRenderRequest(scene, options, RENDER_LIMITS) } function chromeArgs() { diff --git a/server/server.smoke.test.cjs b/server/server.smoke.test.cjs new file mode 100644 index 0000000..83ccfbd --- /dev/null +++ b/server/server.smoke.test.cjs @@ -0,0 +1,139 @@ +const assert = require('node:assert/strict') +const { spawn } = require('node:child_process') +const net = require('node:net') +const { test } = require('node:test') + +const HOST = '127.0.0.1' +const TOKEN = 'server-smoke-token' + +test('render server health, auth, and validation routes work', async (t) => { + const port = await freePort() + const server = spawn(process.execPath, ['server.js'], { + cwd: __dirname, + env: { + ...process.env, + HOST, + PORT: String(port), + RENDER_TOKEN: TOKEN, + RENDER_MAX_BODY: '1mb', + RENDER_RATE_MAX: '100', + PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let output = '' + server.stdout.on('data', (chunk) => { + output += chunk.toString() + }) + server.stderr.on('data', (chunk) => { + output += chunk.toString() + }) + + t.after(async () => { + if (server.exitCode === null) { + server.kill('SIGTERM') + await waitForExit(server) + } + }) + + await waitForServer(server, port, () => output) + + const base = `http://${HOST}:${port}` + + const health = await fetch(`${base}/health`) + assert.equal(health.status, 200) + assert.deepEqual(await health.json(), { ok: true }) + + const missingToken = await postRender(base, null, invalidRenderBody()) + assert.equal(missingToken.status, 401) + assert.equal((await missingToken.json()).error, 'invalid render token') + + const badToken = await postRender(base, 'wrong-token', invalidRenderBody()) + assert.equal(badToken.status, 401) + assert.equal((await badToken.json()).error, 'invalid render token') + + const invalidRender = await postRender(base, TOKEN, invalidRenderBody()) + assert.equal(invalidRender.status, 400) + assert.match((await invalidRender.json()).error, /canvas must be/) + + const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`) + assert.equal(missingJobEvents.status, 404) + + const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`) + assert.equal(missingJobFile.status, 404) + + const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' }) + assert.equal(missingJobDelete.status, 404) +}) + +function invalidRenderBody() { + return { + scene: { + canvas: { width: 0, height: 100 }, + duration: 1, + fps: 30, + layers: [], + assets: [], + }, + options: { fps: 30 }, + } +} + +function postRender(base, token, body) { + const headers = { 'Content-Type': 'application/json' } + if (token) headers.Authorization = `Bearer ${token}` + return fetch(`${base}/render`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', reject) + server.listen(0, HOST, () => { + const address = server.address() + server.close(() => { + if (!address || typeof address === 'string') reject(new Error('could not allocate test port')) + else resolve(address.port) + }) + }) + }) +} + +async function waitForServer(server, port, output) { + const deadline = Date.now() + 5000 + let lastError = null + + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`server exited early with code ${server.exitCode}\n${output()}`) + } + try { + const res = await fetch(`http://${HOST}:${port}/health`) + if (res.ok) return + } catch (err) { + lastError = err + } + await delay(50) + } + + throw new Error(`server did not start: ${lastError?.message || 'timeout'}\n${output()}`) +} + +function waitForExit(child) { + return new Promise((resolve) => { + child.once('exit', () => resolve()) + setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL') + resolve() + }, 1000).unref() + }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/shared/renderValidation.cjs b/shared/renderValidation.cjs new file mode 100644 index 0000000..337a855 --- /dev/null +++ b/shared/renderValidation.cjs @@ -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, +} diff --git a/shared/renderValidation.test.cjs b/shared/renderValidation.test.cjs new file mode 100644 index 0000000..bbc4f92 --- /dev/null +++ b/shared/renderValidation.test.cjs @@ -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 }) +}) diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue index 0db1123..df53534 100644 --- a/src/components/ExportDialog.vue +++ b/src/components/ExportDialog.vue @@ -16,6 +16,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport' import { downloadSceneAsGif } from '@/lib/gifExport' import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport' +import { hasDesktopRender, renderMp4OnDesktop } from '@/lib/desktopRenderExport' import { downloadFromServer } from '@/lib/serverExport' import { downloadRuwFile } from '@/lib/ruwFile' import { downloadSceneAsStillImage } from '@/lib/stillImageExport' @@ -358,6 +359,8 @@ async function exportApngPreview() { async function exportMp4Server() { if (progress.value) return const controller = new AbortController() + const base = (projectName.value ?? 'animation').toString() + const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animation' progress.value = { progress: 0, controller, @@ -366,12 +369,29 @@ async function exportMp4Server() { } store.pause() try { - await downloadFromServer(activeSceneClone(), { - signal: controller.signal, - onProgress: (p) => { - if (progress.value) progress.value.progress = p - }, - }) + const scene = activeSceneClone() + if (hasDesktopRender()) { + const result = await renderMp4OnDesktop(scene, { + signal: controller.signal, + fps: targetFps.value, + suggestedName: `${safe}.mp4`, + onProgress: (p) => { + if (progress.value) progress.value.progress = p + }, + }) + if (!result) { + progress.value = null + return + } + } else { + await downloadFromServer(scene, { + signal: controller.signal, + fps: targetFps.value, + onProgress: (p) => { + if (progress.value) progress.value.progress = p + }, + }) + } progress.value = null emit('update:modelValue', false) } catch (err) { diff --git a/src/devtools/headless.ts b/src/devtools/headless.ts new file mode 100644 index 0000000..b6d534a --- /dev/null +++ b/src/devtools/headless.ts @@ -0,0 +1,53 @@ +import { useI18n } from '@/i18n' +import { useSceneStore } from '@/stores/scene' +import { runAll, type TestResult } from './testRunner' +import { registerAllTests } from './tests' + +declare global { + interface Window { + __ruwioTestResults?: TestResult[] + } +} + +function shouldRunSuite(name: string, desktop: boolean): boolean { + if (name.startsWith('desktop:')) return desktop + if (name.startsWith('browser:')) return !desktop + return true +} + +export async function runHeadlessTests(): Promise { + const desktop = !!window.ruwioDesktop + const store = useSceneStore() + const { locale } = useI18n() + const snapshot = JSON.parse(JSON.stringify(store.$state)) + const previousLocale = locale.value + + document.body.setAttribute('data-tests-done', 'running') + document.body.setAttribute('data-test-host', desktop ? 'electron' : 'browser') + locale.value = 'en' + + try { + const results = await runAll(registerAllTests, { + interTestDelayMs: 0, + suiteFilter: (name) => shouldRunSuite(name, desktop), + }) + window.__ruwioTestResults = results + document.body.setAttribute('data-tests-done', results.some((r) => !r.ok) ? 'fail' : 'pass') + return results + } catch (err) { + const result: TestResult = { + suite: 'headless', + name: 'runAll', + ok: false, + error: (err as Error).message || String(err), + stack: (err as Error).stack, + durationMs: 0, + } + window.__ruwioTestResults = [result] + document.body.setAttribute('data-tests-done', 'fail') + return [result] + } finally { + store.$state = snapshot + locale.value = previousLocale + } +} diff --git a/src/devtools/testRunner.ts b/src/devtools/testRunner.ts index a08d567..2cb5991 100644 --- a/src/devtools/testRunner.ts +++ b/src/devtools/testRunner.ts @@ -133,6 +133,7 @@ export interface RunOptions { onTestStart?: (suite: string, name: string) => void onResult?: (result: TestResult) => void interTestDelayMs?: number + suiteFilter?: (suite: string) => boolean } export async function runAll( @@ -141,11 +142,12 @@ export async function runAll( ): Promise { suites.length = 0 register() - const total = suites.reduce((a, s) => a + s.tests.length, 0) + const runnableSuites = opts.suiteFilter ? suites.filter((s) => opts.suiteFilter?.(s.name)) : suites + const total = runnableSuites.reduce((a, s) => a + s.tests.length, 0) const results: TestResult[] = [] let done = 0 const delay = Math.max(0, opts.interTestDelayMs ?? 0) - for (const s of suites) { + for (const s of runnableSuites) { for (const t of s.tests) { opts.onTestStart?.(s.name, t.name) await new Promise((r) => requestAnimationFrame(() => r(null))) diff --git a/src/lib/desktopRenderExport.ts b/src/lib/desktopRenderExport.ts new file mode 100644 index 0000000..f0beb5a --- /dev/null +++ b/src/lib/desktopRenderExport.ts @@ -0,0 +1,65 @@ +import type { Scene } from '@/types/scene' + +export interface DesktopRenderOptions { + onProgress?: (progress: number) => void + signal?: AbortSignal + fps?: number + crf?: number + preset?: string + suggestedName?: string +} + +export function hasDesktopRender(): boolean { + return !!window.ruwioDesktop?.renderMp4 +} + +export async function renderMp4OnDesktop( + scene: Scene, + opts: DesktopRenderOptions = {}, +): Promise<{ path: string } | null> { + const desktop = window.ruwioDesktop + if (!desktop?.renderMp4) throw new Error('Desktop MP4 rendering is not available') + + let jobId: string | null = null + let settled = false + let pendingAbort = false + const cleanupProgress = desktop.onRenderProgress?.((event) => { + jobId = event.jobId + if (typeof event.progress === 'number') opts.onProgress?.(event.progress) + if (pendingAbort && !settled && jobId) { + pendingAbort = false + void desktop.cancelRender?.(jobId) + } + }) + + const abort = () => { + if (settled) return + if (jobId) void desktop.cancelRender?.(jobId) + else pendingAbort = true + } + + if (opts.signal) { + if (opts.signal.aborted) { + throw new DOMException('aborted', 'AbortError') + } + opts.signal.addEventListener('abort', abort, { once: true }) + } + + try { + const result = await desktop.renderMp4({ + scene, + fps: opts.fps, + crf: opts.crf, + preset: opts.preset, + suggestedName: opts.suggestedName, + }) + return result + } catch (err) { + if (opts.signal?.aborted) throw new DOMException('aborted', 'AbortError') + throw err + } finally { + settled = true + cleanupProgress?.() + opts.signal?.removeEventListener('abort', abort) + } +} diff --git a/src/main.ts b/src/main.ts index 2c0f42d..07c6494 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,10 @@ app.use(VueKonva) app.directive('tooltip', vTooltip) app.mount('#app') +if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('__runtests') === '1') { + void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests()) +} + // Service worker exists solely to make the app installable as a PWA. // In Chrome/Edge, an installed PWA runs in standalone display mode, // which removes the top-edge "exit fullscreen" indicator that tabs diff --git a/src/types/desktop.d.ts b/src/types/desktop.d.ts index fdf9721..e60cea5 100644 --- a/src/types/desktop.d.ts +++ b/src/types/desktop.d.ts @@ -1,3 +1,5 @@ +import type { Scene } from './scene' + export {} type RuwioDesktopMenuCommand = @@ -33,6 +35,20 @@ declare global { onMenuCommand?: ( callback: (command: RuwioDesktopMenuCommand) => void, ) => () => void + renderMp4?: (options: { + scene: Scene + fps?: number + crf?: number + preset?: string + suggestedName?: string + }) => Promise<{ path: string } | null> + onRenderProgress?: (callback: (event: { + jobId: string + progress: number + done: boolean + error?: string + }) => void) => () => void + cancelRender?: (jobId: string) => Promise } } } diff --git a/src/version.ts b/src/version.ts index 2029710..08833a3 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '1.2.15' +export const APP_VERSION = '1.3.0' export const APP_NAME = 'RUWIO' diff --git a/test/fixtures/scenes/capture-basic.json b/test/fixtures/scenes/capture-basic.json new file mode 100644 index 0000000..9fcebe0 --- /dev/null +++ b/test/fixtures/scenes/capture-basic.json @@ -0,0 +1,33 @@ +{ + "canvas": { "width": 160, "height": 120, "background": "#f8fafc" }, + "duration": 1, + "assets": [ + { "id": "rect", "kind": "rect", "name": "Rect", "width": 42, "height": 28, "fill": "#ef4444", "stroke": "#111827", "strokeWidth": 2 }, + { "id": "circle", "kind": "circle", "name": "Circle", "radius": 18, "fill": "#22c55e", "stroke": "#0f172a", "strokeWidth": 2 } + ], + "layers": [ + { + "id": "layer-rect", + "name": "Rect", + "assetId": "rect", + "visible": true, + "base": { "x": 36, "y": 62, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 1 }, + "keyframes": [ + { "id": "rect-0", "time": 0, "easing": "linear", "props": { "x": 36, "rotation": 0 } }, + { "id": "rect-1", "time": 1, "easing": "linear", "props": { "x": 112, "rotation": 18 } } + ] + }, + { + "id": "layer-circle", + "name": "Circle", + "assetId": "circle", + "visible": true, + "base": { "x": 88, "y": 44, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 0.82 }, + "keyframes": [ + { "id": "circle-0", "time": 0, "easing": "linear", "props": { "y": 44 } }, + { "id": "circle-1", "time": 1, "easing": "linear", "props": { "y": 78 } } + ] + } + ], + "trackOrder": ["layer-circle", "layer-rect"] +} diff --git a/test/lib/pixelDiff.cjs b/test/lib/pixelDiff.cjs new file mode 100644 index 0000000..7604e02 --- /dev/null +++ b/test/lib/pixelDiff.cjs @@ -0,0 +1,41 @@ +function diffBgra(a, b, options = {}) { + if (!a || !b || a.length !== b.length) { + throw new Error(`bitmap size mismatch: ${a?.length || 0} != ${b?.length || 0}`) + } + const perChannelTolerance = Number.isFinite(options.perChannelTolerance) + ? options.perChannelTolerance + : 2 + const maxMismatchedRatio = Number.isFinite(options.maxMismatchedRatio) + ? options.maxMismatchedRatio + : 0.001 + + let mismatchedPixels = 0 + let maxDelta = 0 + let totalDelta = 0 + const pixels = a.length / 4 + + for (let i = 0; i < a.length; i += 4) { + let pixelMismatch = false + for (let c = 0; c < 4; c += 1) { + const delta = Math.abs(a[i + c] - b[i + c]) + maxDelta = Math.max(maxDelta, delta) + totalDelta += delta + if (delta > perChannelTolerance) pixelMismatch = true + } + if (pixelMismatch) mismatchedPixels += 1 + } + + const mismatchedRatio = pixels ? mismatchedPixels / pixels : 0 + return { + ok: mismatchedRatio <= maxMismatchedRatio, + pixels, + mismatchedPixels, + mismatchedRatio, + maxDelta, + meanDelta: pixels ? totalDelta / (pixels * 4) : 0, + perChannelTolerance, + maxMismatchedRatio, + } +} + +module.exports = { diffBgra }