import { BrowserWindow, app } from 'electron' import type { Rectangle } from 'electron' import { spawn } from 'node:child_process' import fs from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' import { missingFfmpegMessage, resolveFfmpegPath } from './ffmpegPath.cjs' const validation = require('../shared/renderValidation.cjs') as { playbackRange(scene: RenderScene): { start: number; end: number } } // Inactivity watchdog: abort a job if no frame makes progress for this long. // The timer is re-armed on every onProgress, so steady renders never trip it; // only a wedged executeJavaScript/capturePage does. Mirrors the standalone // server's RENDER_JOB_TIMEOUT_MS safety net (finding #3). function readTimeoutMs(): number { const raw = Number(process.env.RUWIO_RENDER_TIMEOUT_MS) return Number.isFinite(raw) && raw >= 1000 ? raw : 2 * 60 * 1000 } const RENDER_INACTIVITY_TIMEOUT_MS = readTimeoutMs() // Isolated, in-memory session for the hidden render window so its webRequest // filter can't affect the main app session (finding #5). const RENDER_PARTITION = 'ruwio-render' function abortReason(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new Error('aborted') } // Reject as soon as `signal` aborts, otherwise settle with `promise`. Lets a // timeout/cancel interrupt an await that would otherwise hang (finding #3). function raceAbort(promise: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(abortReason(signal)) return new Promise((resolve, reject) => { const onAbort = () => reject(abortReason(signal)) signal.addEventListener('abort', onAbort, { once: true }) promise.then( (value) => { signal.removeEventListener('abort', onAbort) resolve(value) }, (err) => { signal.removeEventListener('abort', onAbort) reject(err) }, ) }) } export interface RenderScene { canvas: { width: number; height: number } duration: number layers: Array<{ keyframes: Array<{ time: number }> }> assets?: unknown[] [key: string]: unknown } export interface RenderOptions { fps: number duration: number totalFrames: number crf: string preset: string } export interface RenderJobInput { jobId: string scene: RenderScene options: RenderOptions outputPath: string signal: AbortSignal onProgress: (progress: number) => void } export async function renderMp4Job(input: RenderJobInput): Promise { const renderWindow = createRenderWindow(input.scene) const ffmpegPath = resolveFfmpegPath() let ffmpegErr = '' let settled = false let started = false // Render into a sibling temp file and promote it on success, so a cancelled // or failed render never truncates a pre-existing file the user chose to // overwrite (finding #7). Same directory => rename stays on one filesystem. const finalPath = input.outputPath const tmpPath = path.join( path.dirname(finalPath), `.${path.basename(finalPath)}.${input.jobId}.partial`, ) // Combined abort: trips on user cancel (input.signal) or the inactivity // watchdog. Used to kill ffmpeg and to interrupt per-frame awaits. const jobAbort = new AbortController() const onExternalAbort = () => jobAbort.abort(new Error('aborted')) if (input.signal.aborted) onExternalAbort() else input.signal.addEventListener('abort', onExternalAbort, { once: true }) let watchdog: ReturnType | undefined const armWatchdog = () => { if (watchdog) clearTimeout(watchdog) watchdog = setTimeout( () => jobAbort.abort(new Error('render timed out')), RENDER_INACTIVITY_TIMEOUT_MS, ) } const ffmpeg = spawn(ffmpegPath, [ '-y', '-f', 'image2pipe', '-framerate', String(input.options.fps), '-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', // libx264 + yuv420p require even dimensions, but validation allows any // canvas in 1..4096 — an odd width/height would otherwise make ffmpeg exit // with a confusing "height not divisible by 2" error. Pad up to the next // even size (≤1px edge column/row) so odd canvases encode (finding #13). '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-preset', input.options.preset, '-crf', input.options.crf, '-movflags', '+faststart', // Force the muxer explicitly: the temp filename has no .mp4 extension, and // the final path may lack one too, so don't rely on extension inference. '-f', 'mp4', tmpPath, ]) // If ffmpeg exits early (encode error, disk full, ...), the next stdin write // emits an async 'error' (EPIPE). Without a listener that becomes an // uncaughtException and crashes the app; the real failure still surfaces via // the 'exit'/ffmpegDone path below (finding #8). ffmpeg.stdin.on('error', () => { /* noop: surfaced via ffmpegDone */ }) const abort = () => { try { ffmpeg.kill('SIGKILL') } catch { /* noop */ } } jobAbort.signal.addEventListener('abort', abort, { once: true }) const ffmpegReady = new Promise((resolve, reject) => { ffmpeg.once('spawn', () => resolve()) ffmpeg.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage())) else reject(err) }) }) const ffmpegDone = new Promise((resolve, reject) => { ffmpeg.stderr.on('data', (d: Buffer) => { ffmpegErr += d.toString() }) ffmpeg.on('spawn', () => { started = true }) ffmpeg.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage())) else reject(err) }) ffmpeg.on('exit', (code) => { settled = true if (code === 0) resolve() else if (jobAbort.signal.aborted) reject(abortReason(jobAbort.signal)) else if (!started) reject(new Error(missingFfmpegMessage())) else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`)) }) }) // ffmpeg can fail mid-render, so ffmpegDone may reject long before the // `await ffmpegDone` after the loop attaches a handler. Keep a handler on it // from creation so that gap can't trip Node's unhandled-rejection process // termination; real handling still flows through `await ffmpegDone` (#8). void ffmpegDone.catch(() => {}) const signal = jobAbort.signal try { armWatchdog() await raceAbort(ffmpegReady, signal) await raceAbort(loadRenderPage(renderWindow), signal) await raceAbort( renderWindow.webContents.executeJavaScript( `window.__animatrInit(${JSON.stringify(input.scene)})`, true, ), signal, ) const { start: rangeIn } = validation.playbackRange(input.scene) const captureRect: Rectangle = { x: 0, y: 0, width: input.scene.canvas.width, height: input.scene.canvas.height, } for (let i = 0; i < input.options.totalFrames; i += 1) { if (signal.aborted) throw abortReason(signal) // ffmpeg already exited (e.g. encode error): stop feeding it so the real // error surfaces via `await ffmpegDone` below instead of an EPIPE (#8). if (settled) break const time = rangeIn + Math.min(i / input.options.fps, input.options.duration) await raceAbort( renderWindow.webContents.executeJavaScript(`window.__animatrSeek(${time})`, true), signal, ) const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal) // capturePage returns a NativeImage at the display's device scale factor, // so on a HiDPI screen the bitmap is N× the requested canvas and the MP4 // comes out at the wrong resolution. Normalize every frame back to the // canvas dimensions — the standalone server pins deviceScaleFactor:1 for // the same reason. No-op on scale-factor-1 displays (finding #10). const frameSize = frame.getSize() const png = (frameSize.width === captureRect.width && frameSize.height === captureRect.height ? frame : frame.resize({ width: captureRect.width, height: captureRect.height }) ).toPNG() if (!ffmpeg.stdin.write(png)) { // Wait for backpressure to clear — but don't hang forever if ffmpeg has // already died (no 'drain' will ever fire) or the job is aborted. Racing // ffmpegDone surfaces an early ffmpeg exit here instead of wedging (#8). await raceAbort( Promise.race([ new Promise((resolve) => ffmpeg.stdin.once('drain', resolve)), ffmpegDone, ]), signal, ) } input.onProgress((i + 1) / input.options.totalFrames) armWatchdog() } ffmpeg.stdin.end() await ffmpegDone // Render succeeded and tmpPath is complete: promote it into place. Try a // plain rename first (atomic replace on POSIX); Windows won't overwrite an // existing destination, so fall back to remove-then-rename there (#7). try { await fs.rename(tmpPath, finalPath) } catch { await fs.rm(finalPath, { force: true }) await fs.rename(tmpPath, finalPath) } } catch (err) { if (!settled) abort() try { ffmpeg.stdin.destroy() } catch { /* noop */ } // Only ever remove our temp file, never the user's chosen target (#7). await fs.rm(tmpPath, { force: true }).catch(() => {}) await ffmpegDone.catch(() => {}) throw err } finally { if (watchdog) clearTimeout(watchdog) input.signal.removeEventListener('abort', onExternalAbort) jobAbort.signal.removeEventListener('abort', abort) if (!renderWindow.isDestroyed()) renderWindow.close() } } function createRenderWindow(scene: RenderScene): BrowserWindow { const win = new BrowserWindow({ width: scene.canvas.width, height: scene.canvas.height, show: false, frame: false, // Opaque white background so transparent regions (e.g. backgroundVideo // scenes) flatten onto white under yuv420p — matching the standalone // server's Puppeteer screenshot (omitBackground:false), not black (#4). transparent: false, backgroundColor: '#ffffff', webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, partition: RENDER_PARTITION, // The window is never shown, so Chromium would throttle/pause rAF and the // per-frame paint settle in __animatrSeek would hit its 1s fallback every // frame (~1fps renders). Keep timers/rAF running while hidden (finding #12). backgroundThrottling: false, }, }) // Defense-in-depth: the render page loads only local (file:/data:/blob:) // assets, so block any networked request the captured project might carry // (finding #5). The url filter only matches http/https, leaving local // schemes untouched. Scoped to this window's isolated session partition. win.webContents.session.webRequest.onBeforeRequest( { urls: ['*://*/*'] }, (_details, callback) => callback({ cancel: true }), ) win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) win.webContents.on('will-navigate', (event) => event.preventDefault()) return win } async function loadRenderPage(win: BrowserWindow): Promise { const renderHtml = app.isPackaged ? path.join(process.resourcesPath, 'server', 'render.html') : path.join(process.cwd(), 'server', 'render.html') await win.loadURL(pathToFileURL(renderHtml).toString()) }