Explainer Video

commands beta no always-loaded rules -- loads on demand updated 2026-10-12

Turns a topic into a narrated, Manim-rendered .mp4, rendered headless with deterministic checks (one video and one audio stream, duration within 10 percent of the narration, one frame per scene). Voice: ElevenLabs when a key is set, else macOS say. Opt-in rung 4 of the output ladder.

Tags

  • video
  • manim
  • explainer
  • narration
  • output

README

explainer-video

Turns a topic into a narrated animation. The explainer-video skill researches the topic, writes a scene script and Manim scenes, and runs ccgm-explainer-render, which narrates, renders, joins, and checks the video. Rung 4 of the output ladder: /explain <topic> as video hands off here.

Status: beta. Rendering is headless. Nothing opens a window, plays audio, or takes focus. The tool writes the file and prints the path.

Install

bash start.sh --add explainer-video

The module is in the full preset only.

Prerequisites on the machine that renders:

brew install cairo pango pkg-config

You also need uv (it runs Manim without a global install) and ffmpeg (with ffprobe). The tool exits with missing required tool: <name> when one is absent.

What It Installs

File Installed to
skills/explainer-video/SKILL.md ~/.claude/skills/explainer-video/SKILL.md
bin/ccgm-explainer-render ~/.claude/bin/ccgm-explainer-render

Manual install: copy those two files to the targets and make the script executable.

How a Render Works

ccgm-explainer-render DIR [--scenes ID,ID] reads DIR/script.json and DIR/scenes.py. For each scene it:

  1. Synthesizes the narration and measures it with ffprobe.
  2. Renders the Manim class with uv run --with manim manim -qm, passing the measured length as SCENE_DURATION.
  3. Muxes picture and voice, and extracts one frame to DIR/frames/<id>.png.

It then joins the scenes into DIR/explainer.mp4 and checks it: exactly one video and one audio stream, and a total length within 10 percent of the narration. A failed check exits non-zero and names the check. The skill reads the frames, fixes overlapping or off-screen text, and rebuilds only the broken scenes with --scenes.

Voice Backends

The tool uses the first available backend and prints voice: <name>.

Order Backend Used when
1 ElevenLabs ELEVENLABS_API_KEY is set and the narration is within ELEVENLABS_MAX_CHARS (default 5000 per video). Over the cap, the tool says so and uses a local voice. ELEVENLABS_VOICE_ID overrides the voice.
2 macOS say Always on macOS. Writes to a file with say -o, then converts with ffmpeg. It never speaks aloud.

Voices Considered

Kokoro-82M (Apache-2.0) was vetted on 2026-10-12 and rejected: its weights ship as pickle (.pth and .pt), which the model-vetting checklist refuses. Revisit if a safetensors or ONNX release appears.

Tests

python3 -m pytest modules/explainer-video/tests -q
bash modules/explainer-video/tests/test-explainer-video.sh

The backend-chain, missing-tool, and lint-hook tests run everywhere. The two-scene render test needs macOS say, uv, ffmpeg, and cairo/pango. It skips with a printed note when any is missing.

Kill Criterion

The module stays active only if a sample video teaches an unfamiliar topic better than the same topic as a page (/explain <topic> as page). The operator decides after Epic 8 merges and records the verdict in the plan's decisions.md. If the video loses, this README marks the module parked and it gets no further work.

Will install

Path Action Target Type
skills/explainer-video/SKILL.md → skills/explainer-video/SKILL.md skill
bin/ccgm-explainer-render → bin/ccgm-explainer-render script

Dependencies

No dependencies.

Required by

No other module depends on this one.

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://ccgm.dev/modules/explainer-video.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install explainer-video@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

skill (1)

skills/explainer-video/SKILL.md

---
name: explainer-video
description: Turn a topic into a narrated, Manim-rendered .mp4, rendered headless with deterministic checks. Use when the operator asks for a video explanation (`/explain <topic> as video`). Writes the file and prints its path; never plays it.
disable-model-invocation: false
---

# Explainer Video

Rung 4 of the output ladder. Turn a topic into a 60 to 180 second narrated animation. Everything is headless: no preview window, no audio playback, no focus change. Write the file, print the path, and let the operator open it.

The prose answer still leads the final message. The video adds to it.

## Steps

### 1. Check the tools

Run each check as its own command.

```bash
test -x "$HOME/.claude/bin/ccgm-explainer-render"
```

If it fails, say the `explainer-video` module is not installed and offer a page instead (`/explain <topic> as page`). Stop.

```bash
command -v uv
```

If `uv` is missing, `ffmpeg` is missing, or Manim fails to build, `ccgm-explainer-render` names the missing piece. On macOS, `brew install cairo pango pkg-config` provides the libraries Manim builds against.

