One Topic In, One Narrated Video Out
I built a production loop that takes a single topic and returns a finished, narrated 9:16 explainer video. It runs nine stages on its own and stops for me exactly once. This is how it works, and what its verifiers catch that nothing cheaper could.
The channel is a Vietnamese explainer series in a hand-drawn doodle style, published vertical for TikTok and YouTube. Each episode is the same nine steps: research a topic, write a script, record a voice-over, plan the scenes, draw any missing art, compose it in a video renderer, and export. Done by hand that is an afternoon of tab-switching. I did not want a tool that writes the videos for me. I wanted a loop I could point at a topic and approve once — and have it stop and ask rather than ship something wrong.

- What:
harness/— a one-topic-in, one-video-out production loop for a Vietnamese explainer channel (Gấu Giải Ngố), with exactly one human approval step. - Size: 4,795 lines of Python against 4,382 lines of tests.
pytest tests/harness/ -qreports 268 passed. - Built in: 14 planned tasks, 41 commits over the first four days (21–24 July 2026), plus a later round of hardening.
- Output: a 245-second video, 1080×1920 at 30 fps, 44 shots, narrated in the channel's cloned voice and captioned word-by-word from forced alignment.
The constraint that shaped everything
One rule set the whole design: exactly one human gate. I approve the script before it is voiced, and nothing else. Everything upstream of that has to earn its own correctness, and everything downstream has to be safe to run unattended. That single decision is why the interesting part of this project is not the generation — it is the verification.
Two other principles followed from it. Context is a budget: each worker declares in code the exact slice of prior output it is allowed to read, so no stage quietly grows to depend on everything before it. And verifiers come before generators: I wrote the check that grades a stage's output before I wrote the stage, so the target existed before the thing aiming at it. Six of the eight gate modules were committed before the first real generator worker.
The architecture
The loop is a fixed list of stages. There is no planner deciding what to do next; the order is written down and never negotiated. Each stage runs its worker, then runs the gates registered for it. Green means advance. Red means retry, up to a cap. If the same failure repeats twice in a row, or the cap runs out, the loop stops with a named reason instead of thrashing.
“Loop” here is literal but bounded, and it is worth being precise because the word invites the wrong picture. This is not a daemon that runs forever, turning out one video after another. Each stage loops in place — it re-runs itself until its own gates pass or the cap trips, which is while not passed and tries < cap, not while True. One topic goes in, the retries settle, and the run ends at one of four stop codes with one video out. The only edges that jump between stages are the two that leave the human gate, described below.
harness/verifiers/. Each red curl is a stage retrying its own gates in place, capped at three tries; the green arrows are the only way forward. The only gold box is the only place the loop waits for a person — and it is the only stage that sends work backward, either to script for a rewrite or, at most once, all the way to research. (The text-to-speech stage is labelled elevenlabs in the code for a historical reason explained in the vendor section — it now runs either of two engines.)There are exactly four ways to stop, and each is a distinct outcome I can act on: complete (every stage green), needs_human (retries exhausted, or no progress, or waiting for my approval), budget_exhausted (a spend cap hit), and blocked (a real crash — a missing input file, a broken tool). The distinction matters: needs_human is the loop working as designed and handing me a decision; blocked is something I have to go fix.
Two files make the loop observable and restartable. state.json holds every stage's status, its retry count, and the running budget counters, so a run that stops can be resumed rather than restarted. run.log is JSONL — one line per worker start, per gate verdict, per stop. Every number in this case study was counted out of that file, and the appendix tells you how to count them yourself. It is detailed enough that when an early build once exported a video whose audio was still placeholder tone, the contradiction was sitting in two adjacent log lines; that is why every stage logs whether it ran for real.
Every threshold lives in one config.yaml: the retry cap, the acceptable script duration range, the tag whitelist, the spend caps, which text-to-speech engine to use and which to fall back to. Nothing is hard-coded in a worker. That sounds like tidiness; it turned out to be the thing that saved the project when a vendor disappeared.
Why the gates are tiered
The obvious way to check AI output is to ask an AI. That is also the expensive, slow, and least reliable way, so gates in this system are ranked by how much you should trust them, and the cheap ones run first.
| Tier | What it is | Example | Trust |
|---|---|---|---|
| 1 | Machine certainty | Does the file exist, is the PNG decodable, is the render 1080×1920 at 30 fps, does the word count fall in range | Total. It is arithmetic. |
| 2 | Rule or heuristic | Are there enough sources of high enough tier; do the numbers spoken in the audio match the numbers in the script | High, with known blind spots. |
| 4 | AI judge, never the model that did the work | Is the first 15 seconds a real hook; does the script claim anything the research does not support; is this drawing one isolated object | Judgement, not law. Catches things nothing else can. |
| 5 | Me | Is this script worth voicing | The only opinion that ships. |
The rule I care most about is inside tier 4: the checker is never the maker. The sprite images are drawn by Gemini and graded by Claude reading the resulting PNG. The script is written by Claude and graded by a separate judge call. A model asked to grade its own output grades generously; two different models disagree in useful ways.
The tiers also decide the order the gates run in, and the loop stops at the first red one. A script that is 40 words too long is caught by a free word count, and the paid AI judge is never called — there is no reason to spend a model call proving a script wrong when arithmetic already did. Cheap gates carry the load; the expensive one only runs on output that has already earned it.
What the gates actually catch
On the real run — topic: liability waivers in amateur sports events — here is what the verifiers caught, all of it still sitting in run.log and the run artifacts.
A weak hook, rejected by the judge
The first script draft cleared every machine gate — right length, had its disclaimer, every number traceable to a source — and the tier 4 judge threw it out anyway:
{"stage": "script", "event": "gate", "gate": "script_judge", "tier": 4, "pass": false,
"reason": "Criterion 1 not met: the opening 15 seconds (first 63 words) do not
pose a question, give a concrete number, or set up a strong enough
reason to keep watching before viewers scroll past.
[flags: ['hook_weak']]"}Reason text translated from Vietnamese; JSON keys and the flags identifiers are verbatim. The judge counted the words itself to locate the 15-second boundary.
An invented motive, rejected by the judge
This is the catch that justifies the whole tier 4 layer. A later draft described the Gansu ultramarathon disaster, asserted a reason for it that no source supported, and attached a bribery charge to a whole group of people instead of the individuals actually charged:
{"stage": "script", "event": "gate", "gate": "script_judge", "tier": 4, "pass": false,
"reason": "Criterion 3 not met: the script infers a motive of its own —
'cutting safety to save money' (Gansu) — and lumps the bribery
charge onto all five organising-committee staff.
[flags: ['unsupported_claim: cat_giam_an_toan_cho_re',
'unsupported_claim: sai_toi_danh_nhan_vien']]"}No word count catches that. No regular expression catches that. It is a factual claim that reads perfectly fluently and happens to be unsupported — exactly the failure mode that makes unattended AI writing dangerous on a channel that explains real events to real people. The rewrite that followed passed all four gates.
A sprite that kept drawing the whole scene
The art stage generates missing sprites with Gemini and grades them with Claude. One prop — a swimming pool — was rejected four separate times, always for the same reason: the model drew a scene instead of an object.
judge prop_pool.png attempt 0 pass=false
"not an isolated sprite: this is a whole composite scene (trees, clouds, shed,
fence, lounge chair, towel, pool steps) — far beyond the 'simple rectangular
pool' the prompt asked for."
judge prop_pool.png attempt 2 pass=false
"violates criteria 1 and 2: the image draws an entire backyard (fence, shrubs,
a large tree, a poolside lounger) rather than only a 'simple rectangular pool
with ripple lines and poolside tiles'."
judge prop_pool.png attempt 0 pass=true
"correct rectangular pool with ripple lines, a thin tiled border and a small
metal ladder; single sprite, cleanly cut from white, doodle marker style."The machine gate passed that image every single time — the PNG decoded, the alpha ratio was in range, the short edge was over 400 pixels. Nothing measurable was wrong with it. Only a model that could look at the picture and compare it to the request could tell it was the wrong picture.



