---
name: manimate
description: Generate diagram and animation videos from natural language descriptions using Manim. Outputs MP4 (default) or GIF on request.
title: manimate
canonical_url: https://skilld.dev/gh/bassimeledath/manimate
last_updated: 2026-10-07T10:06:51.000Z
---

> Index of every page on this site: [llms.txt](https://skilld.dev/llms.txt)

> **Skill from skilld.dev.** Follow the user's requested action.
> If the user asked to fork this Skill, follow the fork workflow below. Do not execute the Skill instructions while copying it.
> Otherwise, follow the instructions below for this session. You do not need to install anything.
>
> 23 supporting files sit beside this SKILL.md. When the Skill refers to one, fetch https://skilld.dev/api/skills-raw/bassimeledath/manimate/manimate/PATH with PATH replaced.
>
> If the user asked to install this Skill, run `npx skilld install bassimeledath/manimate/manimate`. Install writes the Skill files into the project, so every session loads them.
>
> ## Fork workflow
>
> A fork creates an editable local Skill with its original author and licence. The request authorizes copying and local installation.
> 1. Check `./skills/manimate`, the project lockfile, and selected Agent targets together. If the local directory or installed Skill exists, stop. Never overwrite an existing directory or Agent target.
> 2. Read [source metadata](https://skilld.dev/api/v1/skills/bassimeledath/manimate/manimate) once. Use sourceUrl, sourceCommit, skillPath, sourceGone, and license. If the source is gone or its path is missing, stop. If license is null, read licence files at the source commit.
> 3. Fetch only the source commit into a temporary Git repository. Do not clone full history. Derive repository_url from sourceUrl, including repository renames. If sourceCommit is absent, resolve the sourceUrl ref once. Set source_commit to that actual commit. Run these commands in one shell call:
>
> ```sh
> git init --quiet "$temporary_dir"
> git -C "$temporary_dir" fetch --quiet --depth=1 "$repository_url" "$source_commit"
> git -C "$temporary_dir" checkout --quiet --detach FETCH_HEAD
> ```
>
> Read applicable licence declarations and notices at that commit. If copying is not permitted, report the restriction and stop.
> 4. Inspect source entries together, then copy the directory containing skillPath into `./skills/manimate`. Use the user's path if selected. Keep the original SKILL.md, relative links, scripts, binary assets, and executable modes. Exclude .git metadata. Reject symlinks and paths outside the Skill directory. After checking entries, use cp -a where available. A regular source directory needs no custom copy script. Do not save this page wrapper as SKILL.md.
> Preserve author credit, notices, and applicable licence files from repository or parent directories. Add PROVENANCE.md with the Skill page, source URL, actual commit, original path, and licence. Retain any existing PROVENANCE.md and record new provenance separately. Batch source inspection, copying, and provenance work where practical.
> 5. In the project root, run `skilld install ./skills/manimate --mode copy --plain`. If skilld is unavailable, use `npx skilld install ./skills/manimate --mode copy --plain`. This known command needs no help lookup. Install does not support --json. Use detected Agent targets, or add --agent for the targets the user selected. Install the local path, never the upstream selector. If installation fails, preserve the local copy and report the exact failure.
> 6. Confirm the local lockfile source and installed Agent copies once. Report the local path, actual commit, and Agent targets. After edits, reinstall the same local path. Upstream updates must not replace it. Do not publish or push unless the user asks.

# /manimate — Manim Animation Video Maker

Generate diagram and animation videos from natural language descriptions using Manim. Outputs MP4 video by default; GIF available on request.

## Usage

```
/manimate "explain how binary search works"
/manimate "show the Pythagorean theorem proof"
/manimate "visualize bubble sort step by step"
```

## Pipeline

When the user invokes `/manimate`, execute these 12 steps in order:

---

### Step 1: Parameter Inference

Parse the user's prompt and infer rendering parameters:

| Parameter | Range | Default | How to infer |
|-----------|-------|---------|--------------|
| `scenes` | 1-6 | 2 | Count distinct concepts/steps/phases in the prompt |
| `quality` | l/m/h | h | Default high; use medium for quick drafts |
| `format` | gif/mp4/both | mp4 | Default mp4; use gif or both only if user explicitly requests GIF |
| `style` | educational/minimal/cinematic | educational | Infer from the tone/subject |
| `duration_per_scene` | 5-15s | 8 | Longer for complex concepts, shorter for simple transitions |

Write to `$WORK_DIR/params.json`:

```json
{
  "prompt": "explain how binary search works",
  "scenes": 3,
  "quality": "h",
  "format": "mp4",
  "style": "educational",
  "duration_per_scene": 8
}
```

Write `$WORK_DIR/manim.cfg`:

```ini
[CLI]
quality = high_quality
format = mp4
renderer = cairo
disable_caching = True

[output]
media_dir = media
video_dir = {media_dir}/videos
images_dir = {media_dir}/images
```

> **Why `renderer = cairo`**: Cairo is the safe default for headless/CI environments. It requires no GPU, no display server, and no OpenGL context.

---

### Step 2: Preflight Checks

Verify dependencies and set pipeline-wide capability flags:

```bash
# Required: Python 3.8+
python3 --version 2>/dev/null || { echo "python3 not found"; exit 1; }

# Required: ManimCE
python3 -c "import manim; print(f'manim {manim.__version__}')" 2>/dev/null || {
  echo "manim not found. Install: pip install manim"
  exit 1
}

# Required: ffmpeg
command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg not found"; exit 1; }

# Optional: LaTeX + dvisvgm
LATEX_AVAILABLE=false
if command -v latex >/dev/null 2>&1 && command -v dvisvgm >/dev/null 2>&1; then
  LATEX_AVAILABLE=true
  echo "LaTeX + dvisvgm available — MathTex/Tex enabled"
else
  echo "LaTeX or dvisvgm not found. Falling back to Text-only mode."
  echo "  Install: brew install --cask mactex-no-gui (macOS) or apt install texlive-full (Linux)"
fi

# Detect timeout command (used for render timeouts in Step 10)
TIMEOUT_CMD=""
if command -v gtimeout >/dev/null 2>&1; then
  TIMEOUT_CMD="gtimeout"
elif command -v timeout >/dev/null 2>&1; then
  TIMEOUT_CMD="timeout"
else
  echo "Neither timeout nor gtimeout found. Render hangs won't be caught."
fi
```

Create a **run-scoped** working directory to allow concurrent pipeline executions:

```bash
WORK_DIR=".manimate-$(date +%s)-$$"
mkdir -p "$WORK_DIR"/scenes "$WORK_DIR"/assets "$WORK_DIR"/lastframes "$WORK_DIR"/output
echo "Working directory: $WORK_DIR"
```

> **`WORK_DIR` is the pipeline root for this run.** All subsequent steps reference `$WORK_DIR` instead of a hardcoded `.manimate` path. This prevents data loss when multiple `/manimate` invocations run concurrently.

> **Pipeline-wide LATEX_AVAILABLE flag**: When `false`, scene code must NOT use MathTex or Tex — use Text() for all text, including math expressions. Render equations as Unicode or ASCII.

---

### Step 3: Story Decomposition

Break the prompt into scenes. Each scene specifies visual elements, animations, and narrative arc.

**Default to SVG icons for every real-world concept.** For each scene, identify concepts that can be icons and add them to `asset_manifest`. Every video should have at least 2 SVG assets — if the manifest is empty, revisit the decomposition.

Use basic Manim shapes **only** for: array cells, flowchart boxes, graphs/axes, code blocks, containers, math expressions. **Everything else gets an SVG.**

Write `$WORK_DIR/story.json` with a top-level `asset_manifest` and per-scene `svg_assets` referencing manifest keys:

```json
{
  "title": "How Binary Search Works",
  "asset_manifest": {
    "magnifier_icon": {
      "description": "Magnifying glass with circular lens and angled handle",
      "viewbox": "0 0 64 64",
      "primary_color_token": "ACCENT",
      "used_in": [1]
    },
    "checkmark_icon": {
      "description": "Bold checkmark inside a rounded square",
      "viewbox": "0 0 64 64",
      "primary_color_token": "SUCCESS",
      "used_in": [3]
    }
  },
  "scenes": [
    {
      "id": 1,
      "title": "The Problem",
      "description": "Show a sorted array of numbers. Highlight that we need to find a target value.",
      "visual_elements": ["sorted array of boxes with numbers", "target value highlighted"],
      "animations": ["Create array", "Highlight target", "Write question text"],
      "svg_assets": ["magnifier_icon"],
      "scene_class": "TheProblem",
      "duration": 8,
      "template": "basic",
      "text_elements": ["title: 3 words", "description: 12 words"],
      "estimated_reading_pauses": 6.0,
      "continuity_in": null,
      "continuity_out": "Array remains visible, target highlighted"
    }
  ],
  "shared_style": {
    "NOTE": "LOCKED — copy these values verbatim into shared.py. Do NOT change any hex code.",
    "bg_color": "#2a2a3a",
    "surface_color": "#3a3a4a",
    "border_color": "#4a4a5a",
    "primary_color": "#ff3366",
    "accent_color": "#33ccff",
    "highlight_color": "#ffcc00",
    "success_color": "#66ff66",
    "negative_color": "#ff4444",
    "text_color": "#ffffff",
    "muted_color": "#8a8aaa",
    "font_heading": "Helvetica Neue",
    "font_body": "Helvetica Neue",
    "font_code": "Monaco",
    "font_size_title": 44,
    "font_size_body": 26
  },
  "latex_available": true
}
```

**`asset_manifest` schema**: Each key is an asset ID (snake_case). Fields:
- `description` — what the icon depicts, enough detail for accurate SVG generation
- `viewbox` — SVG viewBox (tall: `"0 0 80 100"`, square: `"0 0 64 64"`, wide: `"0 0 100 60"`)
- `primary_color_token` — which palette token to use as the main fill (`PRIMARY`, `ACCENT`, `HIGHLIGHT`, `SUCCESS`, `NEGATIVE`)
- `used_in` — list of scene IDs that use this asset

**Per-scene `svg_assets`** is a list of asset IDs from the manifest (not freeform hints). If a scene needs no SVG assets, use an empty list `[]`.

**Asset density target**: Aim for 1-2 SVG assets per scene, 3-6 per video. Scenes with SVG icons are dramatically more engaging than scenes with only basic shapes.

**If no scenes need SVG assets** (e.g., a pure math derivation), set `asset_manifest` to `{}` and all `svg_assets` to `[]`. Steps 6-7 will no-op.

**Continuity rules:**
- `continuity_out` of scene N must match `continuity_in` of scene N+1
- Shared visual elements should use identical styling constants
- Color palette must be consistent across all scenes (defined in `shared_style`)

**Pacing rules:**
- `text_elements` lists each text block with its approximate word count — used to calculate reading pauses
- `estimated_reading_pauses` is the total seconds of `self.wait()` needed for reading time (sum of `max(2, words / 3)` for each text block)
- `duration` must be >= animation time + `estimated_reading_pauses` — increase duration if needed to fit reading time
- Use the formula: **`self.wait(max(2, word_count / 3))`** after every text appearance

---

### Step 4: Outline Confirmation

Before generating any code, present the story outline to the user for review and approval.

**Build a readable summary from `$WORK_DIR/story.json`:**

```
Scene Outline for: "{title}"

  Scene 1: {scene_title}
    {description}
    Key visuals: {visual_elements joined as comma-separated list}
    Duration: {duration}s

  Scene 2: {scene_title}
    {description}
    Key visuals: {visual_elements joined as comma-separated list}
    Duration: {duration}s

  ...

SVG Assets to generate:
  - magnifier_icon
    Icon: Magnifying glass with circular lens and angled handle
    Color: ACCENT (#33ccff)
    Used in: Scene 1
  - checkmark_icon
    Icon: Bold checkmark inside a rounded square
    Color: SUCCESS (#66ff66)
    Used in: Scene 3

Total duration: {sum of all durations}s
Output format: MP4 (default). Would you like GIF, or both?
```

**Present this outline to the user and ask:**

```
Does this outline look good? You can:
  1. Approve and continue
  2. Request changes (add/remove/reorder scenes, adjust descriptions, change durations)
  3. Change output format (mp4 / gif / both)
```

**⛔ HARD STOP — Do NOT proceed past this point until the user explicitly approves.**

After presenting the outline, STOP. Do not generate any code, write any files, or start any subsequent steps. Wait for the user to respond. This is a mandatory approval gate.

**Revision loop:**

- If the user requests changes, update `$WORK_DIR/story.json` accordingly (add/remove scenes, edit descriptions, adjust durations, etc.) and re-present the outline.
- If the user changes the output format, update `$WORK_DIR/params.json` (`format` field) and `$WORK_DIR/manim.cfg` to match.
- Repeat until the user explicitly approves (e.g., "looks good", "approved", "go ahead", "yes").

Once — and ONLY once — the user explicitly approves, proceed to Step 5.

---

### Step 5: Shared Preamble Generation

Generate `$WORK_DIR/shared.py` — a single module containing palette constants, helpers, and asset loading that all scenes import. This eliminates ~50 lines of duplicated boilerplate from each scene file.

**Write `$WORK_DIR/shared.py`** by copying the code block below VERBATIM. Do NOT change ANY hex value — not even the background tones (BG, SURFACE, BORDER). The exact hex codes below are the Creative Chaos brand palette and must appear character-for-character in the generated file:

```python
from manim import *
import tempfile, os

# ── Creative Chaos Dark — LOCKED palette, do not modify ──
BG        = "#2a2a3a"
SURFACE   = "#3a3a4a"
BORDER    = "#4a4a5a"
PRIMARY   = "#ff3366"
ACCENT    = "#33ccff"
HIGHLIGHT = "#ffcc00"
SUCCESS   = "#66ff66"
NEGATIVE  = "#ff4444"
TEXT_CLR  = "#ffffff"
TEXT_DIM  = "#8a8aaa"

# ── Asset directory ──
ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")


def svg_icon(svg_string, scale=1.0):
    """Write inline SVG to temp file and load as SVGMobject.
    Use for rare one-off SVGs (under 5 lines). Prefer load_asset() for validated assets."""
    tmpdir = tempfile.mkdtemp()
    path = os.path.join(tmpdir, "icon.svg")
    with open(path, "w") as f:
        f.write(svg_string)
    return SVGMobject(path).scale(scale)


def load_asset(asset_id, scale=1.0):
    """Load a validated SVG asset from the assets directory."""
    path = os.path.join(ASSET_DIR, f"{asset_id}.svg")
    if not os.path.exists(path):
        raise FileNotFoundError(f"Asset not found: {path}")
    return SVGMobject(path).scale(scale)


def tw(text_str):
    """Calculate reading time wait duration: max(2, word_count / 3)."""
    return max(2, len(text_str.split()) / 3)


def dot_grid():
    """Create the signature Creative Chaos dot grid background."""
    return VGroup(*[
        Dot([x, y, 0], radius=0.02, fill_opacity=0.08, color=TEXT_CLR)
        for x in range(-7, 8) for y in range(-4, 5)
    ])


def setup_scene(scene):
    """Set background color and add dot grid. Call at the start of construct()."""
    scene.camera.background_color = BG
    scene.add(dot_grid())


def title_card(scene, text, wait=2.0):
    """Show title with signature underline, then move to corner.

    Args:
        scene: the Scene instance (pass `self` from construct)
        text: the title string
        wait: seconds to display before moving to corner (default 2.0)
    Returns:
        title Mobject (now in the UL corner at scale 0.55)
    """
    title = Text(text, font="Helvetica Neue", font_size=44, color=TEXT_CLR, weight=BOLD)
    underline = Line(
        title.get_left() + DOWN * 0.35,
        title.get_right() + DOWN * 0.35,
        color=PRIMARY, stroke_width=2.5,
    )
    scene.play(
        FadeIn(title, shift=UP * 0.4),
        GrowFromCenter(underline),
        run_time=0.7,
    )
    scene.wait(wait)
    scene.play(
        title.animate.scale(0.55).to_corner(UL, buff=0.5),
        FadeOut(underline, run_time=0.3),
        run_time=0.5,
    )
    return title


def make_node(label, color=None, w=2.5, h=0.8):
    """Create a labeled rounded rectangle node. Box auto-sizes to fit text."""
    if color is None:
        color = PRIMARY
    text = Text(label, font="Helvetica Neue", font_size=22, color=TEXT_CLR)
    box_w = max(w, text.width + 0.6)
    box_h = max(h, text.height + 0.4)
    box = RoundedRectangle(
        corner_radius=0.15, width=box_w, height=box_h,
        fill_color=SURFACE, fill_opacity=1,
        stroke_color=color, stroke_width=1.5,
    )
    text.move_to(box)
    return VGroup(box, text)


def progress_bar(width=8, height=0.4, fill_color=None):
    """Create a progress bar. Returns VGroup(track, fill) with fill at 0%.
    Animate with: self.play(set_progress(bar, 0.75), run_time=1.0)"""
    if fill_color is None:
        fill_color = PRIMARY
    pad = height * 0.12
    track = RoundedRectangle(
        corner_radius=height / 2, width=width, height=height,
        fill_color=SURFACE, fill_opacity=1,
        stroke_color=BORDER, stroke_width=1.5,
    )
    fill = RoundedRectangle(
        corner_radius=max(0.05, (height - 2 * pad) / 2),
        width=pad, height=height - 2 * pad,
        fill_color=fill_color, fill_opacity=1, stroke_width=0,
    )
    fill.align_to(track, LEFT).shift(RIGHT * pad)
    return VGroup(track, fill)


def set_progress(bar, pct):
    """Return animation for bar fill to reach pct (0.0-1.0).
    Rebuilds the fill shape each frame to avoid .animate vertex interpolation artifacts."""
    track, fill = bar[0], bar[1]
    pad = track.height * 0.12
    start_w = fill.width
    target_w = max(pad, (track.width - 2 * pad) * max(0.0, min(1.0, pct)))
    cr = max(0.05, (track.height - 2 * pad) / 2)
    fc = fill.get_fill_color()

    def _update(mob, alpha):
        w = interpolate(start_w, target_w, alpha)
        mob.become(RoundedRectangle(
            corner_radius=cr, width=w, height=track.height - 2 * pad,
            fill_color=fc, fill_opacity=1, stroke_width=0,
        ))
        mob.move_to([track.get_left()[0] + pad + w / 2, track.get_center()[1], 0])

    return UpdateFromAlphaFunc(fill, _update)


def make_cell(value, color=None, w=0.7, h=0.7):
    """Create a data cell — sharp-cornered square with a number inside.
    Use for array elements, grid data, table cells."""
    if color is None:
        color = PRIMARY
    box = Square(
        side_length=max(w, h),
        fill_color=SURFACE, fill_opacity=0.6,
        stroke_color=color, stroke_width=1.5,
    )
    text = Text(str(value), font="Monaco", font_size=22, color=TEXT_CLR)
    text.move_to(box)
    return VGroup(box, text)


def make_array(values, color=None, cell_w=0.7, cell_h=0.7, buff=0.05):
    """Create a horizontal array of data cells."""
    if color is None:
        color = PRIMARY
    cells = VGroup(*[make_cell(v, color, cell_w, cell_h) for v in values])
    cells.arrange(RIGHT, buff=buff)
    return cells
```

**IMPORTANT — PALETTE IS LOCKED**: Every hex value above is final. BG must be `#2a2a3a`, SURFACE must be `#3a3a4a`, BORDER must be `#4a4a5a`. Do NOT substitute theme-specific or topic-specific colors. The only exception is the light theme palette from the style guide. If the generated shared.py contains any hex value not listed above, it is wrong — fix it before proceeding.

**Semantic aliases are allowed**: After the palette block, you may add project-specific aliases that map to palette tokens (e.g., `US_COLOR = ACCENT`, `SENDER_COLOR = PRIMARY`). These improve scene code readability without introducing custom hex values. Never assign a raw hex code to an alias.

**Scene files import via:**

```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
```

This works because render runs from `$WORK_DIR` as CWD (`cd "$WORK_DIR" && manim render scenes/scene_NN.py`).

---

### Step 6: Asset Generation

For each entry in `asset_manifest` from story.json, generate a validated SVG file.

**If `asset_manifest` is empty `{}`**, pause — are there real-world concepts that could use icons? Only skip for purely mathematical/abstract videos. Otherwise revisit Step 3.

**For each asset in the manifest:**

1. Read the `description`, `viewbox`, and `primary_color_token` from the manifest entry
2. Read the SVG Icon Style Rules from `library/style-guide.md`
3. Generate the SVG file following these constraints:
   - Flat fills only — NO gradients, NO filters, NO `<text>` elements, NO `stroke-dasharray`
   - Use palette hex colors from `shared_style` (e.g., `#ff3366` for PRIMARY, not Manim color names)
   - Outer strokes: `stroke="#ffffff" stroke-width="2"`
   - Detail strokes: `stroke-width="1.5"`
   - Line endings: `stroke-linecap="round" stroke-linejoin="round"`
   - Center content within the viewBox
   - Keep simple — Manim's SVG parser handles basic shapes well but struggles with complex paths
4. Write the SVG file to `$WORK_DIR/assets/{asset_id}.svg`

```bash
# Verify each asset file was written
for ASSET_ID in $(python3 -c "
import json
m = json.load(open('$WORK_DIR/story.json'))['asset_manifest']
print(' '.join(m.keys()))
"); do
  [ -f "$WORK_DIR/assets/${ASSET_ID}.svg" ] || echo "Missing asset: ${ASSET_ID}"
done
```

---

### Step 7: Asset Validation Gate

Verify all generated SVG assets render correctly in Manim before using them in scenes.

**If `asset_manifest` is empty**, skip this step.

**Procedure:**

1. Write a temporary validation scene `$WORK_DIR/scenes/_asset_validation.py`:

```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *

class AssetValidation(Scene):
    def construct(self):
        setup_scene(self)
        title = Text("Asset Validation", font="Helvetica Neue", font_size=32,
                      color=TEXT_CLR, weight=BOLD)
        title.to_edge(UP, buff=0.5)
        self.add(title)

        assets = []
        # One VGroup(icon, label) per asset — filled dynamically
        # ASSET_ENTRIES_PLACEHOLDER

        if assets:
            grid = VGroup(*assets).arrange_in_grid(
                rows=max(1, (len(assets) + 2) // 3),
                cols=min(3, len(assets)),
                buff=1.0,
            )
            grid.move_to(DOWN * 0.3)
            self.add(grid)
        self.wait(1)
```

2. Fill in the asset loading code for each manifest entry (load via `load_asset()`, add a label below each icon)

3. Render the validation scene as a still frame:

```bash
cd "$WORK_DIR" && manim render -ql -s --renderer=cairo --disable_caching \
  scenes/_asset_validation.py AssetValidation 2>/dev/null
```

4. Find and read the output PNG:

```bash
VALIDATION_PNG=$(find "$WORK_DIR"/media/images -name "AssetValidation*.png" 2>/dev/null | head -1)
```

5. Inspect the grid image. For each asset, verify:
   - The icon is **recognizable** (matches the description)
   - Colors are **correct** (matches the `primary_color_token`)
   - Rendering is **clean** (no artifacts, broken paths, or missing elements)

6. If any asset fails: regenerate its SVG file, re-render the grid (max 2 retries per asset)

7. Clean up: `rm "$WORK_DIR/scenes/_asset_validation.py"`

---

### Step 8: Scene Generation

Generate each scene file. Scenes import from `shared.py` and load assets via `load_asset()` — no inlined palette constants or helper functions.

**For each scene N (sequentially):**

1. **Read the scene spec** from `$WORK_DIR/story.json` — extract the scene entry, `shared_style`, and `latex_available` flag.

2. **Read the relevant library files** based on scene template type:

| Scene template | Always read | Conditionally read |
|---------------|------------|-------------------|
| All types | `library/cheatsheet.md`, `library/style-guide.md`, `library/common-errors.md` | — |
| `basic` | — | `library/animations.md` |
| `math` | — | `library/animations.md`, `library/text-and-math.md` |
| `graph` | — | `library/animations.md` |
| `code` | — | `library/text-and-math.md` |

3. **Read the template** from `templates/{template}.py` (e.g., `templates/basic.py`).

4. **Write the scene file** to `$WORK_DIR/scenes/scene_NN.py` following these rules:

   1. Start with the shared import: `import sys, os` / `sys.path.insert(...)` / `from shared import *`
   2. Define exactly ONE Scene subclass named `{scene_class}` (from story.json)
   3. All animation logic goes in the `construct(self)` method
   4. Call `setup_scene(self)` at the start of `construct()` (sets BG + dot grid)
   5. Use `title_card(self, "...")` for the title entrance
   6. Use `load_asset(asset_id, scale)` for SVG icons from the manifest
   7. Use `svg_icon()` only for rare one-off inline SVGs (under 5 SVG lines)
   8. Use `tw(text_string)` for reading time: `self.wait(tw("your text here"))`
   9. Use `make_node(label, color)` for diagram nodes
   10. Keep the scene self-contained (no file I/O beyond asset loading, no network)
   11. Target duration: ~{duration}s (use self.wait() to pad if needed)
   12. Use .animate syntax for simple property changes
   13. Use Transform/ReplacementTransform for morphing between objects
   14. If `latex_available` is false: do NOT use MathTex or Tex — use Text() for all text including math. Render equations as Unicode. If true: use MathTex (not Tex) for math expressions.

   **Layout Rules (CRITICAL — prevents overlapping elements):**

   15. Use `next_to()`, `arrange()`, `arrange_in_grid()` for spatially-related elements — NOT absolute coordinates
   16. Group with `VGroup()` before positioning — position the group, not individual items
   17. Absolute coords only for placing independent groups at anchor positions (e.g., `left_panel.move_to(LEFT * 3)`)
   18. Never place content within 0.8 units of the frame edge

   **Text Pacing Rules (CRITICAL — text must be readable):**

   19. After EVERY Write(text) or FadeIn(text), add a reading pause: `self.wait(tw("your text content"))` — this gives ~180 WPM reading speed with a 2-second minimum.
   20. Title cards: display for at least 2 seconds before animating to corner/top
   21. Key insight or annotation text: minimum 3 seconds on screen
   22. NEVER use bare `self.wait()` after text — always calculate from word count
   23. NEVER use `self.wait(0.5)` or `self.wait(1)` after text that has more than 3 words
   24. Between conceptual sections, use `self.wait(1.5)` as a transition pause

   **Visual Polish Rules (CRITICAL — prevents rendering issues):**

   25. **Text color**: body text (font_size >= 20) always uses `TEXT_CLR`. Use `TEXT_DIM` ONLY for captions (font_size 16) and axis labels.
   26. **Contained text**: when placing text inside a container, ALWAYS measure text width first and size the container to fit: `max(desired_w, text.width + 0.6)`. Or use `make_node()` which auto-sizes. NEVER hard-code a container width without checking the text.
   27. **Progress bars**: use `progress_bar()` and `set_progress()` from shared.py. NEVER animate a raw Rectangle's width for progress — it will overflow the track.

**Expected scene size**: 80-130 lines (vs 180-230 with inlined constants).

5. **Validate the generated file:**

```bash
FILE="$WORK_DIR/scenes/scene_$(printf "%02d" $N).py"
SCENE_CLASS="<scene_class from story.json>"

python3 -c "compile(open('$FILE').read(), '$FILE', 'exec')" 2>/dev/null || {
  echo "Syntax error in $FILE — fix before rendering"
}

python3 -c "
import ast, sys
tree = ast.parse(open('$FILE').read())
classes = [n.name for n in ast.walk(tree) if isinstance(n, ast.ClassDef)]
if '$SCENE_CLASS' not in classes:
    print(f'$FILE does not define class $SCENE_CLASS (found: {classes})')
    sys.exit(1)
print('$FILE defines $SCENE_CLASS')
"
```

If validation fails (syntax error or wrong class name), fix the file immediately and re-validate before moving on.

---

### Step 9: Layout Validation Gate

After writing each scene, validate the layout visually. Scenes end with `FadeOut(*self.mobjects)` which makes the last frame blank — so we extract a mid-animation frame instead.

**For each scene N:**

1. **Render a low-quality video** (fast: ~5-10s at 480p 15fps):

```bash
cd "$WORK_DIR" && manim render -ql --renderer=cairo --disable_caching \
  scenes/scene_$(printf "%02d" $N).py $SCENE_CLASS 2>/dev/null
```

2. **Extract a frame at ~2 seconds** (frame 30 at 15fps) — content should be visible before exit animations:

```bash
SCENE_FILE="scene_$(printf "%02d" $N)"
VIDEO_PATH=$(find "$WORK_DIR"/media/videos/${SCENE_FILE} -name "${SCENE_CLASS}.mp4" 2>/dev/null | head -1)
ffmpeg -i "$VIDEO_PATH" \
  -vf "select=eq(n\,30)" -vframes 1 -y \
  "$WORK_DIR"/lastframes/${SCENE_FILE}_layout.png 2>/dev/null
```

3. **Read the extracted PNG** and evaluate against this rubric:

| Check | Pass condition |
|-------|---------------|
| Text readability | No text cut off or extending beyond frame |
| No overlaps | All elements visible, no unintended overlap |
| Asset rendering | SVG icons recognizable and properly colored |
| Safe margins | Nothing within 0.8 units of frame edge |

4. **If any check fails**: fix the scene positioning code, re-render at `-ql`, re-inspect (max 2 retries per scene)

> **Cost**: ~5-10s per scene for a `-ql` render vs 60-180s for a wasted full render. This catches layout issues early.

---

### Step 10: Full Render & Recovery

For each scene, render at the target quality. If rendering fails, read the error output, fix the scene file directly, and retry. Max 3 attempts per scene.

**Output path resolution**: Manim writes to structured subdirs. After each render, resolve the actual output path.

**For each scene N**, run the render command:

```bash
SCENE_FILE="scene_$(printf "%02d" $N)"
SCENE_CLASS="<scene_class from story.json>"

RENDER_LOG=$(mktemp)
RENDER_CMD="manim render scenes/${SCENE_FILE}.py $SCENE_CLASS \
    --renderer=cairo -qh --format=mp4 --disable_caching"

if [ -n "$TIMEOUT_CMD" ]; then
  RENDER_CMD="$TIMEOUT_CMD 180 $RENDER_CMD"
fi

cd "$WORK_DIR" && eval $RENDER_CMD 2>"$RENDER_LOG"
RENDER_EXIT=$?
cd ..
```

**On success**: locate the output MP4:

```bash
QUALITY_SUBDIR="1080p60"  # matches -qh
EXPECTED_PATH="$WORK_DIR/media/videos/${SCENE_FILE}/${QUALITY_SUBDIR}/${SCENE_CLASS}.mp4"
if [ ! -f "$EXPECTED_PATH" ]; then
  FOUND_PATH=$(find "$WORK_DIR"/media/videos -name "${SCENE_CLASS}.mp4" 2>/dev/null | head -1)
fi
```

**On failure (up to 3 retries):**

1. Read the render error output from the log file
2. Read the current scene file and `library/common-errors.md`
3. Fix the scene file directly — preserve the animation intent, fix the error, ensure the Scene class name stays `{scene_class}`
4. If LaTeX is failing, switch to Text() as fallback
5. Re-run the render command

---

### Step 11: Stitch & Convert

Run the render script to concatenate scene videos and convert to GIF:

```bash
bash "scripts/render.sh" \
  --scenes-dir "$WORK_DIR"/scenes \
  --media-dir "$WORK_DIR"/media \
  --output-dir "$WORK_DIR"/output \
  --format "$FORMAT" \
  --story-file "$WORK_DIR"/story.json
```

> `scripts/render.sh` is relative to the skill directory. `$FORMAT` comes from `$WORK_DIR/params.json`.

---

### Step 12: Report

```
Animation complete!

Prompt: "explain how binary search works"
Scenes: 3 (all rendered successfully)
Duration: 28s (8s + 12s + 8s)
Quality: 1080p @ 60fps
Renderer: cairo

Assets: 2 SVGs generated and validated (magnifier_icon, checkmark_icon)
Layout validation: 3/3 scenes passed

Output:
  MP4: $WORK_DIR/output/animation.mp4 (1.2MB)
  GIF: $WORK_DIR/output/animation.gif (3.4MB)
  Layout previews: $WORK_DIR/lastframes/
```

---

## Component Library Reference

Read these library files before writing each scene (see Step 8 for which files apply per scene type):

| File | Purpose | Used by |
|------|---------|---------|
| `library/cheatsheet.md` | Manim API quick reference | All scene types |
| `library/style-guide.md` | Color palette, font sizes, timing, SVG style rules, layout best practices | All scene types |
| `library/animations.md` | Animation patterns with code | basic, math, graph |
| `library/text-and-math.md` | Text, MathTex, Code patterns | math, code |
| `library/common-errors.md` | Known pitfalls and fixes | All scene types |

## Key Conventions

1. **ManimCE only** — `from manim import *` (never `manimlib`). Cairo renderer for headless safety.
2. **One Scene class per file** — each scene is a separate `.py` file for isolated error recovery.
3. **Inline generation** — the orchestrating agent writes scene files directly (no sub-processes), ensuring the user's chosen model is used throughout.
4. **Shared preamble** — `shared.py` contains palette constants, helpers (`setup_scene`, `title_card`, `dot_grid`, `tw`, `make_node`, `progress_bar`, `set_progress`), and asset loading (`load_asset`). Scenes import, not copy.
5. **Asset-first** — SVG icons are generated and validated in Steps 6-7 before scene code is written. Scenes load validated assets via `load_asset()`, not inline SVG strings.
6. **Two validation gates** — asset grid (Step 7) and layout frame (Step 9) catch visual issues before the expensive full render in Step 10.
7. **Import from shared.py** — scenes use `from shared import *` for palette, helpers, and asset loading. No inlined constants.
8. **LaTeX fallback** — if LaTeX is unavailable, use `Text()` instead of `MathTex()`.
9. **Render timeout** — 180s timeout on render commands to catch hangs.
10. **Error recovery** — on render failure, read the error, fix the scene file, and retry. Max 3 attempts per scene.
11. **Selective library reads** — only read library docs relevant to the scene type to stay focused.
12. **SVG-first visuals** — every video defaults to custom SVG icons for real-world concepts. Aim for 3-6 assets per video. A video with zero SVG assets should be the exception (pure math only), not the norm.
