diff --git a/.claude/skills/make-explainer-video/SKILL.md b/.claude/skills/make-explainer-video/SKILL.md index 1d391f8..91793e2 100644 --- a/.claude/skills/make-explainer-video/SKILL.md +++ b/.claude/skills/make-explainer-video/SKILL.md @@ -35,7 +35,7 @@ On a fresh checkout or install, run `studio setup --check`. If it exits non-zero - Edit `brand/` for house-style changes, never `episodes//assets/brand/` (a synced copy). 5. **Review**: `studio preview ` opens HyperFrames Studio with narration. For a look without a browser, run `npx hyperframes snapshot episodes/ --at 12.5,40` and view the frames. 6. **Check**: `studio check ` must pass with 0 errors (lint, layout, contrast) before any render. -7. **Render**: `studio render --draft` for a review copy, then `studio render ` for `out/.mp4`, `.srt` and `.chapters.txt`. Only changed scenes re-render, and `--force [ids]` forces them. A full render takes about real time. Optionally run `studio thumbnail ` after editing `thumbnail/thumbnail.html`. +7. **Render**: `studio render --draft` for a review copy, then `studio render ` for `out/.mp4`, `.srt` and `.chapters.txt`. `render` then runs `studio verify` on the master (loudness, black frames, silences, speech vs script, captions, chapters) and exits 1 on errors; `--no-verify` skips it. Only changed scenes re-render, and `--force [ids]` forces them. A full render takes about real time. Optionally run `studio thumbnail ` after editing `thumbnail/thumbnail.html`. ## Voice diff --git a/AGENTS.md b/AGENTS.md index b340def..ae49fd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ uv run studio status # word counts, est vs actual length, cues p uv run studio preview # HyperFrames Studio in the browser (with narration) uv run studio check # lint + layout + contrast audits; must pass uv run studio render --draft # fast review copy -> out/-draft.mp4 -uv run studio render # final -> out/.mp4 + .srt + .chapters.txt +uv run studio render # final -> out/.mp4 + .srt + .chapters.txt, then verifies it (--no-verify) +uv run studio verify # QA the master: loudness, black frames, silences, speech vs script, captions uv run studio music --prompt "..." # ElevenLabs Music bed sized to the synced episode (after sync) uv run studio thumbnail # out/-thumbnail.jpg (edit thumbnail/thumbnail.html) uv run studio archive # copy the master, captions and thumbnail to $STUDIO_ARCHIVE (rclone) diff --git a/docs/commands.md b/docs/commands.md index 3175b33..6d93867 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -74,7 +74,7 @@ Runs `sync`, then `hyperframes check`: lint, layout and contrast audits. Exits n ## `render` ```bash -uv run studio render [--draft] [--force [ID ...]] +uv run studio render [--draft] [--force [ID ...]] [--no-verify] ``` Runs `sync`, renders changed scenes, assembles and masters the episode into `out/`. @@ -84,9 +84,30 @@ Runs `sync`, renders changed scenes, assembles and masters the episode into `out | `--draft` | Fast, lower-quality render to `out/-draft.mp4` | | `--force` | Re-render every scene | | `--force ID …` | Re-render only the named scenes | +| `--no-verify` | Skip the [`verify`](#verify) pass that otherwise runs on the finished master | Requires ffmpeg. See [Rendering and publishing](rendering.md). +## `verify` + +```bash +uv run studio verify [--draft] [--no-speech] +``` + +QA of the file you would upload (`out/.mp4`), also run at the end of `render`. It checks the +resolution, frame rate and length against `timing.json`; integrated loudness and true peak against +`loudness` and `true_peak`; black frames (error) and long frozen video (warning); silences longer than the +script's own pauses; that a transcript of the mastered audio matches the script (so the music bed isn't +burying the voice, and it lists the words it didn't hear); and that the `.srt` and `.chapters.txt` exist +with chapters that meet YouTube's rules. Exits 1 if anything is an error; warnings don't fail it. + +| Flag | Meaning | +| --- | --- | +| `--draft` | Check `out/-draft.mp4`, without the captions and chapters | +| `--no-speech` | Skip the transcription check, the slowest step | + +It reads `timing.json` and the existing render, so it doesn't re-sync: after editing the script, `render` first. + ## `thumbnail` ```bash diff --git a/docs/rendering.md b/docs/rendering.md index 4f8d7a6..c562c2c 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -36,6 +36,14 @@ The cache lives in `episodes//build/` and is safe to delete. - `studio render` warns if the chapters don't meet YouTube's rules: at least 3 chapters, each at least 10 seconds. +## Verification + +After mastering, `render` runs `studio verify` on the file it just wrote and exits 1 if anything is +an error: wrong resolution, frame rate or length, loudness or true peak off target, black frames, a +silence longer than the script plans, a transcript that no longer matches the script (the music bed is +too loud), or missing captions and chapters. Skip it with `--no-verify`, or run it alone later with +`uv run studio verify `. + ## Outputs | File | Use | diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..5cec752 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,216 @@ +"""verify: ffmpeg-backed checks on synthetic masters (a black segment, a silent gap, a too-quiet file).""" + +import shutil +import subprocess +import sys +import textwrap + +import pytest + +from studio import cli, verify +from studio.script import parse + +needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="needs ffmpeg") + +W, H, FPS = 320, 180, 30 + + +def make_master(path, secs=12.0, black=None, silent=None, level=-14.0, size=(W, H)): + """A noisy-video, tone-audio mp4. black/silent are (start, end) spans; level is the tone's dB gain.""" + video = f"testsrc2=s={size[0]}x{size[1]}:r={FPS}:d={secs}" + vf = "null" + if black: + vf = f"drawbox=x=0:y=0:w=iw:h=ih:color=black:t=fill:enable='between(t,{black[0]},{black[1]})'" + af = f"volume={level}dB" + if silent: + af += f",volume=enable='between(t,{silent[0]},{silent[1]})':volume=0" + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", video, "-f", "lavfi", + "-i", f"sine=f=300:r=48000:d={secs}", "-vf", vf, "-af", af, "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-c:a", "aac", str(path)], check=True) + return path + + +def timing(secs=12.0, scenes=None): + scenes = scenes or [{"id": "a", "start": 0.0, "duration": secs, "voStart": 0.5, "voEnd": secs - 1}] + return {"scenes": scenes, "width": W, "height": H, "fps": FPS, "duration": secs} + + +@pytest.fixture +def script(write_script): + return write_script("## a: A\nHello there.\n") + + +@needs_ffmpeg +def test_loudness_ok_and_too_quiet(tmp_path): + loud = tmp_path / "loud.mp4" + subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "sine=f=300:r=48000:d=8", "-af", + "loudnorm=I=-14:TP=-1.5", "-ar", "48000", str(loud)], check=True) + assert verify.check_loudness(loud, -14, -1.5).level == "ok" + quiet = make_master(tmp_path / "quiet.mp4", secs=8, level=-40) + found = verify.check_loudness(quiet, -14, -1.5) + assert found.level == "error" and "LUFS" in found.message + + +@needs_ffmpeg +def test_black_segment_is_an_error_but_a_scene_fade_is_not(tmp_path): + t = timing(12.0, [{"id": "a", "start": 0.0, "duration": 6.0, "voStart": 0, "voEnd": 5}, + {"id": "b", "start": 6.0, "duration": 6.0, "voStart": 0, "voEnd": 5}]) + mid = verify.check_video(make_master(tmp_path / "mid.mp4", black=(2.0, 4.0)), t) + assert [f.level for f in mid if f.name == "black frames"] == ["error"] + boundary = verify.check_video(make_master(tmp_path / "edge.mp4", black=(5.7, 6.3)), t) + assert [f.level for f in boundary if f.name == "black frames"] == ["ok"] + + +@needs_ffmpeg +def test_silent_gap_is_an_error_but_a_short_pause_is_not(tmp_path, script): + t = timing() + gap = verify.check_silence(make_master(tmp_path / "gap.mp4", silent=(3.0, 8.0)), script, t) + assert gap.level == "error" and "0:03" in gap.message + short = verify.check_silence(make_master(tmp_path / "short.mp4", silent=(3.0, 4.0)), script, t) + assert short.level == "ok" + + +@needs_ffmpeg +def test_silence_is_allowed_in_a_scene_without_narration(tmp_path, script): + t = timing(12.0, [{"id": "a", "start": 0.0, "duration": 4.0, "voStart": 0.5, "voEnd": 3.0}, + {"id": "b", "start": 4.0, "duration": 6.0, "voStart": 0, "voEnd": 0}, + {"id": "c", "start": 10.0, "duration": 2.0, "voStart": 0.5, "voEnd": 1.5}]) + assert verify.check_silence(make_master(tmp_path / "m.mp4", silent=(4.0, 10.0)), script, t).level == "ok" + + +@needs_ffmpeg +def test_format_checks_size_and_duration(tmp_path): + good = verify.check_format(make_master(tmp_path / "good.mp4"), timing()) + assert [f.level for f in good] == ["ok", "ok"] + wrong = verify.check_format(make_master(tmp_path / "wrong.mp4", size=(160, 90)), timing(9.0)) + assert [f.level for f in wrong] == ["error", "error"] + + +def test_speech_compares_the_transcript_with_the_script(tmp_path, monkeypatch, script): + monkeypatch.setattr(verify, "run", lambda *a, **k: "") + heard = lambda *words: [{"text": w, "start": 0, "end": 1} for w in words] # noqa: E731 + monkeypatch.setattr(verify.voice, "transcribe", lambda wav: heard("Hello", "there")) + assert verify.check_speech(tmp_path / "m.mp4", script).level == "ok" + monkeypatch.setattr(verify.voice, "transcribe", lambda wav: heard("Hello")) + found = verify.check_speech(tmp_path / "m.mp4", script) + assert found.level == "error" and "not heard: there" in found.message + monkeypatch.setattr(verify.voice, "transcribe", lambda wav: []) + assert verify.check_speech(tmp_path / "m.mp4", script).level == "error" + + +def test_speech_without_narration_is_ok(tmp_path, write_script): + silent = write_script("## a: A\n> duration: 3\n") + assert verify.check_speech(tmp_path / "m.mp4", silent).level == "ok" + + +def test_silence_limit_follows_the_script(write_script): + s = write_script("## a: A\nOne. {pause 3} Two.\n") + assert verify.silence_limit(s, {}) == pytest.approx(3.5) + + +def test_subtract_removes_allowed_spans(): + assert verify._subtract(0, 10, [(2, 4), (8, 12)]) == [(0, 2), (4, 8)] + assert verify._subtract(3, 4, [(0, 10)]) == [] + + +@pytest.fixture +def out_dir(tmp_path, monkeypatch): + monkeypatch.setattr(verify, "OUT", tmp_path) + return tmp_path + + +def chapter_timing(*lengths): + t, scenes = 0.0, [] + for i, n in enumerate(lengths): + scenes.append({"id": f"s{i}", "title": f"T{i}", "start": t, "duration": n}) + t += n + return {"scenes": scenes, "duration": t} + + +def test_deliverables(out_dir): + t = chapter_timing(20, 20, 20) + assert [f.level for f in verify.check_deliverables("ep", t)] == ["error", "error"] + (out_dir / "ep.srt").write_text("1\n", encoding="utf-8") + (out_dir / "ep.chapters.txt").write_text("0:00 T0\n0:20 T1\n0:40 T2\n", encoding="utf-8") + assert [f.level for f in verify.check_deliverables("ep", t)] == ["ok", "ok"] + assert verify.check_deliverables("ep", chapter_timing(20, 5))[1].level == "warn" + (out_dir / "ep.chapters.txt").write_text("0:05 T0\n", encoding="utf-8") + assert verify.check_deliverables("ep", t)[1].message == "the first chapter must start at 0:00" + assert verify.check_deliverables("ep", {**t, "format": "reel"})[1].level == "error" + + +def test_report_counts_errors(capsys): + n = verify.report([verify.Finding("ok", "a", "fine"), verify.Finding("warn", "b", "hmm"), + verify.Finding("error", "c", "bad")]) + assert n == 1 + out = capsys.readouterr().out + assert "ERROR c" in out and "1 error(s), 1 warning(s)" in out + + +def test_verify_master_needs_a_render(tmp_path, script, capsys): + with pytest.raises(SystemExit): + verify.verify_master(tmp_path / "ep", script, timing(), tmp_path / "missing.mp4") + assert "studio render" in capsys.readouterr().err + + +# ---------------------------------------------------------------- CLI + +@pytest.fixture +def episode(tmp_path, monkeypatch): + ep = tmp_path / "episodes" / "ep" + ep.mkdir(parents=True) + (ep / "script.md").write_text(textwrap.dedent("## a: A\nHello there.\n"), encoding="utf-8") + (tmp_path / "out").mkdir() + monkeypatch.setattr(cli, "EPISODES", tmp_path / "episodes") + monkeypatch.setattr("studio.common.EPISODES", tmp_path / "episodes") + monkeypatch.setattr(cli, "ROOT", tmp_path) + monkeypatch.setattr(cli, "OUT", tmp_path / "out") + monkeypatch.setattr(cli, "require_tools", lambda: None) + return ep + + +def studio(monkeypatch, *argv): + monkeypatch.setattr(sys, "argv", ["studio", *argv]) + cli.main() + + +def stub_verify(monkeypatch, *levels): + calls = [] + monkeypatch.setattr(cli.verify, "verify_master", + lambda *a, **k: calls.append(k) or [verify.Finding(lv, "x", "y") for lv in levels]) + return calls + + +def test_verify_command_exit_status(episode, monkeypatch): + (episode / "timing.json").write_text('{"scenes": []}', encoding="utf-8") + calls = stub_verify(monkeypatch, "ok", "warn") + studio(monkeypatch, "verify", "ep", "--draft", "--no-speech") # warnings don't fail + assert calls == [{"speech": False, "deliverables": False}] + stub_verify(monkeypatch, "ok", "error") + with pytest.raises(SystemExit) as exit: + studio(monkeypatch, "verify", "ep") + assert exit.value.code == 1 + + +def test_verify_command_needs_timing(episode, monkeypatch, capsys): + with pytest.raises(SystemExit): + studio(monkeypatch, "verify", "ep") + assert "studio sync ep" in capsys.readouterr().err + + +@pytest.mark.parametrize("flags,verified", [([], True), (["--no-verify"], False)]) +def test_render_verifies_unless_skipped(episode, monkeypatch, flags, verified): + monkeypatch.setattr(cli, "_sync", lambda slug: (episode, parse(episode / "script.md"), {})) + monkeypatch.setattr(cli.renderer, "render_episode", lambda *a, **k: episode / "master.mp4") + calls = stub_verify(monkeypatch, "ok") + studio(monkeypatch, "render", "ep", *flags) + assert bool(calls) is verified + + +def test_render_fails_when_verification_finds_errors(episode, monkeypatch): + monkeypatch.setattr(cli, "_sync", lambda slug: (episode, parse(episode / "script.md"), {})) + monkeypatch.setattr(cli.renderer, "render_episode", lambda *a, **k: episode / "master.mp4") + stub_verify(monkeypatch, "error") + with pytest.raises(SystemExit) as exit: + studio(monkeypatch, "render", "ep") + assert exit.value.code == 1 diff --git a/tools/studio/cli.py b/tools/studio/cli.py index 11fa574..d8fb2c0 100644 --- a/tools/studio/cli.py +++ b/tools/studio/cli.py @@ -7,6 +7,7 @@ uv run studio preview open HyperFrames Studio on the episode uv run studio check HyperFrames lint + layout/contrast audits uv run studio render [--draft] render changed scenes, assemble, master -> out/ + uv run studio verify QA the finished master: loudness, black frames, silences, speech, captions uv run studio thumbnail screenshot thumbnail/thumbnail.html -> out/ uv run studio voices audition Kokoro blends or Chatterbox clips on one scene -> out/voice-samples/ uv run studio music --prompt generate an instrumental music bed (ElevenLabs Music) sized to the episode @@ -30,7 +31,7 @@ import soundfile as sf from . import build as builder -from . import music, setup, skill, voice +from . import music, setup, skill, verify, voice from . import render as renderer from .common import ( EPISODES, @@ -128,7 +129,26 @@ def cmd_render(a): require_tools() ep, script, timing = _sync(a.slug) info("rendering") - renderer.render_episode(ep, script, timing, a.draft, set(a.force) if a.force else None, a.force is not None) + master = renderer.render_episode(ep, script, timing, a.draft, set(a.force) if a.force else None, + a.force is not None) + if not a.no_verify: + info("\nverifying") + if verify.report(verify.verify_master(ep, script, timing, master, deliverables=not a.draft)): + raise SystemExit(1) + + +def cmd_verify(a): + require_tools() + ep = episode_dir(a.slug) + script = parse(ep / "script.md") + if not (ep / "timing.json").exists(): + die(f"no timing.json: run `studio sync {a.slug}` and `studio render {a.slug}` first") + timing = json.loads((ep / "timing.json").read_text(encoding="utf-8")) + master = OUT / f"{a.slug}{'-draft' if a.draft else ''}.mp4" + info(f"verifying {master.relative_to(ROOT)}") + findings = verify.verify_master(ep, script, timing, master, speech=not a.no_speech, deliverables=not a.draft) + if verify.report(findings): + raise SystemExit(1) def find_browser() -> str: @@ -299,8 +319,15 @@ def main(): s.add_argument("slug") s.add_argument("--draft", action="store_true", help="fast draft quality -> out/-draft.mp4") s.add_argument("--force", nargs="*", metavar="SCENE", help="re-render these scenes (or all, if none named)") + s.add_argument("--no-verify", action="store_true", help="skip the automated QA of the finished master") s.set_defaults(fn=cmd_render) + s = sub.add_parser("verify", help="QA the finished master in out/: loudness, black frames, silences, speech, captions") + s.add_argument("slug") + s.add_argument("--draft", action="store_true", help="check out/-draft.mp4 (skips the captions and chapters)") + s.add_argument("--no-speech", action="store_true", help="skip the transcription check (the slowest step)") + s.set_defaults(fn=cmd_verify) + s = sub.add_parser("thumbnail", help="render thumbnail/thumbnail.html to out/-thumbnail.jpg") s.add_argument("slug") s.set_defaults(fn=cmd_thumbnail) diff --git a/tools/studio/verify.py b/tools/studio/verify.py new file mode 100644 index 0000000..b496913 --- /dev/null +++ b/tools/studio/verify.py @@ -0,0 +1,240 @@ +"""Automated QA of the finished master: loudness, black/frozen frames, silences, format, speech, deliverables.""" + +from __future__ import annotations + +import difflib +import json +import re +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from . import voice +from .common import OUT, SAMPLE_RATE, die, info, run +from .render import chapters +from .script import Script + +LOUDNESS_TOL = 1.0 # LU either side of the `loudness` target +TRUE_PEAK_TOL = 0.3 # dB above the `true_peak` ceiling +BLACK_MIN = 0.5 # seconds of black before it is reported +FREEZE_MIN = 15.0 # seconds of identical frames before it is reported (a held quote or stat is normal) +FADE_WINDOW = 0.6 # black this close to a scene boundary is the exit fade, not a fault +SILENCE_DB = -50 +SILENCE_SLACK = 0.5 # seconds beyond the longest planned gap +SPEECH_WARN, SPEECH_ERROR = 0.90, 0.75 +SPEECH_LISTED = 10 + + +@dataclass +class Finding: + level: str # ok | warn | error + name: str + message: str + + +def _ffmpeg(path: Path, *args: str) -> str: + """Run an analysis pass over `path` and return ffmpeg's log (the filters report on stderr).""" + proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(path), *args, "-f", "null", "-"], + capture_output=True, text=True, encoding="utf-8", errors="replace") + if proc.returncode != 0: + die(f"ffmpeg could not read {path.name}:\n" + "\n".join(proc.stderr.strip().splitlines()[-10:])) + return proc.stderr + + +# ---------------------------------------------------------------- loudness + +def check_loudness(path: Path, target: float, ceiling: float) -> Finding: + log = _ffmpeg(path, "-vn", "-af", "ebur128=peak=true") + summary = log[log.rindex("Summary:"):] if "Summary:" in log else "" + i = re.search(r"\bI:\s+(-?[\d.]+|-inf) LUFS", summary) + tp = re.search(r"Peak:\s+(-?[\d.]+|-inf) dBFS", summary) + if not i or not tp: + return Finding("error", "loudness", "could not measure (no audio?)") + loud, peak = float(i.group(1)), float(tp.group(1)) + text = f"{loud:.1f} LUFS (target {target:g}), true peak {peak:.1f} dBTP (ceiling {ceiling:g})" + if abs(loud - target) > LOUDNESS_TOL or peak > ceiling + TRUE_PEAK_TOL: + return Finding("error", "loudness", text) + return Finding("ok", "loudness", text) + + +# ---------------------------------------------------------------- video + +def _boundaries(timing: dict) -> list[float]: + return [s["start"] for s in timing["scenes"]] + [timing["duration"]] + + +def _hms(t: float) -> str: + return f"{int(t) // 60}:{t % 60:04.1f}" + + +def check_video(path: Path, timing: dict) -> list[Finding]: + """Black frames (error) and frozen video (warning) away from scene boundaries.""" + log = _ffmpeg(path, "-an", "-vf", f"blackdetect=d={BLACK_MIN}:pix_th=0.10,freezedetect=n=-50dB:d={FREEZE_MIN}") + bounds = _boundaries(timing) + + def at_boundary(a: float, b: float) -> bool: + return any(abs(a - t) <= FADE_WINDOW and abs(b - t) <= FADE_WINDOW for t in bounds) + + black = [(float(a), float(b)) for a, b in re.findall(r"black_start:([\d.]+) black_end:([\d.]+)", log)] + black = [(a, b) for a, b in black if not at_boundary(a, b)] + starts = [float(x) for x in re.findall(r"freeze_start: ([\d.]+)", log)] + durs = [float(x) for x in re.findall(r"freeze_duration: ([\d.]+)", log)] + frozen = [(a, a + d) for a, d in zip(starts, durs)] + + out = [] + if black: + spans = ", ".join(f"{_hms(a)}-{_hms(b)}" for a, b in black[:5]) + out.append(Finding("error", "black frames", f"{len(black)} stretch(es) of black: {spans}")) + else: + out.append(Finding("ok", "black frames", "none")) + if frozen: + spans = ", ".join(f"{_hms(a)}-{_hms(b)}" for a, b in frozen[:5]) + out.append(Finding("warn", "frozen video", f"{len(frozen)} stretch(es) frozen for {FREEZE_MIN:g}s+: {spans}")) + else: + out.append(Finding("ok", "frozen video", f"nothing frozen for {FREEZE_MIN:g}s+")) + return out + + +# ---------------------------------------------------------------- silence + +def _subtract(a: float, b: float, holes: list[tuple[float, float]]) -> list[tuple[float, float]]: + pieces = [(a, b)] + for x, y in holes: + nxt = [] + for p, q in pieces: + if y <= p or x >= q: + nxt.append((p, q)) + continue + if x > p: + nxt.append((p, x)) + if y < q: + nxt.append((y, q)) + pieces = nxt + return pieces + + +def silence_limit(script: Script, timing: dict) -> float: + """Longest silence the script itself plans: a {pause}, a paragraph gap, or one scene's pads, plus slack.""" + gaps = [c.gap_after for sc in script.scenes for c in sc.chunks] + planned = max(gaps + [float(script.setting("paragraph_gap")), + float(script.setting("pad_in")) + float(script.setting("pad_out"))]) + return planned + SILENCE_SLACK + + +def check_silence(path: Path, script: Script, timing: dict) -> Finding: + limit = silence_limit(script, timing) + log = _ffmpeg(path, "-vn", "-af", f"silencedetect=noise={SILENCE_DB}dB:d={limit:g}") + starts = [float(x) for x in re.findall(r"silence_start: (-?[\d.]+)", log)] + ends = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", log)] + ends += [timing["duration"]] * (len(starts) - len(ends)) # silent to the very end + # Scenes without narration are allowed to be quiet (their own pads still count). + quiet = [(s["start"], s["start"] + s["duration"]) for s in timing["scenes"] if s["voEnd"] <= s["voStart"]] + bad = [] + for a, b in zip(starts, ends): + bad += [(p, q) for p, q in _subtract(max(a, 0.0), b, quiet) if q - p > limit] + if bad: + spans = ", ".join(f"{_hms(p)}-{_hms(q)} ({q - p:.1f}s)" for p, q in bad[:5]) + return Finding("error", "silence", f"quiet for longer than {limit:.1f}s: {spans}") + return Finding("ok", "silence", f"no gap longer than {limit:.1f}s") + + +# ---------------------------------------------------------------- format + +def check_format(path: Path, timing: dict) -> list[Finding]: + proc = run(["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,width,height,r_frame_rate:format=duration", + "-of", "json", str(path)]) + probe = json.loads(proc) + video = next((s for s in probe["streams"] if s["codec_type"] == "video"), None) + audio = next((s for s in probe["streams"] if s["codec_type"] == "audio"), None) + out = [] + if not video: + return [Finding("error", "format", "no video stream")] + num, den = (int(x) for x in video["r_frame_rate"].split("/")) + fps = num / den + want = f"{timing['width']}x{timing['height']} @ {timing['fps']} fps" + got = f"{video['width']}x{video['height']} @ {fps:g} fps" + ok = (video["width"], video["height"]) == (timing["width"], timing["height"]) and abs(fps - timing["fps"]) < 0.01 + out.append(Finding("ok" if ok else "error", "format", got if ok else f"{got}, expected {want}")) + dur, frame = float(probe["format"]["duration"]), 1 / timing["fps"] + text = f"{dur:.2f}s (timing.json says {timing['duration']:.2f}s)" + out.append(Finding("ok" if abs(dur - timing["duration"]) <= 3 * frame else "error", "duration", text)) + if not audio: + out.append(Finding("error", "audio stream", "missing")) + return out + + +# ---------------------------------------------------------------- speech + +def _words(script: Script) -> list[str]: + return [n for sc in script.scenes for w in sc.words if (n := voice._norm(w))] + + +def check_speech(path: Path, script: Script) -> Finding: + """Transcribe the mastered audio and compare it with the script: does the voice survive the music bed?""" + want = _words(script) + if not want: + return Finding("ok", "speech", "no narration to check") + with tempfile.TemporaryDirectory() as tmp: + wav = Path(tmp) / "master.wav" + run(["ffmpeg", "-v", "error", "-y", "-i", str(path), "-vn", "-ac", "1", "-ar", str(SAMPLE_RATE), str(wav)]) + heard = [n for w in voice.transcribe(wav) if (n := voice._norm(w["text"]))] + match = difflib.SequenceMatcher(None, want, heard, autojunk=False) + hit = [False] * len(want) + for blk in match.get_matching_blocks(): + for k in range(blk.size): + hit[blk.a + k] = True + ratio = sum(hit) / len(want) + text = f"{ratio:.0%} of {len(want)} script words heard" + level = "error" if ratio < SPEECH_ERROR else "warn" if ratio < SPEECH_WARN else "ok" + if level != "ok": + missed = [w for w, h in zip(want, hit) if not h] + text += "; not heard: " + " ".join(missed[:SPEECH_LISTED]) + (" ..." if len(missed) > SPEECH_LISTED else "") + return Finding(level, "speech", text) + + +# ---------------------------------------------------------------- deliverables + +def check_deliverables(slug: str, timing: dict) -> list[Finding]: + out = [] + srt, chap = OUT / f"{slug}.srt", OUT / f"{slug}.chapters.txt" + out.append(Finding("ok" if srt.exists() and srt.stat().st_size else "error", "captions", f"out/{srt.name}")) + if not chap.exists() or not chap.stat().st_size: + return out + [Finding("error", "chapters", f"out/{chap.name} missing")] + lines = chap.read_text(encoding="utf-8").splitlines() + if not lines[0].startswith("0:00"): + out.append(Finding("error", "chapters", "the first chapter must start at 0:00")) + elif timing.get("format") == "reel": + out.append(Finding("ok", "chapters", "reel (chapters are a YouTube long-form feature)")) + else: + problems = chapters(timing)[1] + out.append(Finding("warn", "chapters", "; ".join(problems)) if problems + else Finding("ok", "chapters", f"{len(lines)} chapters, first at 0:00, each 10s or longer")) + return out + + +# ---------------------------------------------------------------- top level + +def verify_master(ep: Path, script: Script, timing: dict, master: Path, speech: bool = True, + deliverables: bool = True) -> list[Finding]: + if not master.exists(): + die(f"no master at {master}: run `studio render {ep.name}` first") + findings = check_format(master, timing) + findings.append(check_loudness(master, float(script.setting("loudness")), float(script.setting("true_peak")))) + findings += check_video(master, timing) + findings.append(check_silence(master, script, timing)) + if speech: + findings.append(check_speech(master, script)) + if deliverables: + findings += check_deliverables(ep.name, timing) + return findings + + +def report(findings: list[Finding]) -> int: + """Print one line per finding, `setup --check` style. Returns the number of errors.""" + for f in findings: + info(f" {f.level.replace('error', 'ERROR'):<6} {f.name:<14} {f.message}") + errors = sum(f.level == "error" for f in findings) + warns = sum(f.level == "warn" for f in findings) + info(f"\n {errors} error(s), {warns} warning(s)") + return errors