### 2. Research the topic

Read the code and docs the topic covers. Pick the three to six ideas a newcomer needs, in the order they build on each other.

### 3. Write `script.json`

Work in a new directory, for example `explainer-<topic>/`. Write `script.json`:

```json
{
  "title": "How the queue works",
  "scenes": [
    {
      "id": "intro",
      "scene": "Intro",
      "narration": "A queue holds work until a worker is free.",
      "description": "Title, then a box labeled queue."
    }
  ]
}
```

- `id`: letters, digits, and underscores. Names the build files and the frame.
- `scene`: the Manim class in `scenes.py`.
- `narration`: spoken text. One short sentence per beat. Follow the STE-lite limits in `writing-system`. Spell out symbols and acronyms the way a person says them. The render script runs `ccgm-prose-lint` on it when the linter is installed and prints the findings; fix them.
- `description`: what is on screen. Only you read it.
- Total narration: 60 to 180 seconds, about 150 words per minute. The render script prints a note outside that range.

### 4. Write `scenes.py`

Manim Community Edition. Use `Text`, shapes, and arrows only. No `MathTex`, `Tex`, or any LaTeX. No audio calls.

Each scene reads its length from the `SCENE_DURATION` environment variable (seconds, set from the measured narration) and holds until then.

```python
import os
from manim import Scene, Text, FadeIn

DURATION = float(os.environ.get("SCENE_DURATION", "4"))

class Intro(Scene):
    def construct(self):
        self.play(FadeIn(Text("Queue", font_size=48)), run_time=1)
        self.wait(max(0.0, DURATION - 1))
```

Keep the animations in a scene shorter than its duration. Keep every object inside the frame (about 14 units wide, 8 high): use `font_size` 24 or larger, call `.scale_to_fit_width(12)` on long text, and space objects so labels do not overlap. Start each scene from a clean frame.

### 5. Render

```bash
"$HOME/.claude/bin/ccgm-explainer-render" explainer-<topic>
```

The script picks a voice (ElevenLabs when `ELEVENLABS_API_KEY` is set and the text is within `ELEVENLABS_MAX_CHARS`, else macOS `say -o`) and prints which. It measures each narration, renders each scene to that length, joins them, and checks the result: one video stream, one audio stream, total length within 10 percent of the narration. It exits non-zero and names the failed check. It writes `explainer.mp4` and one frame per scene to `frames/`.

### 6. Inspect the frames

Read every PNG in `frames/`. Look for overlapping text, text or shapes off screen, and scenes that are empty. Fix `scenes.py`, then rebuild only the broken scenes:

```bash
"$HOME/.claude/bin/ccgm-explainer-render" explainer-<topic> --scenes intro,flow
```

Repeat until the frames are clean.

### 7. Report

Print the absolute path of `explainer.mp4`, then three lines: what the video covers, its length, and the voice used. Do not open or play the file.
script (1)

bin/ccgm-explainer-render

#!/usr/bin/env python3
"""ccgm-explainer-render: turn script.json + scenes.py into a narrated .mp4.

Usage: ccgm-explainer-render DIR [--scenes ID[,ID...]]

DIR holds:
  script.json  {"title": str, "scenes": [{"id", "scene", "narration", "description"}]}
               "id" names the build files and frame; "scene" is the Manim class.
  scenes.py    Manim CE scenes. Each reads its length in seconds from the
               SCENE_DURATION environment variable and holds until then.

For each scene the tool synthesizes narration, measures it with ffprobe,
renders the Manim class with that duration, muxes, and extracts one frame to
DIR/frames/<id>.png. It then joins the scenes into DIR/explainer.mp4 and checks
the result. With --scenes, only those scenes are rebuilt; the rest reuse the
files in DIR/build/.

Narration voice, first available: ElevenLabs (ELEVENLABS_API_KEY set and the
text within ELEVENLABS_MAX_CHARS, default 5000), then macOS `say -o FILE`. The
tool prints which one.

Headless: never previews a render, never plays audio, never opens a window.

Exit codes: 0 done, 1 a stage or check failed (named on stderr), 2 bad input
or a missing tool.
"""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path

DEFAULT_MAX_CHARS = 5000
TARGET_SECONDS = (60, 180)
DURATION_TOLERANCE = 0.10
ID_RE = re.compile(r"^[A-Za-z0-9_]+$")


class RenderError(Exception):
    def __init__(self, message, code=1):
        super().__init__(message)
        self.code = code


# --- backend chain -----------------------------------------------------------


