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>
187 lines
6.9 KiB
JavaScript
187 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* The audio mix: the score + every sound-effect cue of the film.
|
||
*
|
||
* node --import tsx scripts/mix.mjs [--music-gain 1] [--sfx-gain 1] [--lufs -14]
|
||
*
|
||
* Each cue's sample is decoded once per playback rate, its measured onset
|
||
* (sfx/sources.json) subtracted so the transient lands exactly on `at`, then
|
||
* gained, panned and summed into an SFX stem at 48 kHz. Score + stem are
|
||
* mixed, brought to the target loudness and peak-limited:
|
||
*
|
||
* out/audio/sfx.wav the stem out/audio/mix.wav the final mix
|
||
* out/audio/cues.tsv the cue sheet as placed (for checking)
|
||
*/
|
||
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 { ALL_CUES } = await import(path.join(ROOT, "src/cues.ts"));
|
||
const { DURATION, at } = await import(path.join(ROOT, "src/timeline.ts"));
|
||
|
||
const args = process.argv.slice(2);
|
||
const opt = (k, d) => (args.includes(k) ? args[args.indexOf(k) + 1] : d);
|
||
const MUSIC_GAIN = Number(opt("--music-gain", "1"));
|
||
const SFX_GAIN = Number(opt("--sfx-gain", "1"));
|
||
const TARGET_LUFS = Number(opt("--lufs", "-14"));
|
||
|
||
const SR = 48000;
|
||
const N = Math.round(DURATION * SR);
|
||
const OUT = path.join(ROOT, "out/audio");
|
||
fs.mkdirSync(OUT, { recursive: true });
|
||
|
||
const sources = JSON.parse(fs.readFileSync(path.join(ROOT, "sfx/sources.json"), "utf8"));
|
||
const score = path.resolve(ROOT, opt("--score", "public/audio/score.wav"));
|
||
if (!fs.existsSync(score)) throw new Error("no score — run `npm run music` first");
|
||
|
||
const ff = (a, opts = {}) => execFileSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...a], { maxBuffer: 1 << 30, ...opts });
|
||
|
||
/** name@rate → [left, right] Float32Arrays at 48 kHz */
|
||
const cache = new Map();
|
||
function decode(name, rate) {
|
||
const key = `${name}@${rate}`;
|
||
if (cache.has(key)) return cache.get(key);
|
||
const file = path.join(ROOT, "public/sfx", `${name}.mp3`);
|
||
if (!fs.existsSync(file)) throw new Error(`missing ${file} — run \`npm run sfx\``);
|
||
const af = rate === 1 ? `aresample=${SR}` : `aresample=${SR},asetrate=${Math.round(SR * rate)},aresample=${SR}`;
|
||
const buf = ff(["-i", file, "-af", af, "-ac", "2", "-f", "f32le", "-"]);
|
||
const inter = new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4);
|
||
const n = inter.length / 2;
|
||
const l = new Float32Array(n);
|
||
const r = new Float32Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
l[i] = inter[2 * i];
|
||
r[i] = inter[2 * i + 1];
|
||
}
|
||
cache.set(key, [l, r]);
|
||
return [l, r];
|
||
}
|
||
|
||
/**
|
||
* Per-sample house rules, so a cue list can't smear the mix: the longest a
|
||
* long-ringing sample may play when its cue sets no `dur` (faded out over the
|
||
* last 0.3 s), and a level trim for the odd quiet recording (sfx/README.md).
|
||
*/
|
||
const MAX_DUR = { impact: 1.4, impact2: 1.0, register: 1.6, sparkle: 1.3, bell: 2.0, tear: 0.8 };
|
||
const TRIM = { bell: 2.0 };
|
||
/**
|
||
* Mix automation: SFX gain by film span. The night breakdown (bars 31–34)
|
||
* drops the score to about −26 LUFS, so the cron chapter's UI sounds come
|
||
* down 6 dB there to stay under it rather than over it.
|
||
*/
|
||
const SPAN_GAIN = [{ from: at(31), to: at(35), gain: 0.5 }];
|
||
const spanGain = (t) => SPAN_GAIN.reduce((g, s) => (t >= s.from && t < s.to ? g * s.gain : g), 1);
|
||
|
||
const L = new Float32Array(N);
|
||
const R = new Float32Array(N);
|
||
const missing = new Set();
|
||
const sheet = ["at\tsfx\tgain\trate\tpan\tstart"];
|
||
for (const c of ALL_CUES) {
|
||
const src = sources[c.sfx];
|
||
if (!src) {
|
||
missing.add(c.sfx);
|
||
continue;
|
||
}
|
||
const rate = c.rate ?? 1;
|
||
const [l, r] = decode(c.sfx, rate);
|
||
const onset = (src.onset ?? 0) / rate;
|
||
let start = Math.round((c.at - onset) * SR);
|
||
let from = 0;
|
||
if (start < 0) {
|
||
from = -start;
|
||
start = 0;
|
||
}
|
||
let len = l.length - from;
|
||
const dur = c.dur ?? MAX_DUR[c.sfx];
|
||
if (dur) len = Math.min(len, Math.round(dur * SR));
|
||
len = Math.min(len, N - start);
|
||
if (len <= 0) continue;
|
||
const fadeOut = Math.round((c.fadeOut ?? (c.dur ? 0.012 : MAX_DUR[c.sfx] ? 0.3 : 0)) * SR);
|
||
const pan = Math.max(-1, Math.min(1, c.pan ?? 0));
|
||
const g = (c.gain ?? 0.6) * (TRIM[c.sfx] ?? 1) * spanGain(c.at) * SFX_GAIN;
|
||
const gl = g * Math.cos(((pan + 1) * Math.PI) / 4) * Math.SQRT2;
|
||
const gr = g * Math.sin(((pan + 1) * Math.PI) / 4) * Math.SQRT2;
|
||
for (let i = 0; i < len; i++) {
|
||
const f = fadeOut && i > len - fadeOut ? (len - i) / fadeOut : 1;
|
||
L[start + i] += l[from + i] * gl * f;
|
||
R[start + i] += r[from + i] * gr * f;
|
||
}
|
||
sheet.push(`${c.at.toFixed(4)}\t${c.sfx}\t${g.toFixed(2)}\t${rate}\t${pan}\t${(start / SR).toFixed(4)}`);
|
||
}
|
||
if (missing.size) console.warn(`⚠ cues with no source in sfx/sources.json (skipped): ${[...missing].join(", ")}`);
|
||
fs.writeFileSync(path.join(OUT, "cues.tsv"), sheet.join("\n") + "\n");
|
||
|
||
// the stem as 32-bit float WAV
|
||
function writeWavF32(file, l, r) {
|
||
const n = l.length;
|
||
const data = Buffer.alloc(n * 8);
|
||
for (let i = 0; i < n; i++) {
|
||
data.writeFloatLE(l[i], i * 8);
|
||
data.writeFloatLE(r[i], i * 8 + 4);
|
||
}
|
||
const h = Buffer.alloc(44);
|
||
h.write("RIFF", 0);
|
||
h.writeUInt32LE(36 + data.length, 4);
|
||
h.write("WAVE", 8);
|
||
h.write("fmt ", 12);
|
||
h.writeUInt32LE(16, 16);
|
||
h.writeUInt16LE(3, 20); // IEEE float
|
||
h.writeUInt16LE(2, 22);
|
||
h.writeUInt32LE(SR, 24);
|
||
h.writeUInt32LE(SR * 8, 28);
|
||
h.writeUInt16LE(8, 32);
|
||
h.writeUInt16LE(32, 34);
|
||
h.write("data", 36);
|
||
h.writeUInt32LE(data.length, 40);
|
||
fs.writeFileSync(file, Buffer.concat([h, data]));
|
||
}
|
||
const stem = path.join(OUT, "sfx.wav");
|
||
writeWavF32(stem, L, R);
|
||
console.log(`sfx stem: ${ALL_CUES.length - [...ALL_CUES].filter((c) => missing.has(c.sfx)).length} cues, ${cache.size} decoded variants → ${stem}`);
|
||
|
||
/** Integrated loudness (LUFS) and true peak (dBTP) of a file, via ebur128. */
|
||
function parse(file) {
|
||
const r = execFileSync("bash", ["-c", `ffmpeg -hide_banner -nostats -i "${file}" -af ebur128=peak=true -f null - 2>&1 | tail -14`], {
|
||
encoding: "utf8",
|
||
});
|
||
const I = Number(/I:\s+(-?[\d.]+) LUFS/.exec(r)?.[1]);
|
||
const TP = Number(/Peak:\s+(-?[\d.]+) dBFS/.exec(r)?.[1]);
|
||
return { I, TP };
|
||
}
|
||
|
||
// pass 1: plain sum; measure
|
||
const pre = path.join(OUT, "pre.wav");
|
||
ff([
|
||
"-i",
|
||
score,
|
||
"-i",
|
||
stem,
|
||
"-filter_complex",
|
||
`[0:a]aformat=sample_rates=${SR}:channel_layouts=stereo,volume=${MUSIC_GAIN}[m];[1:a]aformat=sample_rates=${SR}:channel_layouts=stereo[s];[m][s]amix=inputs=2:normalize=0:duration=first[out]`,
|
||
"-map",
|
||
"[out]",
|
||
"-c:a",
|
||
"pcm_f32le",
|
||
pre,
|
||
]);
|
||
const m1 = parse(pre);
|
||
// pass 2: gain to target, then a true-peak-safe limiter at −1 dBTP
|
||
const gainDb = TARGET_LUFS - m1.I;
|
||
const mix = path.join(OUT, "mix.wav");
|
||
ff([
|
||
"-i",
|
||
pre,
|
||
"-af",
|
||
`volume=${gainDb.toFixed(2)}dB,alimiter=limit=0.78:attack=2:release=60:level=false`,
|
||
"-ar",
|
||
String(SR),
|
||
"-c:a",
|
||
"pcm_s24le",
|
||
mix,
|
||
]);
|
||
const m2 = parse(mix);
|
||
fs.rmSync(pre);
|
||
console.log(`mix: ${m1.I.toFixed(1)} LUFS → gain ${gainDb.toFixed(1)} dB → ${m2.I.toFixed(1)} LUFS, true peak ${m2.TP.toFixed(1)} dBTP → ${mix}`);
|