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.

Download

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

PropertyType / ValuesNotes
conceptstringTitle of the animation
total_durationnumberSum of all phase durations in seconds
background#hexCanvas background color
subtitleslistAlways present. Include at least one empty string - ""
phasesmapNamed 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)]
PropertyType / ValuesNotes
rnumberRadius
fill#hex/opacityFill color
stroke#hex/opacityStroke color
strokeWidthnumberStroke 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)
PropertyType / ValuesNotes
srcstringMust start with images/ (repo-relative)
w / hnumberCanvas units. Omit one to preserve aspect ratio
fitcover | containFill mode
radiusnumberCorner radius
grayscale0–1Desaturation — interpolates in morph
blurnumberBlur radius — interpolates in morph
tint#hex/opacityColor 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)
PropertyType / ValuesNotes
icon"prefix:name"Required. Exact: mdi:server · lucide:zap · logos:react
sizenumber1 ≈ 50px. Lead: 1.5–2.5. Background: 0.6–1.2
color#hex/opacityIcon color with opacity: color:#4fc3f7/0.7
opacity0–1Multiplies color opacity
at(x,y)Position
rotationNdegStatic orientation at spawn
fliphorizontal | vertical | bothMirror the icon
mdi 7,638 icons
Expressive, filled. Creatures, machines, environments, personality.
lucide 1,771 icons
Clean outline. System components, data flows, technical diagrams.
logos 2,091 icons
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)
PropertyType / ValuesNotes
use"name"References an avatar defined in the avatars: block, by its key
at(x,y)Root position — feet land roughly here
scalenumberCharacter 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.

PropertyType / ValuesNotes
root → chest → neck → headspineroot has shape:none; the others carry the head/torso design
eye_l · eye_r · mouthfaceoffset off head
shoulder_l/r → elbow_l/r → hand_l/rarmsshoulder is an offset off chest, elbow/hand chain from it
hip_l/r → knee_l/r → foot_l/rlegship 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

PropertyType / ValuesNotes
circler, fillJoint markers — shoulder, hip, head
ellipsew, h, fill, angleEyes, mouth, eyebrows, lens-shaped accessories
capsulewidth, widthEnd, fillContract chain-points only — draws the tapered bone segment
pathpoints, smoothingLocal-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

PropertyType / ValuesNotes
Proportionsneck length:0.22, head length:0.20, head r:0.33–0.34Person-like head-to-body ratio
Joint sizingshoulder/hip r 0.13–0.14, elbow/knee/hand/foot capsule width tapered toward the jointScaled relative to head r
ShadingshadeFill two shades darker, shadeOpacity ~0.2, shadeScale 0.75Avoids a flat sticker look on the head
Cosmetic layerpupils on eye_l/r, role-tagged eyebrows, 2–3 finger extras per handBare capsule hands read as mittens without it
Verifyrender happy / sad / surprised / angry + one plain gestureCatches 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.

PropertyType / ValuesNotes
wave · head-tilt · talkGreeting, listening, speaking
look-left · look-right · look-upGaze direction
point-left · point-right · point-upDirecting attention off-body
explain · shrug · idleDefault presenting poses
happy · sad · surprised · angryEmotion
walkLocomotion — pair with moveTo for travel
flinch · laughReaction 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.

PropertyType / ValuesNotes
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

PropertyType / ValuesNotes
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

PropertyType / ValuesNotes
ease.outFast start, decelerates to stop. Components activating, actors landing.
ease.inSlow start, accelerates. Urgency, pressure, approaching failure.
ease.inOutSmooth both ends. Natural travel, breathing motion.
linearConstant speed. Running processes, orbits, signal streams.
ease.springOvershoots then snaps back. Actors arriving with weight.
ease.elasticExaggerated overshoot with wobble. Comedic, surprise arrivals.
bounceRebound 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.

#4fc3f7cyan

alive, active, working, healthy, data in motion

#ffd54famber

cinematic moment only — one per scene, never before it

#ffffffwhite

labels, neutral structural elements

#ff6b9dpink

broken, failed, overloaded, blocked, under attack

#6bffb8mint

resolved, correct, complete, the answer found

#c77affviolet

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)
PropertyType / ValuesNotes
textstringContent. Escape-aware: \n \t \" \\ \uXXXX
fontSizenumberSize in pixels
fill#hex/opacitySolid filled text
outline#hex/opacityHollow stroke-only text. Do not combine with fill.
strokeWidthnumberStroke thickness for outline text
fontstringFont family (default Arial)
fontWeightnormal | boldSnaps at 50% during morph — use for activation moments
fontStylenormal | italicSnaps at 50% during morph
letterSpacingnumber0 = default · 0.22 = spread · -0.03 = tighter. Interpolates in morph.
textAlignleft | center | rightat:(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

RuleWhy
shape: is always one lineParser 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:nameWrong names render as a placeholder — verify before use
One cinematic moment per sceneAmber is sacred. One. Never two. Never zero.
Amber appears nowhere before the cinematic beatNot on any icon, text, or shape before that moment
Delayed elements must be spawn-gatedFirst keyframe at opacity 0 or off-canvas
Maximum two lead actors per sceneViewer must always know exactly what to follow
Every icon actsA static icon is decoration — give it a role or remove it
Text inside a filled shape is a separate elementPlace at the same center. fill minimum /0.85

Video Markdown (VMD) — Created by Sohan Dananjaya

Open IDE and start writing →