#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [] # /// """Join per-scene narration takes into one track and write global word times for the page. python3 timeline.py [--gap 0.7] [--lead-in 1.0] [--tail 2.0] Reads /narration.json and audio/timings.json (from narrate.py). Writes: audio/narration.wav all takes in order, `gap` seconds of silence between scenes words.js window.WORDS (global word times), window.SCENES, window.NARRATION, window.CAPTIONS (the .srt's caption lines, each with its word times) The page loads words.js before its own script and times everything with cue("phrase"). Scenes still read by the scratch voice (narrate.py --scratch) are listed in NARRATION.scratch and in narration.wav's comment tag, so mux.py can warn that the video is a draft. """ import argparse import json import subprocess import sys from pathlib import Path # Caption lines break exactly as in the .srt that mux.py writes: both use assemble.py's breaking. sys.path.insert(0, str(Path(__file__).resolve().parent)) from assemble import caption_lines # noqa: E402 def caption_data(words: list[dict]) -> list[dict]: """The caption lines over the global word times, each with the words it shows.""" lines, k = [], 0 for start, end, text in caption_lines(words): part = [] while k < len(words) and " ".join(w["word"] for w in part) != text: part.append({"word": words[k]["word"], "start": words[k]["start"], "end": words[k]["end"]}) k += 1 if " ".join(w["word"] for w in part) != text: raise SystemExit(f"caption line {text!r} does not match the word list") lines.append({"start": start, "end": end, "text": text, "words": part}) return lines def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("project", type=Path) ap.add_argument("--gap", type=float, default=0.7, help="silence between scenes (s)") ap.add_argument("--lead-in", type=float, default=1.0, help="silence before the first word (s)") ap.add_argument("--tail", type=float, default=2.0, help="silence after the last scene (s)") args = ap.parse_args() # A project can pin its own spacing in narration.json: {"timeline": {"gap": 1.0, ...}}. pinned = json.loads((args.project / "narration.json").read_text()).get("timeline", {}) for k in ("gap", "lead_in", "tail"): if k in pinned and getattr(args, k) == ap.get_default(k): setattr(args, k, pinned[k]) cfg = json.loads((args.project / "narration.json").read_text()) timings = json.loads((args.project / "audio/timings.json").read_text()) words, scenes, inputs, filters = [], {}, [], [] t = args.lead_in for i, scene in enumerate(cfg["scenes"]): sid = scene["id"] timing = timings[sid] for w in timing["words"]: words.append({"word": w["word"], "start": round(t + w["start"], 3), "end": round(t + w["end"], 3)}) scenes[sid] = {"start": round(t, 3), "end": round(t + timing["duration"], 3)} inputs += ["-i", str(args.project / "audio" / f"{sid}.mp3")] filters.append(f"[{i}:a]aresample=48000,adelay={int(t * 1000)}:all=1[a{i}]") t += timing["duration"] + args.gap total = t - args.gap + args.tail mix = "".join(f"[a{i}]" for i in range(len(cfg["scenes"]))) filters.append(f"{mix}amix=inputs={len(cfg['scenes'])}:normalize=0,apad=whole_dur={total:.3f}[out]") scratch = [s["id"] for s in cfg["scenes"] if timings[s["id"]].get("scratch")] tag = ["-metadata", f"comment=scratch narration: {' '.join(scratch)}"] if scratch else [] out = args.project / "audio/narration.wav" subprocess.run(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex", ";".join(filters), "-map", "[out]", "-ac", "1", "-ar", "48000", *tag, str(out)], check=True) marked = f", scratch: {json.dumps(scratch)}" if scratch else "" (args.project / "words.js").write_text( "// Generated by video/scripts/timeline.py. Do not edit.\n" f"window.WORDS = {json.dumps(words, ensure_ascii=False)};\n" f"window.SCENES = {json.dumps(scenes)};\n" f"window.NARRATION = {{duration: {total:.3f}{marked}}};\n" f"window.CAPTIONS = {json.dumps(caption_data(words), ensure_ascii=False)};\n" ) print(f"{out} {total:.1f}s {len(words)} words scenes: {', '.join(scenes)}" + (f" scratch voice: {', '.join(scratch)}" if scratch else "")) if __name__ == "__main__": main()