
Manimate
- 24 installs
- 2 repo stars
- Updated March 3, 2026
- bassimeledath/manim-video-maker
Manimate is a skill that generates diagram and animation videos from natural-language descriptions using Manim, outputting MP4 by default or GIF on request.
About
Manimate generates diagram and animation videos from a natural-language description using Manim. It runs a 12-step pipeline that infers rendering parameters, decomposes the prompt into scenes with an SVG asset manifest, authors scene code, and renders to MP4 (GIF on request). A developer uses it to turn a concept explanation into an educational animation.
- Generates diagram and animation videos from natural-language prompts using Manim
- 12-step pipeline with SVG asset manifest and per-scene story decomposition
- Outputs MP4 by default with GIF on request; renders headless via Cairo
Manimate by the numbers
- 24 all-time installs (skills.sh)
- Ranked #993 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
manimate capabilities & compatibility
- Capabilities
- video generation · animation
- Use cases
- video generation
- Platforms
- macOS · Linux
- Pricing
- Free
What manimate says it does
Generate diagram and animation videos from natural language descriptions using Manim. Outputs MP4 (default) or GIF on request.
When the user invokes `/manimate`, execute these 12 steps in order:
npx skills add https://github.com/bassimeledath/manim-video-maker --skill manimateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 2 |
| Last updated | March 3, 2026 |
| Repository | bassimeledath/manim-video-maker ↗ |
What it does
Turn a natural-language concept into a rendered Manim animation video (MP4 or GIF).
Who is it for?
Producing educational animation videos that explain algorithms, math, or step-by-step concepts
Skip if: Live-action video, general video editing, or tasks with no visual-explanation intent
When should I use this skill?
The user asks to explain, visualize, or animate a concept as a video, or invokes /manimate
What you get
A rendered MP4 (or GIF) animation that walks through the concept scene by scene
- MP4 animation video
- optional GIF
- per-scene Manim code
By the numbers
- 12-step pipeline
- 1-6 scenes per video
- 5-15s per scene
Files
/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:
{
"prompt": "explain how binary search works",
"scenes": 3,
"quality": "h",
"format": "mp4",
"style": "educational",
"duration_per_scene": 8
}Write $WORK_DIR/manim.cfg:
[CLI]
quality = high_quality
format = mp4
renderer = cairo
disable_caching = True
[output]
media_dir = media
video_dir = {media_dir}/videos
images_dir = {media_dir}/imagesWhy `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:
# 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."
fiCreate a run-scoped working directory to allow concurrent pipeline executions:
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_DIRinstead of a hardcoded.manimatepath. This prevents data loss when multiple/manimateinvocations 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:
{
"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 generationviewbox— 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_outof scene N must matchcontinuity_inof 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_elementslists each text block with its approximate word count — used to calculate reading pausesestimated_reading_pausesis the total seconds ofself.wait()needed for reading time (sum ofmax(2, words / 3)for each text block)durationmust 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.jsonaccordingly (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(formatfield) and$WORK_DIR/manim.cfgto 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:
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 cellsIMPORTANT — 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:
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, NOstroke-dasharray - Use palette hex colors from
shared_style(e.g.,#ff3366for 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
# 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:
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:
cd "$WORK_DIR" && manim render -ql -s --renderer=cairo --disable_caching \
scenes/_asset_validation.py AssetValidation 2>/dev/null4. Find and read the output PNG:
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:
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):
cd "$WORK_DIR" && manim render -ql --renderer=cairo --disable_caching \
scenes/scene_$(printf "%02d" $N).py $SCENE_CLASS 2>/dev/null2. Extract a frame at ~2 seconds (frame 30 at 15fps) — content should be visible before exit animations:
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/null3. 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:
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:
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)
fiOn 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 "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.jsonscripts/render.shis relative to the skill directory.$FORMATcomes 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.
node_modules/
.manimate/
__pycache__/
*.pyc
.DS_Store
.git/
.manimate/
__pycache__/
*.pyc
.DS_Store
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SKILL_NAME="manimate"
echo "Installing manimate skill..."
INSTALLED=0
# Claude Code
if [ -d "$HOME/.claude" ] || command -v claude >/dev/null 2>&1; then
CLAUDE_DIR="$HOME/.claude/skills/$SKILL_NAME"
mkdir -p "$(dirname "$CLAUDE_DIR")"
ln -sfn "$SCRIPT_DIR" "$CLAUDE_DIR"
echo " Linked to Claude Code: $CLAUDE_DIR"
INSTALLED=$((INSTALLED + 1))
fi
# Codex / OpenAI
if [ -d "$HOME/.codex" ] || command -v codex >/dev/null 2>&1; then
CODEX_DIR="$HOME/.codex/skills/$SKILL_NAME"
mkdir -p "$(dirname "$CODEX_DIR")"
ln -sfn "$SCRIPT_DIR" "$CODEX_DIR"
echo " Linked to Codex: $CODEX_DIR"
INSTALLED=$((INSTALLED + 1))
fi
# Generic agents
AGENTS_DIR="$HOME/.agents/skills/$SKILL_NAME"
mkdir -p "$(dirname "$AGENTS_DIR")"
ln -sfn "$SCRIPT_DIR" "$AGENTS_DIR"
echo " Linked to generic agents: $AGENTS_DIR"
INSTALLED=$((INSTALLED + 1))
echo ""
echo "manimate installed! ($INSTALLED agent location(s))"
echo ""
echo "Usage: /manimate \"explain how binary search works\""
echo ""
echo "Dependencies: python3, manim (pip install manim), ffmpeg"
#!/usr/bin/env node
const { execSync } = require('child_process');
const path = require('path');
const installScript = path.join(__dirname, 'install.sh');
try {
execSync(`bash "${installScript}"`, { stdio: 'inherit' });
} catch (e) {
console.warn('Skill linking skipped (this is normal during npx one-shot installs)');
}
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class BinarySearchIntro(Scene):
"""Scene 1: Show the problem — a sorted array, we need to find a target."""
def construct(self):
setup_scene(self)
title = title_card(self, "Binary Search")
# Sorted array (Square for data cells — the one exception to roundness)
values = [2, 5, 8, 12, 16, 23, 38, 56]
boxes = VGroup(*[
Square(
side_length=0.8,
fill_color=SURFACE, fill_opacity=0.6,
stroke_color=BORDER, stroke_width=1.5,
)
for _ in values
]).arrange(RIGHT, buff=0.1)
nums = VGroup(*[
Text(str(v), font="Monaco", font_size=24, color=TEXT_CLR).move_to(boxes[i])
for i, v in enumerate(values)
])
array = VGroup(boxes, nums)
self.play(
AnimationGroup(*[
FadeIn(VGroup(boxes[i], nums[i]), shift=UP * 0.4)
for i in range(len(values))
], lag_ratio=0.12),
run_time=0.7,
)
self.wait(1)
# Target
target_label = Text("Find: 23", font="Helvetica Neue", font_size=26, color=HIGHLIGHT)
target_label.next_to(array, DOWN, buff=0.8)
self.play(FadeIn(target_label, shift=UP * 0.3), run_time=0.4)
self.wait(tw("Find: 23"))
# Highlight target in array
self.play(boxes[5].animate.set_fill(HIGHLIGHT, opacity=0.4), run_time=0.3)
self.wait(2)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
class BinarySearchAlgorithm(Scene):
"""Scene 2: Demonstrate the algorithm step by step."""
def construct(self):
setup_scene(self)
title = title_card(self, "Binary Search")
values = [2, 5, 8, 12, 16, 23, 38, 56]
boxes = VGroup(*[
Square(
side_length=0.8,
fill_color=SURFACE, fill_opacity=0.6,
stroke_color=BORDER, stroke_width=1.5,
)
for _ in values
]).arrange(RIGHT, buff=0.1)
nums = VGroup(*[
Text(str(v), font="Monaco", font_size=24, color=TEXT_CLR).move_to(boxes[i])
for i, v in enumerate(values)
])
self.add(boxes, nums)
target = 23
lo, hi = 0, len(values) - 1
lo_arrow = Arrow(UP * 0.5, ORIGIN, color=ACCENT, tip_length=0.15).next_to(boxes[lo], DOWN)
hi_arrow = Arrow(UP * 0.5, ORIGIN, color=ACCENT, tip_length=0.15).next_to(boxes[hi], DOWN)
lo_label = Text("lo", font="Monaco", font_size=16, color=ACCENT).next_to(lo_arrow, DOWN, buff=0.1)
hi_label = Text("hi", font="Monaco", font_size=16, color=ACCENT).next_to(hi_arrow, DOWN, buff=0.1)
self.play(
FadeIn(lo_arrow, shift=UP * 0.3),
FadeIn(hi_arrow, shift=UP * 0.3),
FadeIn(lo_label, shift=UP * 0.2),
FadeIn(hi_label, shift=UP * 0.2),
run_time=0.5,
)
self.wait(0.5)
while lo <= hi:
mid = (lo + hi) // 2
mid_arrow = Arrow(DOWN * 0.5, ORIGIN, color=HIGHLIGHT, tip_length=0.15).next_to(boxes[mid], UP)
mid_label = Text("mid", font="Monaco", font_size=16, color=HIGHLIGHT).next_to(mid_arrow, UP, buff=0.1)
self.play(FadeIn(mid_arrow, shift=UP * 0.3), FadeIn(mid_label, shift=UP * 0.2), run_time=0.4)
self.wait(0.5)
if values[mid] == target:
self.play(boxes[mid].animate.set_fill(SUCCESS, opacity=0.5), run_time=0.3)
found = Text("Found!", font="Helvetica Neue", font_size=32, color=SUCCESS, weight=BOLD)
found.next_to(boxes[mid], UP, buff=1.2)
self.play(FadeIn(found, shift=UP * 0.4), run_time=0.5)
self.wait(2)
break
elif values[mid] < target:
for i in range(lo, mid + 1):
self.play(boxes[i].animate.set_fill(NEGATIVE, opacity=0.3), run_time=0.2)
lo = mid + 1
self.play(
lo_arrow.animate.next_to(boxes[lo], DOWN),
lo_label.animate.next_to(boxes[lo], DOWN, buff=0.6),
run_time=0.4,
)
else:
for i in range(mid, hi + 1):
self.play(boxes[i].animate.set_fill(NEGATIVE, opacity=0.3), run_time=0.2)
hi = mid - 1
self.play(
hi_arrow.animate.next_to(boxes[hi], DOWN),
hi_label.animate.next_to(boxes[hi], DOWN, buff=0.6),
run_time=0.4,
)
self.play(FadeOut(mid_arrow), FadeOut(mid_label), run_time=0.3)
self.wait(2)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
class BinarySearchComplexity(Scene):
"""Scene 3: Show O(log n) complexity compared to O(n)."""
def construct(self):
setup_scene(self)
title = title_card(self, "Why is it fast?")
import math
axes = Axes(
x_range=[1, 20, 2],
y_range=[0, 20, 2],
axis_config={
"include_numbers": True,
"color": TEXT_DIM,
"stroke_width": 1.5,
},
x_length=8,
y_length=5,
)
x_label = axes.get_x_axis_label("n", color=TEXT_DIM)
y_label = axes.get_y_axis_label("steps", color=TEXT_DIM)
self.play(FadeIn(axes, shift=UP * 0.3), FadeIn(x_label), FadeIn(y_label), run_time=0.7)
linear = axes.plot(lambda x: x, color=NEGATIVE, x_range=[1, 20])
log_graph = axes.plot(lambda x: math.log2(x) * 2, color=SUCCESS, x_range=[1, 20])
lin_label = Text("O(n) linear", font="Monaco", font_size=16, color=NEGATIVE)
lin_label.next_to(linear.get_end(), RIGHT, buff=0.2)
log_label = Text("O(log n) binary", font="Monaco", font_size=16, color=SUCCESS)
log_label.next_to(log_graph.get_end(), RIGHT, buff=0.2)
self.play(Create(linear), FadeIn(lin_label, shift=UP * 0.2), run_time=0.7)
self.play(Create(log_graph), FadeIn(log_label, shift=UP * 0.2), run_time=0.7)
self.wait(3)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class QuadraticSetup(Scene):
"""Scene 1: Introduce the quadratic equation."""
def construct(self):
setup_scene(self)
title = title_card(self, "The Quadratic Formula")
# Starting equation in a card
card = RoundedRectangle(
corner_radius=0.15, width=8, height=2,
fill_color=SURFACE, fill_opacity=0.8,
stroke_color=BORDER, stroke_width=1,
)
eq = MathTex(r"ax^2 + bx + c = 0", font_size=44)
eq.set_color(TEXT_CLR)
eq.move_to(card)
self.play(FadeIn(card, shift=UP * 0.4), run_time=0.4)
self.play(Write(eq), run_time=0.7)
self.wait(1)
desc = Text(
"We want to solve for x",
font="Helvetica Neue", font_size=26, color=TEXT_CLR,
)
desc.next_to(card, DOWN, buff=0.5)
self.play(FadeIn(desc, shift=UP * 0.3), run_time=0.4)
self.wait(tw("We want to solve for x"))
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
class QuadraticDerivation(Scene):
"""Scene 2: Derive the formula step by step."""
def construct(self):
setup_scene(self)
title = title_card(self, "Completing the Square")
steps = [
r"ax^2 + bx + c = 0",
r"x^2 + \frac{b}{a}x = -\frac{c}{a}",
r"x^2 + \frac{b}{a}x + \frac{b^2}{4a^2} = \frac{b^2}{4a^2} - \frac{c}{a}",
r"\left(x + \frac{b}{2a}\right)^2 = \frac{b^2 - 4ac}{4a^2}",
r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}",
]
current = MathTex(steps[0], font_size=36)
current.set_color(TEXT_CLR)
self.play(Write(current), run_time=0.7)
self.wait(1.5)
for step in steps[1:]:
next_eq = MathTex(step, font_size=36)
next_eq.set_color(TEXT_CLR)
self.play(TransformMatchingTex(current, next_eq), run_time=0.6)
current = next_eq
self.wait(1.5)
# Highlight the final formula with a rounded card
highlight = RoundedRectangle(
corner_radius=0.15,
width=current.width + 0.6, height=current.height + 0.4,
fill_color=SURFACE, fill_opacity=0.5,
stroke_color=PRIMARY, stroke_width=2,
)
highlight.move_to(current)
self.play(FadeIn(highlight), run_time=0.3)
# Pop emphasis
self.play(highlight.animate.scale(1.05), run_time=0.2)
self.play(highlight.animate.scale(1.0), run_time=0.3)
self.wait(2)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class BubbleSortIntro(Scene):
"""Scene 1: Show unsorted array and explain the concept."""
def construct(self):
setup_scene(self)
title = title_card(self, "Bubble Sort")
values = [5, 3, 8, 1, 4]
bars = VGroup(*[
RoundedRectangle(
corner_radius=0.08, width=0.8, height=v * 0.5,
fill_color=ACCENT, fill_opacity=0.6,
stroke_color=BORDER, stroke_width=1.5,
)
for v in values
]).arrange(RIGHT, buff=0.2, aligned_edge=DOWN)
labels = VGroup(*[
Text(str(v), font="Monaco", font_size=24, color=TEXT_CLR).next_to(bars[i], DOWN, buff=0.2)
for i, v in enumerate(values)
])
chart = VGroup(bars, labels).move_to(ORIGIN)
self.play(
AnimationGroup(*[
FadeIn(VGroup(bars[i], labels[i]), shift=UP * 0.4)
for i in range(len(values))
], lag_ratio=0.12),
run_time=0.7,
)
self.wait(1)
desc = Text(
"Compare adjacent pairs, swap if needed",
font="Helvetica Neue", font_size=26, color=TEXT_CLR,
)
desc.next_to(chart, DOWN, buff=0.8)
self.play(FadeIn(desc, shift=UP * 0.3), run_time=0.4)
self.wait(tw("Compare adjacent pairs, swap if needed"))
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
class BubbleSortAnimation(Scene):
"""Scene 2: Animate one pass of bubble sort."""
def construct(self):
setup_scene(self)
title = title_card(self, "Bubble Sort — Pass 1")
values = [5, 3, 8, 1, 4]
spacing = 1.0
bars = VGroup(*[
RoundedRectangle(
corner_radius=0.08, width=0.8, height=v * 0.5,
fill_color=ACCENT, fill_opacity=0.6,
stroke_color=BORDER, stroke_width=1.5,
)
for v in values
]).arrange(RIGHT, buff=0.2, aligned_edge=DOWN)
labels = VGroup(*[
Text(str(v), font="Monaco", font_size=24, color=TEXT_CLR).next_to(bars[i], DOWN, buff=0.2)
for i, v in enumerate(values)
])
self.add(bars, labels)
for i in range(len(values) - 1):
# Highlight pair being compared
self.play(
bars[i].animate.set_stroke(color=PRIMARY, width=2.5),
bars[i + 1].animate.set_stroke(color=PRIMARY, width=2.5),
run_time=0.3,
)
self.wait(0.3)
if values[i] > values[i + 1]:
# Swap animation
self.play(
bars[i].animate.shift(RIGHT * spacing),
bars[i + 1].animate.shift(LEFT * spacing),
labels[i].animate.shift(RIGHT * spacing),
labels[i + 1].animate.shift(LEFT * spacing),
run_time=0.5,
)
values[i], values[i + 1] = values[i + 1], values[i]
bars[i], bars[i + 1] = bars[i + 1], bars[i]
labels[i], labels[i + 1] = labels[i + 1], labels[i]
# Reset stroke
self.play(
bars[i].animate.set_stroke(color=BORDER, width=1.5),
bars[i + 1].animate.set_stroke(color=BORDER, width=1.5),
run_time=0.2,
)
# Mark last element as sorted
self.play(bars[-1].animate.set_fill(SUCCESS, opacity=0.6), run_time=0.3)
self.wait(2)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
Animation Patterns
Reading-Time Pacing (apply to ALL text)
Every text element must stay on screen long enough to read. Formula: self.wait(max(2, len("text content".split()) / 3))
After writing any text, pause for reading:
title = Text("The Problem", font_size=48) self.play(Write(title)) self.wait(2) # max(2, 2/3) = 2 (minimum floor)
explanation = Text("We need to find the target in a sorted array", font_size=32) self.play(Write(explanation)) self.wait(3.3) # max(2, 10/3) = 3.3
detail = Text("Binary search eliminates half the remaining elements each step", font_size=28) self.play(FadeIn(detail)) self.wait(3.3) # max(2, 10/3) = 3.3
Progressive Disclosure
Show elements one at a time, building complexity: items = VGroup(item1, item2, item3).arrange(DOWN, buff=0.5) for item in items: self.play(FadeIn(item, shift=UP * 0.3))
Pause for reading — calculate from text content
self.wait(max(2, len(item.text.split()) / 3))
Transform Chain
Morph through a sequence of states: eq1 = MathTex(r"x^2 + 2x + 1") eq2 = MathTex(r"(x + 1)^2") self.play(Write(eq1)) self.wait(2) # pause to read the equation self.play(TransformMatchingTex(eq1, eq2)) self.wait(2) # pause to read the transformed result
Highlight and Annotate
Draw attention to a specific part: box = RoundedRectangle( corner_radius=0.08, width=target.width + 0.3, height=target.height + 0.2, stroke_color=HIGHLIGHT, stroke_width=1.5, fill_opacity=0, ) box.move_to(target) label = Text("key insight", font_size=24, color=TEXT_CLR).next_to(box, DOWN) self.play(Create(box), Write(label)) self.wait(3) # annotations/insights: minimum 3 seconds
Slide Transition
Clear the current scene, bring in the next: self.play(*[FadeOut(mob) for mob in self.mobjects]) self.wait(1.5) # transition pause between conceptual sections
Now build the next section
Array/List Visualization
boxes = VGroup([ Square(side_length=0.8).set_fill(ACCENT, opacity=0.3) for _ in range(8) ]).arrange(RIGHT, buff=0.1) numbers = VGroup([ Text(str(n), font_size=24).move_to(boxes[i]) for i, n in enumerate([2, 5, 8, 12, 16, 23, 38, 56]) ]) array = VGroup(boxes, numbers) self.play(Create(array))
Pointer/Arrow Indicator
arrow = Arrow(start=UP, end=DOWN, color=HIGHLIGHT, tip_length=0.15).next_to(boxes[3], UP) label = Text("mid", font_size=20).next_to(arrow, UP) self.play(Create(arrow), Write(label))
Move pointer:
self.play(arrow.animate.next_to(boxes[5], UP), label.animate.next_to(boxes[5], UP).shift(UP * 0.5))
Graph Plotting
axes = Axes(x_range=[0, 10, 1], y_range=[0, 100, 10], axis_config={"include_numbers": True}) graph = axes.plot(lambda x: x**2, color=ACCENT) label = axes.get_graph_label(graph, label="x^2") self.play(Create(axes), run_time=1.5) self.play(Create(graph), Write(label), run_time=2)
AnimationGroup (simultaneous)
self.play(AnimationGroup( Create(circle), Write(label), FadeIn(arrow), lag_ratio=0.3 # stagger by 30% ))
Succession (sequential in one play call)
self.play(Succession( Write(step1), Write(step2), Write(step3), lag_ratio=1.0 # each waits for previous to finish ))
SVG Icon Animations
Staggered Icon Entrance
Build a system diagram by revealing icons one at a time: icons = [svg_icon(SVG_USER, 0.8), svg_icon(SVG_SERVER, 0.8), svg_icon(SVG_DB, 0.8)] positions = [LEFT 4, ORIGIN, RIGHT 4] for icon, pos in zip(icons, positions): icon.move_to(pos) self.play(FadeIn(icon, shift=UP * 0.3), run_time=0.5)
SVG Morphing (State Change)
Morph between two SVGs with matching structure to show state transitions: lock = svg_icon(SVG_LOCK, scale=1.5) unlock = svg_icon(SVG_UNLOCK, scale=1.5) unlock.move_to(lock) self.play(FadeIn(lock, shift=UP * 0.3), run_time=0.6) self.wait(1) self.play(ReplacementTransform(lock, unlock), run_time=1.2)
TIP: keep same viewBox + element count for smooth morphs
Data Flow Between SVG Icons
Animate a small object traveling from one icon to another: source = svg_icon(SVG_USER, 0.8).move_to(LEFT 4) target = svg_icon(SVG_SERVER, 0.8).move_to(RIGHT 4) arrow = Arrow(source.get_right(), target.get_left(), color=BORDER, buff=0.3)
Traveling object
token = svg_icon(SVG_KEY, 0.4).move_to(source.get_right() + RIGHT 0.3) self.play(FadeIn(token), run_time=0.3) self.play(token.animate.move_to(target.get_left() + LEFT 0.3), run_time=1.5, rate_func=smooth) self.play(FadeOut(token), run_time=0.3)
Icon-to-Icon Transition (Replace Concept)
Replace one concept icon with another to show progression: old_icon = svg_icon(SVG_WARNING, 0.8) new_icon = svg_icon(SVG_CHECK, 0.8).move_to(old_icon) self.play(FadeIn(old_icon, shift=UP * 0.3), run_time=0.6) self.wait(1) self.play(FadeOut(old_icon, scale=0.5), FadeIn(new_icon, scale=1.5), run_time=0.8)
Hybrid Layout: SVG + Native Shapes
Mix SVG icons with Manim shapes for architecture diagrams:
SVG icons for real-world concepts
user = svg_icon(SVG_USER, 0.7).move_to(LEFT 4) server = svg_icon(SVG_SERVER, 0.7).move_to(RIGHT 4)
Native shapes for abstract elements
api_box = RoundedRectangle(corner_radius=0.15, width=2, height=0.8, fill_color=SURFACE, fill_opacity=1, stroke_color=PRIMARY, stroke_width=1.5) api_label = Text("API", font_size=20, color=TEXT_CLR).move_to(api_box) api_group = VGroup(api_box, api_label).move_to(ORIGIN)
Arrows connecting everything
arr1 = Arrow(user.get_right(), api_group.get_left(), color=BORDER, buff=0.2, tip_length=0.15) arr2 = Arrow(api_group.get_right(), server.get_left(), color=BORDER, buff=0.2, tip_length=0.15)
Coloring SVG Submobjects
Each top-level SVG element becomes a separate Manim submobject: icon = svg_icon(SVG_SERVER, 0.8)
Color individual rack units differently
icon.submobjects[0].set_fill("#ff3366") # first rect = pink icon.submobjects[1].set_fill("#ffcc00") # second rect = yellow
Animate color change on a submobject
self.play(icon.submobjects[2].animate.set_fill("#33ccff"), run_time=0.5)
Manim Cheatsheet
Import
from manim import * # ALWAYS use this — never manimlib
Scene Structure
class MyScene(Scene): def construct(self):
All animation logic here
pass
Common Mobjects
- Text("hello", font_size=26) # Pango text (no LaTeX needed)
- Text("title", font_size=44, weight=BOLD) # Bold title
- MathTex(r"a^2 + b^2 = c^2") # LaTeX math
- Tex(r"Hello \textbf{World}") # LaTeX text
- RoundedRectangle(corner_radius=0.15, width=3, height=1.5) # Rounded container (preferred)
- Circle(), Square(), Triangle() # Shapes (Square for data cells only)
- Arrow(start, end, tip_length=0.15) # Arrow (thin tip)
- Line(start, end) # Line
- Dot(point) # Point
- VGroup(obj1, obj2, ...) # Group of objects
- NumberLine(x_range=[0, 10]) # Number line
- Axes(x_range=[-3,3], y_range=[-1,5]) # Coordinate axes
- Code(code="...", language="python", background="rectangle") # Code block
Positioning
- obj.to_edge(UP / DOWN / LEFT / RIGHT)
- obj.to_corner(UL / UR / DL / DR)
- obj.next_to(other, RIGHT, buff=0.5)
- obj.move_to(ORIGIN)
- obj.shift(2 RIGHT + 1 UP)
- Constants: UP, DOWN, LEFT, RIGHT, ORIGIN, UL, UR, DL, DR
Key Animations
- self.play(Create(obj)) # Draw object
- self.play(Write(text)) # Write text
- self.play(FadeIn(obj, shift=UP * 0.3)) # Rise entrance (manimate signature)
- self.play(FadeOut(obj, shift=DOWN * 0.2)) # Sink exit (manimate signature)
- self.play(GrowFromCenter(line)) # Grow line from center
- self.play(GrowArrow(arrow)) # Grow arrow
- self.play(Transform(a, b)) # Morph a into b (a is modified)
- self.play(ReplacementTransform(a, b)) # Replace a with b
- self.play(TransformMatchingTex(eq1, eq2)) # Morph matching LaTeX parts
- self.play(obj.animate.shift(RIGHT)) # Animate property change
- self.play(obj.animate.set_color(PRIMARY)) # Animate color change
- self.wait(2) # Pause 2 seconds
Animation Modifiers
- self.play(Create(obj), run_time=0.6) # Duration (keep snappy)
- self.play(obj.animate.shift(RIGHT), rate_func=linear) # Easing
- self.play(AnimationGroup(a1, a2, lag_ratio=0.12)) # Staggered cascade
- self.play(AnimationGroup([FadeIn(i, shift=UP0.3) for i in group], lag_ratio=0.12)) # Rise cascade
Quality Flags (CLI)
- -ql → 854x480 @ 15fps
- -qm → 1280x720 @ 30fps
- -qh → 1920x1080 @ 60fps (default for this skill)
- -qk → 3840x2160 @ 60fps
Manimate Color Tokens (use these, NOT raw Manim colors)
Dark "Creative Chaos" (default): BG="#2a2a3a" SURFACE="#3a3a4a" BORDER="#4a4a5a" PRIMARY="#ff3366" ACCENT="#33ccff" HIGHLIGHT="#ffcc00" SUCCESS="#66ff66" NEGATIVE="#ff4444" TEXT_CLR="#ffffff" TEXT_DIM="#8a8aaa"
Light "Daylight Chaos": BG="#ffffff" SURFACE="#f5f5f5" BORDER="#e0e0e5" PRIMARY="#cc2952" ACCENT="#0099cc" HIGHLIGHT="#cc9900" SUCCESS="#339933" NEGATIVE="#cc0000" TEXT_CLR="#2a2a3a" TEXT_DIM="#8a8aaa"
Raw Manim Colors (avoid in manimate scenes — use tokens above)
PRIMARY: BLUE, RED, GREEN, YELLOW, PURPLE, ORANGE, TEAL, PINK SHADES: RED_A (lightest) → RED_E (darkest), same for all colors NEUTRAL: WHITE, BLACK, GREY, GREY_A → GREY_E SPECIAL: GOLD, MAROON, DARK_BROWN, LIGHT_BROWN
SVG Icons (custom visuals — KEY differentiator)
For real-world concepts (servers, databases, users, locks, etc.), generate custom SVGs instead of using basic shapes. This is what makes manimate videos look professional.
Primary Pattern: load_asset() (generated in Step 6, loaded in Step 8)
Step 6 generates SVG files to .manimate/assets/. Scene code loads them by ID:
icon = load_asset("terminal_icon", scale=0.8) # loads .manimate/assets/terminal_icon.svg
db = load_asset("database_icon", scale=0.8)The asset IDs come from asset_manifest in story.json — do NOT hardcode icon names from templates.
Fallback: svg_icon() for rare one-off SVGs (under 5 lines)
icon = svg_icon('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">...</svg>', scale=0.8)SVG Rules
- DO:
<path>,<rect>,<circle>,<ellipse>, fill w/ hex colors,stroke,viewBox - NO: gradients,
<text>, filters,stroke-dasharray,<image>,<clipPath>, CSS<style> - Use manimate palette colors (PRIMARY, ACCENT, etc.) as hex values in SVG fills/strokes
Animating SVGs
# All standard animations work on SVGMobjects
self.play(FadeIn(icon, shift=UP * 0.3)) # Rise entrance
self.play(icon.animate.shift(RIGHT * 2)) # Move
self.play(icon.animate.scale(1.3)) # Scale
self.play(Indicate(icon, color=HIGHLIGHT)) # Emphasis
self.play(ReplacementTransform(lock, unlock)) # Morph between SVGs
# Color individual submobjects (each top-level SVG element = 1 submobject)
icon.submobjects[0].set_fill(PRIMARY) # Recolor first elementHybrid Layouts: SVG + Native Shapes
# SVG icons alongside Manim arrows and text
user = load_asset("user_icon", scale=0.8).move_to(LEFT * 3)
server = load_asset("server_icon", scale=0.8).move_to(RIGHT * 3)
arrow = Arrow(user.get_right(), server.get_left(), color=BORDER, buff=0.3)
label = Text("request", font_size=20, color=TEXT_CLR).next_to(arrow, UP, buff=0.15)SVG assets are generated in Step 6 following the SVG Icon Style Rules in the style guide, then loaded in scene code via load_asset().
Common Errors and Fixes
1. Wrong import
BAD: from manimlib import # This is ManimGL (3b1b's version) GOOD: from manim import # This is ManimCE (Community Edition)
2. ShowCreation is deprecated
BAD: self.play(ShowCreation(circle)) GOOD: self.play(Create(circle))
3. Multiple .animate on same object in one play()
BAD: self.play(obj.animate.shift(RIGHT), obj.animate.set_color(RED)) GOOD: self.play(obj.animate.shift(RIGHT).set_color(RED)) # chain on one .animate
4. Transform doesn't replace the object
After Transform(a, b), the variable a still refers to the scene object. Use ReplacementTransform(a, b) if you want b to be the scene object.
5. Forgetting to add objects to scene
Objects must be added (via self.add() or self.play(Create/FadeIn)) before animating.
6. LaTeX errors
If you get "LaTeX Error", check:
- Raw string prefix: r"..." not "..."
- Balanced braces: every { has a matching }
- Valid LaTeX commands (no custom macros without defining them)
Fallback: Use Text() instead of MathTex() if LaTeX keeps failing.
7. Overlapping text
Always check positions. Use .next_to() instead of absolute positioning. Use VGroup().arrange() for layouts.
8. Animation after FadeOut
After FadeOut(obj), the object is removed. Don't animate it again without re-adding.
9. run_time must be positive
BAD: self.play(Create(obj), run_time=0) GOOD: self.play(Create(obj), run_time=0.5)
10. .animate chaining limitation with Succession
BAD: Succession(circle.animate.shift(RIGHT), circle.animate.shift(UP)) GOOD: Use separate self.play() calls for sequential animations on the same object
11. Text too faint against background
BAD: subtitle = Text("...", color=TEXT_DIM) # invisible at font_size >= 20
GOOD: subtitle = Text("...", color=TEXT_CLR) # TEXT_DIM only for font_size 16 captions12. Text overflows container
BAD: box = RoundedRectangle(width=3, ...) # fixed width, text may not fit
text = Text("educational", ...)
text.move_to(box) # text wider than box → overflow
GOOD: text = Text("educational", ...)
box = RoundedRectangle(width=max(3, text.width + 0.6), ...) # measure first
text.move_to(box)
BEST: node = make_node("educational", color=HIGHLIGHT) # auto-sizes13. Character-by-character text animation causes flicker
BAD: self.play(AddTextLetterByLetter(text)) # flickers badly in Manim CE
BAD: for char in text: self.play(FadeIn(char)) # same issue, jarring
GOOD: self.play(Write(text), run_time=0.7) # smooth handwriting effect
GOOD: self.play(FadeIn(text, shift=UP * 0.3)) # bounce entrance (preferred)Never use AddTextLetterByLetter, AddTextWordByWord, or manual character-by-character loops. They produce visible flicker artifacts. Use Write() for a drawing effect or FadeIn(shift=UP) for the signature bounce.
14. Child elements escape parent container
BAD: terminal = RoundedRectangle(width=8, height=4, ...)
badge = make_node("quality: high")
badge.next_to(terminal, DOWN, buff=0.5) # badge is OUTSIDE the terminal
GOOD: badge.move_to(terminal.get_bottom() + UP * 0.5) # badge stays INSIDEWhen placing elements inside a container, use .move_to() with offsets from the container's edges, or use .align_to(). Never use .next_to(container, ...) for child elements — that places them OUTSIDE. Verify with: assert container.get_left()[0] <= child.get_left()[0].
15. Missing spaces in concatenated/formatted text
BAD: Text(f"{label}{value}") # "Parametersinferred"
BAD: Text(f">{command}") # ">/manimate..."
GOOD: Text(f"{label}: {value}") # "Parameters: inferred"
GOOD: Text(f"> {command}") # "> /manimate ..."Always double-check string literals for missing spaces, especially in f-strings and concatenations. Preview the string value mentally before passing to Text().
16. Font kerning issues (AAT vs GPOS)
Pango/Cairo ignores AAT kerning tables (used by Apple system fonts like Galvji and Avenir Next), producing uneven character spacing. Helvetica Neue uses GPOS kerning which Pango reads correctly — this is why we standardized on it. If you see irregular spacing with a different font, check whether it uses AAT or GPOS kerning.
# Helvetica Neue works correctly at all sizes
label = Text("Access granted", font="Helvetica Neue", font_size=20, color=TEXT_CLR)
# Monaco (monospace) is also fine for technical text
label = Text("access_token", font="Monaco", font_size=16, color=TEXT_DIM)17. Progress bar fill overflows track
BAD: fill = Rectangle(width=0.1, ...)
self.play(fill.animate.stretch_to_fit_width(8)) # exceeds track
GOOD: bar = progress_bar(width=8)
self.play(set_progress(bar, 0.75), run_time=1.0) # always stays inside18. Hardcoded hex colors outside the palette
BAD: lake = RoundedRectangle(fill_color="#1a6b8a", ...) # invented color
BAD: sky = Rectangle(fill_color="#87CEEB", ...) # not in palette
GOOD: lake = RoundedRectangle(fill_color=ACCENT, fill_opacity=0.4, ...) # palette + opacity
GOOD: sky = Rectangle(fill_color=ACCENT, fill_opacity=0.15, ...) # tinted from paletteEvery fill, stroke, and text color must come from the palette (BG, SURFACE, BORDER, PRIMARY, ACCENT, HIGHLIGHT, SUCCESS, NEGATIVE, TEXT_CLR, TEXT_DIM). To create variations, adjust fill_opacity or stroke_opacity — never invent new hex codes. For natural elements (water, sky, vegetation), use the closest palette token with reduced opacity.
Narrow exception: Physical material fills (coffee grounds, soil, food, liquids representing a real substance) may use custom hex codes when no palette color is a reasonable match. This applies ONLY to fill_color of objects depicting real-world materials — never for stroke_color, text color, backgrounds, or UI containers.
Manimate Style Guide — Creative Chaos
Every manimate video should feel alive — bouncy, colorful, and unmistakably playful.
This is not generic Manim output. This is Creative Chaos: playful rainbow energy inspired by The Coding Train.
---
Quick Reference — Dark Mode "Creative Chaos" (default)
# ── Creative Chaos Dark ──────────────────────────────
BG = "#2a2a3a" # Canvas — soft dark purple-gray
SURFACE = "#3a3a4a" # Elevated containers, cards
BORDER = "#4a4a5a" # Subtle borders, structural lines
PRIMARY = "#ff3366" # Hot Pink — THE manimate color
ACCENT = "#33ccff" # Cyan — secondary concepts, cool complement
HIGHLIGHT = "#ffcc00" # Yellow — emphasis, focus, "look here"
SUCCESS = "#66ff66" # Green — success, correct, positive
NEGATIVE = "#ff4444" # Red — errors, wrong, removed
TEXT_CLR = "#ffffff" # White text
TEXT_DIM = "#8a8aaa" # Muted text, labels, secondary info
# ───────────────────────────────────────────────────────Quick Reference — Light Mode "Daylight Chaos"
# ── Creative Chaos Light ─────────────────────────────
BG = "#ffffff" # Canvas — white
SURFACE = "#f5f5f5" # Light gray containers
BORDER = "#e0e0e5" # Soft gray borders
PRIMARY = "#cc2952" # Deeper Pink — darkened for contrast
ACCENT = "#0099cc" # Deep Cyan — darkened for readability
HIGHLIGHT = "#cc9900" # Amber — darkened yellow
SUCCESS = "#339933" # Deep Green
NEGATIVE = "#cc0000" # Crimson
TEXT_CLR = "#2a2a3a" # Dark text
TEXT_DIM = "#8a8aaa" # Muted gray
# ───────────────────────────────────────────────────────---
The Five Signatures
These are what make a video manimate. Every scene should incorporate at least signatures 1, 3, and 4.
1. The Rainbow
The Pink + Cyan + Yellow + Green palette. Bold, saturated, playful — like a Coding Train project. Not muted pastels. Not corporate blues.
Rule: Maximum 3 accent colors visible at any time. PRIMARY (pink) is always dominant.
2. The Grid
A barely-visible dot grid on the background. Adds texture and structure without competing with content. Makes the canvas feel alive.
# Add at the start of construct(), before any content
dots = 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)
])
self.add(dots)3. The Bounce
Elements enter by bouncing into view — never a flat FadeIn. This upward bounce creates playful energy.
# Standard entrance — bouncy rise
self.play(FadeIn(element, shift=UP * 0.4), run_time=0.5)
# For groups — stagger the bounce with lag_ratio
self.play(
AnimationGroup(*[
FadeIn(item, shift=UP * 0.4) for item in group
], lag_ratio=0.15),
run_time=0.7,
)4. The Roundness
Every rectangle uses rounded corners. No sharp boxes. This single detail separates manimate from default Manim instantly.
# ALWAYS use RoundedRectangle, never Rectangle or Square for containers
box = RoundedRectangle(
corner_radius=0.15, width=3, height=1.5,
fill_color=SURFACE, fill_opacity=1,
stroke_color=BORDER, stroke_width=1.5,
)Note: Use Square only for data cells (arrays, grids) where sharp alignment matters. For everything else — containers, cards, highlights — use RoundedRectangle.
5. The Pop
Key elements get a quick scale pop for emphasis. Clean and 2D — no gradients, no blur.
# Pop emphasis: quick scale up then settle back
self.play(element.animate.scale(1.15), run_time=0.2)
self.play(element.animate.scale(1/1.15), run_time=0.3)
# Or with a colored stroke flash
flash = element.copy().set_stroke(
color=PRIMARY, width=6, opacity=0.4
).set_fill(opacity=0)
self.play(FadeIn(flash, scale=0.95), run_time=0.2)
self.play(FadeOut(flash, scale=1.1), run_time=0.4)
self.remove(flash)---
SVG Icon Style Rules
SVG icons are manimate's visual differentiator. When a scene represents real-world concepts, use custom SVGs instead of basic shapes.
Color Mapping
SVG fills and strokes must use the Creative Chaos palette hex values — never raw Manim color names:
| Palette Token | Hex Value | SVG Usage |
|---|---|---|
| PRIMARY | #ff3366 | Main icon fills — servers, users, documents |
| ACCENT | #33ccff | Secondary icons, cool elements — shields, data |
| HIGHLIGHT | #ffcc00 | Focus icons — keys, locks, warnings |
| SUCCESS | #66ff66 | Success icons — checkmarks, shields |
| NEGATIVE | #ff4444 | Error/denied icons |
| SURFACE | #3a3a4a | Dark inner fills — screen areas, keyholes |
| BG | #2a2a3a | Cut-out shapes, inner details |
#ffffff | White | Outlines (stroke), inner details |
Stroke Consistency
- Outer strokes:
stroke="#ffffff" stroke-width="2"— matches the clean manimate look - Detail strokes:
stroke-width="1.5"for inner lines - Line endings:
stroke-linecap="round" stroke-linejoin="round"for smooth joints
ViewBox Conventions
- Tall icons (person, server, document):
viewBox="0 0 80 100" - Square icons (checkmark, gear, warning):
viewBox="0 0 64 64" - Wide icons (key, token, laptop):
viewBox="0 0 100 60"or similar - Always center the content within the viewBox
Sizing
- Main concept icons:
scale=0.8toscale=1.0 - Small traveling objects (tokens, keys in data flow):
scale=0.3toscale=0.5 - Large hero icons (single concept on screen):
scale=1.2toscale=1.5
Flat Design Only
- NO gradients — renders as black
- NO filters / blur — ignored by Manim
- NO text in SVG — use Manim
Text()placed next to the icon - NO dashed strokes — renders as solid
- Use
opacityattribute for subtle layering (e.g.,opacity="0.5"on background elements)
---
Typography
Fonts
| Role | Font | Usage |
|---|---|---|
| Headings | font="Helvetica Neue" | Titles, scene headers, bold text (weight=BOLD) |
| Body | font="Helvetica Neue" | Primary content, descriptions, labels |
| Code | font="Monaco" | Code snippets, technical text, monospace |
# Heading
title = Text("Scene Title", font="Helvetica Neue", font_size=44, color=TEXT_CLR, weight=BOLD)
# Body text
body = Text("This explains the concept", font="Helvetica Neue", font_size=26, color=TEXT_CLR)
# Code/technical
code_text = Text("def hello():", font="Monaco", font_size=22, color=ACCENT)Sizes
| Role | Size | Usage |
|---|---|---|
| Display | 44 | Scene title on title card |
| Heading | 32 | Section headers, persistent title |
| Body | 26 | Primary content text |
| Label | 20 | Annotations, descriptions, axis labels |
| Caption | 16 | Section tags, fine print |
Weight
- Titles:
weight=BOLD— always - Body text: default weight (regular)
- Labels: default weight
Title Treatment — The Signature
Titles get a thin PRIMARY-colored underline that grows from center. This is the manimate look.
# Title with signature underline
title = Text("Scene Title", 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,
)
# Bounce + underline entrance
self.play(
FadeIn(title, shift=UP * 0.4),
GrowFromCenter(underline),
run_time=0.7,
)
self.wait(2.0)
# Transition title to corner — LEFT aligned, not centered
self.play(
title.animate.scale(0.55).to_corner(UL, buff=0.5),
FadeOut(underline, run_time=0.3),
run_time=0.5,
)Reusable Title Card Helper
Copy this function into any scene to get the standard title card pattern — bounce in, display, move to corner. This ensures consistent underline positioning.
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 titleUsage in a scene:
class MyScene(Scene):
def construct(self):
self.camera.background_color = BG
# ... dot grid setup ...
title = title_card(self, "My Scene Title")
# title is now in the UL corner — continue with main content---
Layout
Spacing Scale
| Token | Value | Usage |
|---|---|---|
| xs | 0.15 | Within tight groups (label to object) |
| sm | 0.3 | Between related items |
| md | 0.5 | Between content sections |
| lg | 0.8 | Major separations, title to content |
| xl | 1.2 | Section breaks |
Safe Area
- Frame: ~14.2 x 8 units
- Minimum edge margin: 0.8 units on all sides
- Content area: ~12.6 x 6.4 units
- Never place content within 0.8 units of the frame edge
Positioning
- Title card: centered at
ORIGIN, then moves toULcorner - Persistent title: top-left at
to_corner(UL, buff=0.5), scale 0.55 - Main content: centered at
ORIGINorDOWN * 0.3(slightly below center) - Side-by-side:
shift(2.5 * LEFT)andshift(2.5 * RIGHT)— wider than default - Labels:
next_to(referent, DOWN, buff=0.3)— below, not beside - Section tag: small PRIMARY-colored text at
ULabove the title —font_size=16, weight=BOLD
Layout Best Practices — Preventing Overlaps
Default to relative positioning for spatially-related elements:
# Label below icon — not absolute coords
label.next_to(icon, DOWN, buff=0.3)
# Row of items — not individual move_to calls
VGroup(a, b, c).arrange(RIGHT, buff=0.8)
# Grid layout — not manual coordinate math
items.arrange_in_grid(rows=2, cols=3, buff=0.8)Absolute coords for anchor points only — placing independent groups at their starting position:
# Place a group at a known anchor point
left_panel.move_to(LEFT * 3)
right_panel.move_to(RIGHT * 3)
# Then connect with relative positioning
arrow = Arrow(left_panel.get_right(), right_panel.get_left(),
color=BORDER, stroke_width=1.5, tip_length=0.15, buff=0.1)Group before position — build VGroup of related elements, arrange internally, then position the group as a unit:
# GOOD: group → arrange → position
node_label = Text("API", font="Helvetica Neue", font_size=20, color=TEXT_CLR)
node_box = RoundedRectangle(corner_radius=0.15, width=2.2, height=0.8,
fill_color=SURFACE, fill_opacity=1,
stroke_color=PRIMARY, stroke_width=1.5)
node_label.move_to(node_box)
node = VGroup(node_box, node_label)
# Position the GROUP, not individual pieces
node.move_to(LEFT * 2 + DOWN * 0.5)
# BAD: positioning pieces independently
# node_box.move_to(LEFT * 2 + DOWN * 0.5)
# node_label.move_to(LEFT * 2 + DOWN * 0.5) # fragile, breaks on resize---
Motion
Entrances — "Bounce"
Everything bounces in. This is non-negotiable.
# Single element
self.play(FadeIn(element, shift=UP * 0.4), run_time=0.5)
# Group with cascade
self.play(
AnimationGroup(*[
FadeIn(item, shift=UP * 0.4) for item in group
], lag_ratio=0.15),
run_time=0.7,
)
# Subtitle / secondary text (shorter bounce, faster)
self.play(FadeIn(subtitle, shift=UP * 0.2), run_time=0.3)Exits — "Drop"
Elements exit by dropping downward. The inverse of the bounce.
# Single element
self.play(FadeOut(element, shift=DOWN * 0.3), run_time=0.4)
# All content — scene transition
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)Emphasis — "Pop"
Prefer the pop pattern for text and labels. Indicate() is acceptable for icons, nodes, and non-text mobjects.
# Quick pop: scale up then back
self.play(element.animate.scale(1.15), run_time=0.2)
self.play(element.animate.scale(1/1.15), run_time=0.3)
# Stroke flash (for containers/boxes)
flash = element.copy().set_stroke(
color=PRIMARY, width=6, opacity=0.4
).set_fill(opacity=0)
self.play(FadeIn(flash, scale=0.95), run_time=0.2)
self.play(FadeOut(flash, scale=1.1), run_time=0.4)
self.remove(flash)Transforms
Keep transforms snappy. Creative Chaos moves fast.
# Equation morphs
self.play(TransformMatchingTex(eq1, eq2), run_time=0.6)
# Shape transforms
self.play(ReplacementTransform(old, new), run_time=0.5)Scene Transitions
Between major sections within a scene, use a clean drop-and-bounce:
# Transition between sections
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in content_group],
run_time=0.4,
)
self.wait(0.3)
# New content enters with bounce (standard entrance)Timing Guide
| Action | Duration | Notes |
|---|---|---|
| Title entrance (bounce + underline) | 0.7s | Display for 2.0s before moving |
| Element entrance (bounce) | 0.5s | |
| Secondary entrance | 0.3s | Subtitles, labels |
| Group cascade | 0.7s total | lag_ratio=0.15 |
| Transform / morph | 0.5-0.6s | Snappier than default |
| Pop emphasis | 0.2s up + 0.3s settle | |
| Exit (drop) | 0.4s | Slightly faster than entrance |
| Text reading pause | max(2, word_count / 3)s | ALWAYS pause after text appears |
| Pause after key concept | 1.5-2.0s | Let it breathe |
| Key insights/annotations | 3.0s minimum | Must be fully readable |
| Pause between steps | 0.8-1.0s | Keep momentum |
| Transition between sections | 1.5s wait | Before new content enters |
CRITICAL: NEVER use self.wait(0.5) or self.wait(1) after text with more than 3 words. Always compute: self.wait(max(2, word_count / 3)). Viewers need time to read.
---
Color Usage Rules
Semantic Meaning
| Token | Dark Value | Light Value | When to Use |
|---|---|---|---|
| PRIMARY | #ff3366 | #cc2952 | Main concepts, active elements, links, the dominant accent |
| ACCENT | #33ccff | #0099cc | Secondary concepts, complementary info, cool contrast |
| HIGHLIGHT | #ffcc00 | #cc9900 | THE most important element on screen, focus, emphasis, "look here" |
| SUCCESS | #66ff66 | #339933 | Correct answers, completed items, positive states |
| NEGATIVE | #ff4444 | #cc0000 | Errors, wrong answers, eliminated items, deletions |
| SURFACE | #3a3a4a | #f5f5f5 | Container backgrounds, card fills |
| BORDER | #4a4a5a | #e0e0e5 | Container strokes, structural arrows, dividing lines |
| TEXT_CLR | #ffffff | #2a2a3a | All readable text |
| TEXT_DIM | #8a8aaa | #8a8aaa | Annotations, axis labels, secondary text |
Rules
1. Max 3 accents at once. If PRIMARY + ACCENT + HIGHLIGHT are all on screen, don't add SUCCESS or NEGATIVE. Remove or dim something first. 2. PRIMARY is dominant. It should be the most-used accent color in any scene. 3. HIGHLIGHT is singular. Only one element at a time should be highlighted. Move it as focus shifts. 4. Text is never raw `WHITE` or `BLACK`. Always use TEXT_CLR or TEXT_DIM. 5. Containers use SURFACE fill + BORDER stroke. Stroke width 1.5, fill_opacity 1 (dark) or 0.95 (light). 6. Structural elements use BORDER color. Arrows connecting nodes, divider lines, axis lines — all BORDER. 7. MathTex defaults to TEXT_CLR. Color individual parts with set_color() for emphasis. 8. Body text is always TEXT_CLR. TEXT_DIM is ONLY for font_size 16 or smaller (captions, axis labels, annotations). Any text font_size >= 20 must use TEXT_CLR. When in doubt, use TEXT_CLR.
---
Container & Node Patterns
Standard Container (card)
card = RoundedRectangle(
corner_radius=0.15, width=4, height=2,
fill_color=SURFACE, fill_opacity=1,
stroke_color=BORDER, stroke_width=1.5,
)Node in a Diagram
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)Data Cell (array element)
# Sharp-cornered squares for data grids — the ONE exception to roundness
cell = Square(
side_length=0.8,
fill_color=SURFACE, fill_opacity=0.6,
stroke_color=BORDER, stroke_width=1.5,
)
value = Text("42", font="Monaco", font_size=24, color=TEXT_CLR)
value.move_to(cell)Connecting Arrows
arrow = Arrow(
start.get_right(), end.get_left(),
color=BORDER, stroke_width=1.5,
tip_length=0.15, buff=0.1,
)Equation Card
# Wrap equations in a surface card for depth
card = RoundedRectangle(
corner_radius=0.15, width=8, height=2.5,
fill_color=SURFACE, fill_opacity=0.8,
stroke_color=BORDER, stroke_width=1,
)
eq = MathTex(r"e^{i\pi} + 1 = 0", font_size=48)
eq.set_color(TEXT_CLR)
eq.move_to(card)Highlighting an Active Element
# Brighten the stroke to PRIMARY and pop
self.play(box.animate.set_stroke(color=PRIMARY, width=2.5), run_time=0.3)
self.play(box.animate.scale(1.05), run_time=0.2)
self.play(box.animate.scale(1/1.05), run_time=0.3)Progress Bar
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)Usage: self.play(set_progress(bar, 0.75), run_time=1.0) — fill always stays inside the track because set_progress() computes the exact target position.
NEVER animate a raw Rectangle's width for progress — it will overflow the track. Always use progress_bar() + set_progress().
---
Axes & Graphs
axes = Axes(
x_range=[0, 10, 1],
y_range=[0, 100, 10],
axis_config={
"color": TEXT_DIM, # Axes in muted color, not white
"stroke_width": 1.5, # Thinner than default
"include_numbers": True,
},
x_length=8,
y_length=5,
)
# Labels in TEXT_DIM
x_label = axes.get_x_axis_label("n", color=TEXT_DIM)
y_label = axes.get_y_axis_label("f(n)", color=TEXT_DIM)
# Plot curves in accent colors
curve1 = axes.plot(lambda x: x**2, color=PRIMARY)
curve2 = axes.plot(lambda x: 2*x, color=ACCENT)
# Graph labels
label1 = Text("O(n²)", font="Monaco", font_size=20, color=PRIMARY)
label1.next_to(curve1.get_end(), RIGHT, buff=0.2)---
Code Blocks
code = Code(
code="def search(arr, target):\n lo, hi = 0, len(arr) - 1\n ...",
language="python",
font_size=18,
background="rectangle", # Use rectangle for control
background_stroke_color=BORDER,
background_stroke_width=1,
line_spacing=0.6,
)
# Highlight a line — use a rounded highlight, not the default SurroundingRectangle
highlight = RoundedRectangle(
corner_radius=0.08,
width=code.width - 0.2,
height=0.35,
fill_color=PRIMARY, fill_opacity=0.12,
stroke_width=0,
)
highlight.move_to(code.code[1]) # highlight line 2---
Scene Structure Template
Every manimate scene follows this structure:
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class SceneName(Scene):
def construct(self):
setup_scene(self) # BG + dot grid
title = title_card(self, "Title Here") # bounce in → corner
# Main content (bounces in)
# ... build content here using bounce entrances ...
# Exit (drop out)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)---
Complete Examples
All examples use from shared import * — never inline palette constants.See templates/basic.py for the minimal starter template.Example A: Title Card with Subtitle
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class TitleCard(Scene):
def construct(self):
setup_scene(self)
title = title_card(self, "Binary Search")
# Subtitle (font_size 24 → TEXT_CLR, not TEXT_DIM)
subtitle = Text(
"Finding elements in sorted data, fast.",
font="Helvetica Neue", font_size=24, color=TEXT_CLR,
)
subtitle.next_to(title, DOWN, buff=0.4)
self.play(FadeIn(subtitle, shift=UP * 0.2), run_time=0.3)
self.wait(tw("Finding elements in sorted data, fast."))
self.play(FadeOut(subtitle, shift=DOWN * 0.3), run_time=0.4)
self.wait(1)Example B: Diagram / Flowchart
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class DiagramExample(Scene):
def construct(self):
setup_scene(self)
title = title_card(self, "Request Lifecycle")
# Nodes (make_node from shared.py)
client = make_node("Client", color=BORDER)
gateway = make_node("API Gateway", color=PRIMARY)
service = make_node("Service", color=ACCENT)
db = make_node("Database", color=HIGHLIGHT)
nodes = VGroup(client, gateway, service, db)
nodes.arrange(RIGHT, buff=1.0).move_to(DOWN * 0.3)
# Arrows
arrows = VGroup()
pairs = [(client, gateway), (gateway, service), (service, db)]
for a, b in pairs:
arrows.add(Arrow(
a.get_right(), b.get_left(),
color=BORDER, stroke_width=1.5,
tip_length=0.15, buff=0.1,
))
# Staggered entrance
for i, node in enumerate(nodes):
self.play(FadeIn(node, shift=UP * 0.4), run_time=0.4)
if i < len(arrows):
self.play(GrowArrow(arrows[i]), run_time=0.3)
self.wait(1)
# Pop the service node
self.play(service[0].animate.set_stroke(color=ACCENT, width=2.5), run_time=0.3)
self.play(service.animate.scale(1.1), run_time=0.2)
self.play(service.animate.scale(1.0), run_time=0.3)
# Caption label (font_size 16 → TEXT_DIM is fine)
label = Text(
"processes the request",
font="Helvetica Neue", font_size=16, color=TEXT_DIM,
)
label.next_to(service, DOWN, buff=0.4)
self.play(FadeIn(label, shift=UP * 0.2), run_time=0.3)
self.wait(2)
# Exit
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)Example C: Equation Derivation
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class EquationExample(Scene):
def construct(self):
setup_scene(self)
# Section tag (font_size 16 → TEXT_DIM-eligible, but tags use PRIMARY)
tag = Text(
"DERIVATION", font="Helvetica Neue", font_size=16,
color=PRIMARY, weight=BOLD,
)
tag.to_corner(UL, buff=0.5)
self.play(FadeIn(tag), run_time=0.3)
# Title below the tag
title = Text(
"Euler's Identity", font="Helvetica Neue", font_size=40,
color=TEXT_CLR, weight=BOLD,
)
title.next_to(tag, DOWN, buff=0.15, aligned_edge=LEFT)
self.play(FadeIn(title, shift=UP * 0.3), run_time=0.4)
self.wait(1)
# Equation card
card = RoundedRectangle(
corner_radius=0.15, width=8, height=2.5,
fill_color=SURFACE, fill_opacity=0.8,
stroke_color=BORDER, stroke_width=1,
)
card.move_to(DOWN * 0.5)
eq = MathTex(r"e^{i\pi} + 1 = 0", font_size=48)
eq.set_color(TEXT_CLR)
eq.move_to(card)
self.play(FadeIn(card, shift=UP * 0.4), run_time=0.4)
self.play(Write(eq), run_time=1.0)
self.wait(1)
# Color individual parts for emphasis
eq_colored = MathTex(
r"e", r"^{i\pi}", r"+", r"1", r"=", r"0",
font_size=48,
)
eq_colored[0].set_color(PRIMARY) # e
eq_colored[1].set_color(ACCENT) # ipi
eq_colored[2].set_color(BORDER) # + (structural, not TEXT_DIM)
eq_colored[3].set_color(HIGHLIGHT) # 1
eq_colored[4].set_color(BORDER) # = (structural, not TEXT_DIM)
eq_colored[5].set_color(HIGHLIGHT) # 0
eq_colored.move_to(card)
self.play(TransformMatchingTex(eq, eq_colored), run_time=0.6)
self.wait(0.5)
# Annotation (font_size 20 → TEXT_CLR, not TEXT_DIM)
desc = Text(
"Five fundamental constants in one equation.",
font="Helvetica Neue", font_size=20, color=TEXT_CLR,
)
desc.next_to(card, DOWN, buff=0.5)
self.play(FadeIn(desc, shift=UP * 0.2), run_time=0.3)
self.wait(tw("Five fundamental constants in one equation."))
# Pop emphasis on the card
self.play(card.animate.scale(1.03), run_time=0.2)
self.play(card.animate.scale(1.0), run_time=0.3)
self.wait(2)
# Exit
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)---
Anti-Patterns — Do NOT
| Instead of... | Do this |
|---|---|
BLUE, YELLOW, GREEN, RED (Manim defaults) | Use PRIMARY, ACCENT, HIGHLIGHT, SUCCESS |
FadeIn(element) (flat entrance) | FadeIn(element, shift=UP * 0.4) (bounce) |
FadeOut(element) (flat exit) | FadeOut(element, shift=DOWN * 0.3) (drop) |
Rectangle() for containers | RoundedRectangle(corner_radius=0.15, ...) |
stroke_width=4 (thick default) | stroke_width=1.5 (refined) |
WHITE text color | TEXT_CLR (#ffffff) |
Indicate(obj) for text emphasis | Pop pattern (scale up + settle). Indicate() OK for icons/nodes |
background_color = "#1e1e2e" | background_color = BG (use the token) |
font_size=48 for everything | Use the size scale: 44/32/26/20/16 |
No font parameter | font="Helvetica Neue" for all text (headings use weight=BOLD) |
Sharp-cornered SurroundingRectangle | RoundedRectangle positioned with move_to |
| 4+ accent colors on screen | Max 3 accents. Dim or remove before adding more |
run_time=2 for simple transforms | run_time=0.5-0.6 — keep it snappy |
| Content touching frame edges | 0.8-unit minimum margin on all sides |
| Center-top persistent title | Top-LEFT corner: .to_corner(UL, buff=0.5) |
self.wait(0.5) after multi-word text | self.wait(max(2, word_count / 3)) |
Rectangle() for a "server" or "database" | Custom SVG icon via svg_icon() helper |
Circle() for a "user" or "person" | Custom SVG person icon |
Raw Manim colors in SVG fills (BLUE) | Palette hex values (#ff3366) |
| Gradients/filters in SVGs | Flat fills only |
TEXT_DIM for body text (font_size >= 20) | TEXT_CLR — TEXT_DIM is only for captions (16) and axis labels |
| Hard-coded box width with text inside | make_node() — auto-sizes box to fit text. Or measure: max(w, text.width + 0.6) |
| Animating a raw Rectangle for a progress bar fill | progress_bar() + set_progress() — fill stays inside track |
AddTextLetterByLetter(text) (flickers) | Write(text) or FadeIn(text, shift=UP * 0.3) |
.next_to(container, DOWN) for child elements | .move_to(container.get_bottom() + UP * 0.5) — keep children INSIDE |
Text(f"{a}{b}") with no separator | Always verify spaces: Text(f"{a}: {b}") or Text(f"{a} {b}") |
Text & Math Reference
Text (Pango-based — no LaTeX needed)
title = Text("Hello World", font_size=48, color=TEXT_CLR) bold = Text("Bold", weight=BOLD, font_size=32) italic = Text("Italic", slant=ITALIC, font_size=32)
MathTex (LaTeX math mode)
eq = MathTex(r"E = mc^2", font_size=36)
Color specific parts:
eq = MathTex(r"a^2", "+", r"b^2", "=", r"c^2") eq[0].set_color(PRIMARY) # a^2 eq[2].set_color(ACCENT) # b^2
TransformMatchingTex
eq1 = MathTex(r"a^2 + b^2 = c^2") eq2 = MathTex(r"c = \sqrt{a^2 + b^2}") self.play(TransformMatchingTex(eq1, eq2))
Code Blocks
code = Code( code="def binary_search(arr, target):\n lo, hi = 0, len(arr) - 1", language="python", font_size=20, background="window", ) self.play(Create(code))
Pitfalls
- MathTex requires LaTeX installed. If LaTeX is not available, use Text() instead.
- Use raw strings (r"...") for LaTeX to avoid escape issues.
- Double braces {{ }} in MathTex for literal braces.
- For multi-line equations, use aligned environment:
MathTex(r"\begin{aligned} a &= b + c \\ d &= e + f \end{aligned}")
MIT License
Copyright (c) 2025 Bassime Ledath
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "@bassimeledath/manimate",
"version": "1.0.0",
"description": "Generate diagram/animation videos from natural language using Manim. Outputs MP4 (default) or GIF on request.",
"keywords": [
"claude-code", "skill", "agent-skill", "animation", "manim",
"video", "codex", "cursor", "ai-coding", "3b1b"
],
"author": "Bassime Ledath",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/bassimeledath/manimate"
},
"bin": {
"manimate-skill": "./bin/install.sh"
},
"scripts": {
"postinstall": "node bin/postinstall.js"
},
"files": [
"bin/",
"SKILL.md",
"library/",
"references/",
"templates/",
"examples/",
"scripts/",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18"
}
}
/manimate
A coding-agent skill that turns natural language prompts into animated videos using Manim.
!How manimate works
Install
npx skills add bassimeledath/manimate<details> <summary>Other install methods</summary>
# npm
npx @bassimeledath/manimate
# Manual (Claude Code)
git clone https://github.com/bassimeledath/manimate ~/.claude/skills/manimate
# Manual (Codex)
git clone https://github.com/bassimeledath/manimate ~/.codex/skills/manimate</details>
Usage
/manimate "show the Pythagorean theorem"
/manimate "explain how binary search works"
/manimate "visualize bubble sort step by step"Outputs to .manimate/output/animation.mp4 (GIF available on request).
Dependencies
- Python 3.8+ and ManimCE (
pip install manim) - ffmpeg — video stitching
- LaTeX (optional) — for math expressions. Falls back to Text() if unavailable
- A supported agent CLI (
claude,codex, or setMANIMATE_AGENT_CLI)
How It Works
Prompt → Story → Code → Render → Video.
The agent decomposes your prompt into scenes, writes Manim Python code for each, renders via Cairo, and stitches the results with ffmpeg. Failed renders auto-recover (max 3 retries). An optional visual validation step checks layout and readability.
Narrative Patterns for Educational Animations
1. Question -> Exploration -> Answer
Start with a question, explore visually, arrive at the answer. Best for: algorithm explanations, "how does X work?"
2. Simple -> Complex -> Simple
Start simple, add complexity, then simplify to the core insight. Best for: math concepts, data structures
3. Problem -> Failed Approach -> Better Approach
Show why the naive approach fails, then introduce the better solution. Best for: algorithm comparisons, optimization
4. Build Up -> Payoff
Incrementally build visual elements until the full picture reveals the insight. Best for: proofs, geometric constructions
5. Concrete -> Abstract
Start with a specific example, then generalize to the abstract concept. Best for: teaching new concepts, mathematical definitions
Duration Guidelines
- 1 scene: Quick demo (5-10s) — single concept, simple visualization
- 2 scenes: Standard explanation (16-20s) — concept + example
- 3 scenes: Full walkthrough (24-30s) — intro + explanation + summary
- 4-6 scenes: Deep dive (32-60s) — complex topics with multiple parts
#!/bin/bash
# Render pipeline: discover scene MP4s -> re-encode + concat -> convert to GIF
# Usage: ./render.sh [options]
#
# Options:
# --scenes-dir DIR Directory containing scene_*.py files
# --media-dir DIR Manim's media output directory
# --output-dir DIR Directory for final output files
# --format FORMAT Output format: gif, mp4, both (default: both)
# --story-file FILE Path to story.json
set -e
# Defaults
SCENES_DIR=".manimate/scenes"
MEDIA_DIR=".manimate/media"
OUTPUT_DIR=".manimate/output"
FORMAT="mp4"
STORY_FILE=".manimate/story.json"
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--scenes-dir) SCENES_DIR="$2"; shift 2 ;;
--media-dir) MEDIA_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--format) FORMAT="$2"; shift 2 ;;
--story-file) STORY_FILE="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# Validate
if [ ! -f "$STORY_FILE" ]; then
echo "Error: story file not found: $STORY_FILE"
exit 1
fi
command -v ffmpeg >/dev/null 2>&1 || { echo "Error: ffmpeg not found"; exit 1; }
mkdir -p "$OUTPUT_DIR"
# Read scene count
TOTAL_SCENES=$(python3 -c "import json; print(len(json.load(open('$STORY_FILE'))['scenes']))")
if [ "$TOTAL_SCENES" -eq 0 ]; then
echo "Error: no scenes in story.json"
exit 1
fi
echo "Stitching $TOTAL_SCENES scene(s)..."
# Discover rendered scene MP4s
SCENE_VIDEOS=()
for N in $(seq 1 $TOTAL_SCENES); do
SCENE_FILE="scene_$(printf "%02d" $N)"
SCENE_CLASS=$(python3 -c "
import json
story = json.load(open('$STORY_FILE'))
print(story['scenes'][$((N-1))]['scene_class'])
")
# Search for the rendered video (quality dir varies by -q flag)
VIDEO=$(find "$MEDIA_DIR/videos" -name "${SCENE_CLASS}.mp4" 2>/dev/null | head -1)
if [ -z "$VIDEO" ] || [ ! -f "$VIDEO" ]; then
echo "Error: could not find rendered video for scene $N ($SCENE_CLASS)"
exit 1
fi
SCENE_VIDEOS+=("$VIDEO")
echo " Scene $N: $VIDEO"
done
# Single scene — just copy
if [ ${#SCENE_VIDEOS[@]} -eq 1 ]; then
cp "${SCENE_VIDEOS[0]}" "$OUTPUT_DIR/animation.mp4"
echo " Single scene — copied directly"
else
# Create concat filelist
FILELIST=$(mktemp)
for v in "${SCENE_VIDEOS[@]}"; do
echo "file '$(cd "$(dirname "$v")" && pwd)/$(basename "$v")'" >> "$FILELIST"
done
# Re-encode and concatenate (handles codec parameter mismatches)
FFMPEG_LOG=$(mktemp)
if ffmpeg -y -f concat -safe 0 -i "$FILELIST" \
-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p \
-movflags +faststart \
"$OUTPUT_DIR/animation.mp4" 2>"$FFMPEG_LOG"; then
echo " MP4 stitched"
else
echo " MP4 stitching failed. ffmpeg output:"
cat "$FFMPEG_LOG"
rm -f "$FFMPEG_LOG" "$FILELIST"
exit 1
fi
rm -f "$FFMPEG_LOG" "$FILELIST"
fi
MP4_SIZE=$(du -h "$OUTPUT_DIR/animation.mp4" | cut -f1)
echo " MP4: $OUTPUT_DIR/animation.mp4 ($MP4_SIZE)"
# Convert to GIF if requested
if [[ "$FORMAT" == "gif" || "$FORMAT" == "both" ]]; then
echo "Converting to GIF..."
FFMPEG_LOG=$(mktemp)
if ffmpeg -y -i "$OUTPUT_DIR/animation.mp4" \
-filter_complex "[0:v]fps=12,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=196:stats_mode=diff[p];[b][p]paletteuse=dither=floyd_steinberg" \
-loop 0 "$OUTPUT_DIR/animation.gif" 2>"$FFMPEG_LOG"; then
GIF_SIZE=$(du -h "$OUTPUT_DIR/animation.gif" | cut -f1)
echo " GIF: $OUTPUT_DIR/animation.gif ($GIF_SIZE)"
else
echo " GIF conversion failed. ffmpeg output:"
cat "$FFMPEG_LOG"
rm -f "$FFMPEG_LOG"
exit 1
fi
rm -f "$FFMPEG_LOG"
fi
echo ""
echo "Animation complete!"
echo " Scenes: $TOTAL_SCENES"
[[ "$FORMAT" != "gif" ]] && echo " MP4: $OUTPUT_DIR/animation.mp4"
[[ "$FORMAT" == "gif" || "$FORMAT" == "both" ]] && echo " GIF: $OUTPUT_DIR/animation.gif"
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
# ── SVG Assets (generated in Step 6, loaded here by asset_manifest ID) ──
# icon = load_asset("your_asset_id", scale=0.8) # ID from story.json asset_manifest
# For rare one-off SVGs (under 5 lines), use svg_icon() instead:
# icon = svg_icon('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">...</svg>', scale=0.8)
class BasicScene(Scene):
def construct(self):
setup_scene(self)
# 1. Title card — bounce in, display, move to corner
title = title_card(self, "Scene Title")
# 2. Main content — replace with your asset_manifest ID
icon = load_asset("your_asset_id", scale=0.8)
label = Text("Server", font="Helvetica Neue", font_size=20, color=TEXT_CLR)
label.next_to(icon, DOWN, buff=0.3)
content = VGroup(icon, label)
content.move_to(ORIGIN)
self.play(FadeIn(icon, shift=UP * 0.4), run_time=0.5)
self.play(FadeIn(label, shift=UP * 0.2), run_time=0.3)
self.wait(tw("Server"))
# 3. Exit (drop out)
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class CodeScene(Scene):
def construct(self):
setup_scene(self)
# 1. Title card
title = title_card(self, "Code Walkthrough")
# 2. Code Block
code = Code(
code="def example():\n return 42",
language="python",
font_size=18,
background="rectangle",
background_stroke_color=BORDER,
background_stroke_width=1,
line_spacing=0.6,
)
self.play(FadeIn(code, shift=UP * 0.4), run_time=0.5)
self.wait(2)
# 3. Highlight a line — rounded highlight
highlight = RoundedRectangle(
corner_radius=0.08,
width=code.width - 0.2,
height=0.35,
fill_color=PRIMARY, fill_opacity=0.12,
stroke_width=0,
)
highlight.move_to(code.code[1])
self.play(FadeIn(highlight), run_time=0.3)
self.wait(1.5)
# 4. Exit
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class GraphScene(Scene):
def construct(self):
setup_scene(self)
# 1. Title card
title = title_card(self, "Graph Title")
# 2. Axes
axes = Axes(
x_range=[0, 10, 1],
y_range=[0, 100, 10],
axis_config={
"color": TEXT_DIM,
"stroke_width": 1.5,
"include_numbers": True,
},
x_length=8,
y_length=5,
)
self.play(Create(axes), run_time=1.0)
# 3. Plot curve
graph = axes.plot(lambda x: x**2, color=PRIMARY)
label = Text("O(n\u00b2)", font="Monaco", font_size=20, color=PRIMARY)
label.next_to(graph.get_end(), RIGHT, buff=0.2)
self.play(Create(graph), run_time=1.5)
self.play(FadeIn(label, shift=UP * 0.2), run_time=0.3)
self.wait(2)
# 4. Exit
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from shared import *
class MathScene(Scene):
def construct(self):
setup_scene(self)
# 1. Title card
title = title_card(self, "Mathematical Concept")
# 2. Equation in a card
card = RoundedRectangle(
corner_radius=0.15, width=8, height=2.5,
fill_color=SURFACE, fill_opacity=0.8,
stroke_color=BORDER, stroke_width=1,
)
card.move_to(DOWN * 0.3)
eq1 = MathTex(r"a^2 + b^2 = c^2", font_size=48)
eq1.set_color(TEXT_CLR)
eq1.move_to(card)
self.play(FadeIn(card, shift=UP * 0.4), run_time=0.4)
self.play(Write(eq1), run_time=1.0)
self.wait(2)
# 3. Transform
eq2 = MathTex(r"c = \sqrt{a^2 + b^2}", font_size=48)
eq2.set_color(TEXT_CLR)
eq2.move_to(card)
self.play(TransformMatchingTex(eq1, eq2), run_time=0.6)
self.wait(2)
# 4. Exit
self.play(
*[FadeOut(mob, shift=DOWN * 0.3) for mob in self.mobjects],
run_time=0.4,
)
Related skills
FAQ
What does Manimate output?
MP4 video by default, with GIF available on request.
What renderer does it use?
The Cairo renderer, chosen as the safe default for headless/CI environments with no GPU or display server.