When a constraint is expensive to violate, it belongs in the architecture rather than in a review step.

At a glance

  • What it does — chooses a subject, verifies it has not already been covered, researches it, writes a script, sources footage whose rights are provable, narrates it, transcribes and aligns it, renders it, and assembles a finished film. Thirteen stages, end to end, without human intervention.
  • Stack — Python and FastAPI for the runner, n8n for control flow only, a local LLM through Ollama, Kokoro for text to speech, whisper-ctranslate2 for transcription, Manim for scientific cards, FFmpeg with VideoToolbox for assembly.
  • Scale — 257 of 289 commits are mine; 31 of the 289 were authored by an AI coding tool operating under my direction, which I am stating here rather than leaving to be noticed. Commit counts are self-counted in a private repository I own.
  • Documentation — 25 design specifications, versioned v1 to v3, plus a handover document holding the repository map, data schemas, the full stage DAG and the measured timings.
  • Output — 1440p, archive restoration enabled, generative upscaling deliberately disabled.

The product problem

The naive version of this idea is a content mill: scrape footage, generate a voiceover, upload. That version is both legally dangerous and worthless, and the two failures are related.

Automated production from archive material has one obvious way to fail badly — publishing something that is legally someone else's — and one quieter way, which is publishing something that is functionally a duplicate of what you already made. Most systems in this space treat rights as a review step: somebody checks afterwards, or nobody does. That does not scale, and it fails silently, because the absence of a rights problem looks exactly like nobody having looked.

So the engineering problem is not video. It is making rights and originality structural properties of the pipeline — conditions an asset must satisfy in order to enter — rather than checks applied to what came out.

What I owned, and what I did not

Everything architectural is mine: the orchestration split, the rights gate, the deduplication design, the topic-selection gate, the build-versus-buy analysis, the measurement pass, and the specifications that record why each of those is the way it is.

Two honest boundaries. First, 31 of the 289 commits were authored by an AI coding tool working under my direction; the design documents, gates and measurements around it are what make that a defensible way to work rather than a shortcut. Second, the heavy lifting inside stages is other people's software — Ollama, Kokoro, Whisper, Manim, FFmpeg, and a sentence-embedding model. I did not build a text-to-speech engine. I built the pipeline that decides what is allowed to reach one.

System view

Thirteen stages under a thin orchestrator. Stage 10, card rendering, is the measured bottleneck — and it was not the suspect.

The system splits into a thin orchestrator and a thick runner. n8n, in a Docker container, does only cron triggers, HTTP calls, branching and looping. All state and all compute live in the FastAPI service: the topic queue, the produced ledger and the rights ledger; the brain that selects topics, deduplicates them, talks to the model and directs enrichment; and the compute that renders, narrates, transcribes and assembles.

Two gates sit early in the DAG on purpose. Stage 1 rejects any subject with no rights-clear footage available before a script is written. Stage 2 rejects any subject too close to something already produced. Both cost an API call or an embedding comparison; discovering either failure at the asset or render stage costs the whole run.

Portability was designed in rather than hoped for. Code, workflow JSON and the produced ledger all live in git, so a clone reproduces the full system including its memory of what it has already made. Secrets never enter the repository.

Decisions that shaped it

Default deny in the rights ledger

rights-ledger.json records every asset the system has used, with licence fields that default to deny. An asset is not usable because nothing said it was forbidden; it is usable only when provenance and licence are affirmatively established. Every downloaded asset is hashed with SHA-256 and the hash recorded alongside its source and licence determination and the time that determination was made.

That inversion is the whole design. Under default allow, a bug, an API change or an unhandled response shape ends with unlicensed material in a published video. Under default deny, the same failures end with the pipeline stopping. One failure mode is a legal exposure discovered by a rights holder; the other is a job that did not run.

Sources are restricted to material that is public domain by origin, and the three permitted sources are not treated identically:

  • DVIDS (US Department of Defense visual information) — works of the US federal government are public domain by statute.
  • NASA — the same basis, with documented exceptions that the gate accounts for.
  • Archive.org — a broad collection hosting material under many licences, so items from it are checked individually rather than assumed.

That distinction matters. DVIDS and NASA are safe by category; Archive.org is not. Treating those two cases the same way is what makes an "only public domain" claim untrue, and it is why the ledger records a per-item determination rather than a source name.

The same reasoning was applied to dependencies. The roadmap records a build-or-adopt decision per layer with licence analysis attached, and the row I would point at is the finishing layer: one referenced project is AGPL, so the technique was reimplemented rather than the code vendored. Vendoring an AGPL library into a system that produces published output creates an obligation that most people building this kind of pipeline never consider, and it is discovered far too late.

The trade-off in both cases is throughput. Default deny stops runs that a looser gate would have completed, and reading licences before adopting code costs days.

