Files
ai-agent/reel/scripts/stills.mjs
Gabriel Vidal 1e2cce8d2b feat(reel): a Remotion reel where picture, score and sound share one beat grid
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>
2026-10-11 04:50:31 +02:00

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}`);