Base directory of this skill: the directory containing this file. All relative paths below are relative to it.
-
Know what you are animating before you animate. This skill decides HOW something is shown; it never decides WHAT is worth showing. If you were handed a beat-by-beat script (by a human, or by a pedagogy skill such as professor), execute it: its stage directions say what is visible, its events are what you animate, its narration becomes subcaptions, and its scene split, per-scene space (2D/3D), and persistent anchor visual are given — never rebuild an anchor, morph it. If you were handed only a topic, write that beat list yourself before touching code. A weak explanation animated beautifully is still a weak explanation, and nothing in this skill can fix that.
-
Storyboard first, in words. Before any code, list the beats. One idea per beat. For each beat write down: what is on screen, the single focal point, which Move (see below) carries the transition into it, and why the viewer should care at that moment.
-
Choose the space. If the concept has natural spatial structure — surfaces, terrain, trees, vector fields, gradients, anything with depth — you MUST sketch a 3D treatment and explicitly justify 2D if you reject it. "2D is easier to code" is not a justification.
-
Render keyframes before animating. For each beat, build its final composition with self.add(...) and render a still: manim -s -ql file.py SceneName. Read the PNG with your image-reading tool. Score it against the rubric. Fix layout problems now, before any motion exists.
-
Animate, then look. Render with -ql, then run scripts/contact_sheet.sh <video.mp4> and read the sheet image. Check the motion arc across tiles: does each transition read? Is anything popping in/out that should morph? Is the pacing monotone? Three or more near-identical consecutive tiles need judging against the .srt: if narration is playing over them, that is a hold — correct and necessary, the viewer is listening while looking at the finished picture. If nothing is being said, it is dead air — cut it or give the beat something to do. A tile showing scenery without its beat's focal point is a dead beat either way. For dark or atmospheric scenes, also run scripts/frame_lint.py <video.mp4> — it flags sustained unreadable-dark stretches that per-frame eyeballing misses. Contact-sheet tiles are too small to certify caption-band integrity: spot-check full-resolution frames at the swap timestamps (scripts/keyframes.sh or ffmpeg -ss). And if a render disagrees with what the code can possibly produce, suspect the segment cache before your code — re-render with --disable_caching (landmines §14).
-
Lint every beat. Add the skill's scripts/ dir to sys.path and call from craft import lint; lint(self, "beat name") at the end of each beat inside construct(). Fix every warning or write down why it is intentional.
-
Check the pacing against the narration. Run python3 scripts/pace_lint.py <SceneName>.srt <SceneName>.mp4. RUSHED means the narrator cannot say that line in the time you left; TOO-DENSE means they never get to breathe. Fix by adding self.wait() and lengthening run_time — never by shortening the narration.
-
Iterate 3–6 until the rubric passes. Never deliver a scene whose rendered frames you have not personally looked at.
Named patterns. Refer to them by name in your storyboard.
-
Every scene has a title — a short Text (≤ 5 words, a chapter marker not a sentence), top edge, readable throughout the scene (in 3D scenes: fixed-in-frame).
-
One focal point per beat. If you cannot say what it is, the beat is not designed yet.
-
Nothing pops. Everything Creates, Grows, Morphs, or slides with purpose. self.add() mid-scene is for backgrounds only.
-
Contrast: dark background needs bright fills (*_A/*_B color variants, not *_E). Labels ≥ font_size=24. Judge readability on the rendered frame, never in your head.
-
Timing: see hard rule 14 — narration sets the clock. Floors: nothing under 0.4 s except micro-adjustments; every beat ends with a wait; big reveals get 1.5–2.5 s plus a pause. Vary the pace — uniform timing reads as monotone.
-
rate_func with intent: smooth by default, rush_into for falling/dropping, there_and_back for pulses, linear only for ambient motion.
-
Never trust LaTeX. Probe MathTex with a tiny -s render before relying on it; if the pipeline is broken, use Text (see references/landmines.md §4).
-
Read references/landmines.md before writing code. Every item in it is a real bug that actually happened.
-
Study the example matching your scene type (examples/*/NOTES.md + their keyframe PNGs) before writing your own scene. Look at the keyframes — that is what "good" looks like.
-
Caption bands cut, they never crossfade. Swapping title/footer text with simultaneous FadeOut+FadeIn, Transform, or FadeTransform renders garbled double-text (landmines §12). Use the sequential swap or scripts/caption_slot.py. Morph-Don't-Fade is for subjects; caption bands are narration, and narration cuts.
-
Justified 2D is not a static camera. If a beat's STAGE implies a close-up, a scale change, or a reveal, use MovingCameraScene and pin the HUD with scripts/pin_to_frame.py (unpinned HUD vanishes on push-in — landmines §13). Do not downgrade a scripted camera move to opacity dimming.
-
Atmosphere never beats legibility. Fog and darkness must keep the focal point readable at 480p — verify with scripts/frame_lint.py, and never leave a beat on stage without its focal point. Rule of thumb: a deliberately dim beat still needs ≥1% of pixels genuinely bright; a lone title plus one small glow is borderline.
-
Narration is spoken, never printed. The script's narration lines go to self.add_subcaption(text, duration=...), which writes a SceneName.srt sidecar for the voice artist — they must NEVER become Text mobjects on stage. There is no subtitle band. Burning the script into the frame is the single most common way an LLM-made animation announces itself: it forces the viewer to read and listen at once, duplicates the narrator, and eats the frame.
On-screen text is allowed for exactly four things: the scene title (≤5 words), labels on objects and axes, a named term at the moment it is defined (that's the payoff of picture→name→symbol), and live numbers (tickers, counters, readouts). Everything else is the narrator's job.
Practical test: if a string is a sentence — has a verb and reads like speech — it is narration; move it to add_subcaption. craft.lint flags any on-screen Text over 6 words as WORDY-TEXT. A caption that survives that test is a label, not a subtitle.
-
Narration sets the clock — the video waits for the speaker. Time every beat by how long its narration takes to say, at 2.5 words/second maximum (150 wpm; slower is usually better for teaching):
beat seconds ≳ (words in its narration) / 2.5 + ~1 s of silence to breathe
A 20-word line needs ≥8 s of screen time before pauses — not the 2 s of animation it takes to draw. Make up the difference with real self.wait() calls and longer run_times, not by cramming. Every beat ends with self.wait(); a reveal the viewer must absorb gets self.wait(1.5) or more, held on the finished picture in silence. Aim for ≥15% of each scene unnarrated.
Verify, don't estimate: python3 scripts/pace_lint.py SceneName.srt SceneName.mp4 reports RUSHED cues, NO-BREATH gaps and TOO-DENSE scenes against the real rendered timings. It must pass before you deliver.
Score every keyframe / contact sheet 1–5 on each axis. Any axis ≤ 3 → revise before proceeding. Deliver only when everything is ≥ 4.