Case study
The Ninth Room pipeline
A local Python pipeline that turns raw footage into a finished episode in DaVinci Resolve.
- Role
- Sole engineer
- Period
- June 2026 to present
- Stack
- Python 3.9, ffmpeg, faster-whisper, Pillow, DaVinci Resolve scripting
- Links
- Repository
What it is
The Ninth Room is a YouTube channel: a family explores the world's most interesting places, and every episode finds the one thing that was not on the map. I host and narrate. This repository is the program that edits the episodes.
Raw footage goes into a work folder and one command runs the pipeline: it transcribes the takes locally with faster-whisper, picks the best take of each line, cuts dead space, designs the story, builds the timeline in DaVinci Resolve with cuts, transitions, brand cards and captions, and renders the finished episode. Every stage runs on the machine by default; the two paid cloud paths are opt-in extras, a flag that sends transcription to a hosted speech model when proper nouns matter and a pull from a licensed sound library. Editorial judgment runs as Claude Code subagents reading the transcript and the brief, and the numbers that gate a cut come from film studies I wrote up first.

How it is built
Python 3.9 on the interpreter macOS ships, plus ffmpeg, Pillow, and headless Chrome for the cards. The only service is a loopback-only engine, an HTTP API and media server on the machine itself, and there is no database: an episode is a folder of files, and every stage reads the files the stage before it wrote. The repository has 271 commits and 75 test files, run with the standard library's unittest.
The hard part was DaVinci Resolve. Its scripting API cannot be driven from outside the application reliably, so the pipeline installs a small Lua script inside Resolve that loops forever watching a spool directory. Python writes a Lua chunk into the inbox, the bridge runs it with Resolve's own objects and writes a result file, and Python reads it back. Every timeline import, proxy link, and render request goes through that one door.
def send(label: str, lua: str, timeout: float = 120.0) -> str:
"""Run a Lua chunk inside Resolve; return its string result.
The chunk sees the global `resolve` object and must `return` a string.
Raises BridgeError on ERROR results or timeout.
"""
if not alive():
raise BridgeError("bridge is not running — call ensure_bridge() first")
_seq[0] += 1
name = "%06d_%s.lua" % (_seq[0], label)
result = SPOOL / "outbox" / (name + ".result")
# _seq seeds from time%%100000 and wraps — a leftover result file from a
# previous day could satisfy a NEW command instantly (P5 review F11).
# The result must not exist before its command is spooled.
if result.exists():
result.unlink()
(SPOOL / "inbox").mkdir(parents=True, exist_ok=True)
(SPOOL / "inbox" / name).write_text(lua)
deadline = time.time() + timeout
while time.time() < deadline:
if result.exists():
time.sleep(0.2) # let the bridge finish writing
text = result.read_text()
head, _, body = text.partition("\n")
if head == "OK":
return body.strip()
raise BridgeError("%s failed in Resolve: %s" % (label, body.strip()))
time.sleep(0.3)
raise BridgeError("timeout after %.0fs waiting for %s" % (timeout, name))Decision. One function sends every command to Resolve, and the command is a file. A file-based spool survives the things a socket does not: Resolve blocking on a modal dialog, a render holding the API for an hour, the bridge script being restarted from the menu. Each command gets a sequence number, its own result file, and a deadline.
Measurement. The sequence seeds from the clock and wraps at 100,000, so a result file left over from a previous day could satisfy a new command instantly. That happened in review, which is why the result file is deleted before its command is spooled. The bridge signals completion by writing the result, and the reader waits 200 milliseconds after seeing it so a half-written file is never parsed.
What breaks if wrong. A timeline that imports twice, a render that reports done while Resolve is still writing, or a Python process that waits forever on a bridge that died. Each is a failure the spool's rules exist to prevent, and each is why a rule is there.

The bar a cut must clear
The shipped first cut was a transcript in shoot order: 82 beats, 78 percent of them purpose "build", one hook, one payoff, and a pace of 12.6 cuts a minute against the 18 to 22 that the channels I studied run. Nothing in the pipeline had checked. So the craft bar exists, and it is written down before the code.
# -*- coding: utf-8 -*-
"""The assembly craft bar — the cut's numbers, and the bar a cut must clear.
The cut is the least-checked artifact in the program (2026-08-28).
_run_script is hard-gated by schemas.script_notes and _run_coverage by
schemas.coverage_notes, but _run_editplan only checks that a file appeared.
What that bought us is the shipped hmns cut: a transcript in shoot order —
82 beats of which 64 (78%) are purpose "build", one hook, one stakes, one
payoff, zero beats marked peak, zero named techniques, 12.6 cuts/min flat
against 18.5 (Mark Rober), 21.6 (Kara & Nate) and 18.8 (Yes Theory) in
docs/film-studies/. This module is where the studies' measured numbers
finally gate something.
It is a NEW MODULE rather than more lines in schemas.py (1,933 lines
already) — the same reason docs/decisions-job-preconditions.md gives for
the predicate registry.
Two entry points, mirroring the coverage_notes/script_notes contract:
cut_metrics(plan, takes, broll) -> dict — numbers, NEVER gates
cut_notes(plan, takes, broll, brief) -> list — the bar; empty is pass
Both are PURE: parsed dicts in, numbers or sentences out; the caller reads
the files. Every check tolerates a plan mid-surgery — an absent field, an
unknown id or a wrong type is a note or a skip, never a raise.
The realized-pace estimate is APPROXIMATE exactly the way gear_change is.
Visual state changes are counted from the plan — each beat is one change,
each cover two (the cut away and the cut back), plus punch-ins and in-beat
cuts — and silence cuts are added later by pipeline/timeline.py
plan_beats, so the estimate compares the SHAPE of the declared ladder
against the plan, not the truth of the finished timeline.
"""Decision. The numbers that gate a cut live in one pure module with two entry points: one returns the metrics and never blocks, the other returns the notes a cut must clear and an empty list is a pass. Both take parsed dictionaries in and give numbers or sentences out, so they can be run on a plan that is half edited without raising.
Measurement. The thresholds come from three channels I studied and wrote up: 18.5, 21.6, and 18.8 cuts a minute. The shipped first cut measured 12.6. The pace estimate counts visual state changes in the plan (each beat one, each cover two, plus punch-ins and in-beat cuts) and is approximate on purpose; it compares the shape of the declared ladder against the plan, not the truth of the finished timeline.
What breaks if wrong. An episode ships flat and nobody can say why, because the only check was that a file appeared. The bar turns a taste argument into a number a subagent can be told to clear.
What it taught me
A pipeline is only as honest as its least-checked stage. The interesting engineering was not the transcription or the rendering; it was finding the one stage that had no gate and building the measurement it needed.