Language Reference
VMD Primitive DSL
The complete reference for Video Markdown — every primitive, command, and pattern. Write your first scene in minutes.
Video Markdown (VMD) — Created by Sohan Dananjaya
Shortcut — use any AI to write VMD for you
Download the VMD AI Guide and give it to Claude, ChatGPT, Gemini — any AI you prefer. Describe what you want animated in plain language. Paste the code into the IDE. It renders instantly.
Quick Start
A VMD file is plain YAML. Every element is either a primitive (static or single animation) or a morph (transforms through multiple states). The shape: line is always one single line — never break it.
concept: "My First Scene"
total_duration: 5
background: "#080c14"
subtitles:
- ""
phases:
intro:
duration: 5
elements:
circle:
type: primitive
shape: circle[r:1.0 fill:#4fc3f7/0.8 at:(0,0)] -> fadeIn(0.6s, 0.3s)
label:
type: primitive
shape: text[text:"HELLO" fontSize:32 fill:#ffffff/0.9 at:(0,-2.5)] -> fadeIn(0.5s, 0.8s)Render: vmd render my-scene.vmd --output out.mp4
File Structure
| Property | Type / Values | Notes |
|---|---|---|
| concept | string | Title of the animation |
| total_duration | number | Sum of all phase durations in seconds |
| background | #hex | Canvas background color |
| subtitles | list | Always present. Include at least one empty string - "" |
| phases | map | Named phases, each with duration + elements |
concept: "Concept Title"
total_duration: 30
background: "#080c14"
subtitles:
- ""
phases:
scene_name:
duration: 30
description: "One sentence — what happens here"
elements:
element_name:
type: primitive | morph
shape: shape[...] -> command(...)Canvas
x: -8.0 → +8.0 (16 units wide) y: -6.0 → +6.0 (12 units tall) center: (0, 0) positive Y is UP
All coordinates are in logical units. Position with at:(x,y). Canvas is always 16×12 units regardless of output resolution.
Primitives
All primitives support morph chains and all animation commands.
circle
circle[r:0.8 fill:#4fc3f7/0.7 stroke:#4fc3f7/0.4 strokeWidth:1.5 at:(0,0)]
| Property | Type / Values | Notes |
|---|---|---|
| r | number | Radius |
| fill | #hex/opacity | Fill color |
| stroke | #hex/opacity | Stroke color |
| strokeWidth | number | Stroke thickness |
| at | (x,y) | Center position |
rectangle
rectangle[w:3.0 h:1.5 fill:#4fc3f7/0.2 stroke:#4fc3f7/0.45 strokeWidth:1.5 at:(0,0)]
square / ellipse
square[w:1.2 fill:#6bffb8/0.3 stroke:#6bffb8/0.6 strokeWidth:1.5 at:(0,0)] ellipse[w:3.0 h:1.2 fill:#4fc3f7/0.15 stroke:#4fc3f7/0.4 strokeWidth:1.5 at:(0,0)]
line
# Static line line[from:(-4,0) to:(4,0) stroke:#4fc3f7/0.5 strokeWidth:1.5] # Draws itself (morph from zero length) line[from:(-4,0) to:(-4,0) stroke:#4fc3f7/0.5 strokeWidth:1.5] ~> line[from:(-4,0) to:(4,0) stroke:#4fc3f7/0.5 strokeWidth:1.5] (1.8s, ease.inOut, 3s)
triangle
triangle[size:0.9 fill:#4fc3f7/0.2 stroke:#4fc3f7/0.5 strokeWidth:1.5 at:(0,0) direction:up]
direction: up (default) · down · left · right. Also accepts rotation:180deg.
star
star[points:5 outerRadius:0.6 innerRadius:0.28 fill:#ffd54f/0.25 stroke:#ffd54f/0.6 strokeWidth:1.5 at:(0,0)]
regularPolygon
regularPolygon[sides:6 r:0.65 fill:#4fc3f7/0.2 stroke:#4fc3f7/0.45 strokeWidth:1.5 at:(0,0)]
functionGraph
functionGraph[fn:"sin(x)" xStart:-6.5 xEnd:6.5 segments:150 stroke:#4fc3f7/0.3 strokeWidth:1.5 at:(0,0)]
Supported functions: sin cos tan exp log sqrt pow. Use as world terrain at low opacity.
parametric
parametric[paramX:"2.5*cos(t)" paramY:"1.0*sin(t)" tStart:0 tEnd:6.28 segments:100 stroke:#4fc3f7/0.15 strokeWidth:1 at:(0,0)]
group
# Icon with aura — one actor, one unit
group{at:(0,0.5)} [ circle[r:1.2 fill:#4fc3f7/0.12 stroke:#4fc3f7/0.3 strokeWidth:1 at:(0,0)], icon[icon:"lucide:server" size:2.0 color:#4fc3f7 at:(0,0)] ] -> fadeIn(0.8s, 1s)Entire group moves with moveTo. All children comma-separated on the same line.
image
image[src:"images/icons/photo.jpg" w:4 h:2.6 fit:cover radius:0.2 at:(0,0)] -> fadeIn(0.8s, 0.5s) # Crossfade between images image[src:"images/a.jpg" w:4 h:2.6 fit:cover at:(0,0)] ~> image[src:"images/b.jpg" w:4 h:2.6 fit:cover at:(0,0)] (1.4s, ease.inOut, 4s) # Crop wipe reveal image[src:"images/a.jpg" w:6 h:3.0 fit:cover crop:(-3,-1.5,0,3) at:(0,0)] ~> image[src:"images/a.jpg" w:6 h:3.0 fit:cover crop:(-3,-1.5,6,3) at:(0,0)] (1.8s, ease.inOut, 2s)
| Property | Type / Values | Notes |
|---|---|---|
| src | string | Must start with images/ (repo-relative) |
| w / h | number | Canvas units. Omit one to preserve aspect ratio |
| fit | cover | contain | Fill mode |
| radius | number | Corner radius |
| grayscale | 0–1 | Desaturation — interpolates in morph |
| blur | number | Blur radius — interpolates in morph |
| tint | #hex/opacity | Color overlay — interpolates in morph |
| crop | (x,y,w,h) | Crop region — interpolates (use for wipe reveals) |
Icons
Icons are first-class actors. Treat them like characters — every icon in the scene does something. It fades in, changes color, morphs, moves, or scales in response to events. A static icon is decoration. Remove it or give it a role.
icon[icon:"lucide:database" size:2.0 color:#6bffb8 at:(0,0.5)] -> fadeIn(0.7s, 1s) -> scale(1.08, 2s, ease.inOut, 1.5s) icon[icon:"mdi:rocket" size:1.4 color:#ff8fab at:(-5,-2)] -> fadeIn(0.4s, 1.2s) -> moveTo((5,2), 4.5s, ease.inOut, 1.5s)
| Property | Type / Values | Notes |
|---|---|---|
| icon | "prefix:name" | Required. Exact: mdi:server · lucide:zap · logos:react |
| size | number | 1 ≈ 50px. Lead: 1.5–2.5. Background: 0.6–1.2 |
| color | #hex/opacity | Icon color with opacity: color:#4fc3f7/0.7 |
| opacity | 0–1 | Multiplies color opacity |
| at | (x,y) | Position |
| rotation | Ndeg | Static orientation at spawn |
| flip | horizontal | vertical | both | Mirror the icon |
Expressive, filled. Creatures, machines, environments, personality.
Clean outline. System components, data flows, technical diagrams.
Brand identities. Only when the brand is the subject.
Figures & Characters
A figure is a fully-rigged 2D character — a chest, neck, head, two arms, two legs, eyes, and a mouth, all driven by one shared skeleton. Use one when a scene needs an actor with a body, not just an icon. Every figure is designed inline in an avatars: block, then animated with gesture() — no separate rigging file or tool.
avatars:
hero: figure[name:"hero"]{root[shape:none] chest[...] neck[...] head[...] eye_l[...] eye_r[...] mouth[...] shoulder_l[...] shoulder_r[...] elbow_l[...] elbow_r[...] hand_l[...] hand_r[...] hip_l[...] hip_r[...] knee_l[...] knee_r[...] foot_l[...] foot_r[...]}
phases:
scene1:
elements:
hero:
type: figure
shape: figure[use:"hero" at:(0,-1.9) scale:85] -> gesture("wave", 3s, 0.5s)| Property | Type / Values | Notes |
|---|---|---|
| use | "name" | References an avatar defined in the avatars: block, by its key |
| at | (x,y) | Root position — feet land roughly here |
| scale | number | Character height in canvas units. 85 ≈ standing adult |
The 19-point contract
Every figure must shape all 19 of these points or it fails to render. They form two chains off a fixed parent: the spine, and a pair each of arms and legs.
| Property | Type / Values | Notes |
|---|---|---|
| root → chest → neck → head | spine | root has shape:none; the others carry the head/torso design |
| eye_l · eye_r · mouth | face | offset off head |
| shoulder_l/r → elbow_l/r → hand_l/r | arms | shoulder is an offset off chest, elbow/hand chain from it |
| hip_l/r → knee_l/r → foot_l/r | legs | hip is an offset off root, knee/foot chain from it |
Reuse a point's length, direction, offset, and width as-is unless deliberately resizing the body — the gesture library is tuned against the contract's default angles. For a new design, only change shape, fill, and add extra[...] accessories.
Shapes & the joint-cap rule
| Property | Type / Values | Notes |
|---|---|---|
| circle | r, fill | Joint markers — shoulder, hip, head |
| ellipse | w, h, fill, angle | Eyes, mouth, eyebrows, lens-shaped accessories |
| capsule | width, widthEnd, fill | Contract chain-points only — draws the tapered bone segment |
| path | points, smoothing | Local-offset point list, Catmull-Rom smoothed — hair, capes |
Color shoulder_l/r and hip_l/r to match chest's own fill, not the limb beyond them. Put any fabric→skin or trouser→shoe color change at hand_l/r or foot_l/r instead — the renderer auto-paints a joint cap there in that point's color, which covers the seam for free.
Design checklist
| Property | Type / Values | Notes |
|---|---|---|
| Proportions | neck length:0.22, head length:0.20, head r:0.33–0.34 | Person-like head-to-body ratio |
| Joint sizing | shoulder/hip r 0.13–0.14, elbow/knee/hand/foot capsule width tapered toward the joint | Scaled relative to head r |
| Shading | shadeFill two shades darker, shadeOpacity ~0.2, shadeScale 0.75 | Avoids a flat sticker look on the head |
| Cosmetic layer | pupils on eye_l/r, role-tagged eyebrows, 2–3 finger extras per hand | Bare capsule hands read as mittens without it |
| Verify | render happy / sad / surprised / angry + one plain gesture | Catches clipping or off-model accessories before shipping |
Accessories
Extras attach to a point and inherit its position and rotation every frame. Never use capsule on an extra — only circle, ellipse, or path.
# Glasses — two lenses + a bridge, all on head extra[attachTo:"head" shape:ellipse w:0.16 h:0.13 fill:none offset:(-0.15,0.08)] extra[attachTo:"head" shape:ellipse w:0.16 h:0.13 fill:none offset:(0.15,0.08)] extra[attachTo:"head" shape:ellipse w:0.10 h:0.02 fill:#2b2b2b offset:(0,0.08)] # Pupils — attach to the eye itself, not head, to get gaze tracking for free extra[attachTo:"eye_l" shape:circle r:0.032 fill:#211a16] extra[attachTo:"eye_r" shape:circle r:0.032 fill:#211a16] # Eyebrows — role tag is required for them to react to expressions extra[attachTo:"head" shape:ellipse w:0.10 h:0.026 fill:#2b211c angle:0.08 offset:(-0.15,0.10) role:"eyebrow_l"] extra[attachTo:"head" shape:ellipse w:0.10 h:0.026 fill:#2b211c angle:-0.08 offset:(0.15,0.10) role:"eyebrow_r"]
Gestures
Chain gesture(name, duration, delay) after a figure exactly like any other command. Each gesture animates a specific set of joints — body language and lip-sync, not random motion.
| Property | Type / Values | Notes |
|---|---|---|
| wave · head-tilt · talk | — | Greeting, listening, speaking |
| look-left · look-right · look-up | — | Gaze direction |
| point-left · point-right · point-up | — | Directing attention off-body |
| explain · shrug · idle | — | Default presenting poses |
| happy · sad · surprised · angry | — | Emotion |
| walk | — | Locomotion — pair with moveTo for travel |
| flinch · laugh | — | Reaction beats |
Combining gestures
Gestures merge by joint, not by overwrite — two gestures touching different joints can run at the same time. Give them the same delay to layer them; give them increasing delays to sequence them.
# Sequential: explain plays alone for 1.5s, then wave + talk play together for the next 3.5s
figure[use:"hero" at:(3,-1.9) scale:85]
-> gesture("explain", 1.5s, 0s)
-> gesture("wave", 3.5s, 1.5s)
-> gesture("talk", 3.5s, 1.5s)
# Concurrent: same delay on every gesture — walk, talk, and an emotion all play at once
figure[use:"hero" at:(-3,-0.45) scale:85]
-> gesture("walk", 7s, 0s)
-> gesture("talk", 7s, 0s)
-> gesture("happy", 7s, 0s)
-> moveTo((3,-0.45), 7s, 0s)Animation Commands
All commands chain with -> and apply to every primitive and icon. All times are in seconds.
| Property | Type / Values | Notes |
|---|---|---|
| fadeIn(dur, delay) | — | Fade from transparent to target opacity |
| fadeOut(dur, delay) | — | Fade to transparent |
| moveTo((x,y), dur, ease, delay) | — | Move to canvas position |
| rotate(Ndeg, dur, ease, delay) | — | Rotate by angle. 360deg for full spin. |
| scale(factor, dur, ease, delay) | — | 1.0 = unchanged. 1.4 = 40% larger. |
# Chain freely shape: circle[r:0.5 fill:#4fc3f7/0.8 at:(-5,0)] -> fadeIn(0.5s, 0.3s) -> moveTo((5,0), 3s, ease.inOut, 0.8s) -> fadeOut(0.4s, 3.8s) # Spin shape: icon[icon:"mdi:api" size:1.4 color:#ffd54f at:(0,0)] -> fadeIn(0.6s, 1s) -> rotate(360deg, 5s, linear, 1.8s)
Text-only commands
| Property | Type / Values | Notes |
|---|---|---|
| typewriter(dur, delay) | — | Characters appear one by one with soft 0.12s ramp each |
| typewriter(dur, delay, cursor) | — | Same + blinking caret that fades after completion |
| wordReveal(stagger, delay) | — | Words drift upward into position. stagger = per-word interval (0.12s–0.25s sweet spot) |
| focusPull(dur, delay) | — | Starts blurred + spread, collapses to sharp. Minimum 0.8s. |
| focusPull(dur) ~> ... | — | Place before a morph — blurs through transition, lands sharp |
shape: text[text:"INITIALIZING" fontSize:22 fill:#ffd54f/0.85 at:(0,-2)] -> typewriter(2.0s, 1.0s, cursor) shape: text[text:"write animations like code" fontSize:16 fill:#4fc3f7/0.7 at:(0,1)] -> wordReveal(0.18s, 2.5s) shape: text[text:"DEADLOCK" fontSize:52 fill:#ff6b9d/0.9 at:(0,0)] -> focusPull(1.6s, 0.5s) shape: text[text:"O(n²)" fontSize:44 fill:#4fc3f7/0.9 at:(0,0)] -> focusPull(1s) ~> text[text:"O(log n)" fontSize:44 fill:#6bffb8/0.9 at:(0,0)] (1.6s, ease.out, 2s)
Morphing
The ~> operator transforms one state into another. Chain any number of steps. Delay is the absolute start time within the phase — not relative to the previous step.
# Shape to shape shape: circle[r:0.9 fill:#4fc3f7/0.4 at:(0,0)] ~> square[w:1.8 fill:#6bffb8/0.9 at:(0,0)] (1.2s, ease.inOut, 2s) # Multi-step shape: text[text:"pending" fontSize:28 fill:#fff/0.5 at:(0,0)] ~> text[text:"running" fontSize:28 fill:#4fc3f7/0.85 at:(0,0)] (0.5s, ease.out, 2s) ~> text[text:"done" fontSize:28 fill:#6bffb8/0.9 at:(0,0)] (0.5s, ease.out, 5s) # Icon morph — crossfades position, size, color, opacity shape: icon[icon:"mdi:fish" size:1.6 color:#ff9f43 at:(-4,0)] ~> icon[icon:"mdi:dolphin" size:2.0 color:#60a5fa at:(0,0)] (2.0s, ease.inOut, 2s) # Shape to icon — data becoming structured shape: circle[r:0.2 fill:#38bdf8 at:(-4,0)] ~> icon[icon:"lucide:binary" size:0.9 color:#38bdf8 at:(0,0)] (1.6s, ease.inOut, 3s) # Materialize then morph shape: icon[icon:"lucide:server" size:2.0 color:#4fc3f7/0.3 at:(0,0)] -> fadeIn(0.6s, 0.5s) ~> icon[icon:"lucide:server" size:2.0 color:#4fc3f7 at:(0,0)] (0.5s, ease.out, 4s)
Interpolates smoothly
at:(x,y) · r · w · h · icon size · opacity · strokeWidth · stroke color · fill color · icon color · fontSize · letterSpacing · image grayscale · blur · tint · crop
Snaps at 50% midpoint
icon name · image src · fontWeight · fontStyle
Easing
| Property | Type / Values | Notes |
|---|---|---|
| ease.out | — | Fast start, decelerates to stop. Components activating, actors landing. |
| ease.in | — | Slow start, accelerates. Urgency, pressure, approaching failure. |
| ease.inOut | — | Smooth both ends. Natural travel, breathing motion. |
| linear | — | Constant speed. Running processes, orbits, signal streams. |
| ease.spring | — | Overshoots then snaps back. Actors arriving with weight. |
| ease.elastic | — | Exaggerated overshoot with wobble. Comedic, surprise arrivals. |
| bounce | — | Rebound easing for impact-like motion. |
Colors & Opacity
Color format: #RRGGBB with optional opacity suffix — e.g. #4fc3f7/0.6. State colors carry fixed meaning. Never reassign them.
alive, active, working, healthy, data in motion
cinematic moment only — one per scene, never before it
labels, neutral structural elements
broken, failed, overloaded, blocked, under attack
resolved, correct, complete, the answer found
deep system, unknown, beneath the surface, AI layer
Opacity roles
background / ambient fill /0.06–0.20 stroke /0.15–0.35 resting / seed state fill /0.25–0.40 stroke /0.45–0.65 active / interactable fill /0.40–0.65 stroke /0.65–0.85 triggered / fired fill /0.65–0.85 stroke /0.85–1.00 cinematic / hero fill /0.85–1.00 stroke /1.00 text inside filled shape fill /0.85 minimum label whispers fill /0.28–0.35
Text
Text is physical matter — it morphs, travels, builds itself, carries values. All standard animation commands apply.
# Solid text text[text:"LABEL" fontSize:32 fill:#4fc3f7/0.9 at:(0,0)] # Bold italic text[text:"CRITICAL" fontSize:30 fill:#ff6b9d/0.9 fontWeight:bold fontStyle:italic at:(0,0)] # Left-aligned story point text[text:"O(n) comparisons per pass" fontSize:16 fill:#ffffff/0.82 textAlign:left at:(-7,-1)] # Hollow outline text text[text:"WAITING" fontSize:52 outline:#4fc3f7/0.28 strokeWidth:1 at:(0,1.2)] # Seed state — dim+spread contracts to bright+tight text[text:"DEADLOCK" fontSize:52 fill:#ff6b9d/0.18 letterSpacing:0.22 at:(0,0)] ~> text[text:"DEADLOCK" fontSize:52 fill:#ff6b9d/0.9 letterSpacing:0 at:(0,0)] (1.0s, ease.out, 6s) # Formula assembly text[text:"f(x)" fontSize:46 fill:#4fc3f7/0.9 at:(0,0.5)] -> fadeIn(0.6s, 3s) ~> text[text:"f(x) = x²" fontSize:46 fill:#4fc3f7/0.9 at:(0,0.5)] (0.5s, ease.out, 5s) ~> text[text:"f(x) = x² + 1" fontSize:46 fill:#ffd54f/0.9 at:(0,0.5)] (0.5s, ease.out, 7s)
| Property | Type / Values | Notes |
|---|---|---|
| text | string | Content. Escape-aware: \n \t \" \\ \uXXXX |
| fontSize | number | Size in pixels |
| fill | #hex/opacity | Solid filled text |
| outline | #hex/opacity | Hollow stroke-only text. Do not combine with fill. |
| strokeWidth | number | Stroke thickness for outline text |
| font | string | Font family (default Arial) |
| fontWeight | normal | bold | Snaps at 50% during morph — use for activation moments |
| fontStyle | normal | italic | Snaps at 50% during morph |
| letterSpacing | number | 0 = default · 0.22 = spread · -0.03 = tighter. Interpolates in morph. |
| textAlign | left | center | right | at:(x,y) is the alignment anchor (default center) |
Text width limits
fontSize:64 → 8 chars fontSize:36 → 18 chars fontSize:52 → 10 chars fontSize:30 → 22 chars fontSize:46 → 12 chars fontSize:22 → 30 chars fontSize:40 → 14 chars fontSize:16 → 44 chars
Design Patterns
Seed state — object permanence
Every element exists from second zero. Initialize lead actors at low opacity and morph them to full brightness when they activate. Nothing materializes from a void.
lead_actor: type: morph shape: icon[icon:"lucide:server" size:2.0 color:#4fc3f7/0.3 at:(0,0.5)] -> fadeIn(0.6s, 0.5s) ~> icon[icon:"lucide:server" size:2.0 color:#4fc3f7 at:(0,0.5)] (0.5s, ease.out, 4s)
Timeline causality
Element A completes at T. Element B responds at T + 0.3s. The viewer feels the logic caused it.
packet: type: morph shape: circle[r:0.18 fill:#38bdf8 at:(-4,0.5)] ~> circle[r:0.18 fill:#38bdf8 at:(2,0.5)] (1.5s, ease.inOut, 2s) # Packet ends at 3.5s. Database activates at 3.8s (+0.3s) database: type: morph shape: icon[icon:"lucide:database" size:1.8 color:#6bffb8/0.3 at:(3,0.5)] ~> icon[icon:"lucide:database" size:1.8 color:#6bffb8 at:(3,0.5)] (0.5s, ease.out, 3.8s)
Three-layer structure
Every scene has three simultaneous layers. Design all three before placing any element.
# Background layer — world identity, dim, slow or static bg_icon: type: primitive shape: icon[icon:"mdi:server" size:1.1 color:#4fc3f7/0.12 at:(-5.5,1.2)] -> fadeIn(1s, 0.2s) # Actor layer — lead character at full opacity main_actor: type: morph shape: icon[icon:"lucide:database" size:2.0 color:#6bffb8/0.3 at:(0,0.5)] -> fadeIn(0.7s, 1s) ~> icon[icon:"lucide:database" size:2.0 color:#6bffb8 at:(0,0.5)] (0.5s, ease.out, 4s) # Detail layer — small ambient elements, independent motion bubble: type: primitive shape: circle[r:0.1 fill:#4fc3f7/0.5 at:(-3,-2.8)] -> fadeIn(0.4s, 4s) -> moveTo((-3,-1), 2.5s, ease.out, 4.5s)
The cinematic moment
One per scene. Always one. Amber. Large. When it appears all active elements dim to 0.2–0.3 opacity. The word is the most precise name for what the animation just showed.
cinematic: type: morph shape: text[text:"DEADLOCK" fontSize:64 fill:#ffd54f/0.9 at:(0,0.5)] -> fadeIn(1.2s, 22s) ~> text[text:"DEADLOCK" fontSize:18 fill:#ffd54f/0.3 at:(0,0.5)] (1.5s, ease.inOut, 25s) lbl_1: type: primitive shape: text[text:"mutex" fontSize:13 fill:#ffffff/0.3 at:(-4.5,-3.2)] -> fadeIn(0.6s, 26.5s) lbl_2: type: primitive shape: text[text:"starvation" fontSize:13 fill:#ffffff/0.3 at:(0,-3.2)] -> fadeIn(0.6s, 27s) lbl_3: type: primitive shape: text[text:"cycle" fontSize:13 fill:#ffffff/0.3 at:(4.5,-3.2)] -> fadeIn(0.6s, 27.5s)
Rules & Limits
| Rule | Why |
|---|---|
| shape: is always one line | Parser fails immediately on line breaks |
| subtitles: must be present with - "" | Required field — omitting causes a parse error |
| image src must start with images/ | Wrong path renders blank silently — no error thrown |
| icon name must be exact prefix:name | Wrong names render as a placeholder — verify before use |
| One cinematic moment per scene | Amber is sacred. One. Never two. Never zero. |
| Amber appears nowhere before the cinematic beat | Not on any icon, text, or shape before that moment |
| Delayed elements must be spawn-gated | First keyframe at opacity 0 or off-canvas |
| Maximum two lead actors per scene | Viewer must always know exactly what to follow |
| Every icon acts | A static icon is decoration — give it a role or remove it |
| Text inside a filled shape is a separate element | Place at the same center. fill minimum /0.85 |
Video Markdown (VMD) — Created by Sohan Dananjaya
Open IDE and start writing →