Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104// compose — lay the narration onto the recorded clip and emit captions.//// Audio is placed at MEASURED offsets, not planned ones. The runner timestamps// each beat as it actually happens against the recorder's own clock (`reel`// reports `since`, the wall-clock moment the stream began), so a beat that// overran its narration — an AI generation that took 40s instead of the 6s we// scripted — cannot drift the rest of the video. Every later beat is pinned to// where it truly landed. This is why nothing here needs to "re-sync".//// Captions are a VTT sidecar, not burned pixels. Two reasons: fuser's// <VideoDocs> already renders <track kind="captions" /> (empty until now), and// burning text needs an ffmpeg with libass — which the stock Homebrew build// does NOT have. Sidecar captions are also selectable, searchable, and// translatable. `--burn` can come later for platforms that demand baked text.
import { execFileSync } from "node:child_process";import { writeFileSync } from "node:fs";import { captionPhrases } from "./captions.mjs";
const FFMPEG = process.env.FFMPEG || "ffmpeg";
const stamp = (sec) => { const s = Math.max(0, sec); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = (s % 60).toFixed(3).padStart(6, "0"); return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${ss}`;};
export function writeVTT(beats, path) { const all = captionPhrases(beats); const body = all .map((c, i) => `${i + 1}\n${stamp(c.from)} --> ${stamp(c.to)}\n${c.text}\n`) .join("\n"); writeFileSync(path, `WEBVTT\n\n${body}`); return all.length;}
export function narrationFilter(beats) { const chains = beats.map((b, i) => { const ms = Math.round(b.offsetSec * 1000); return `[${i + 1}:a]adelay=${ms}|${ms}[d${i}]`; }); const mixIn = beats.map((_, i) => `[d${i}]`).join(""); return ( `${chains.join(";")};${mixIn}` + `amix=inputs=${beats.length}:normalize=0:dropout_transition=0,` + `loudnorm=I=-16:LRA=7:TP=-1.5,aresample=48000[a]` );}
/// Mux: video from the reel clip (stream-copied — it is already h264 and/// re-encoding would only soften the text), narration mixed in at each beat's/// measured offset.////// amix with normalize=0 is load-bearing. The default normalizes by input count,/// so an N-beat tutorial would come out roughly 1/N as loud — quiet in a way/// that reads as a broken render rather than a mixing choice. The beats never/// overlap, so summing them straight is exactly right.////// The ElevenLabs source clips are intentionally conservative (the finished/// tutorials measured around -30 LUFS), so normalize the summed narration bus/// to a web-friendly spoken-word target. `loudnorm` oversamples internally for/// true-peak detection; bring the result back to 48 kHz before AAC encoding.export function mux({ clip, beats, out, vtt }) { const args = ["-y", "-i", clip]; for (const b of beats) args.push("-i", b.mp3); // Embed the captions as a real subtitle track (tx3g/mov_text), not just a // sidecar file. A sidecar only helps in the browser, where <VideoDocs> can // point a <track> at it — open the same mp4 in QuickTime and the captions // simply are not there. Embedding means the file carries them everywhere. if (vtt) args.push("-i", vtt);
const filter = narrationFilter(beats);
args.push( "-filter_complex", filter, "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k");
if (vtt) { args.push( "-map", `${beats.length + 1}:s`, "-c:s", "mov_text", "-metadata:s:s:0", "language=eng"); }
args.push("-movflags", "+faststart", out);
execFileSync(FFMPEG, args, { stdio: ["ignore", "ignore", "pipe"] }); return out;}
export function probe(path) { const raw = execFileSync("ffprobe", [ "-v", "error", "-show_entries", "format=duration,size", "-show_entries", "stream=codec_name,width,height", "-of", "json", path, ], { encoding: "utf8" }); return JSON.parse(raw);}