#!/usr/bin/env node /** * tools/encode.mjs — turn a rendered frame directory into a finished video. * * node tools/encode.mjs --dir shots/film --out shots/aquatic-world.mp4 * * Frames are read as a contiguously numbered PNG sequence starting at 00000. * If the frames were rendered above the delivery resolution (--scale), they are * downsampled here with lanczos, which is a real supersample and worth the * render time: it is the cheapest image-quality win available and it costs * nothing but wall clock on a job that is not interactive. */ import { spawn, spawnSync } from 'node:child_process'; import { existsSync, statSync, readdirSync } from 'node:fs'; import path from 'node:path'; // How many frames are actually on disk, so the reported duration is the file's // real length rather than a number we hoped for. function countFrames(dir, start, limit) { try { const n = readdirSync(dir).filter((f) => /^\d{5}\.(png|jpg)$/.test(f)).length; return limit ? Math.min(n, parseInt(limit, 10)) : n; } catch { return 0; } } function probeDuration(file) { try { const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', file], { encoding: 'utf8' }); return parseFloat(r.stdout); } catch { return null; } } const argv = process.argv.slice(2); const arg = (n, d) => { const i = argv.indexOf('--' + n); return i >= 0 ? argv[i + 1] : d; }; const DIR = path.resolve(arg('dir', 'shots/film')); const OUT = path.resolve(arg('out', 'shots/aquatic-world.mp4')); const FPS = parseInt(arg('fps', '30'), 10); const SCALE = arg('scale', null); // e.g. 1600:900 to downsample 2x renders const CRF = arg('crf', '17'); const PRESET = arg('preset', 'slow'); const FADE_IN = parseFloat(arg('fade-in', '0')); const FADE_OUT = parseFloat(arg('fade-out', '0')); const FRAMES = arg('frames', null); // limit, for a partial render const START = parseInt(arg('start', '0'), 10); const firstFrame = String(START).padStart(5, '0') + '.png'; if (!existsSync(path.join(DIR, firstFrame))) { console.error(`[encode] no ${firstFrame} in ${DIR} — render first`); process.exit(1); } // Duration comes from the manifest when there is one, so a fade-out can be // placed at the real end rather than guessed from a frame count that might be // a partial render. // The manifest records the render's own frame rate. Trusting it over the // command line is the whole point: encoding 60fps footage at the 30fps default // produces a file that plays at half speed and still reports the render's // duration, so the mistake is invisible in the log. It was made once. let fps = FPS; let seconds = null; try { const m = JSON.parse((await import('node:fs')).readFileSync(path.join(DIR, 'manifest.json'), 'utf8')); if (m.fps && m.fps !== FPS) { console.log(`[encode] manifest says ${m.fps} fps, command line said ${FPS} - using the manifest`); fps = m.fps; } const frames = m.frames ? m.frames.length : 0; const total = m.total || frames; // A chunked render leaves only the LAST chunk's manifest, so its `total` is // the whole film while its frame list is one chunk. The frame count on disk // is the only honest source for how long the file will be. const onDisk = countFrames(DIR, START, FRAMES); seconds = (FRAMES ? parseInt(FRAMES, 10) : (onDisk || total)) / fps; } catch { /* no manifest: the filters below simply will not reference duration */ } const filters = []; if (SCALE) filters.push(`scale=${SCALE}:flags=lanczos`); if (FADE_IN > 0) filters.push(`fade=t=in:st=0:d=${FADE_IN}`); if (FADE_OUT > 0 && seconds) { filters.push(`fade=t=out:st=${(seconds - FADE_OUT).toFixed(3)}:d=${FADE_OUT}`); } const args = ['-y', '-loglevel', 'error', '-stats', '-framerate', String(fps), '-start_number', String(START), '-i', path.join(DIR, '%05d.png'), ]; if (FRAMES) args.push('-frames:v', FRAMES); if (filters.length) args.push('-vf', filters.join(',')); args.push( '-c:v', 'libx264', '-preset', PRESET, '-crf', String(CRF), '-pix_fmt', 'yuv420p', '-movflags', '+faststart', OUT, ); console.log('[encode] ' + OUT); console.log('[encode] ' + filters.join(' , ') + (filters.length ? '' : '(no filters)')); const child = spawn('ffmpeg', args, { stdio: 'inherit' }); child.on('exit', (code) => { if (code !== 0) { console.error('[encode] ffmpeg failed with code ' + code); process.exit(code || 1); } const kb = statSync(OUT).size / 1024; // Report what ffmpeg actually wrote, not what we expected it to. const real = probeDuration(OUT); console.log(`[encode] done: ${kb.toFixed(0)} kB, ${real ? real.toFixed(2) + 's' : '?'} at ${fps} fps`); if (real && seconds && Math.abs(real - seconds) > 0.1) { console.error(`[encode] WARNING: expected ${seconds.toFixed(2)}s, got ${real.toFixed(2)}s`); } });