narrated-video
Skill0 starsChanged 4 months agoMITView on GitHub
Turn a long document nobody will read — a reading, report, analysis, research writeup, dossier — into a narrated 1080p MP4 with burned-in subtitles and visuals computed from the document's own data. Fully local and free (Kokoro TTS, cairosvg, ffmpeg); no GPU, no API, no video-generation model. Use this whenever the user wants a video, voiceover, narration, subtitles, or an explainer out of existing written material, or says people don't read the long version, wants something for social/demo/YouTube, wants to "make this watchable", or asks to convert a PDF/report/reading into video. Also use it when they ask what free TTS or video models exist for narrating their own content — the answer is usually this pipeline, not a video model.
5 other projects use a version of this.
Adopted by
Most recently taken up by
- StellarLucas/article-experttheir own version, first seen Nov 8, 2026
- AI-Business-Tools/claude-codetheir own version, first seen Nov 1, 2026
- Carthavate/narrated-video-templatetheir own version, first seen Nov 1, 2026
- every-app/video-skillstheir own version, first seen Oct 17, 2026
- lumisum/article-experttheir own version, first seen Aug 12, 2026
Other versions5 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 5
- every-app/video-skills★ 14same name
- AI-Business-Tools/claude-code★ 5same name
- lumisum/article-expert★ 2same name
- StellarLucas/article-expert★ 2same name
- Carthavate/narrated-video-template★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
The file
We split this skill into the rules it gives an agent
12 rules. 0 are common, 6 are distinctive, and 6 are very specific to this project.
0
Common
Most of the 3 edited versions we read keep these.
6
Distinctive
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 42
Run references/setup.md once per machine — Python 3.12 venv, kokoro-onnx, cairosvg, espeak-ng, and the Kokoro model weights.
- Very specific: line 49
Create a module with a SEGMENTS list.
- Distinctive: line 71
Strip the jargon, or the audience stops listening: If the source document uses a specialist vocabulary, translate it.
- Distinctive: line 84
Keep the document's best lines verbatim: If the source says *"you do the sovereign's work and then introduce yourself as the assistant"*, …
- Distinctive: line 91
Write what the material earns and check the estimate before synthesising.
- Very specific: line 112
Import the primitives from scripts/video_kit.py and write one function per visual, each taking (p, data) where p is 0→1 progress through …
- Distinctive: line 138
Show the comparison the sentence is making: If the narration says a value is unusual, put the other values next to it.
- Very specific: line 150
Use render_frames() from the kit — it walks the manifest, picks the active segment and the active subtitle for each frame, and rasterises:
- Very specific: line 203
Check: subtitles readable and on the audio.
- Very specific: line 207
If a frame looks sparse, check p before assuming it's broken — a frame sampled at the start of a segment is *supposed* to be mid-fade-in.
- Distinctive: line 223
If the user wants generative footage — a dreamlike sequence, b-roll, an imagined scene — this is the wrong tool.
- Distinctive: line 227
If the deliverable is a talking head, this pipeline still produces the narration and subtitles.
“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Narrated video from a document
Long documents don't get read. The fix is not a shorter document — it's the same content as something a person will sit through. This pipeline turns written material into a narrated MP4 with subtitles and visuals, entirely on the local machine.
The thing that makes it work: the visuals are computed from the document's own numbers. A generative video model cannot draw your data — it will produce handsome, unrelated footage, which is worse than nothing when the document's value is that it's specific and checkable. Everything here is SVG drawn from JSON, so if a bar moves on screen, a number moved in the source.
The shape
document + its underlying data
│
├─► narration script (you write this — the only judgment call)
│ │
│ └─► tts_narrate.py ──► per-sentence WAV + manifest + subtitles.srt
│ │ measured durations
│ ▼
└─► visual functions ──────────► render loop ──► f000000.png … (1080p)
│
▼
assemble.sh ──► narrated.mp4One model call writes the narration. Everything after it is deterministic code. Cost is roughly $0.15–0.40 per video at API rates plus ~18 minutes of CPU for a 10-minute runtime.
Before you start
Run references/setup.md once per machine — Python 3.12 venv, kokoro-onnx,
cairosvg, espeak-ng, and the Kokoro model weights. It has the two failure
modes that will otherwise cost you twenty minutes (espeak-ng phoneme data path,
and system Python 3.9 being too old).
Step 1 — Write the narration script
Create a module with a SEGMENTS list. Each segment is one visual and the
sentences spoken over it:
SEGMENTS = [
{
"id": "open", "visual": "gravities",
"sentences": [
"Almost everything in your chart drains toward a single point.",
"It is the strongest thing you carry.",
],
},
]Store sentences individually, not as a paragraph. The TTS step synthesises one sentence at a time and records each one's measured start and end. That is what lets subtitles sit exactly on the audio — an estimate drifts badly over ten minutes, and the drift is the first thing a viewer notices.
Three things decide whether the narration lands:
Strip the jargon, or the audience stops listening. If the source document uses a specialist vocabulary, translate it. Nobody outside the field knows what an antardasha or a p-value adjustment for family-wise error is, and a narrator saying it aloud sounds like a robot reading a foreign menu. Keep a banned-term list in the module docstring and assert against it — a mechanical check catches what your ear won't:
BANNED = ["dasha", "lagna", "ashtakavarga", "yogakaraka"]
text = " ".join(s for seg in SEGMENTS for s in seg["sentences"]).lower()
assert not [b for b in BANNED if b in text]Keep the document's best lines verbatim. If the source says "you do the sovereign's work and then introduce yourself as the assistant", that sentence is why the document works. Paraphrasing it into "you tend to under-credit yourself" throws away the only part a viewer will repeat afterwards.
Length follows from the material, not a target. Roughly 150 words per minute at a measured pace. A five-minute video is ~750 words; ten minutes is ~1,500. Write what the material earns and check the estimate before synthesising.
Step 2 — Synthesise the narration
./ttsenv/bin/python scripts/tts_narrate.py \
--script my_video_script.py --out audio --voice bm_george --speed 0.88Emits per-sentence WAVs, a joined narration.wav, manifest.json (measured
start/end for every sentence and segment), and a standards-compliant
subtitles.srt.
Voices worth knowing: bm_george and bm_lewis (British male, measured — good
for anything serious), am_michael (US male), af_bella and af_nicole (US
female). --speed around 0.85–0.90 reads as considered; 1.0 is noticeably
brisk for reflective material. Kokoro runs faster than you expect — synthesise
first and read the total, rather than tuning speed blind.
Step 3 — Write the visuals
Import the primitives from scripts/video_kit.py and write one function per
visual, each taking (p, data) where p is 0→1 progress through that segment:
from video_kit import frame, txt, bars, ring, fade, ease, W, H, BLUE
def v_scores(p, d):
o = fade(p) # opacity envelope: fade in, hold, fade out
return frame(
txt(W/2, 180, "how it scores", size=44, op=o) +
bars([(k, v) for k, v in d["scores"].items()], reveal=ease(p * 1.6))
)
VISUALS = {"scores": v_scores}The kit provides frame, txt, bars, ring, timeline, orb, wave,
fade, ease, stars, and a palette. Read scripts/video_kit.py — it is
short and the function signatures are the documentation.
Two habits separate visuals that add value from ones that fill space:
Pass real values in. bars(d["scores"]), not bars([("a", 80), ("b", 60)]).
The moment a number is typed by hand it can drift from the document, and nobody
will catch it.
Show the comparison the sentence is making. If the narration says a value is unusual, put the other values next to it. A lone number means nothing; the same number against six smaller ones is an argument. The strongest frame in the reference build was a plain bar chart — one planet at 15, everything else at 5 or below — because it made a claim visible rather than decorating one.
Animate with ease() rather than linear interpolation, and wrap each segment in
fade() so cuts land soft. There is no crossfade filter in the assembly step
because the opacity envelope already does it.
Step 4 — Render frames
Use render_frames() from the kit — it walks the manifest, picks the active
segment and the active subtitle for each frame, and rasterises:
from video_kit import render_frames
render_frames(VISUALS, data, "audio/manifest.json", "frames", fps=12)12 fps is the right default. These are typographic visuals, not motion footage; 12 fps looks identical to 24 and halves the render. cairosvg costs ~50 ms/frame for simple compositions and ~100 ms for busy ones at 1080p, so ten minutes of video is ~7,900 frames and roughly 7–13 minutes of single-threaded CPU. That is the pipeline's real bottleneck — if you ever need volume, parallelise this step, not anything else.
Preview every visual before committing to the full render. One frame per
visual at p=0.6 takes under a second and catches the composition problems that
would otherwise cost you a full render:
for name, fn in VISUALS.items():
svg = fn(0.6, data).replace("</svg>", subtitle("a sample line") + "</svg>")
cairosvg.svg2png(bytestring=svg.encode(), write_to=f"preview/{name}.png",
output_width=W, output_height=H)Then actually look at them. In the reference build this caught a flow diagram whose inflow arrows didn't reach the reservoir they were supposed to fill, and leak lines too thin to see at 1080p — both invisible in code, both obvious in the image.
Step 5 — Assemble
scripts/assemble.sh . out.mp4 12 frames audioTakes the working directory, output path, fps, frame directory, and audio
directory — the last two are parameters rather than defaults on purpose. When a
project has more than one video in flight, hardcoded frames/ will silently mux
the wrong project's frames onto the right project's narration, and the result
looks plausible enough to ship.
Step 6 — QA the encoded file, not the source
Pull frames back out of the MP4 and look at them:
for t in 12 90 240 400; do
ffmpeg -v error -ss $t -i out.mp4 -frames:v 1 -y qa/t$t.png
doneCheck: subtitles readable and on the audio; every visual reaches the state the
narration describes; no text overlapping a graphic; the last segment isn't cut
off. ffprobe the duration and confirm it matches the manifest's total.
If a frame looks sparse, check p before assuming it's broken — a frame sampled
at the start of a segment is supposed to be mid-fade-in.
Where this goes wrong
| Symptom | Cause |
| Subtitles drift late over a long video | Timings estimated from word count instead of measured per sentence |
moov atom not found from ffprobe | Encode still running; +faststart writes the index last |
| Chart renders as an empty box | SVG uses CSS custom properties the page never defines — declare them on :root or pass literal colours |
| Wrong footage muxed to right audio | Frame/audio directories defaulted instead of passed |
| espeak phoneme error on first TTS run | ESPEAK_DATA_PATH unset — see references/setup.md |
| Narration much shorter than planned | Kokoro is faster than 150 wpm; lower --speed, or add material |
When something else is the better answer
If the user wants generative footage — a dreamlike sequence, b-roll, an imagined scene — this is the wrong tool; that needs a video model (Wan, LTX, Hunyuan, or a hosted service), and on a Mac it needs rented GPU.
If the deliverable is a talking head, this pipeline still produces the narration and subtitles; only the face needs an external service.
And if the document has no data behind it — pure prose with nothing to plot — say so before building. You can still make a handsome typographic video, but be honest that the visuals are illustration rather than evidence, because the whole argument for this approach is that they usually aren't.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

