← snapshot
2606 bytes
/**
* Muxes the recorded video with the narration clips.
*
* Each clip is delayed to the exact millisecond its scene began (from
* timeline.json), so narration and picture stay locked even though the
* recorder and the synthesiser ran independently.
*/
import { execFile } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const run = promisify(execFile);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(HERE, "out");
const AUDIO = path.join(HERE, "audio");
async function main() {
const timeline = JSON.parse(await readFile(path.join(OUT, "timeline.json"), "utf8"));
const manifest = JSON.parse(await readFile(path.join(AUDIO, "manifest.json"), "utf8"));
const webm =
timeline.video ??
(await readdir(OUT)).find((f) => f.endsWith(".webm"));
if (!webm) throw new Error("no recorded video in tour/out");
const videoPath = path.join(OUT, webm);
const byId = new Map(manifest.scenes.map((s) => [s.id, s]));
const clips = timeline.timeline
.map((t) => ({ ...t, clip: byId.get(t.id) }))
.filter((t) => t.clip);
// One input per clip, each delayed to its scene start, then summed.
const inputs = [];
const filters = [];
clips.forEach((c, i) => {
inputs.push("-i", path.join(AUDIO, c.clip.file));
// +1 because input 0 is the video.
filters.push(`[${i + 1}:a]adelay=${c.startMs}|${c.startMs}[a${i}]`);
});
const mixed = clips.map((_, i) => `[a${i}]`).join("");
const filter = `${filters.join(";")};${mixed}amix=inputs=${clips.length}:normalize=0[out]`;
const target = path.join(OUT, "lowell-tour.mp4");
console.log(`muxing ${clips.length} narration clips over ${webm}`);
await run(
"ffmpeg",
[
"-y", "-loglevel", "error",
"-i", videoPath,
...inputs,
"-filter_complex", filter,
"-map", "0:v", "-map", "[out]",
// The webm is VP8 at a variable frame rate; normalise for players.
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
"-r", "30",
"-c:a", "aac", "-b:a", "160k",
"-movflags", "+faststart",
"-shortest",
target,
],
{ maxBuffer: 1 << 26 },
);
const { stdout } = await run("ffprobe", [
"-v", "error",
"-show_entries", "format=duration,size",
"-of", "default=noprint_wrappers=1",
target,
]);
console.log(stdout.trim());
console.log(`-> ${target}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});