- render.mjs: CRF 26 with x264 aq-mode 3, which spends the bits on the dark gradients this mostly-ink film is made of — the same look as CRF 23 at ~30% less (31 MB at 1080p, 15 MB at 720p); the poster is the end card (logo, line, address, the bill) at 115 s - the score is plain TypeScript DSP now, so Tone.js and playwright-core go - README: how the score is actually made Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
2.9 KiB
JavaScript
84 lines
2.9 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* The final render: picture → mix → web encodes → poster.
|
||
*
|
||
* npm run render [-- --skip-video] [--skip-audio] [--concurrency 6] [--crf 23]
|
||
*
|
||
* out/video.mp4 Remotion's picture, near-lossless, silent
|
||
* out/audio/mix.wav scripts/mix.mjs (score + sound effects)
|
||
* out/tokunia-reel.mp4 1920×1080 web encode (the landing page's)
|
||
* out/tokunia-reel-720.mp4 1280×720, for small screens
|
||
* out/tokunia-reel-poster.jpg the poster frame (the end card)
|
||
*/
|
||
import { execFileSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
const args = process.argv.slice(2);
|
||
const has = (k) => args.includes(k);
|
||
const opt = (k, d) => (has(k) ? args[args.indexOf(k) + 1] : d);
|
||
const CONCURRENCY = opt("--concurrency", "6");
|
||
const CRF = opt("--crf", "26");
|
||
const POSTER_T = Number(opt("--poster", "115.0"));
|
||
|
||
const OUT = path.join(ROOT, "out");
|
||
fs.mkdirSync(OUT, { recursive: true });
|
||
const run = (cmd, a) => execFileSync(cmd, a, { cwd: ROOT, stdio: "inherit" });
|
||
const ff = (a) => run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...a]);
|
||
const mb = (f) => (fs.statSync(f).size / 1e6).toFixed(1) + " MB";
|
||
|
||
const video = path.join(OUT, "video.mp4");
|
||
const mix = path.join(OUT, "audio/mix.wav");
|
||
|
||
if (!has("--skip-video")) {
|
||
const t0 = Date.now();
|
||
run("npx", ["remotion", "render", "reel", video, "--codec=h264", "--crf=12", "--muted", `--concurrency=${CONCURRENCY}`]);
|
||
console.log(`picture in ${((Date.now() - t0) / 1000).toFixed(0)} s → ${video} (${mb(video)})`);
|
||
}
|
||
if (!has("--skip-audio")) run("node", ["--import", "tsx", "scripts/mix.mjs"]);
|
||
|
||
// libx264, slow preset; aq-mode 3 spends the bits on the dark gradients this
|
||
// mostly-ink film is made of (CRF 26 + aq3 ≈ CRF 23 to the eye, ~30% smaller);
|
||
// AAC 160k; +faststart so the landing page can start before the file is down.
|
||
const encode = (out, scale, crf) =>
|
||
ff([
|
||
"-i",
|
||
video,
|
||
"-i",
|
||
mix,
|
||
"-map",
|
||
"0:v",
|
||
"-map",
|
||
"1:a",
|
||
...(scale ? ["-vf", `scale=${scale}:flags=lanczos`] : []),
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"slow",
|
||
"-crf",
|
||
String(crf),
|
||
"-x264-params",
|
||
"aq-mode=3",
|
||
"-profile:v",
|
||
"high",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"160k",
|
||
"-shortest",
|
||
"-movflags",
|
||
"+faststart",
|
||
out,
|
||
]);
|
||
const full = path.join(OUT, "tokunia-reel.mp4");
|
||
const small = path.join(OUT, "tokunia-reel-720.mp4");
|
||
encode(full, null, CRF);
|
||
encode(small, "1280:720", Number(CRF) + 1);
|
||
const poster = path.join(OUT, "tokunia-reel-poster.jpg");
|
||
ff(["-ss", String(POSTER_T), "-i", video, "-frames:v", "1", "-q:v", "3", poster]);
|
||
console.log(`→ ${full} (${mb(full)})\n→ ${small} (${mb(small)})\n→ ${poster} (${mb(poster)})`);
|