Three bugs the run exposed in the loop itself
The most useful thing about running a system for real is that it breaks in ways the tests did not. Three fixes came directly out of this run, each with a commit and a test:
- The judge crashed the worker it was judging for. The image judge returns a JSON verdict. One verdict contained nested unescaped quotes inside its reason string, the parser threw, and the worker died — so instead of counting as one failed attempt, a malformed opinion took down the stage. Fixed with a three-layer tolerant parser in
f9c39d6. The lesson is unglamorous and general: the thing that checks also needs checking. - Nineteen dead imports blocked every render. The video renderer's entry file imported 19 components that were not on disk, so no composition could build on a clean checkout. Pre-existing, not caused by the loop, and completely blocking. Cleaned up in
03281ffalong with 13 orphaned compositions. - Re-running duplicated the asset catalogue. The art stage appended a row to the shared asset catalogue each time it copied a sprite in, without checking whether the row was already there — so every re-run added duplicates. Made idempotent in
109c148.
The one human gate, in practice
The single human gate did real work twice. Both times the script had passed every machine gate and the AI judge, and both times it was still not worth voicing — because the problem was not the writing, it was that the facts underneath were thin.
The first rejection was about a story the script kept citing but never told:
"From beginning to end the voice-over keeps repeating 'the 2007 ruling' but NEVER says what the 2007 ruling actually was — you have to tell the story of City of Santa Barbara v. Superior Court properly: who the victim was, what accident happened, where, whether the family had signed a waiver, and only then what the court held. Use the new narrative facts added to research.json. Stop treating the precedent as a black box."
vo_reject_used_1.md. Written as free text into a file the loop picks up on resume.The second rejection restructured half the episode — keep the first case, drop the second, replace it with a different disaster, again pointing at facts that had to be in the research first:
"1. The first case (little Katie Janeway) is FINE — KEEP nearly all of both sections. 2. The second case (Charles Krueger / 49ers) DROP ENTIRELY. 3. Instead, tell the 100km trail race in Gansu, China, May 2021 that killed 21 runners (use the new facts added to research.json) […] and land it back on the waiver angle: once you've signed, does the organiser walk away?"
vo_reject_used_0.md, condensed with […] where a fourth point about length follows.The mechanism matters more than the content. I do not edit a prompt when I reject a draft — I write the reason in plain language into a file, and the loop treats it exactly like a failed machine gate: it logs it as a tier 5 verdict, appends it to the gate results, and the next call to the script worker receives my sentences in the same feedback block a failing word-count check would produce. The worker has no concept of "the human rejected this." It just sees another red gate with a reason. That channel was itself a mid-run addition (a205340), because the first rejection had nowhere to put its argument.
But notice what both rejections actually asked for: facts that were not in the research yet. Rewriting the script alone could not fix that — the material had to come from a stage two steps back. When I first ran this, the loop only moved forward, so I did the ugly thing: I opened state.json by hand, reset the research stage, and restarted. That worked, but editing a state file by hand to route around your own pipeline is not a feature.
So it is one now. A rejection whose reason begins with research: resets the research and script stages and re-runs from research — a single, bounded, logged back-edge instead of manual surgery, capped so it cannot loop forever and falling back to an ordinary script rewrite once the cap is spent. The loop is still fundamentally a forward list; it now has exactly one deliberate way back, for exactly the situation the human kept hitting.
When the vendor wall hit
The channel's voice is a cloned voice, and partway through this project that clone stopped being available to me. Not a rate limit or an outage — a plan-tier restriction at the text-to-speech vendor. The account could still list voices successfully; it just could not synthesise with a cloned one. Rotating keys changed nothing, because nothing was wrong with the keys.
That is a failure you cannot engineer around inside the vendor's API, so the fix was to stop depending on it: I added a second engine that runs entirely on my own machine — a Vietnamese voice-clone model with a LoRA fine-tune of the same voice — and made the engine a configuration value, with an automatic fallback.
tts:
engine: elevenlabs # vieneu (local voice clone, free) | elevenlabs (API, costs credit)
fallback: vieneu # standby engine when the primary is out of creditVerbatim from harness/config.yaml; the trailing comments are translated from Vietnamese.
This is the one architectural decision I would defend without qualification. Only step one of the audio stage — turning text into sound — is engine-specific. Everything after it is shared: speed adjustment, stitching sections with fixed gaps, loudness normalisation, retiming, and forced alignment for captions. Because that seam existed before the vendor became a problem, losing the vendor cost one line of configuration and a new renderer behind the same interface. It did not cost a pipeline rewrite.
Once both engines existed, the switch between them stopped needing me. The vendor is primary again — it is faster and I have credit — and the local engine is the standby. The care went into defining what counts as “out of credit,” because getting that wrong either way is expensive: fall back too eagerly and a thirty-second network hiccup silently changes the voice halfway through an episode; fall back too reluctantly and the run stops at 2am over a billing state I could have worked around. So only account-level refusals switch engines — a credit balance too low for the script, an HTTP 401/402/403, or a response body containing quota_exceeded, subscription_required, or ivc_not_permitted. Rate limits, 5xx, and timeouts retry and then fail loudly. And the engine that actually produced the audio is written into the run state and the log, so a resume never re-asks a vendor that already said no — the loop is allowed to substitute a component, but never without saying so in a place I can grep.
The local engine works. The per-section quality gate on the final render reports word recall between 0.806 and 0.951 against the script. It costs something the vendor did not — several minutes per episode on local hardware instead of seconds over an API, in an isolated environment because its dependencies cannot coexist with the rest of the project — but it is mine, and it cannot be revoked by a billing tier. The stage is still named elevenlabs everywhere in the code, even on runs the local engine voiced; renaming it would touch the state files of every historical run, so it keeps a vendor's name for something now vendor-agnostic. A small, honest piece of debt I would rather show than hide.
What it produces
One command, one topic, and — with my single approval — a finished vertical video: 245 seconds, 1080×1920 at 30 fps, 44 shots, narrated in the channel's cloned voice, captioned word by word from forced alignment, with the cover image at the top of this page and a ready-to-paste caption file.




