aquatic-world / tools /encode.mjs
Mike0021's picture
Upload folder using huggingface_hub
b08c4a1 verified
Raw History Blame Contribute Delete
4.84 kB
#!/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`);
}
});