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.

Case study · July 2026

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.

The generated cover: a hand-drawn doodle narrator with a microphone beside a SUBSCRIBE card, under a three-line Vietnamese title reading GIẤY MIỄN TRỪ / CÓ CỨU ĐƯỢC / AI KHÔNG, with a small label reading two real cases — US and China
The cover for one finished episode, straight out of the export folder — topic: liability waivers in amateur sports. Title, label, narrator, and the SUBSCRIBE card are all placed by the loop from the scene plan; the Vietnamese text is the channel's, kept as it renders. One command produced this and the 245-second narrated video behind it.

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.

Diagram of nine stages in fixed order — research, script, VO sign-off, audio_tag, tts, scene_plan, asset_gen, compose, export_qa — each with the tiered gates it must clear. Every worker stage has a small red arrow curling back into itself: it retries its own gates until they pass, capped at three tries. Green arrows advance to the next stage on a pass. VO sign-off is the one human gate, and two red edges run up its left side — a solid one back to script (rewrite with the reason) and a dashed one further up to research (rebuild, at most once). A key box explains the glyphs, and four stop codes sit at the bottom.
The whole loop. The gate names in the right-hand column are the actual function names in 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.

TierWhat it isExampleTrust
1Machine certaintyDoes the file exist, is the PNG decodable, is the render 1080×1920 at 30 fps, does the word count fall in rangeTotal. It is arithmetic.
2Rule or heuristicAre there enough sources of high enough tier; do the numbers spoken in the audio match the numbers in the scriptHigh, with known blind spots.
4AI judge, never the model that did the workIs the first 15 seconds a real hook; does the script claim anything the research does not support; is this drawing one isolated objectJudgement, not law. Catches things nothing else can.
5MeIs this script worth voicingThe 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.

A hand-drawn doodle of a rectangular swimming pool in three-quarter view with blue ripple lines, a tiled border and a small ladder, on a transparent backgroundA hand-drawn doodle stick figure of a trail runner mid-stride wearing a cap and running vest, with grass tufts and rocks around the feetA hand-drawn doodle statistics card in Vietnamese showing 172 starters and 21 deaths, with icons for hail, freezing rain and a plunging thermometer
Three sprites that survived their gates, as they appear in the finished video. Left: the pool, on its fourth attempt. Middle: the trail runner, passed on the first attempt — note it carries grass and rocks around the feet, scenery not unlike what got the pool rejected. Tier 4 is judgement, not a rule, and you can see the line drawn unevenly at the margins. Right: the evidence card (Vietnamese: 172 starters, 21 dead in the Gansu disaster), rejected first by the machine gate for having only 2.6% transparent pixels — a card that fills its whole frame — and passed on the retry.

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 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."

Translated from 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?"

Translated from 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 credit

Verbatim 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.

Video frame: a doodle narrator holding a microphone, facing a SUBSCRIBE card on the left whose text reads correctly, with a burned-in Vietnamese captionVideo frame: a detailed ink drawing of a stern judge in black robes seated at the benchVideo frame: a Vietnamese evidence card reading 172 starters, hail, freezing rain, 21 deaths circled in red, beside a shocked doodle narratorVideo frame: a doodle character beside a SUBSCRIBE card, both reading correctly, with a burned-in Vietnamese caption
Four frames from the finished episode, in Vietnamese. The two middle frames are the cases the script settled on after my rejections — a US precedent and the Gansu ultramarathon. Look at the SUBSCRIBE card in the first and last frames: the narrator is mirrored to face it, but the card's baked-in text still reads left-to-right — because the loop now only ever mirrors character sprites, never props with text drawn into them.

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.

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.yaml

Key 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).