From a 20h50m Wait to a 6h40m Wrap ─ Cutting 14 Hours by Automating the Video "Exit"
Introduction
I'm an individual developer building a Python-based horse racing prediction system (V5py / V58py), and I publish its weekly results as videos on my YouTube channel, "71-Year-Old Retiree Jiji's Horse Racing Newspaper."
In my previous post, "Slide Production, Finally Zero Manual Fixes ─ A Record of 3 Hours → 1.5 Hours → 5 Seconds", I finished the pipeline that auto-composes xlsx prediction tables into SVG slides, eliminating every "manual touch-up" step from a single episode's slide production.
This is the sequel. Once the slides were done, there was still a whole stretch of work left: converting SVG to PNG, matching those slides against narration audio, and rendering everything into an mp4 — the video's "exit." This post is the record of automating that exit. With it done, the time from starting an episode to finishing it dropped from 20 hours 50 minutes to 6 hours 40 minutes — about a 14-hour cut.
Tools built this time
| Tool | Role |
|---|---|
| svg_to_png_batch.py | Detects generated SVGs in a target folder and batch-converts them to PNG |
| build_mp4_real.py | Combines slide PNGs and narration WAV clips per a JSON spec, and renders the final mp4 |
The main script, スライド画像加工.py, also went through revisions No.53–67 in this window, including further layout stabilization work.
Where the 14 hours were actually going
Before automating this part, a single episode looked like this:
| Time | Step |
|---|---|
| 10:10 | Official race entries published; prediction app build starts |
| 11:00–15:30 | Build slide materials from the results (pick featured races, pull horse-form data, analyze, write the narration script, generate narration WAV, auto-generate 34 slides) |
| 15:30 | Send the finished 34 slides (SVG), narration WAV, and script to the AI, requesting an mp4 |
| 15:35–21:35 | AI starts building the video → hits chat usage limit within minutes, 6-hour freeze |
| 21:35–5:30 (next day) | Resume; hits the limit again within minutes, another 6-hour freeze. Go to sleep |
| 5:30–6:00 | Resume, mp4 finished |
| 6:00–7:00 | Preview, prep, and upload to YouTube |
From the 10:10 build to the 7:00 upload the next morning: 20 hours 50 minutes, total. Most of that was dead time waiting for the AI chat to finish generating the video — and two separate usage-limit freezes alone ate 12 of those hours. The real cost wasn't the work itself; it was having no choice but to wait.
Replacing that with a local, self-contained pipeline using svg_to_png_batch.py and build_mp4_real.py changed the picture entirely:
| Time | Step |
|---|---|
| 10:10–15:30 | (same) build through auto-generating 34 slides |
| 15:30 | Convert the 34 slides (SVG) to PNG |
| 15:35 | Send the 34 converted PNGs, narration WAV, and script to the AI |
| 15:40–15:45 | AI produces slides_real.json (the composition blueprint) |
| 15:50 | Drop the 34 PNGs, WAV, and slides_real.json into the working folder and run build_mp4_real.py → mp4 done in 5 minutes |
| 15:55 | Preview |
| 16:25–16:50 | Prep and upload to YouTube |
From 10:10 to 16:50: 6 hours 40 minutes, total. What I now ask the AI to do isn't "generate the video" — it's just "produce the JSON blueprint." The actual rendering is handled by a local script in 5 minutes. There's no more waiting on chat usage limits at all.
20h50m → 6h40m (roughly a 14-hour cut)
Getting there took a fair amount of trial and error. Here's what actually happened.
① The Day I Saw "Tofu" in the PNG ─ A Misdiagnosis and Its Retraction
The PNG conversion step itself had long been manual, for a simple reason: batch-converting with Inkscape corrupted the fonts. I automated it with Krita's command-line mode instead, and it seemed to be working fine — until one morning, this came in:
A new problem has come up. The SVG→PNG batch conversion is working, but the text on that results-verification slide looks wrong. Please look into it.
My first diagnosis was:
The emoji in the "category" column (🟢BUY / 🟡SHOW / 🚫SKIP) are rendering as black-bordered boxes ("tofu" glyphs) in the PNGs converted by Krita.
The reasoning held together: the specified font has no glyphs for those emoji, and Krita uses its own text engine, so the OS-level font-fallback mechanism never kicks in. But when I precisely calculated the coordinates and zoomed into the actual cell, it was just a plain circle glyph, rendering fine. I'd misread the noise from an over-zoomed, coarsely-interpolated image as a real "tofu" glyph. I acknowledged the misdiagnosis and retracted it.
The real bug was somewhere else entirely. The summary text block on the right side of the results-verification slide, which should have wrapped onto 5 separate lines, was collapsing into one overlapping line.
Digging into the SVG, the culprit was a shape-inside CSS property left over on the text element:
<text style="shape-inside:url(#rect625829)">
<tspan x="1329" y="190">Featured races: 5 total...</tspan>
<tspan x="1329" y="224"/>
<tspan x="1329" y="257">1st place: 1 hit...</tspan>
...
</text>
Each tspan already carried its own correct y-coordinate, but Krita was attempting to interpret shape-inside (an SVG2 "flow text inside a shape" feature), failing, and drawing every line stacked on top of the others at the same position.
I remembered GIMP had bailed me out of a similar text-corruption issue before, so I switched the conversion engine to GIMP's batch mode:
cmd = [
"gimp", "-i", "-d",
"-b", (
'(let* ((image (car (file-svg-load RUN-NONINTERACTIVE '
f'"{svg_path}" "{svg_path}" 90 1920 1080 0))) '
'(drawable (car (gimp-image-flatten image)))) '
f'(file-png-save RUN-NONINTERACTIVE image drawable "{png_path}" "{png_path}" 0 9 1 1 1 1 1) '
'(gimp-image-delete image))'
),
"-b", "(gimp-quit 0)",
]
GIMP loads SVGs via librsvg, and its text rendering rides on the standard OS stack of Pango + fontconfig. Since it simply ignores SVG2 features like shape-inside it doesn't support, the correct per-line y-coordinates on each tspan end up taking effect as-is, and the 5 lines separate out correctly:
Result (GIMP version):
Featured races: 5 total, 4 place hits, hit rate 80%
1st place: 1 hit, hit rate 20%
2nd place: 2 hits, hit rate 40%
3rd place: 1 hit, hit rate 20%
All races: 35 total, 22 hits, place hit rate 62.9%
Lesson: The more visually striking a bug looks, the easier it is to get pulled toward the wrong diagnosis by whatever's most eye-catching. Tracing exact coordinates and cross-checking with an independent renderer was, in the end, the fastest path — even though it felt like the slow one.
Bonus: A Worksheet-vs-Workbook Mix-up
Around the same time, another bug surfaced: PNG conversion worked fine on its own, but the batch pipeline specifically kept failing.
# Before the fix (in the batch script)
stats_lines = M.build_kekka_stats_lines(wb["照合結果一覧"]) # ← passing a single sheet
Traceback (most recent call last):
File "batch_slide_auto.py", line 419, in main
stats_lines = M.build_kekka_stats_lines(wb["照合結果一覧"])
File "スライド画像加工.py", line 2873, in build_kekka_stats_lines
if "推しレースTOP5集計" not in wb.sheetnames:
AttributeError: 'Worksheet' object has no attribute 'sheetnames'
The callee had been changed to look up wb.sheetnames (the list of all sheet names in the workbook), but the caller was still passing a single sheet (a Worksheet object). It had been working fine from the standalone menu only because that other call site was correctly passing the whole workbook.
# After the fix
stats_lines = M.build_kekka_stats_lines(wb) # pass the whole workbook
The fix itself is one line, but it's a classic case of "one of two call sites didn't get the memo" — the kind of inconsistency that creeps into code with multiple entry points. Getting PNG conversion right doesn't mean much if the downstream batch integration trips over the next hurdle; the "exit" automation isn't done until every link in the chain works.
② Matching PNGs to WAVs ─ The Real Source of the "Mental Load"
Automating the step where slide PNGs and narration WAVs get combined into an mp4 first surfaced a mismatch: 3 stretches with no PNG assigned, and 3 more where two PNGs were competing for the same audio clip. Rather than guessing, I checked the auto-generated preview text embedded in each WAV filename one by one, verified everything, and mapped all 152 clips onto the 41 PNGs with nothing left over or missing.
Once that was done, the honest feedback came in:
Matching up the narration numbering with the slides took two and a half hours. And some numbers ended up missing. Something has to be done about this — I'd rather that than getting stopped by a usage limit, but it wears me out (mentally).
My first proposal was to eliminate the manual PNG-renaming step itself. That missed the point entirely.
The manual renaming itself doesn't really bother me. What wears me out is playing back the narration WAVs one by one and figuring out which slide each one matches.
The real burden wasn't "renaming" — it was "re-listening to the audio and judging which slide it belongs to." Once I understood what the burden actually was, the fix changed too. At the point of writing the script, the narrator already knows exactly where each transition happens — "here's where I switch to this horse's analysis," "here's where I go back to the prediction table." Adding one marker line at each of those points removes the need to ever re-listen and judge after the fact.
Top pick: Chris Regina, jockey Shun Samejima, Rank B.
(...analysis of the top pick...)
◆Slide change: Runner-up (Freagar)◆
Runner-up: Freagar, jockey Jun Nishimura, Rank A.
This applies the same idea as the marker-string approach from the previous post (embedding a dedicated marker string in the slide background SVG and searching-and-replacing it) — just extended to the narration script itself. It replaces the mentally-taxing "listen and check" step with a one-line marker written in the moment of scripting.
Introducing this wasn't something I got to guess my way through:
Don't do anything that uncertain — go check the actual code.
Checking the code, build_mp4_real.py reads a fixed filename, slides_real.json, via a relative path from the current working directory, and the image/audio paths inside that JSON are bare filenames with no folder prefix. In other words, both PNGs and WAVs need to sit flat in the execution folder — an operational constraint I hadn't fully appreciated. This later led to separating source material (mp4_task_folder/) from finished output (mp4_output/), preventing the kind of "forgot to clear the old files" accident that happens when swapping in a new episode.
Simplified, the marker logic looks like this: read the script text line by line, treat each occurrence of the marker (◆Slide change: ...◆) as a boundary, and pair up narration WAV clips with slide PNGs one-to-one within each segment.
MARKER_PATTERN = re.compile(r"◆Slide change: (.+?)◆")
def split_script_by_marker(script_lines):
"""Split the script into blocks at each marker line."""
blocks = []
current = {"label": "Opening", "lines": []}
for line in script_lines:
m = MARKER_PATTERN.match(line.strip())
if m:
blocks.append(current)
current = {"label": m.group(1), "lines": []}
else:
current["lines"].append(line)
blocks.append(current)
return blocks
By feeding this split result directly into slides_real.json as the source of truth for "which PNG pairs with which WAV clip," the mentally taxing task of "listen to the WAV and judge the slide" got replaced with a single marker line at script-writing time. It's a design shift that moves information a human used to reconstruct after the fact — by re-listening — up to the moment the person who knows it best (the script's author) is already writing it down.
Lesson: A proposal to eliminate "work that looks hard" and a proposal to eliminate "work the person actually finds draining" can be two different things. I should have asked what the real burden was before proposing a fix. Automating everything around a pain point does nothing meaningful unless you eliminate its actual source (in this case, the re-listening step itself).
Summary: Where the 14 Hours Went, and the Lessons That Don't Show Up as Numbers
Breaking down this round's time savings:
- The wait for AI-driven video generation — including two separate 6-hour freezes from hitting the chat usage limit — is essentially gone. This is where the bulk of the 14-hour cut came from.
- Narrowing what I ask the AI to do, from "generate the whole video" down to just "produce the slides_real.json blueprint," also shrank the AI's own working time to around 15 minutes.
- The actual rendering now finishes in 10 seconds via the local
build_mp4_real.py. Stalls from waiting simply can't happen anymore, by design.
There were a few takeaways that don't show up in the numbers, too:
- Don't let the most visually dramatic clue drive the diagnosis. The spot that looks the most broken isn't necessarily the actual cause — trace it with verifiable evidence like coordinates and logs instead.
-
Code with more than one entry point can drift out of sync in just one of them. Bugs like the
wb.sheetnamesmismatch — working fine from a standalone menu, but broken only through the batch pipeline — only turn up if you check every call site, not just the one you happened to test. - Ask what the real burden is before proposing a fix. "Work that looks hard" and "work someone actually finds draining" can be different things. Identify the actual source of the burden before you design around it.
- Don't move forward on a guess. Writing code on a "this is probably right" assumption always costs more later — true whether it's a layout calculation or a spec you assumed rather than confirmed.
The previous post took slide production from "20 hours" down to "3 hours, then 5 seconds" of manual work. This time, including the video's exit, the whole pipeline went from 20h50m to 6h40m. Solving one problem in independent development keeps surfacing the next one, and that cycle continued here too. Next time, I plan to write about the authentication headaches that stalled development of the chat-log management tool.