A thin orchestrator and a thick runner

n8n makes a pipeline visible, which is exactly why it is tempting to put logic in it. Keeping it to cron, HTTP, branching and looping means the system stays a normal Python codebase — unit-testable, debuggable locally, and portable to a different orchestrator in an afternoon.

This is the decision in the project I would most want to be questioned about, because I gave something up for it: the visual, click-through view of a running pipeline, and the convenience of node-level retries and per-node state. In exchange, nothing important lives inside a tool I cannot test, version meaningfully or leave.

Two-axis deduplication, run before anything expensive

produced_ledger.json records everything the pipeline has made and travels in git, so its memory survives being cloned to a new machine. Candidate subjects are checked on two axes: exact comparison against produced entries, and MiniLM sentence-embedding similarity.

The second axis is the one that matters. Exact matching is defeated by rephrasing — "The Panama Canal Expansion" and "How Panama Widened Its Canal" are different strings and the same video — and near-duplicates are what actually degrade a catalogue. The cost is a similarity threshold I have not validated against a labelled set, which means it can suppress a legitimately distinct subject that reads as close in embedding space. That is the failure I would rather have than the reverse.

A seeded generator instead of a model call

Editorial direction — card type, placement, tone and timing — is chosen by a seeded pseudo-random generator, not by a language model. Measured at zero seconds.

There is a strong pull towards using a model for every decision in a pipeline like this, and most of those decisions do not need one. A deterministic, seeded, testable function that costs nothing beats an inference call that costs money, adds latency and cannot be reproduced. It also produces the property the output actually needs: two films about adjacent subjects do not share a visual grammar and become recognisably templated.

1440p, restoration on, generative upscaling off

Output renders at 1440p rather than 4K, with archive restoration enabled and Real-ESRGAN upscaling disabled. The sources are inherently low-resolution — historical footage does not contain 4K of information — so rendering at 4K would multiply render time and file size to upscale material that has no detail to recover.

The stronger reason is not performance. Restoration repairs what is actually in the frame; generative upscaling invents what is not. For a film whose value rests on evidential accuracy, fabricating visual detail is the wrong trade even when the fabricated detail is flattering.

What is measured, and how

Every stage was timed on a real production run rather than estimated. The conditions were a single 16 GB Mac, sequential execution with no CPU contention, producing a 244.85-second finished video — one run, on one machine, so treat it as a profile of that machine rather than a benchmark.

Bold figures below were measured in isolation. Network-bound stages are given as ranges and flagged as not isolated, because a single measurement of a network operation is not a measurement of anything:

  • Topic selection — roughly 3–8 minutes cold, under a minute cached (network-bound, not isolated)
  • Deduplication — 5–10 seconds, mostly loading the MiniLM model
  • Brief, script, cards and call to action60–90 seconds via the local model
  • Enrichment direction0 seconds
  • Asset discovery and rights checking — roughly 1–3 minutes (network)
  • Download and hashing — roughly 2–6 minutes (network; about 1.8 GB for ten assets, the highest-variance stage)
  • Shot matching30 seconds
  • Narration34 seconds
  • Subtitles11 seconds
  • Card rendering101 seconds
  • Scientific cards5 seconds

Three things came out of that table. Card rendering is the bottleneck and it was not the suspect — at 101 seconds it is roughly a third of the local compute budget, more than narration, transcription and Manim combined, while text to speech, the stage that feels expensive, takes 34 seconds. Optimisation effort went to the per-frame backdrop blur instead of to the model stages. Download variance dominates wall clock, which means any future parallelism work belongs at the download stage rather than in the render. And enrichment direction is free, which is the empirical argument for the seeded-generator decision above.

The reason the measurement is worth writing down is the gap it exposed. Before timing it, my model of the system was wrong in a specific way: I believed the expensive stages were the AI ones. They are not. The expensive stage is compositing pixels.

Limitations, and what I would change

  • The timings are n=1. One production run, one machine, sequential. They are good enough to locate a bottleneck and not good enough to quote as performance characteristics, which is why the network-bound stages stay as ranges.
  • Only eleven of the thirteen stages are in the measured table. Intro/outro generation and final assembly are not separately timed.
  • The deduplication threshold is unvalidated. There is no labelled set behind it, so I know the mechanism works and cannot tell you its false-positive rate.
  • The publishing layer is deliberately unbuilt. The roadmap has it designed, built on official client libraries, with a kill switch, and scheduled last. Several sub-project designs for the unfinished finishing layers exist as specs rather than code. A pipeline that can publish autonomously is a pipeline whose rights gate has to be right, and I would rather ship that last than first.
  • Rights determinations are recorded, not adjudicated. The ledger makes provenance auditable — given a finished film, every clip in it traces to a specific archive record and a specific licence determination made at a specific time. It is not a substitute for legal review, and I would not present it as one.