def choose_backend(env, which, chars):
    """Return (backend, note). `which` maps a tool name to a path or None."""
    note = None
    if env.get("ELEVENLABS_API_KEY"):
        cap = int(env.get("ELEVENLABS_MAX_CHARS") or DEFAULT_MAX_CHARS)
        if chars <= cap:
            return "elevenlabs", None
        note = (
            f"narration is {chars} characters, above ELEVENLABS_MAX_CHARS={cap}; "
            "using the local voice"
        )
    if which("say"):
        return "say", note
    raise RenderError(
        "no narration backend: set ELEVENLABS_API_KEY or run on macOS for `say`",
        code=2,
    )


def synth_say(text, raw):
    raw = raw.with_suffix(".aiff")
    run(["say", "-o", str(raw), "-f", "-"], "narration (say)", input=text)
    return raw


def synth_elevenlabs(text, raw):
    raw = raw.with_suffix(".mp3")
    voice = os.environ.get("ELEVENLABS_VOICE_ID") or "21m00Tcm4TlvDq8ikWAM"
    req = urllib.request.Request(
        f"https://api.elevenlabs.io/v1/text-to-speech/{voice}?output_format=mp3_44100_128",
        data=json.dumps({"text": text, "model_id": "eleven_multilingual_v2"}).encode(),
        headers={
            "xi-api-key": os.environ["ELEVENLABS_API_KEY"],
            "Content-Type": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            raw.write_bytes(resp.read())
    except OSError as e:
        raise RenderError(f"narration (elevenlabs): {e}")
    return raw


SYNTH = {"say": synth_say, "elevenlabs": synth_elevenlabs}


# --- helpers -----------------------------------------------------------------


def run(cmd, stage, **kw):
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True, **kw)
    except OSError as e:
        raise RenderError(f"{stage}: {e}")
    if proc.returncode != 0:
        tail = "\n".join((proc.stderr or proc.stdout).strip().splitlines()[-15:])
        raise RenderError(f"{stage} failed (exit {proc.returncode}):\n{tail}")
    return proc


def require_tools(which):
    for tool in ("uv", "ffmpeg", "ffprobe"):
        if not which(tool):
            raise RenderError(f"missing required tool: {tool}", code=2)


def probe(path, *args):
    out = run(
        ["ffprobe", "-v", "error", *args, "-of", "json", str(path)], f"ffprobe {path.name}"
    ).stdout
    return json.loads(out)


def duration(path):
    return float(probe(path, "-show_entries", "format=duration")["format"]["duration"])


def load_script(proj):
    path = proj / "script.json"
    if not path.is_file() or not (proj / "scenes.py").is_file():
        raise RenderError(f"{proj} needs script.json and scenes.py", code=2)
    try:
        scenes = json.loads(path.read_text())["scenes"]
    except (ValueError, KeyError) as e:
        raise RenderError(f"script.json unreadable: {e!r}", code=2)
    if not scenes:
        raise RenderError("script.json has no scenes", code=2)
    seen = set()
    for s in scenes:
        for key in ("id", "scene", "narration"):
            if not isinstance(s.get(key), str) or not s[key].strip():
                raise RenderError(f"scene {s.get('id', '?')}: missing {key}", code=2)
        if not ID_RE.match(s["id"]) or not ID_RE.match(s["scene"]) or s["id"] in seen:
            raise RenderError(f"scene id/class invalid or repeated: {s['id']}", code=2)
        seen.add(s["id"])
    return scenes


def lint_narration(texts, home=None):
    """Run ccgm-prose-lint on the narration when installed; None when absent."""
    linter = Path(home or Path.home()) / ".claude" / "bin" / "ccgm-prose-lint"
    if not os.access(linter, os.X_OK):
        return None
    proc = subprocess.run(
        [str(linter), "-"], input="\n\n".join(texts), capture_output=True, text=True, timeout=30
    )
    return (proc.stdout + proc.stderr).strip()


# --- per-scene pipeline --------------------------------------------------------


def synthesize(scene, backend, build):
    raw = SYNTH[backend](scene["narration"], build / scene["id"])
    wav = build / f"{scene['id']}.wav"
    run(["ffmpeg", "-y", "-v", "error", "-i", str(raw), "-ar", "44100", "-ac", "1", str(wav)],
        f"convert narration {scene['id']}")
    return wav


def render_scene(proj, scene, seconds, build):
    media = build / "media" / scene["id"]
    shutil.rmtree(media, ignore_errors=True)
    env = {**os.environ, "SCENE_DURATION": f"{seconds:.3f}"}
    run(
        ["uv", "run", "--with", "manim", "manim", "-qm", "--disable_caching",
         "--progress_bar", "none", "-v", "WARNING", "--media_dir", str(media),
         "-o", scene["id"], "scenes.py", scene["scene"]],
        f"manim render {scene['id']} (needs `brew install cairo pango` on macOS)",
        cwd=proj, env=env,
    )
    found = sorted(media.rglob(f"{scene['id']}.mp4"))
    if not found:
        raise RenderError(f"manim render {scene['id']}: no output video under {media}")
    return found[0]


def mux_scene(video, wav, seconds, out):
    run(
        ["ffmpeg", "-y", "-v", "error", "-i", str(video), "-i", str(wav),
         "-filter_complex", f"[0:v]tpad=stop_mode=clone:stop_duration={seconds:.3f}[v]",
         "-map", "[v]", "-map", "1:a", "-t", f"{seconds:.3f}",
         "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30",
         "-c:a", "aac", "-ar", "44100", "-ac", "1", str(out)],
        f"mux {out.stem}",
    )


def extract_frame(segment, seconds, out):
    out.parent.mkdir(exist_ok=True)
    run(["ffmpeg", "-y", "-v", "error", "-ss", f"{seconds * 0.8:.3f}", "-i", str(segment),
         "-frames:v", "1", str(out)], f"frame {out.stem}")


def concat(segments, build, out):
    lst = build / "concat.txt"
    lst.write_text("".join(f"file '{s.name}'\n" for s in segments))
    run(["ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", lst.name,
         "-c", "copy", str(out.resolve())], "concat", cwd=build)


def verify(video, narration_total):
    streams = probe(video, "-show_entries", "stream=codec_type")["streams"]
    counts = {k: sum(s["codec_type"] == k for s in streams) for k in ("video", "audio")}
    if counts != {"video": 1, "audio": 1} or len(streams) != 2:
        raise RenderError(f"check streams: want 1 video + 1 audio, got {counts}")
    total = duration(video)
    if abs(total - narration_total) > DURATION_TOLERANCE * narration_total:
        raise RenderError(
            f"check duration: video {total:.1f}s vs narration {narration_total:.1f}s "
            f"(allowed +/-{DURATION_TOLERANCE:.0%})"
        )
    return total


# --- main --------------------------------------------------------------------


def render(proj, only):
    require_tools(shutil.which)
    scenes = load_script(proj)
    ids = [s["id"] for s in scenes]
    todo = [s for s in scenes if only is None or s["id"] in only]
    unknown = set(only or ()) - set(ids)
    if unknown or not todo:
        raise RenderError(f"--scenes names no known scene: {sorted(unknown)}", code=2)

    build = proj / "build"
    build.mkdir(exist_ok=True)
    for s in scenes:
        if s not in todo and not (build / f"{s['id']}.mp4").is_file():
            raise RenderError(f"--scenes needs a prior full render; {s['id']} has no build", code=2)

    chars = sum(len(s["narration"]) for s in todo)
    backend, note = choose_backend(os.environ, shutil.which, chars)
    print(f"voice: {backend}")
    if note:
        print(f"note: {note}")
    lint = lint_narration([s["narration"] for s in scenes])
    if lint:
        print(f"lint:\n{lint}")

    for s in todo:
        wav = synthesize(s, backend, build)
        seconds = duration(wav)
        print(f"scene {s['id']}: narration {seconds:.1f}s")
        video = render_scene(proj, s, seconds, build)
        seg = build / f"{s['id']}.mp4"
        mux_scene(video, wav, seconds, seg)
        extract_frame(seg, seconds, proj / "frames" / f"{s['id']}.png")

    narration_total = sum(duration(build / f"{i}.wav") for i in ids)
    if not TARGET_SECONDS[0] <= narration_total <= TARGET_SECONDS[1]:
        print(f"note: narration is {narration_total:.0f}s; the target is "
              f"{TARGET_SECONDS[0]}-{TARGET_SECONDS[1]}s")
    out = proj / "explainer.mp4"
    concat([build / f"{i}.mp4" for i in ids], build, out)
    total = verify(out, narration_total)
    print(f"checks: 1 video + 1 audio stream; {total:.1f}s vs {narration_total:.1f}s narration")
    print(f"frames: {proj / 'frames'}")
    print(f"video: {out}")


def main(argv=None):
    ap = argparse.ArgumentParser(description="Render a narrated Manim explainer video.")
    ap.add_argument("dir", type=Path)
    ap.add_argument("--scenes", help="comma-separated scene ids to rebuild")
    args = ap.parse_args(argv)
    only = set(args.scenes.split(",")) if args.scenes else None
    try:
        render(args.dir.resolve(), only)
    except RenderError as e:
        print(f"error: {e}", file=sys.stderr)
        return e.code
    return 0


if __name__ == "__main__":
    sys.exit(main())