jacquardSnapshot

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