Spaces:
Running
Running
Download tools/encode.mjs from Mike0021/aquatic-world: direct link, hf CLI and curl.
- Browser
- Download file 4.84 kB
-
https://huggingface.co/spaces/Mike0021/aquatic-world/resolve/main/tools/encode.mjs
- Command line
-
hf download hf://spaces/Mike0021/aquatic-world/tools/encode.mjs
-
curl -L -o encode.mjs https://huggingface.co/spaces/Mike0021/aquatic-world/resolve/main/tools/encode.mjs
4.84 kB
| /** | |
| * 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`); | |
| } | |
| }); | |