File size: 4,840 Bytes
b08c4a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#!/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`);
  }
});