reel/ is a new Remotion project for the Tokunia landing page's motion-design film: 120 s at 128 BPM = 64 bars, and every position in it is a bar/beat on that grid (src/timeline.ts). The drum pattern, arrangement, drops, risers, notification pings and cron chimes are data (src/score.ts) that both the music renderer and the picture read, so a pulse on screen lands on the sample it belongs to. - src/kit/: the motion kit — kinetic type (Slam, Reveal, Typewriter, Odometer, Scramble, Marquee), effects and the chapter cut (Backdrop, Flash, RingBurst, Confetti, Camera, SlabWipe), the app redrawn in its own dark theme (Panel, Chip, ToolCard, Composer, StatusChecks, Kpi…), device frames, the receipt, Hemp Henry stepping on eighth notes, and the shared ChapterTitle every feature chapter opens with - the intro (hook → logo drop → the reel's own receipt) and the outro (the final bill, the end card, the ring-out) - scripts/stills.mjs (stills + contact sheets at bar:beat), mix.mjs (lays every sound cue on the score, transient on the beat, loudness-normalised), render.mjs (picture → mix → 1080p/720p web encodes → poster) - .dockerignore: keep the reel and the landing's assets out of the image Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
4.0 KiB
JavaScript
84 lines
4.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Render stills of a composition at given film times, plus a contact sheet.
|
|
*
|
|
* npm run stills -- <compId> <time…> [--scale 0.5] [--cols 3]
|
|
*
|
|
* <compId>: `reel` or `ch-<chapter>` (e.g. ch-notifications).
|
|
* <time>: ABSOLUTE film seconds (`47.3`) or bar:beat[:16th] (`25:3`, `25:3:2`)
|
|
* — for a chapter composition they are converted to its own frames.
|
|
* Output: out/stills/<compId>/<time>.jpg and out/stills/<compId>-sheet.jpg
|
|
*/
|
|
import { bundle } from "@remotion/bundler";
|
|
import { renderStill, selectComposition } from "@remotion/renderer";
|
|
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 { at, sectionSpan, FPS } = await import(path.join(ROOT, "src/timeline.ts"));
|
|
|
|
const args = process.argv.slice(2);
|
|
const opt = (k, d) => {
|
|
const i = args.indexOf(k);
|
|
if (i < 0) return d;
|
|
const v = args[i + 1];
|
|
args.splice(i, 2);
|
|
return v;
|
|
};
|
|
const scale = Number(opt("--scale", "0.5"));
|
|
const cols = Number(opt("--cols", "3"));
|
|
const [compId, ...times] = args;
|
|
if (!compId || !times.length) {
|
|
console.error("usage: npm run stills -- <compId> <time…> [--scale 0.5] [--cols 3]");
|
|
process.exit(2);
|
|
}
|
|
const parseT = (s) => (s.includes(":") ? at(...s.split(":").map(Number)) : Number(s));
|
|
const offset = compId.startsWith("ch-") ? sectionSpan(compId.slice(3)).start : 0;
|
|
|
|
const outDir = path.join(ROOT, "out/stills", compId);
|
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
const t0 = Date.now();
|
|
// Bundle on disk, not in /tmp: here /tmp is a RAM tmpfs, and each bundle copies
|
|
// public/ (the score alone is ~23 MB). Removed again when the run ends.
|
|
fs.mkdirSync(path.join(ROOT, "out"), { recursive: true });
|
|
const bundleDir = fs.mkdtempSync(path.join(ROOT, "out/.bundle-"));
|
|
process.on("exit", () => fs.rmSync(bundleDir, { recursive: true, force: true }));
|
|
const serveUrl = await bundle({ entryPoint: path.join(ROOT, "src/index.ts"), outDir: bundleDir });
|
|
const composition = await selectComposition({ serveUrl, id: compId });
|
|
const files = [];
|
|
for (const s of times) {
|
|
const t = parseT(s);
|
|
const frame = Math.min(composition.durationInFrames - 1, Math.max(0, Math.round((t - offset) * FPS)));
|
|
const file = path.join(outDir, `${t.toFixed(3).padStart(8, "0")}.jpg`);
|
|
await renderStill({ serveUrl, composition, frame, output: file, imageFormat: "jpeg", jpegQuality: 88, scale });
|
|
files.push({ file, label: `${s} (${t.toFixed(2)}s f${frame})` });
|
|
}
|
|
console.log(`rendered ${files.length} stills in ${((Date.now() - t0) / 1000).toFixed(1)}s → ${outDir}`);
|
|
|
|
// contact sheet with a time label on each tile (one still = the still itself)
|
|
const sheet = path.join(ROOT, "out/stills", `${compId}-sheet.jpg`);
|
|
if (files.length === 1) {
|
|
fs.copyFileSync(files[0].file, sheet);
|
|
console.log(`sheet → ${sheet}`);
|
|
process.exit(0);
|
|
}
|
|
const inputs = files.flatMap((f) => ["-i", f.file]);
|
|
const w = Math.round(1920 * scale);
|
|
const h = Math.round(1080 * scale);
|
|
const n = files.length;
|
|
const c = Math.min(cols, n);
|
|
const rows = Math.ceil(n / c);
|
|
const lab = files
|
|
.map((f, i) => `[${i}:v]scale=${w}:${h},drawtext=text='${f.label.replace(/[:']/g, (m) => "\\" + m)}':x=12:y=12:fontsize=${Math.round(26 * scale * 2)}:fontcolor=white:box=1:boxcolor=black@0.65:boxborderw=6[v${i}]`)
|
|
.join(";");
|
|
const pads = [];
|
|
for (let i = n; i < rows * c; i++) pads.push(`color=c=black:s=${w}x${h}:d=1[v${i}]`);
|
|
const layout = Array.from({ length: rows * c }, (_, i) => `${(i % c) * w}_${Math.floor(i / c) * h}`).join("|");
|
|
const graph = [lab, ...pads, `${Array.from({ length: rows * c }, (_, i) => `[v${i}]`).join("")}xstack=inputs=${rows * c}:layout=${layout}[out]`].join(";");
|
|
execFileSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...inputs, "-filter_complex", graph, "-map", "[out]", "-frames:v", "1", "-q:v", "3", sheet]);
|
|
console.log(`sheet → ${sheet}`);
|