That last detail is a fix, not a given. Mirroring a sprite to make it face inward is fine for a person; do it to a drawing with letters baked in and you get EBIRCSBUS. An earlier render shipped exactly that. The rule now is simple and enforced by a test: only character sprites flip. It is a small thing, but small reversed words are the kind of detail that tells a viewer no one was watching — and the whole point of this system is that something always is.
What is still genuinely hard
The harness/README.md carries a limitations section written during construction; most of what was in it has since been closed — gates short-circuit now, the art stage stops as needs_human like every other stage instead of looking like a crash, a single counter tallies every model call, the back-edge above replaced the manual state-file surgery. What remains is the honest part: the things I cannot simply fix.
- The loop cannot tell whether a video is any good. Every gate checks process, sourcing, and voice against a rubric. Whether anyone watches it is measured in days, by an audience, and nothing in here models that. A run that ends
completemeans “correctly made,” not “worth making.” - Vietnamese speech recognition is unreliable on ordinary words. The gate that checks the spoken audio against the script only trusts numbers and proper nouns after normalisation, because everyday-word transcription errors are common enough to cause constant false failures. A genuine mispronunciation of a common word can still slip through. That is a limit of the available tooling, not a threshold I can tighten.
- One human gate means the visuals are never reviewed by a person. I approve the script; nobody approves the scene plan or the art before it renders. The tier 4 image judge is the only thing standing between a bad drawing and the export. That was a deliberate trade to keep the loop to a single stop, and it is the first place I would add a second human gate if picture quality ever slipped.
None of those are bugs. They are the edges of what a verification loop can promise: it can guarantee a video was made correctly and from real sources, in a voice that cannot be revoked, with every claim it makes traceable to something I can open. It cannot guarantee the video is good. That part is still mine — which is exactly the one job I wanted to keep.
Appendix: how to verify
Nothing in this write-up is an estimate. Every number came from a command; here are the commands.
# the test suite count quoted above
.venv/bin/python -m pytest tests/harness/ -q
# the engine-fallback and back-edge rules, as executable specifications
.venv/bin/python -m pytest tests/harness/test_tts_fallback.py -v
.venv/bin/python -m pytest tests/harness/test_loop.py -k research_backedge -v
# every gate verdict in the real run, with tier and pass/fail
python3 -c "import json;[print(d['gate'],d['tier'],d['pass'],d['reason'])
for d in map(json.loads,open('runs/20260722-164040-giay-mien-tru-the-thao/run.log'))
if d.get('event')=='gate']"
# the two human rejections, verbatim and in Vietnamese
cat runs/20260722-164040-giay-mien-tru-the-thao/vo_reject_used_*.md
# the engine that actually voiced the export, from the run state
python3 -c "import json;print(json.load(open(
'runs/20260722-164040-giay-mien-tru-the-thao/state.json'))['tts_engine_used'])"
# all thresholds and the fallback config in one place
cat harness/config.yamlKey commits referenced above: f9c39d6 tolerant judge parser · 03281ff dead-import cleanup · 109c148 idempotent catalogue · a205340 rejection-reason channel · e18dbd2 local TTS engine · 3d1dc6a the round of hardening (gate short-circuit, art stop code, model-call tally, research back-edge, character-only mirroring).