Architecture
One run is a single pass: input guard -> align -> analyse -> four stages -> edge
finalization -> one render -> machine verification -> visuals overlays -> report.
run_pipeline in src/talkinghead/pipeline.py is the whole orchestration; every
stage below is a function it calls in order.
Two things make the run reproducible rather than merely repeatable:
- Nothing is computed twice. Deterministic results (transcript, audio analysis, render) live in content-addressed caches; judged results are memoised per stage and every individual LLM sample is stored.
- The plan is the deliverable.
plan.jsonholds the edit decision list, so a run can be re-rendered exactly without re-deciding anything (talkinghead render plan.json --out out.mp4,src/talkinghead/render.py).
Where state lives
| store | path | keyed on | holds |
|---|---|---|---|
| cache (global) | ~/.cache/talkinghead (TALKINGHEAD_CACHE), src/talkinghead/store.py |
<sha256 of the content>/<kind>-<stable_key>, 24-hex key from the sorted JSON of the params + a version |
transcript, silences, moans, mouth_noises, unclassified_voice (voiced speech-level audio no word covers), speech_mask, speech_probe |
| run dir | <video stem>.talkinghead/ next to the video, or --out-dir (store.RunDir) |
one per recording: run.json records the source sha256 and bind refuses a different picture, so two cooks never share a directory |
plan.json, render.json, decisions.json, run.json, preferences.md, audio/, llm/, stages/, verify/, plus master.mov and align.json when --audio was given |
| stage memos | <run>/stages/<stage>.json |
stable_key(inputs, STAGE_VERSION): transcription + the config fields that stage reads (pipeline.STAGE_CONFIG_KEYS) + guidelines + judge name + topic + decisions + preferences (only when non-empty) |
the stage’s cuts and notes, plus stages/<stage>.superseded.json when a re-key replaced one |
| LLM samples | <run>/llm/<prompt sha>.prompt.txt and <prompt sha>-<i>.json (judge.LLMCache) |
the prompt and the judge name, so switching models never replays another model’s answers | every prompt and every sampled answer |
STAGE_VERSION and VISUALS_STAGE_VERSION (pipeline.py) and ANALYSIS_VERSION
(analysis.py) are bumped when a stage’s logic changes, which invalidates its memos
without touching the input hashes. --fresh drops stage memos and the render memo for
one run; LLM samples stay cached, because resampling a nondeterministic model to get a
different verdict is the opposite of what a rerun should do.
Stage memos key on the config a stage READS, not on the whole dict
(pipeline.STAGE_CONFIG_KEYS; tests/v2/test_stages.py checks the lists against the
config.<field> reads in each stage module). A waveform or probe setting therefore cannot
recompute a judge’s work, and a pause policy change re-derives the pauses without asking
the judge again. The stages not listed (visuals) keep the full dict. When a memo IS
replaced because its key moved, the previous file is copied to
stages/<stage>.superseded.json first and the stage is named in the report’s stage notes:
the memos are hand-editable, so a re-key must not discard manual cuts without a trace.
The stages
| # | stage | decides | driver | writes |
|---|---|---|---|---|
| 0 | input guard | video/audio duration agreement, or the actual audio duration in audio-only mode | deterministic | - |
| 1 | align (only with --audio) |
where the external recording sits on the video timeline, plus its clock drift | deterministic | master.mov, align.json |
| 2 | analyse | words, native sound events, protected laughter, silences, boundaries and acoustic noise candidates | deterministic | audio/audio-16k.wav + cache entries |
| 3 | structure | the true start (cold open) and the outro sentence | judge, supermajority | stage memo |
| 4 | retakes | which abandoned attempts and marked restarts are removed | judge, two passes | stage memo |
| 5 | content | which whole sentences do not earn their runtime (and a coherence veto) | judge + coherence pass | stage memo |
| 6 | pauses | pause trims, fillers, similarity-detected restarts, mouth noises | deterministic | stage memo |
| 6b | restructure (opt-in: restructure = true) |
topic blocks: which have no payoff (dropped), which open the cut (moved first) | judge: one sample per window, then one vote | stage memo, plan.json layout |
| 6c | polish (on by default; polish = false opts out) |
gap tightening between kept words (deterministic); word spans a judge names as disfluent; output pass: fillers, repeats and fragment restarts the render still has | judge per window, k-vote agreement (gaps need no judge); output pass judges one batch per round | stage memo, one per output round |
| 7 | edges | final span of every cut | deterministic | plan.json |
| 8 | render | picture and mastered audio, or mastered audio alone | deterministic | final.mp4 or final.wav, render.json |
| 9 | verify | did the render keep the plan’s promises | deterministic, declarative checks | verify/facts.json |
| 10 | visuals | what to overlay, where, from which asset | judge | final-visuals.mp4, verify/visuals/ |
| 11 | report | - | deterministic | report.md |
0. Input guard
Video analysis calls transcribe.check_av_mismatch before anything else.
If the video and audio durations differ by more than Config.av_mismatch_tolerance, the run stops unless --allow-av-mismatch is supplied.
Audio-only analysis measures the real audio stream and does not manufacture a video duration or picture.
1. Align
src/talkinghead/align.py (ported from the retired audiolign). align() measures and
then remuxes; analyze() measures only. The measurement is a coarse pass (30 ms RMS
envelopes, full cross-correlation) followed by GCC-PHAT on 30 s windows spread across
the timeline, median-filtered and fitted with a line whose slope is the drift in ppm.
The video stream is copied untouched unless a display rotation has to be baked into the
pixels; the external audio is encoded to AAC 256k. --fix-drift adds an atempo
time-stretch of 1/(1+drift) so the recording’s clock matches the video’s, and the tail is
padded when the audio runs out first.
In a run, the result is master.mov plus align.json in the run dir, and every later
stage reads the master as its single source. The memo key is (video sha256, audio
sha256, fix_drift).
2. Analyse
Still in analysis.analyze, still no LLM:
transcribe.prepare_audioextracts canonical 16 kHz mono PCM once to<run>/audio/audio-16k.wavand caches a structuredTranscript. Three backends: Scribe v2 (through OpenRouter), Parakeet and Whisper. The transcriber has no built-in default: it is named byTALKINGHEAD_TRANSCRIBER,--transcriberor the config file. Scribe splits a recording longer than one OpenRouter request into overlapping chunks cut at silences.plan_scribe_chunksuses the fewest chunks, and each word is kept from the chunk that owns its midpoint. The transcript includes words and native audio events rather than dropping non-word sounds. Saved Scribe responses replay only after their original request audio matches the current canonical PCM, with response identity included in the replay cache version. Parakeet’sTDT_EMISSION_DELAY_FRAMESoverride still joins its cache key.audio.envelope/audio.noise_floorgive the dB and zero-crossing envelope and the file’s floor;transcribe.detect_silencesfinds silences from the waveform (a speech-referenced threshold, never from gaps between ASR words);audio.refine_word_boundsmoves each word’s edges to where the voice actually starts and stops.bursts.detect_voiced_burstsfinds vocalisations the ASR swallowed into word spans;noise.detect_mouth_noiseslabels smacks, breaths and throat clears with the dB + ZCR classifier calibrated indocs/noise-detector-spec.md.audio.unclassified_voicecollects the voiced, speech-level spans no refined word and no already-claimed event covers: audio no transcript accounts for, which no word-driven rule can see. They are UNCLASSIFIED rather than proven words (level and voicing gates pass a hum as easily as a word), so nothing promotes one: when a speech model is available it may only DEMOTE a span (audio.filter_masked, run once and only when there is a candidate), and probe text is evidence, never a cut.analysis.probe_unclassified_voicere-transcribes each surviving span (longest first, capped byspeech_probe_max, successful decodes cached) with the OTHER backend, so the report can name what was heard instead of only measuring it.
Everything lands in Context (context.py), which is frozen for the rest of the run.
Complete laughter, chuckles and giggles become Context.protected_spans, not noise cuts.
Unknown native sound events are kept conservatively; recognized sighs, breaths and mouth sounds remain removal candidates subject to the ordinary guards.
Protected intervals reach final edge placement so pause, noise and neighboring retake cuts cannot erase quieter parts of a laugh.
3. Structure (judge)
stages/structure.py. The candidates come from code: candidates.cold_open_candidates
is every sentence starting inside cold_open_window_sec (300 s), plus KEEP_HEAD;
outro_candidates is every sentence in the last outro_window_sec (120 s).
The judge never emits a timestamp - it picks a sentence id, and code turns that id into
the cut. judge.vote takes Config.votes (5) samples and vote_scalar needs
Config.agree (4) identical answers.
No supermajority raises NeedsDecision (context.py), which the CLI reports and exits 3
with the flag that answers it (--cold-open, --outro). A supplied answer is written to
decisions.json and returned as source: "user" from then on, so the question is asked
at most once per run dir. structure.run returns the cold-open and outro cuts.
4. Retakes (judge, two passes)
stages/retakes.py.
- Pass A sweeps the whole transcript once and aggregates the five samples by span
(
spans.cluster_spans) rather than by word, because per-word voting made agreeing samples with edges one word apart cancel each other out. - Pass B is mechanical detection followed by focused judgment:
markers.find_markersfinds every spoken restart marker, and the judge adjudicates each marker alone with its surrounding sentences (marker_votes3,marker_agree2). STRONG markers (an apology, “no wait”, “start over”) default to cut and require the judge to argue for keeping; WEAK markers (“I mean”, “actually”) keep the standing “when unsure, keep” default. The tiers and their evidence live ineditorial/guidelines.md.
Both passes produce Cuts with reason="retake" and the vote count in provenance.
Clear single-word and short within-sentence corrections are included even without a spoken restart marker.
The abandoned wording is removed while the corrected wording and shared subject remain.
Intentional emphasis, valid lists and meaningful contrasts are kept.
5. Content (judge + coherence)
stages/content.py. The judge grades the sentence table; a sentence is removed when
Config.agree of Config.votes samples name it. Removal is capped twice - per contiguous
passage (content_max_cut_sec, 30 s) and in total (content_max_fraction, 10 % of the
source) - and the weakest-voted passage is dropped first when the budget is exceeded.
Surviving cuts then go through coherence.build_coherence_prompt: a second judged pass
that can veto a cut or extend it, both by the same supermajority. --no-content skips the
whole stage.
6. Pauses (deterministic)
stages/pauses.py, the only fully mechanical stage. candidates.machinery_candidates
turns the calibrated derive pass into three kinds of cut: pause trims (interior pauses keep
a boundary-typed, length-scaled amount split across the two edges, leading and trailing
silence goes entirely), filler cuts for every hesitation token emitted as a word (min_filler_dur, default 0, sets an optional length floor), and restart cuts - an abandoned sentence that the next one
restates, found by token similarity. The mouth-noise cuts computed during analysis are
appended unchanged.
With voice isolation on, the silence the pauses stage and the dead-air check read comes from the isolated track (16 kHz, unrepaired, cached per source). The pauses memo keys on that silence; stages after it recompute only when their input cuts change.
A pause of raw length raw keeps min(raw - 0.02, cap(boundary), pause_keep + pause_keep_ratio * (raw - pause_above)). pause_keep (0.30 s) was chosen on a reference narrative cut, where
~0.15 s of room on each side of a join was the difference between a smooth splice and a clipped one; it is now the amount kept at the
trigger, and longer pauses keep pause_keep_ratio (0.25) of the excess. A fixed value made
every pause the same length, so an 11 s chapter break became 0.6 s.
The cap depends on the boundary the pause sits on (derive.pause_boundary, pause_cap):
mid-sentence 0.40 s, clause 0.45 s, sentence end 0.55 s, and a sentence end after a raw
pause of at least pause_topic_above (1.5 s) counts as a topic change, 0.80 s. The caps
sit near the speaker’s own p75-p90 natural pauses, so a topic change keeps a longer beat
than a hesitation.
6b. Restructure (judge, opt-in)
stages/restructure.py, enabled by restructure = true. The kept transcript (sentences no earlier cut
removes) is judged in windows of restructure_window_sec with restructure_context_sec of read-only context
each side, one sample per window. The windows’ answers partition the sentences into topic blocks. One vote over
the block list (restructure_votes samples, strict majority) drops a block only when it has no payoff. Up to
restructure_front blocks that stand without earlier context move to the front, Borda-ranked and majority-backed.
Dropped blocks become tangent cuts; an opener’s lead-in sentences become lead-in cuts. restructure_brief
is the owner’s brief, sent verbatim to both passes.
Only after refine_joins does restructure.reorder split the plan at each block boundary and
lay the openers first. A boundary is a seam, so it is placed at apply time (the stored block starts and
ends are never changed): the nearest sentence gap within six sentences where the sentence before ends a
thought (., ?, !, not a dash or an ellipsis), the sentence after opens one (not a lowercase
continuation), and no speaker’s unfinished sentence is split across a backchannel. A gap with a removed span
between its sentences is cut there, so no kept segment is split; otherwise the quietest frame is used, and only
where both pieces at that frame are at least 0.15 s. The plan then runs out of source order: the picture and
the audio join in list order, the join at a moved block’s old place is a crossfade, and
plan.provenance["blocks"] records each block’s output range, the source span it now owns (placed) and
how far its start moved (seam_moved). Board overlays refuse such a plan. The stage memo holds the blocks
with their decisions, so a hand-edited decision is honoured on replay.
6c. Polish (judge, on by default)
stages/polish.py, on unless the cook sets polish = false, runs after restructure on the kept material (the raw cuts plus
the block cuts). Two source-side parts, both returning cuts that the edge pass places with the rest:
- Gap tightening (no judge). The silence between two consecutive kept words is cut to a cap set by the boundary
it sits on, most specific first: block boundary (
polish_gap_block, 0.45 s), speaker change (polish_gap_speaker, 0.20 s), sentence end (polish_gap_sentence, 0.30 s), inside a sentence (polish_gap_within, 0.15 s). The cut leaves half the cap on each side. A gap is tightened only when both words are kept and no word-removing cut lies in it, and protected events inside it are kept whole. The gap cut has reasongap: it removes no words. - Disfluency judge. Kept sentences are judged in windows of
polish_window_secwithpolish_context_secof read-only context. The judge names word spans to remove (repeats, false starts, self-corrections, fillers, fragments, clutter exchanges, interrupting backchannels). Each window is sampledpolish_votestimes and a span is cut only whenpolish_agreesamples agree on it (spans.cluster_spans, narrow). Spans longer thanpolish_max_secare dropped. Cuts have reasonpolish, a judged word-removing reason that thejudged_removalscheck verifies.
The stage memo holds every cut with its reason, words and votes, so a hand-edited memo is honoured on replay. With
polish = false, no polish key reaches the config dict or the plan provenance, so a cook that opts out keeps its keys.
Polish knobs stay out of every other stage’s memo key (pipeline._stage_config): tuning polish re-keys only the polish
stages and the plan they produce, never the structure, retake, content, pause or visuals memos.
On a plan-only rebuild the polish judge is the one judge allowed to answer (--plan-only), since its memo is built there.
Output pass (residual.py, rounds in stages/polish.py)
The source transcript cannot show a stutter Scribe merged into one word (“I, I, I” comes back as one “I”), so after
each render the pipeline transcribes the render and scans that transcript (residual.scan) for three things. With voice isolation on, the transcript comes from an original-audio companion of the render, because the unit changes which words and stamps the transcriber returns; the isolated render is transcribed only for its word count.
- fillers: uh, um, erm, hmm, hm, mm, in any case or spelling;
- a 1-3 word phrase said twice in a row, after normalising case and punctuation (“I, I”, “it’s, it’s”);
- a fragment restarted: a token ending in a hyphen (or a 2-4 letter prefix) followed by a word that starts with it.
Each round’s findings go to the judge in one batched prompt (vote, polish_votes samples, polish_agree to cut),
with about 10 s of transcript around each finding and the flagged words in double brackets. The rules are the same text
the source pass uses (POLISH_KINDS, POLISH_NEVER). A confirmed finding maps to one main-source span
(residual.to_source) and is cut with reason polish and pass: output in its provenance: the first copy and the gap
before the kept copy, ending OUTPUT_KEPT_PAD before the kept word’s onset. A span may cross a join that runs forward
in the source (a gap cut sits there, and the cut runs through the removed silence). A span crossing a join that runs
backward (a moved block) is reported and not cut. Output-pass filler cuts settle their voiced edges like source fillers
(edges.settle_filler_edges); a filler the source transcript never named can still keep a tail, which the residual check reports.
A fragment restarted inside one token (“impl-implement”) is placed on the source audio, not the render. intraword.locate aligns the token’s onset within ALIGN_SEC (80 ms) of Scribe’s stamp, since the stamp can start and end tens of milliseconds off the sound. It then takes the restart as the candidate whose onset matches best (similarity at least MIN_SIMILARITY, 0.90) among those whose cut point is a closure at least MIN_DIP_DB (8 dB) below the loudest frame before it. A token that does not qualify is skipped, and the report says why: skipped: no restart in the audio (the match or the closure failed), skipped: too short for a restart, skipped: crosses a join, or skipped: too short (the cut would be below min_cut_sec). A skipped finding stays in the residual list. A broken-off fragment the output pass confirms (“impl-” in the render) keeps its confirmed span in the source token: the rest of the token is kept speech, never removed with the fragment.
These thresholds were set on one recording: four of its five stutters pass with their numbers steady across stamp offsets, the fifth is not placed, and about one ordinary word in ten also passes the onset test. A placement therefore needs Scribe’s X-Xy token and the judge’s confirmation first. Nothing guarantees that every stutter is removed or that no word is clipped: read the residual list and the placed cuts in the report.
The rounds run up to polish_output_rounds (default 2, 0 disables). Each round re-renders the plan, transcribes the
render again and stops early when a round confirms nothing. The round’s memo is keyed on the cuts it builds on, so a
plan-only rebuild replays it without transcribing. The final check residual_disfluencies (warn, never fatal) lists
findings the last render still shows that the judge did not keep, with output times. The report’s Polish section
lists every round, the skipped findings, the residuals and the judged-keep list.
A round runs one more time when the final render shows a repeat or restart that no round covered (its transcript changed after the cuts): one extra judged round at most, and only when polish_output_rounds is above 0. Plan-only replays that stored round.
The pass runs only when the render is transcribed. With --no-output-asr it is skipped, and the report says so.
7. Edges (deterministic)
edges.finalize_cuts receives the union of all four stages and is the only place cut
edges are finalized.
The pass merges overlapping cuts, protects kept speech, steers away from vocalisations, and places edges in genuine quiet.
Valley placement is followed by tight within-sentence repairs, completed-sentence tail retention and non-repair clause pacing.
Flow guards and optional frame snapping run before the final protected-event reconciliation and micro-filler safety check.
Within-sentence repair seams retain phoneme protection without adding sentence or clause pacing. Completed-sentence seams retain 80 ms of real source tail, with outward rounding on a frame grid. Protected sound intervals are reconciled after cut fusion and snapping, so preserving a laugh is not undone by a later pass.
Edge clearance depends on the cut. silence and noise cuts keep pad = pause_keep / 2
(0.15 s) from the neighbouring word, so a trimmed pause leaves room on both sides.
Word-removing cuts (content, retake, cold-open, outro, join, filler, tangent, lead-in) only keep
splice_pad (0.03 s) clear of kept voiced audio, because the join is crossfaded and the
edge is placed by ear-relevant quiet rather than by a fixed margin.
snap_to_valleys moves each such edge to the quietest envelope frame within
splice_valley_window (0.12 s) of its word boundary. Candidates sit on the frame grid
because the final snap_to_frames would otherwise move a hand-picked valley off its
minimum.
Scribe supplies native timestamps; local Parakeet stamps may be coarser, so the acoustic guard remains useful for either backend.
Within-sentence retakes cannot move inward of the abandoned word or acquire a clause/sentence pacing pause.
keep_sentence_tail retains the 80 ms completed-sentence source tail where that much source room exists, rounded outward for video.
keep_splice_quiet applies the existing 0.18 s clause pacing only to non-repair word cuts.
keep_splice_lead_in then moves a cut’s end earlier, through quiet audio only and never over a removed word, so splice_lead_in (0.06 s) of source sits before the next kept onset. refine_joins applies the same rule to the incoming edge.
Natural continuous-speech pauses are not shortened solely to force an 80 ms edit seam.
drop_voiced_fillers can then drop a filler cut of at most filler_keep_voiced_max
(default 0, so off) whose edge still sits on voiced audio, judged by joins.edge_state, the same
predicate verify’s join_edges uses: when enabled, a micro-filler glued into running speech is kept
because the tight splice its removal makes can be more noticeable than the filler.
Retake, content and join cuts stay even with a voiced edge, since they remove real words.
protect_words guards two kinds of kept audio, and the difference matters. Words are
always protected, and so are Context.voices: the voiced spans no word covers. That
protection is what the waveform buys the edit. On the reference narrative cut a 0.16 s
fragment had no transcript entry at all, and a pause trim spanning it was the only thing
standing between it and deletion. A silence or noise cut may SPLIT around such a span
(its whole job is removing non-speech, so eating voice is its own bug); an editorial cut
(content, retake, cold-open, outro, join, filler) only has its EDGES kept clear
of it, because splitting a passage the judge chose to remove would ship a fragment of it.
flow_guard counts a guarded span as speech too, or it would fuse two trims across one and
delete it. What an editorial cut swallows is reported instead (see unclassified_voice in
§9). Config.voice_guard turns the whole guard off.
Context.protected_spans is separate from the optional unidentified-voice guard.
It preserves full native laughter/unknown-event intervals for every mechanical cut reason, so a laugh is never
trimmed by a silence, filler, noise or join cut. A judged cut (JUDGED_REASONS: cold-open, outro, tangent,
lead-in, retake, content) is different: its span is a decision, so an event inside it goes too, and only its
two edges are refined. Cut fusion and frame snapping cannot bridge or trim the protected intervals of mechanical cuts.
A judged decision is also held: hold_judged puts back any word a judged cut removes (by coverage: the cut covers at
least half the word’s audio, the same test the judged_removals verify check uses) that an edge step had pulled out of the span. Fusing
cuts never loses a judged reason to a mechanical cut that comes first.
timeline.Plan.from_cuts then derives the kept segments from the merged cuts and
plan.json is written: cuts with reasons and provenance, the segment list, the
structural decisions, the anchors, and the provenance block (hashes, backend, floor,
effective config) that stage memos are keyed on.
8. Render (deterministic, once)
render.render_plan builds three ffmpeg runs: a picture pass (one CFR decode per source
run, fps then chained half-open select filters that drop the removed spans, frame-snapped),
an audio pass (segments decoded and joined in numpy, then the broadcast chain - highpass, compressor, de-esser, loudnorm to
Config.loudness_lufs - applied exactly once), then a stream-copy mux.
The picture pass avoids per-segment trim fan-out, which stalled on a native 4K HLG recording.
The branch queue was a suspected cause; the observed failure was no output growth for 20 minutes.
render.join_audio overlap-adds the segments with equal-power (sine/cosine) crossfades of
splice_crossfade_sec (0.020 s) at every join, instead of fading each segment to zero,
which dipped ~23 dB where both sides were voiced.
Each side reads a handle of removed source audio past its edge, half the crossfade, so the
two overlap across the cut and the rendered audio length still equals the plan’s; a handle
is clamped to half the removed span and half of each adjacent segment so it never reaches a
neighbour’s own audio.
Output boundaries are rounded from the running total, so rounding never accumulates, and
the program start and end get a 10 ms fade so the first and last sample cannot click.
The join is numpy rather than an atrim/concat graph because a graph of ~200 links is
known to hang. The split exists
because ffmpeg 9.0.1 emits digitally silent audio from a combined concat=v=1:a=1 graph
for some source regions.
Both passes use the same frame-snapped segment boundaries; audio is processed and encoded once.
docs/render-proof.md records why closed between intervals are unsafe: they remove one extra frame per grid-aligned cut.
Colour is probed from the sources (color.probe_color): an HLG/PQ source is re-encoded
as 10-bit with its own primaries/matrix/transfer and -map_metadata -1, so a Dolby
Vision record on the source cannot ride into an edited picture; an SDR source keeps the
libx264/yuv420p encode. No tone-map LUT is ever applied implicitly.
The render.json memo is keyed on the segment list, the encoder settings and
render.RENDERER_VERSION (the version of the render chain itself, which the plan and
settings cannot express): changing an unrelated config value must not re-encode a
finished render, but changing the chain must.
9. Verify (deterministic)
verify.collect_facts measures the finished file - container/video/audio duration, LUFS,
a fresh transcript of the render, join grades (joins.check_join), a two-sided source edge check (join_edges:
a chopped voiced word on either side of a cut, judged on the source), residual dead air
(verify.residual_dead_air: the source VAD silences that survive the plan; the gap budget
counts only the excess over the pause caps, so pauses the policy keeps on purpose never
count), pacing facts (verify.pacing_facts: kept pause lengths against the source’s), source-to-output audio timing (verify.measure_source_timing), and color preservation (verify.color_facts).
It writes the measurements to verify/facts.json.
The report’s ## Pacing section prints the pacing facts (kept pause lengths by boundary type, and the quiet each cut reason leaves at its seam) as facts for the editor’s ear, never a verdict.
verify.run_checks evaluates twelve declarative checks (CHECKS in verify.py). Fatal:
duration, av_sync, loudness, cold_open (first word within 0.5 s), residual_gaps,
render_silence (measured on the render; the mute threshold is below one 16-bit PCM quantization step), source_timing (a
sampled keep’s audio more than 60 ms from where the plan put it) and color (a source’s
HDR colour silently converted, including right tags on an 8-bit stream). Warnings: joins, join_edges,
source_similarity (a sample that no longer correlates with the source, which the
broadcast chain can legitimately cause) and unclassified_voice (voiced audio no word
covers that the plan removes, with the re-transcription’s text when one ran).
A measurement that could not be taken is never reported as a pass: source_timing says
not measured (and warns) when no keep is long enough, no window carries alignable audio,
or nothing decodes, and distinguishes that from a run that never had a source path to
measure. Any failing fatal check writes report.md with status FAILED and raises
PipelineError, which the CLI turns into exit 1. The visuals pass never runs after a fatal
check.
10. Visuals (judge, second pass)
visuals/stage.plan_visuals chooses anchors, cards and the chapter plan card, memoised
under VISUALS_STAGE_VERSION because it is judged and therefore must not be re-decided;
screenshot research (--no-research) and chapter selection are single-sample judge calls.
visuals/render.render_visuals composites the overlays in a second ffmpeg run over
final.mp4 with the audio stream copied, which is why every check above keeps its
meaning and why final.mp4 stays shippable without the overlays. The base’s colour is
probed: an SDR cut keeps the libx264/yuv420p encode, an HLG/PQ cut keeps its 10 bits and
its tags, and the SDR overlay assets are converted into that system with the cube from
color.write_sdr_to_hdr_cube (SDR white to BT.2408 graphics white) rather than tagged as
if they were already HDR. With no overlays the pass is a stream copy, so there is nothing
to convert.
visuals/verify.verify_visuals proves that promise: the audio is untouched and the
duration did not move, plus a contact sheet of frames at overlay midpoints and in clean
control gaps, all under verify/visuals/. --no-visuals skips the stage.
11. Report
report.build_report writes report.md: status, source and duration accounting,
decisions with their vote tallies, stage notes, per-marker retake decisions, how each
visual anchor got its asset, the verification table, every cut with its reason and votes,
and the render command. talkinghead inspect OUT_DIR prints it.
Judge adapters
judge.py defines one protocol (decide(prompt, schema) -> dict) and four
implementations: ShellJudge (any command: prompt on stdin, JSON on stdout; it leads every
prompt with the system prompt (role + guidelines) and the schema contract, and retries on invalid JSON),
ClaudeCodeJudge (headless claude -p, subscription, structured output),
FallbackJudge (switches to a second judge when the primary reports a usage limit) and
FakeJudge for tests. Config.judge selects the adapter; Config.model rewrites the
command’s --model token (judge.with_model) rather than replacing the command, so
provider pinning and other flags survive an override.
Judges are always asked to classify code-generated candidates or to pick ids from a table in front of them. No judge answer becomes a timestamp.