The goal is simple to say: every post on this page should also exist as a video we can put on YouTube. Not someday, not as a weekend editing project per post. A post folder goes in, and a finished, captioned, hosted video comes out.
So tonight we built that machine, and this post is the first to go through the last stage too, the YouTube folder. The short film above is the making-of: this post's own files and renders as they came out, in pipeline order. The instructional is the pipeline explaining itself, hosted by two of our AI hosts, Daisy Lane and Ama Boateng (AI characters with AI voices). The narration is Theo Lindqvist, also an AI voice.
Here's what's automated, what each stage costs on one DGX Spark, and what we still decide by hand.
How we tested
Everything ran on one DGX Spark on Nov 2, 2026, during a long overnight run of Video Lab tests. Two kinds of runs feed the numbers below:
- This post itself. Its four on-camera host lines, seven voice-over lines, edit, film, narration and player went through the real tools. The TTS numbers come from the JSON line
bin/tts.pyprints for every call (saved inmedia/tts-hosts.jsonlandmedia/vo/tts-vo.jsonl). The host render numbers come fromtests/HOSTS.tsv, whichbin/host-segmentsappends one row to per finished clip. - Test fixtures. Before trusting the tools with a real post, we ran them on two small fixtures: a 30 s instructional built from a two-host smoke test (Daisy and Rafael, one line each, rendered on the GPU queue), and a 34-36 s instructional that used an older lip-sync clip as a stand-in host. Render, QA and packaging times come from those runs' logs.
Nothing here was uploaded to YouTube. The pipeline stops at a folder that's ready to upload.
The pipeline, stage by stage
| # | Stage | Tool | Who does it | Measured | Source |
|---|---|---|---|---|---|
| 1 | Scripts from the post | an agent session | an agent (not a script) | not measured | |
| 2 | Voices with word timings | bin/tts.py (ElevenLabs, eleven_v4) | automated | 3.36 to 5.10 s per call for 11.8 to 20.9 s lines (11 calls) | media/tts-hosts.jsonl, media/vo/tts-vo.jsonl |
| 3 | AI host clips | bin/host-segments -> GPU queue -> LTX 2.5 image+audio-to-video | automated, queued | this post's four takes 16.3 to 18.4 s of GPU per second of speech (smoke test 15.1 and 16.4) | tests/HOSTS.tsv |
| 4 | The edit | instructional.edit.json (+ host-segments --fill-edit) | lengths automated, content written | not measured | |
| 5 | Render | bin/post-to-video -> bin/render-remotion (Remotion 4.0.539) | automated | 34.2 s instructional in 33.7 s (30.4 rendered frames per wall second) | fixture media/renders.log |
| 6 | Narration | bin/post-to-video --narration (tts.py in chunks) | automated | 288.0 s of narration in 74.8 s (67.6 s of it TTS latency, 2 chunks) | media/tts-narration.jsonl |
| 7 | Player | remotion/bin/build-player.mjs | automated | about 0.2 s to bundle a demo player | tests/post-to-video/report.md |
| 8 | QA | bin/qa-post | automated checks + a human look | 36.2 s video checked in 3.5 s | fixture run |
| 9 | YouTube folder | bin/youtube-package | automated | 7.2 to 8.6 s for a 30 to 36 s video | fixture youtube/*/metadata.json |
| 10 | Publish here | site.sh --file, then --publish | a person runs it | not measured | |
| 11 | Upload to YouTube | YouTube Studio | a person | not done yet |
The instructional and the narration group these into six or seven steps (script, voices, hosts, edit and render, QA, YouTube folder); the table splits render, narration and player out.
1. Scripts
An agent reads the post and writes five files: narration.md (the article rewritten for the ear, with a front-matter line naming the voice), hosts.json (the on-camera lines), instructional.edit.json (the blocks of the instructional), film.props.json (the short film's shot list) and player.props.json. This is the stage with judgment in it, so it's the one we don't pretend is automated.
The rule that shapes everything downstream: on-camera speech is the expensive part, so each host line stays under 20 s and each instructional gets at most 60 s on camera. Everything else is the host's voice running under Remotion panels, which costs no GPU. This post has 55.12 s on camera in four lines (11.84, 15.20, 14.88 and 13.20 s) and 124 s of voice-over.
2. Voices
bin/tts.py calls ElevenLabs' with-timestamps endpoint, so every line comes back as an mp3 plus a words.json with a start and end time for every word. Voice and settings come from the cast registry, so Daisy always sounds like Daisy. The word timings are the backbone of the whole thing: they drive the burned-in captions, the lip-sync scoring, and the YouTube caption file.
For this post: 11 calls on eleven_v4, latency 3.36 to 5.10 s each, for lines of 11.8 to 20.9 s. That's roughly a quarter second of waiting per second of speech.
3. The AI hosts
bin/host-segments reads hosts.json, voices any line that isn't voiced yet, refuses any line over 20 s, and writes one GPU job into the queue. The queue runs one heavy job at a time under one lock. The job feeds each host's studio still plus the line's audio to LTX 2.5 (22B distilled, image+audio-to-video) at 1280x704, then muxes the clean mp3 back over the clip so the last syllable can't get clipped.
The smoke test (two 5.6-5.7 s lines) cost 93.2 s and 84.3 s of LTX wall time: 16.4 and 15.1 s of GPU per second of speech. The whole queue job, including freeing the other server and the model load, took 182 s, and free memory dipped to 9 GiB during it (logs/gpu-queue.log, which rounds down to whole GiB). This post's own four lines cost 192.5, 273.8, 274.1 and 223.5 s of LTX wall time: 16.3, 18.0, 18.4 and 16.9 s per second of speech, 963.9 s (16.1 minutes) for 55.12 s on camera, or 17.5 s per second overall. The queue job took 969 s and free memory bottomed out at 13 GiB.
The honest part: lip-sync is loose at pauses. We score every take with a frame sheet: one frame in the middle of every pause between words, where the mouth should be closed, and one on every fourth word, where it should be moving. Across the four takes the mouth was closed at 6 of 21 pauses, well under our gate of 4 of 6 (sheets in qa/score/). On speech the mouth is open or moving in most sampled frames; a few land closed mid-word. One guess at the cause is the voice: eleven_v4 at our cast settings keeps pauses short (these 21 measure 0.36 to 0.60 s), which may be too short for the model to close the mouth and reopen it. The evidence so far doesn't back that guess: eleven_v3 holds much longer pauses, but the v3 host takes scored tonight on another post closed at only about 3 to 4 of 11. So the next host lines use eleven_v3 with "..." as cheap insurance, not as a known fix. We didn't re-render these.
4 and 5. The edit and the render
The instructional is one JSON file: a list of blocks (title card, host clip, explainer panel, host clip, ...) with a length in frames. Host block lengths come from the audio, using host-segments' own rule (audio length plus half a second). Voice-over panels carry their mp3 and its word timings as a caption overlay.
bin/post-to-video checks every path before it starts. It waits until the box has more than 20 GiB free, so a CPU render can't push a GPU job into swap. Then it renders through Remotion, keeps the file under the site's 40 MB cap, picks a poster frame that isn't black, and writes a new versioned file every time; it refuses to overwrite one. Remotion renders on the CPU in headless Chrome. On the 20-core GB10 the test renders ran at about real time:
| Render | Length | Wall | Rendered frames per wall second |
|---|---|---|---|
| Fixture instructional (title cards, one host clip, two panels) | 34.2 s | 33.7 s | 30.4 |
| Same fixture, longer title card | 36.2 s | 30.8 s | 35.3 |
Same edit re-rendered by youtube-package --remaster | 36.2 s | 38.0 s | 28.6 |
| Smoke fixture with both real host clips (concurrency 6) | 30.3 s | 32.7 s | 27.8 |
| This post's instructional (13 blocks, four host clips) | 191.2 s | 241.9 s for the whole call (render, BT.709 encode, poster); the split wasn't saved | not saved |
| This post's short film (14 shots, final take) | 53.2 s | 79.2 s, plus an 8.6 s BT.709 encode; 88.9 s for the whole call | 20.1 |
| Demo short film | 9.0 s | 11.7 s | not printed |
The whole post-to-video --instructional call for the 34.2 s fixture took 34.1 s, render included.
6 and 7. Narration and player
post-to-video --narration strips the Markdown, turns tables into "see the table on the page", splits the text into chunks under 4,500 characters, voices them in order and joins them into one mp3 with its transcript. For this post: 4,809 characters became two chunks, 251.3 s and 36.7 s of audio, with 59.7 s and 7.9 s of TTS latency; the whole call took 74.8 s for 288.0 s of narration. The player is a single self-contained HTML file (React, the Remotion player and the composition, all inlined) that the page shows in a sandboxed frame. This post's player is the pipeline itself: click a stage to see what goes in, what comes out, and what it cost.
8. Quality gates
bin/qa-post fails a post for: a video that isn't H.264 with audio, a film or instructional that isn't 16:9 at 720p or 1080p, no faststart, a narration that doesn't decode to its stated length, a player that loads anything from outside its own file, and any leftover placeholder or private path in the review. It warns on long dark stretches and on videos over 20 MB, and it draws a timestamped contact sheet of every video. It checked the 36.2 s fixture in 3.5 s. On this post's player it also warned five times that a fetch() inside the bundled Remotion code can't be checked statically; every resource tag in the file is inline, but that check is literal, not a guarantee. (Faststart means the file's index sits at the front, so playback starts before the download finishes.)
The warnings matter as much as the failures. Our explainer panels are near-black by design, so the black-frame check flags them every time. That's why it's a warning, and why the last gate is still a person looking at the frame sheets: faces, hands, mouths, text, and whether the AI host is labeled as AI.
9. The YouTube folder
bin/youtube-package is the last piece, and it doesn't upload anything or need any keys. From a post that has a rendered instructional, it writes youtube/<version>/:
- video.mp4: copied if it already meets YouTube's recommended upload settings, otherwise re-encoded to H.264 High, 4:2:0, closed GOP of half the frame rate, two B-frames, capped at YouTube's recommended 8 Mbps for 1080p at 24-30 fps, AAC at 48 kHz, moov atom first (YouTube's upload encoding settings).
- captions.en.srt: from the same ElevenLabs word timings, each host line and voice-over block shifted by where its block starts in the edit, grouped into the same short lines the burned-in captions show.
- chapters.txt: from the panel titles, with YouTube's chapter rules applied: first one at 0:00, at least three, each at least 10 s (YouTube's chapter rules). Short blocks fold into the chapter before them.
- thumbnail.jpg: 1280x720, a frame from the first host clip with the title drawn next to it and an "AI host" badge, plus a contact sheet of six candidate frames (YouTube's thumbnail guidance).
- title, description, tags, metadata.json: the title cut to 100 characters at a clause break, a description with the dek, the chapters, a plain AI disclosure naming the hosts, and a link back to the post here.
On the fixtures the whole package took 7.2 to 8.6 s for 30 to 36 s of video, most of it the encode (4.8 to 5.7 s).
The YouTube package for this post
This post went through the last stage too. bin/youtube-package --remaster re-rendered the instructional from the copy of the edit post-to-video saved, normalized the mix to -14.00 LUFS (true peak -1.80 dBTP), and wrote 89 caption cues from 477 timed words. It took 229 s, 208 s of it the re-render. Its thumbnail is the poster on the instructional above; the frame is one we picked from the six-frame sheet, 1.78 s into Daisy's opening line. The chapters it wrote:
0:00 Intro
0:31 1. The script
0:50 2. Voices with word timings
1:07 3. The AI hosts
1:28 4. The edit and the render
1:47 5. Quality gates
2:18 6. The YouTube folder
2:36 Still not automatic
2:53 Wrap-upThe captions file, chapters, thumbnail, title, description, tags and metadata are in the download link at the bottom of this page. The YouTube video itself (43.3 MB) isn't on this page: it's the same edit as the instructional above, so we keep it with the post's source files until the upload.
Setup and gotchas
- Remotion wrote full-range color (fixed during the run). Its H.264 came out as
yuvj420p(full range, BT.601 matrix). We measured two fixes on the same frame: converting only the range came out 1.1 to 2.3 levels (mean, per channel) from the source; also converting the matrix to BT.709 came out 2.8 to 4.0 levels off. By the time this post rendered,bin/render-remotionencoded BT.709 limited range itself (8.6 s for this post's 53 s film,media/render-film-v2.log), andyoutube-packagestill converts any full-range file it's handed. - The mix is quieter than YouTube plays. Our voices are loudness-normalized at -16 LUFS in a single pass, which measured -17.5 LUFS in the TTS tests, the rendered fixture measured -17.4 LUFS, and this post's instructional -17.0. (LUFS is average loudness; closer to zero is louder.) YouTube publishes no loudness target, but playback has been measured at -14 LUFS, and quieter uploads aren't turned up (Production Advice). So
youtube-packagedoes a two-pass normalization to -14 LUFS and -1 dBTP when the mix is under -15.5. The fixture came out at -14.01 LUFS, true peak -1.43 dBTP. - The automatic thumbnail frame can land mid-blink. It did on the very first real host clip. That's why the package includes a six-frame candidate sheet, and picking the frame is still a person's call (
--thumb-at). - Re-rendering the instructional used to re-render the hosts (fixed during the run).
post-to-video --instructionalalways callshost-segments, which didn't skip clips that already existed, so every re-render of the edit queued every host line on the GPU again and replaced the take you picked.host-segmentsnow skips any line that already has its clip and itsseg.json, unless you pass--force. - The renderer kept no copy of the edit it rendered (fixed during the run). If the edit file changed after a render, the captions and chapters would be computed against the wrong timeline.
post-to-videonow savesinstructional-<version>.edit.jsonnext to each render,youtube-packagereads that copy, and it still refuses an edit whose length doesn't match the MP4 within half a second. - The site's 40 MB cap is not YouTube's.
post-to-videosqueezes long renders under 40 MB for this page.youtube-package --remasterre-renders the same edit at full quality for YouTube, which took 38.0 s for the 36.2 s fixture. - CPU renders wait for memory.
post-to-videowon't start a render below 20 GiB free. During a host job on the GPU, free memory went as low as 9 GiB, so renders queue up behind GPU work on their own. - eleven_v4 doesn't hold long pauses. At our cast settings neither "..." nor a break tag gets a pause past about 0.55 s (from our TTS tests; word timings come in 40 ms steps, so one pause here measures 0.60 s). Fine for voice-over. eleven_v3 holds longer pauses, but it hasn't yet been shown to fix the mouths (see section 3).
Settings
- Voices: ElevenLabs
eleven_v4, cast settings from the registry (Daisy: stability 0.45, similarity 0.75, style 0.3; Ama: the same; Theo: stability 0.55, style 0.15), with-timestamps endpoint, mp3 44.1 kHz 128 kbps, loudness-normalized at -16 LUFS. - Hosts: LTX 2.5 22B distilled image+audio-to-video, 1280x704, seed 42, image strength 1.0, each host's on-set studio still, one queue job per post.
- Edit and render: Remotion 4.0.539, 1920x1080, 30 fps, H.264 with faststart, concurrency 6 (the render-remotion default since a 03:48 memory fix; the first fixture renders, before that change, used 10).
- YouTube re-encode: libx264 High, CRF 18 capped at 8 Mbps (1080p, 24-30 fps), GOP 15 at 30 fps, 2 B-frames, closed GOP, limited range, AAC 384 kbps stereo at 48 kHz, two-pass loudnorm to -14 LUFS / -1 dBTP when needed.
- Thumbnail: 1280x720 JPEG under 2 MB, frame from the first host clip, DejaVu Sans Bold, drawn with Pillow.
Speed and memory
The only GPU stage is the hosts. Everything else runs on the CPU:
- Voices: about 0.25 s of waiting per second of speech for short lines, 0.24 s for the 4-minute narration chunk.
- Hosts: 16.3 to 18.4 s of GPU per second of on-camera speech. With 55.12 s on camera, this post's instructional cost 963.9 s (16.1 minutes) of LTX wall time (
tests/HOSTS.tsv). - Render: about 1x real time on the test renders; this post's 191.2 s instructional took 241.9 s for the whole call, about 1.3x.
- QA: a few seconds. YouTube folder: under 10 s for half a minute of video.
Memory: free memory dipped to 9 GiB during the smoke-test host job and to 13 GiB during this post's (logs/gpu-queue.log); the render step waits for 20 GiB free before it starts.
What we still do by hand
- Writing the scripts. An agent writes them from the post, and the measured numbers in them get checked against the files.
- Picking host takes. One seed per line by default; when a take is off, we render another seed and pick from the contact sheets.
- Choosing the thumbnail frame from the candidate sheet.
- Watching the frame sheets before anything ships: faces, hands, mouths, text.
- Publishing here and uploading to YouTube. The folder is ready; the upload is a person in YouTube Studio for now, including answering YES to the altered or synthetic content question, because the hosts are realistic AI people.
What's next
Run every published Video Lab post through youtube-package, move host lines to eleven_v3 with real pauses and see whether lip-sync improves at the stops (so far it hasn't been shown to), and then decide on the upload itself: by hand from the folder, or through the YouTube API with a review step in front of it.