--- name: video-production description: "AI educational video production pipeline. Use when producing lecture videos, tutorial videos, or educational content. Covers script writing, slide generation (gpt-image-2 hand-drawn style or HTML), TTS narration (ElevenLabs) with ASR verification, subtitle alignment, and FFmpeg video assembly." --- # Video Production Skill End-to-end pipeline for producing AI-narrated educational videos with slides. Distilled from producing 40+ real videos for an AI-run YouTube channel (蝦說 AI), including every mistake we only made once. **Who this is for:** an AI agent (Claude, Codex, Gemini, or any coding agent) asked to produce a narrated slide video. A human can follow it too. ## Quick Start ``` 0. Read references/teaching-style.md + references/narration-style.md (content quality rules) 1. Create project directory and cd into it; copy config.json from references/config-example.json and fill it in 2. Write narration.json (the script — one string per slide) 3. Generate slides (Path A: gpt-image-2 hand-drawn / Path B: HTML + Playwright screenshot) 4. Visually inspect every slide PNG (wrong characters / clipping / unreadable fonts) 5. TTS narration → MP3 (with built-in ASR verification) → node scripts/tts_with_asr.js 6. FFmpeg assemble → video.mp4 → node scripts/assemble.js 7. Quality check (bitrate + frame extraction + visual) 8. Subtitles → SRT (+ optional burn-in) → node scripts/gen_subtitles.js 9. Cover image → thumbnail → python scripts/cover_gen.py 10. Upload wherever you publish; verify the thumbnail and visibility after upload ``` ## Requirements - **Node.js** ≥ 18 (scripts use only built-ins + `playwright` for HTML screenshots) - **Python** ≥ 3.9 (only for gpt-image-2 slide/cover generation and optional rescore) - **FFmpeg + FFprobe** on PATH (or set explicit paths in `config.json`) - **API keys** (environment variables, or a `.env` file in the project directory): - `ELEVENLABS_API_KEY` — TTS - `OPENAI_API_KEY` — Whisper ASR verification + gpt-image-2 slides/cover - A **TTS voice**: set `tts.voiceId` in `config.json` (any voice from your ElevenLabs voice library — a premade voice works out of the box; a cloned voice makes it yours). > ⚠️ Never hardcode API keys in scripts and never commit them to git. One of our keys > was auto-revoked from a public repo push and 32 videos rendered silent. Environment > variables only. --- ## Configuration Create `config.json` in your project directory (start from `references/config-example.json`): ```json { "tts": { "provider": "elevenlabs", "voiceId": "YOUR_ELEVENLABS_VOICE_ID", "model": "eleven_multilingual_v2", "maxRetries": 5 }, "asr": { "provider": "openai", "model": "whisper-1", "language": "zh", "passThreshold": 0.85 }, "video": { "width": 1920, "height": 1080, "audioBitrate": "192k", "slidePadding": 1.0 }, "branding": { "watermark": "🎬 Your Channel Name" }, "ffmpeg": "ffmpeg", "ffprobe": "ffprobe" } ``` --- ## Checklist (mandatory, do not skip steps) ``` □ 0. Read references/teaching-style.md — content/teaching rules (pre-flight constitution) □ 1. Write narration.json □ 1.5 ⭐ ALIGNMENT CHECK: narration entries count == slide count. MUST be equal! If narration splits a topic across 2 entries, there must be 2 separate slides. Mismatch = audio/visual desync for every slide after the mismatch point. □ 2. Generate slides (Path A or B below) □ 3. ⭐ Visual check: open every slide PNG with an image tool (wrong/garbled characters? clipped edges? readable on a phone?) □ 4. TTS synthesis + ⭐ ASR verification (similarity ≥ 0.85, never skip) □ 5. FFmpeg assemble → video.mp4 □ 6. ⭐ Quality check: ffprobe audio bitrate + extract a frame + visual verify □ 7. Subtitles → subtitles_aligned.srt (external track and/or burn-in) □ 8. ⭐ Cover image → thumbnail (16:9; pad, don't crop) □ 9. Upload (start unlisted/private, review, then publish) □ 10. ⭐ After upload: verify the thumbnail actually shows YOUR cover, not an auto frame ``` **The ⭐ steps are non-negotiable.** Each one exists because skipping it once ruined an entire batch of videos. > 🔴 **The cover is part of the video, not an optional extra.** We once shipped a video > where every step was done except the cover — it went live with a default gray frame. > "No cover = not shipped." --- ## Step 1: Script (`narration.json`) A JSON array of strings, **one per slide** (CRITICAL: array length MUST equal slide count): ```json [ "開場白,吸引注意力...", "第一個重點,配合比喻...", "第二個重點...", "結尾,call to action..." ] ``` ### Writing guidelines - **80–150 characters per slide** (Chinese). Too short = rushed; too long = TTS breaks. - **Conversational tone**: metaphors, rhetorical questions, humor. - **One concept per slide.** Don't cram. - **Story > Information.** Viewers remember stories, not bullet lists. - Full teaching methodology (audience empathy, glossing jargon, honest attribution, first-person endings…): `references/teaching-style.md`. Voice & TTS-safe writing: `references/narration-style.md`. ### TTS-friendly writing (important!) TTS engines mispronounce things. Prevent it at the script stage: | Problem | Fix | |---------|-----| | English abbreviations (LLM, NLP) | Spell out in Chinese, or letters with periods (P.U.A.) | | Raw numbers (135,000) | Chinese numerals (十三萬五千) | | Version numbers (4.5) | Display text keeps `4.5`, TTS text gets 四點五 | | Long sentences (>50 chars) | Split at natural breath points | | Parenthetical content | Rewrite as natural speech | | Chinese heteronyms 破音字 (還/重/長/得…) | Scan with references/heteronyms.json, rewrite | **Punctuation and pacing:** our TTS script strips punctuation before synthesis (each punctuation mark becomes a pause in Chinese TTS — dense commas produce machine-gun narration). Write narration that flows in breath-groups of ~8–22 characters; put commas only at real breath points. Subtitles still use the original punctuated text. --- ## Step 2: Slides Two first-class paths. Pick ONE per video (Path A is our channel default; Path B has no image-API dependency). ### Path A — gpt-image-2 hand-drawn teaching style (`scripts/slides_gen.py`) Full-bleed AI-generated slides in a "professor's hand-drawn lecture notes" style: white background, bold black CJK title top-left with underline, thin black arrows, lots of whitespace, a small mascot in the corner, stick figures only (no real faces). 1. Write one Chinese prompt per slide into `slides_prompts.json` (array of strings). Wrap every string that must appear on the slide in 「」. Always end the shared style block with: 「所有中文字必須完全正確、清楚可讀、不可有亂碼或錯字。數字要正確。」 2. `python scripts/slides_gen.py` → generates `slides_raw/slide_NN.png` (1536×1024). Batch ≤4–5 concurrent lanes (~120s per image). **Do not run Whisper at the same time** — concurrent heavy API calls starve each other. 3. **Visually inspect every image** (garbled characters / wrong or invented numbers / typos → regenerate just that slide; HTTP 502 → just retry that slide). gpt-image-2 WILL invent numbers to fill tables unless your prompt explicitly says 「畫面只能出現 X 這幾個數字,其他留空」. 4. `node scripts/pad_and_burn.js pad` → 1536×1024 scaled to 1410×940, padded onto a 1920×1080 white canvas with a 140px bottom band reserved for subtitles. ### Path B — HTML slides + Playwright screenshot (`scripts/screenshot.js`) Create one HTML file per slide: `slides/slide_01.html`, `slide_02.html`, … (template: `references/slide-template.html`), then `node scripts/screenshot.js`. Design rules (hard-won; mobile viewers are the majority): | Element | Minimum font size | |---------|-------------------| | Main title | **≥72px** | | Subtitle / section header | ≥36px | | Body text | **≥32px** (absolute floor) | | Key numbers | ≥48px | | Watermark | ≥20px (only element allowed smaller) | - Resolution = `video.width × video.height` (default 1920×1080). - **Fill 80%+ of the canvas** — `padding: 60-80px`, flexbox centering, no floating cards. - Every element on the slide must be mentioned in the narration (and vice versa). - 🔴 Never use <24px body text. Fewer points in bigger font > more points in tiny font. ### Path C — Node Canvas fallback (`scripts/generate_slides.js`) No browser, no image API: renders simple title+bullets slides from `slides.json`. Use only when Playwright and the image API are both unavailable. --- ## Step 3: TTS + ASR Verification (`scripts/tts_with_asr.js`) ```bash node scripts/tts_with_asr.js [project_dir] ``` Reads `narration.json`, synthesizes each entry via ElevenLabs, saves `audio/slide_XX.mp3`, then **verifies every clip with Whisper ASR**: 1. Transcribe the generated audio 2. Compute character-overlap similarity vs the original text 3. **≥ 0.85 = PASS** (target ≥0.90 for important videos) 4. < 0.85 = FAIL → adjust wording and retry (up to `tts.maxRetries`) **Adjustment rules:** swap synonyms / split long sentences / write numbers in Chinese — but never change the meaning, never drop information. **Don't chase 0.85 forever — verify the words, ship on redundancy.** Whisper produces false alarms on Chinese: same-sound different-tone homophones, digits vs 中文數字, Simplified output vs Traditional script. If the ASR "errors" are homophones and every key word/number comes through, the audio is correct — keep the best attempt. The slide shows the number visually and the subtitle uses the original text, so the viewer has triple redundancy. For digit-heavy narration, run `python scripts/rescore.py` — it strips numerals and compares toneless-pinyin multisets (≥0.90 = pass), which kills most false failures. A real defect = ASR gets the SAME wrong word consistently across multiple synth attempts (that's the TTS mispronouncing, not Whisper mishearing → rewrite that word). --- ## Step 4: FFmpeg Assembly (`scripts/assemble.js`) ```bash node scripts/assemble.js [project_dir] ``` Pairs each `slides/slide_XX.png` with `audio/slide_XX.mp3`, creates per-slide clips, concatenates into `video.mp4`. Key flags (all already in the script): - `-b:a 192k` — without it audio can silently render at 2kbps (present but inaudible) - `-tune stillimage` — much smaller files for slide video - `-pix_fmt yuv420p` — plays everywhere - `-movflags +faststart` — video streams/plays inline instead of "link won't open" > 🔴 **Mixing clips from different sources** (e.g. your narrated slides + a real screen > recording)? Do NOT use the concat demuxer with `-c copy` — strict players (Windows > Media Player) refuse to play the result. Re-encode through the concat FILTER into one > continuous stream, normalize every input (`scale`, `fps=30`, `format=yuv420p`, > 44100 stereo audio), re-encode audio, add faststart, and align loudness > (`loudnorm=I=-16:TP=-1.5:LRA=11`). Details in references/lessons-learned.md. > 🔴 **Mixing two TTS providers in one video?** Their sample rates differ (ElevenLabs > 44100 Hz vs OpenAI TTS 24000 Hz). Resample everything to 44100 BEFORE assembly or > some players choke exactly at the voice-switch point. --- ## Step 5: Quality Check (three checks, all required) ### 5a. Audio bitrate ```bash ffprobe -v error -show_entries stream=codec_type,codec_name,bit_rate -of default video.mp4 ``` ✅ ~130–192 kbps AAC ❌ ~2 kbps or N/A = TTS failed silently ### 5b. Visual verification ```bash ffmpeg -ss 3 -i video.mp4 -frames:v 1 -update 1 verify.png ``` Compare `verify.png` against `slides/slide_01.png` with an image tool. Content must match. ### 5c. Font size check Inspect `verify.png`: is every text element readable at mobile size? --- ## Step 6: Subtitles (`scripts/gen_subtitles.js`) ```bash node scripts/gen_subtitles.js [project_dir] ``` Produces `subtitles_aligned.srt`: **Whisper word timestamps for timing, original narration text for display** (never use ASR output as subtitle text — it mishears). Line breaks are width-aware (CJK=1, Latin=0.5, ≤16 full-width per line) and never cut inside an English word. > ⚠️ **The #1 subtitle bug: drift from assuming clip duration = audio + padding.** > FFmpeg's `-shortest` truncates the padding, so real clip duration ≈ audio duration. > The script therefore reads ACTUAL durations from `temp/clip_XX.mp4` with ffprobe. > If you ever hand-roll offsets: never `offset += audioDur + padding` — that drifts > +1s per slide and by slide 16 subtitles are 15 seconds late. Ship options: - **External SRT track** (recommended when your platform supports it — viewers can toggle, nothing covers the slides) - **Burn-in**: `node scripts/pad_and_burn.js burn` (Path A white-band layout: dark text FontSize 30 / MarginV 30 sits inside the reserved 140px band) For full-bleed dark HTML slides use FontSize=14–18, MarginV=6–12, BorderStyle=3 instead. For rough/cloned voices where Whisper timestamps collapse, there is a geometry-based fallback (ffmpeg `silencedetect` + character-width proportional alignment) — see references/lessons-learned.md § subtitle alignment. --- ## Step 7: Cover / Thumbnail (`scripts/cover_gen.py`) ```bash python scripts/cover_gen.py "一張 YouTube 影片封面,橫式 16:9,白底手繪教學風…主標題用超大粗黑體繁體中文寫「你的標題」…" ``` - Generate at 1536×1024, then **pad — do not crop — to 1280×720**: crop cuts off the top of your title. `scale=1080:720` + `pad=1280:720:100:0:color=white` (white side bars are invisible on a white cover). - Target ≤2MB for YouTube. - Same prompt hygiene as slides: quote exact text in 「」, forbid invented numbers, visually verify before shipping. --- ## Step 8: Upload Platform-specific; do it however you normally operate (browser automation, manual, CLI). Regardless of method, these rules survived contact with reality: 1. Upload as **unlisted/private first**, review the actual playback, then publish. 2. **Verify the thumbnail after upload** — the preview must show YOUR cover, not an auto-selected frame. 3. Re-check title/description/language settings after any dialog reopens (some upload wizards silently reset radio buttons). 4. If you used an external SRT: upload it as a caption track ("with timing"), publish the track, then verify captions actually appear on the watch page. 5. Disclose AI authorship in the description if the narration/production is AI-made. --- ## Output Structure ``` my-video-project/ ├── config.json ← project config (from references/config-example.json) ├── narration.json ← script text per slide ├── slides_prompts.json ← (Path A) one gpt-image-2 prompt per slide ├── slides_raw/ ← (Path A) raw 1536×1024 generations ├── slides/ ← final 1920×1080 PNGs (padded or screenshotted) │ ├── slide_01.png │ └── ... ├── audio/ │ ├── slide_01.mp3 │ └── ... ├── temp/ ← per-slide clips + whisper word caches ├── video.mp4 ← assembled (no subtitles) ├── subtitles_aligned.srt ← aligned subtitle track ├── video_sub.mp4 ← (optional) burned-in version └── thumbnail.jpg ← cover, 1280×720 ``` --- ## Scripts Reference All scripts take the project directory as an optional first argument (default: CWD). | Script | Purpose | |--------|---------| | `scripts/slides_gen.py` | gpt-image-2 slide generation from slides_prompts.json | | `scripts/pad_and_burn.js` | pad 3:2 images to 16:9 + subtitle band / burn SRT | | `scripts/screenshot.js` | Playwright HTML→PNG screenshots | | `scripts/generate_slides.js` | Node Canvas fallback slide renderer | | `scripts/tts_with_asr.js` | ElevenLabs TTS + Whisper ASR verification loop | | `scripts/assemble.js` | FFmpeg per-slide clips + concat | | `scripts/gen_subtitles.js` | aligned SRT (whisper timing + original text) | | `scripts/rescore.py` | homophone/digit-tolerant second-chance ASR scoring | | `scripts/cover_gen.py` | gpt-image-2 cover generation | --- ## Sub-agents If you spawn a sub-agent to produce a video, the task prompt must explicitly say **"read the video-production SKILL.md first"**. Sub-agents that aren't told skip the pipeline and reinvent (worse) wheels. Re-read the skill fresh each session — it evolves. ## When things break `references/lessons-learned.md` is the accident report archive: path bugs, silent audio, subtitle drift, moderation blocks on image generation, players that refuse concat-copied files, Whisper false alarms, batch API contention, and more. Read it before your first video; grep it when something looks weird — the odds are good we already hit it.