Skip to main content

reframe eDSL guide

You write a motion-graphics scene as declarative data using the reframe TypeScript eDSL. Your output is a single .ts file that default-exports a scene({...}) call. Everything imports from @reframe/core.
See examples/scenes/… pointers below refer to the GitHub repo (github.com/kiyeonjeon21/reframe), not the installed npm package — this guide is self-contained; you don’t need them to write a scene.

Brand / house style

When the brief does not specify colors, fonts, or tone, use the house style so scenes look like one system. The full reference (with rationale) is DESIGN.md in the repo root.
  • Colors: background #0A0C14 (dark navy), foreground #FFFFFF, muted text #8B93A7, primary accent #FF4D00 (the brand mark, active/featured, lower-third bar), secondary accent #00C2A8 (positive/user). Chart series: #54D6C0, #7C5CFF, #FF6FA5, #FFC861. One accent leads per shot; reach for the second only for a genuine second category.
  • Type: Inter only (weights 400 / 700 / 800). Scale: display 88-96 / 800, headline 48 / 700, body 22-26 / 400-600, label 18-20 / 600 with letterSpacing: 2.
  • Motion (Balanced tone): default ease easeOutCubic, entrances easeOutBack, exits easeInOutQuad, energy: 0.5. Durations: 0.3 micro, 0.5 base, 1.0 slow. For a corporate/data tone drop to easeOutQuad + energy ~0.3; for promo push speed > 1.
  • Audio: bgm uplift (promo) / ambient-pad / lofi / tension; sfx whoosh/pop (entrances), thud (impact), click/confirm (UI). Anchor cues to labels.
  • Layout: 1920x1080 at 30fps, ~96px safe margin, radius 6 (bars) / 24 (cards) / 56 (panels).
These tokens are also in code. Import brand and reference a token instead of restating a literal, so the palette lives in one place:
brand.color.*, brand.type.{display,headline,body,label} (spreadable into text()), brand.motion.*, and brand.layout.* cover the tokens above. Referencing a token is byte-identical to writing the literal.

Re-skinnable scenes (token())

brand.color.accent bakes the literal at author time. For a scene you want to RE-SKIN later, use token("color.accent") on a color prop (fill / stroke / shadowColor) instead. It is a deferred reference the compiler resolves against the scene’s design (or the house brand), so the same scene renders in any brand:
Re-skin without touching the scene:
  • one frame/render: reframe frame scene.ts --theme brand.json (a brand kit is a nested partial theme, e.g. { "color": { "accent": "#1E90FF" } }).
  • many brands at once: a batch data file with a design.<token.path> column (plus _name) renders one mp4 per brand: [{ "_name": "ocean", "design.color.accent": "#1E90FF" }, …]reframe batch scene.ts brands.json.
An overlay can also patch design.color.accent directly, and that re-skin survives an AI regen of the base (the address is the token name). Tokens resolve on color props (fill/stroke/ shadowColor), the scene background, and gradient stops; numeric and type tokens are a later phase.

Nodes

Factories return plain data. Every node needs a unique id.
  • rect({ id, x, y, width, height, fill?, stroke?, strokeWidth?, radius?, opacity?, rotation?, scale?, anchor? })
  • ellipse({ id, x, y, width, height, fill?, stroke?, strokeWidth?, ... })
  • line({ id, x1, y1, x2, y2, stroke, strokeWidth?, opacity?, progress? })progress 0..1 draws the line on (1 = full line).
  • text({ id, x, y, content, contentDecimals?, contentThousands?, prefix?, suffix?, fontFamily, fontSize, fontWeight?, fill?, letterSpacing?, ... })content may be a number; numeric content interpolates (count-up) and renders via toFixed(contentDecimals ?? 0). For a “8.2”-style label, set contentDecimals: 1; contentThousands: true groups the integer (35,786). prefix/suffix wrap the value so a count-up reads $2.4M or +32% from ONE node ({ content: 2.4, contentDecimals: 1, prefix: "$", suffix: "M" }) — don’t hand-position separate $/% nodes.
  • path({ id, d, x, y, fill?, stroke?, strokeWidth?, progress?, originX?, originY?, opacity?, rotation?, scale?, anchor? }) — a true vector shape from an SVG path d string (crisp at any zoom; recolour by animating fill/stroke). progress 0..1 draws the stroke OUTLINE on (animate 0→1 for a self-drawing logo). originX/originY is the local pivot — set it to the art’s centre (e.g. the viewBox centre) so scale/rotation happen about the middle. d is drawn in its own coords; x/y place that pivot. Classic logo reveal: a stroke path drawing on, then a fill path fading in over it. d is animatable (shape morph): tween(id, { d: otherShape }, …) morphs the path vertex-by-vertex (the Lottie-style shape tween) when both d strings share the same command sequence and arg counts — author the two poses with the same structure (e.g. both 4-cubic ovals). Arcs (A) can’t morph (their 0/1 flags aren’t interpolable) and incompatible shapes snap at the midpoint; build morph targets from M/L/C/Q/Z only.
  • image({ id, src, x, y, width, height, fit?, opacity?, rotation?, scale?, anchor? })src is a file path, absolute or relative to the scene file (png/jpg/webp). fit controls how it maps into width×height: "fill" (default) stretches; "cover" crops to fill the box at the image’s natural aspect, centered (no distortion — drop in any-aspect photos). src switches discretely (no crossfade) — for hard-cut frame sequences stack image nodes and step their opacity; for a dissolve, crossfade two nodes’ opacity.
  • group({ id, x, y, opacity?, rotation?, scale?, anchor? }, children) — children’s coordinates are relative to the group; group opacity/transform multiply down.
anchor controls placement and scale/rotation origin: "top-left" (default) | "top-center" | "top-right" | "center-left" | "center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right". Example: a bar that grows upward = anchor: "bottom-left" + animate height. Text alignment is anchor, not a separate align prop: the anchor’s horizontal half sets the text align — "…-left" left-aligns, "…-center"/"center" centers, "…-right" right-aligns (a right-aligned wordmark in a corner = anchor: "bottom-right"). Font: use fontFamily: "Inter" (weights 400/700/800 are available).

Layout helpers (evenly spacing things)

Positions are absolute pixels. For a row of cards or a grid of tiles, use the layout helpers instead of hand-rolling the column math — they return coordinates you spread into x/y:
column is row for the y axis. Charts/widgets in a panel — derive geometry from the box, and clip it. Don’t hand-pick a pixels-per-unit scale (bars routinely overflow the panel that way). Define the panel rect ONCE, then size from it — bar height (v/max) · innerH, x via row(...) across the panel width — so a tall value can’t exceed the box. As a safety net, wrap the chart in a clipped group so nothing can ever punch out the panel: group({ clip: { kind: "rect", x, y, width, height, radius } }, [ ...bars ]). See examples/scenes/annual-report.ts (and cameraFit above to frame the panel).

States: declare looks, not motion

Base props on nodes describe the finished design. A state is a sparse override — only the props that differ:
to("shown", { duration, ease, stagger?, filter? }) synthesizes a transition from each node’s current value to the state’s value. stagger: 0.1 offsets the affected nodes 0.1s apart in declaration order. filter: ["a", "b"] restricts the transition to those nodes. States are plain objects — generate them with normal TS (Object.fromEntries, .map) for data-driven scenes.

Timeline: compose time

  • seq(...steps) — one after another.
  • par(...steps) — all start together; ends when the longest ends.
  • stagger(interval, ...steps) — like par but each child starts interval later.
  • to(stateName, opts) — transition into a named state (see above).
  • tween(nodeId, { prop: value, ... }, { duration, ease }) — low-level escape hatch for one node. Colors ("#rrggbb") interpolate; numbers interpolate.
  • motionPath(nodeId, [[x,y], ...], { duration, ease, curviness?, autoRotate?, rotateOffset?, closed? }) — drive a node’s x/y along a smooth Catmull-Rom curve through the waypoints (parent-space coords). autoRotate: true banks the node along the path tangent (rotateOffset degrees if the art faces “up”, e.g. -90). The node HOLDS at the final point after the path finishes (a positioning move, not a one-shot), so a later tween can chain from there. Use it for swoops/arcs/orbits — straight tweens on x and y can’t curve. closed: true loops the waypoints (orbit). curviness shapes the path: 1 smooth (default), 0 sharp corners, >1 loopier.
  • wait(seconds, label?) — hold; the optional label names the hold so audio cues and overlay retiming can address it.
  • beat(name, opts, children) — a named, retimable, reorderable span (the unit humans/AI revise; its name is a stable overlay address). opts: parallel, at (absolute start — a NUMBER, or a label string to anchor to), gap, scale/duration (time-stretch), order (reorder within a seq), nodes. Label anchor: beat("caption", { at: "shot-2" }, [...]) starts the beat at the shot-2 label’s time (with gap as the offset), so a title/lower-third/ caption laid over a montage stays locked to its shot when the cut is retimed (via an overlay or AI regen) — the same retime-survival audio.cues get. Put anchored beats in a par branch (an overlay layer), not inside a sequential flow. See examples/scenes/media-story.ts.
Eases: linear, easeIn/Out/InOutQuad, easeIn/Out/InOutCubic, easeIn/Out/InOutQuart, easeIn/Out/InOutExpo, or { cubicBezier: [x1,y1,x2,y2] }. Decelerating entrances = easeOut*, accelerating exits = easeIn*. Expressive eases for a premium feel: easeIn/Out/InOutBack (overshoots past the target then settles — a pop/snap), easeIn/Out/InOutElastic (rings around the target — a playful spring), easeIn/Out/InOutBounce (drops and bounces to rest). A logo or card “popping” in usually wants easeOutBack; a stamp landing, easeOutBounce. Physical springs settle to rest within the tween’s duration: spring (a natural settle), springBouncy (rings more), springStiff (snappy, barely overshoots) — or tune your own with { spring: { stiffness, damping, velocity } } (damping ratio = damping / (2·√stiffness); lower ⇒ bouncier). Scene duration is inferred from the timeline. For a static frame you can omit timeline entirely (or set scene duration: <seconds>) — a still defaults to a 1s render; no throwaway wait is needed.

Behaviors: continuous motion during holds

Composed additively on top of the timeline:
  • oscillate(nodeId, prop, { amplitude, frequency, phase? }, window?) — sine.
  • wiggle(nodeId, prop, { amplitude, frequency, seed }, window?) — smooth seeded noise.
The optional 4th argument { from?, until?, ramp? } limits the behavior to a time window (seconds) with a linear fade of ramp (default 0.2s) at each bound — e.g. a pulse only during the hold: oscillate("title", "scale", { amplitude: 0.04, frequency: 1.2 }, { from: 1.5, until: 3.5 }). Omit the window to run for the whole scene.

Camera (one keyframable viewport)

A scene-level camera moves the whole scene at once: a look-at point + zoom + rotation, animated over the timeline. Add it as a top-level camera field and keyframe it with cameraTo (or by tweening the reserved target "camera"):
  • cameraTo(props, { duration?, ease?, label? }) keyframes the camera; it is a tween on the "camera" target, so motionPath("camera", pts, …) (pan along a curve) and oscillate/wiggle("camera", "rotation"|"x"|…) (handheld drift) also work.
  • Frame a region without clipping — use cameraFit, not a guessed zoom. The visible scene rect is W/zoom × H/zoom centred on (camera.x, camera.y), so a hand-picked zoom that’s too big crops the target. cameraFit(box, { margin }) returns { x, y, zoom } that frames a scene-space bbox (top-left {x,y,width, height}) with padding, guaranteed in-bounds: cameraTo(cameraFit({ x, y, width, height }, { margin: 80 }), { duration: 1, ease: "easeInOutCubic" }). A centre- anchored panel at (px,py) size (pw,ph) is { x: px-pw/2, y: py-ph/2, width: pw, height: ph }. maxZoom (default 2.4) caps absurd close-ups.
  • Pin HUD/titles to the screen with fixed: true on a TOP-LEVEL node — the camera won’t move it (for overlays, watermarks, captions).
  • Defaults are the identity, so a scene without a camera is unchanged. Don’t name a node "camera" if you use the scene camera (the id can’t be both).
See examples/scenes/camera-demo.ts.

Depth & perspective (projected 2.5D)

Add camera.perspective (a focal distance in px) to switch on depth. Then any node’s z (depth) and rotateX/rotateY (3D tilt) take effect: nodes project about the frame centre by p = perspective / (perspective + z) — further back = smaller and pulled toward the optical centre. It’s a pure step in evaluate() projected onto the normal 2D matrix, so renders stay deterministic and the Canvas renderer is unchanged.
  • Parallax falls out for free — pan the camera and near (z small) layers shift more than far ones. Dolly = keyframe camera.perspective. Perspective text = give each splitText glyph an increasing z so the word recedes.
  • A node needs a base value to tween (rotateY: 0 on the card before tweening it to 360).
  • A tilted group foreshortens its whole subtree (cos folds into children). Clips project by the group’s depth. A fixed HUD ignores depth (perspective is part of the camera).
  • Depth of field (needs perspective): add camera.aperture (blur px per unit depth) and camera.focus (the in-focus z, default 0). A layer at depth d softens by aperture·|d − focus| while the focal plane stays sharp; keyframe focus for a rack focus, aperture for an iris pull. Absent/0 ⇒ no blur. HUD/UI text should be fixed so it stays crisp (a fixed node opts out of DOF too). It feeds the same blur op, so it composes with an authored blur.
  • Occlusion by depth is opt-in: set camera.zSort: true and siblings paint far→near (larger z first) so nearer nodes cover farther ones without hand-ordering the tree (a fixed HUD stays on top; a track-matte group keeps its child order). Off by default — paint stays array order. Gotcha: with zSort, a full-screen background rect at z: 0 is the NEAREST plane and paints on top — use the scene background color instead, or give the backdrop a large z.
  • Limits (honest): rotateX/rotateY are an affine approximation (cos-foreshorten + keystone skew) — a single rotated quad is really a trapezoid Canvas 2D can’t draw, so it reads as a flip/tilt, not a pixel-true 3D face (that needs WebGL). Depth positioning (parallax, convergence, dolly) IS exact. No GPU 3D, no z-buffer (per-pixel) — zSort orders whole nodes, so two INTERSECTING planes can’t visually cross.
See examples/scenes/perspective-cards.ts.

Gradients (fill / stroke)

fill and stroke on rect / ellipse / path accept a gradient as well as a color string. Coordinates are normalized to the node’s bounding box (0..1), so a gradient is just an angle + stops:
  • linearGradient(stops, { angle }), radialGradient(stops, { cx, cy, r }), conicGradient(stops, { angle, cx, cy }). stops is a color array (even offsets) or [{ offset, color }]. cx/cy/r are 0..1 of the box (centre defaults to 0.5).
  • Gradients are static (not keyframed). The gradient lives in the node’s local space, so animate the NODE (tween(id, { rotation: 360 }), scale, move) and the gradient sweeps/stretches with it. Color-string fills still tween as today.
  • text fill and line stroke are color-only for now. See examples/scenes/gradient-demo.ts.

Shadow, glow & blur

Drawable nodes (rect / ellipse / path / text / image / line) take animatable paint effects, in screen pixels (not transformed by the node or camera, so a shadow keeps a consistent light direction):
  • Props: blur (gaussian blur of the shape), shadowColor (turns the shadow/glow on), shadowBlur, shadowX, shadowY. All animatabletween/oscillate them for pulsing glows, focus pulls, etc. (set a base value first so there’s something to animate from).
  • Sugar: glow(color, blur) (offset 0) and dropShadow(color, blur, x, y) return a partial you spread into props (...glow("#FFD24B", 28)); still animatable.
  • On a group these apply to the whole subtree as one composite (focus pull / one silhouette shadow) — see “Group effects” below. examples/scenes/shadow-demo.ts.

Backdrop — live “liquid glass”

backdrop on a rect / ellipse turns it into glass: instead of being transparent, the shape SAMPLES whatever is already drawn behind it and redraws it blurred / graded inside the shape — a real backdrop-filter (like CSS backdrop-filter), not a copy you pre-blur. The node’s own translucent fill then reads as the glass TINT and stroke as the rim.
  • Knobs: blur (px), saturate (1 = unchanged; >1 = punchier, like real glass), brightness. Static (not keyframed) — animate the NODE (x/width/height/ opacity) and the backdrop re-samples each frame, so a glass panel can glide and resize over the content beneath it. opacity fades the backdrop in with the node.
  • Sampled in screen pixels by the renderer (the offscreen compositing path, same as mattes/group-fx); deterministic same-machine, works in render/frame AND live in player (no image needed). Absent ⇒ byte-identical goldens.
  • Tint must carry its own alpha: a solid "#rrggbbaa" fill drops its alpha byte, so use a linearGradient (gradient stops keep alpha) for a translucent tint.
  • See examples/scenes/liquid-glass.ts (live, player-able) and examples/scenes/liquid-glass-nav.ts (a nav dropdown gliding over a photo).

Blend modes (compositing)

blend selects how a shape composites with what’s already drawn beneath it — the primitive that makes light read.
  • Modes: normal (default), multiply, screen, overlay, lighten, darken, add (additive light), color-dodge, soft-light, hard-light, difference.
  • Discrete, not interpolated — set per node (a static string). Default normal.
  • Per-shape, or on a group to blend the whole composited subtree against the bg as one layer (see “Group effects” below). See examples/scenes/blend-demo.ts.

Character rig (skeleton, poses, IK)

A first-class, declarative character rig that compiles to plain IR (nested group joints + bone paths) — the character analog of devicePreset. It needs no new renderer concept, so overlays/preview/determinism all apply.
  • humanoid({ id, x, y, scale, opacity?, color?, fill?, glow? }) → a NodeIR: a ready upright body. Joints (stable ids ${id}-${name}): chest, head, armUpperL/armLowerL, armUpperR/armLowerR, legUpperL/legLowerL, legUpperR/legLowerR. Drop it in nodes.
  • rig(boneTree, opts) → build your own skeleton. A Bone is { name, at:[x,y], length?, width?, rotation?, shape?, children? }. The joint sits at the group origin; the bone extends +Y at rotation 0; a child’s at pivot is in the PARENT bone’s local space (e.g. an elbow at [0, upperLength]). Nested groups give forward kinematics — a child’s rotation composes on its parent’s. Default bone = a bezier capsule (morphable); pass shape for custom art.
  • A pose is { jointName: angleDeg } (0 = bone points down). Animate it:
    • poseTo(id, pose, { duration, ease, stagger? }) → a timeline step (a par of rotation tweens). Sequence poses for wave/jump/run.
    • rigPose(id, pose) → a states fragment, to transition with to(state, …).
  • ikReach(upper, lower, dx, dy, flip?)[shoulderDeg, elbowDeg] that place a 2-bone limb’s tip at (dx,dy) relative to its shoulder joint (law of cosines; clamps when out of reach). Feed the two angles into a pose.
  • Joint names are the stable regen addresses — never rename them across a regen; each rig instance needs a distinct id (duplicates collide via scene validation). Squash/stretch and expressions are per-bone d morphs (above), composed on top of FK posing. Idle sway/breathing = oscillate on a joint.
  • figure(opts) — a dressed character (the styled sibling of humanoid): same skeleton, but coloured flat-design shapes. style: "clean" (corporate-flat / undraw register, the default) or "cute" (mascot); palette knobs (skin/hair/top/pants/shoe/accent) re-skin it — for clean the top follows accent, so figure({ palette: { accent: "#3B82F6" } }) recolours the whole figure; face: false makes it faceless. It exposes the humanoid joint ids, so characterPreset / ikReach drive it unchanged. Use it as the supporting actor in a product promo (gesturing at a devicePreset), not the hero.
  • characterPreset(name, opts) — a seeded motion generator for a humanoid or figure rig (the character analog of motionPreset). Returns a composable beat; drop it in the timeline: seq(characterPreset("walk", { target: "hero", at: [cx, cy], cycles: 4 })). Names: walk, run, jump, dance, wave, cheer. Knobs: target (rig id), energy 0..1, speed (>0, divides durations), seed (varies within the family), cycles (walk/run/dance), facing (±1), at: [x,y] (the rig’s scene position — needed for walk travel & jump lift), travel (px/cycle, 0 = in place), label (unique beat name — set it when the same preset is used more than once in a scene). Legs use ikReach, arms FK; pure keyframes, so add continuous idle yourself with oscillate.

Kinetic text (split + effect presets)

reframe’s text node renders a whole string as one node, so per-glyph effects need the string split into per-character nodes. splitText does that once; seeded effect generators animate the glyphs (the text analog of motionPreset).
  • splitText(text, { id, x, y, fontSize, fontWeight?, fill?, letterSpacing?, align?, unit?, opacity? }) → TextBlock — lays the phrase out as center-anchored text nodes using real Inter advance widths (so layout matches the render). Returns { nodes, glyphs, ids, width, ... }; put ...block.nodes in nodes. Glyph ids are ${id}-${i} (stable regen addresses). unit: "word" animates whole words instead of letters; opacity: 0 (default) starts hidden for entrances.
  • textIn(name, block, { speed?, energy?, seed?, stagger?, label? }) → TimelineIR (a beat) — entrance: typewriter, cascade, rise, bounce, assemble (fly in from a seeded scatter), decode (scramble through random glyphs then lock).
  • textLoop(name, block, { from?, until?, ramp?, amplitude?, frequency?, phaseStep? }) → BehaviorIR[] — sustained: wave (standing sine), shimmer, wobble, float. Spread it into behaviors.
  • textOut(name, block, { …, dir? }) → TimelineIR — exit: shatter (random direction + spin + fade), fly (directional), dissolve, fall, collapse.
  • textTypeCues(block, { at, interval?, gain? }) → AudioCueIR[] — per-glyph CC0 keypress for a typewriter entrance; spread into audio.cues.
Every effect is seeded (same seed → identical) and pure keyframes. To time a textLoop window, add up the textIn beat length (≈ (n-1)·stagger + glyphDur).

Photo / video montage (photoMontage / videoMontage)

Turn a list of shots into a polished slideshow — crossfades + seeded Ken Burns (pan/zoom) + an optional cinematic grade (vignette + bottom scrim via gradients + blend) — without hand-wiring each move. Shots may be images AND video clips, mixed freely (a video src, by extension, plays as a clip for its hold). videoMontage is the same generator, named for clip-driven cuts. The photo analog of motionPreset.
  • Returns { nodes, timeline } (like splitText owns its glyph nodes). nodes are the stacked image/video layers (+ ${id}-vignette / ${id}-scrim grade overlays); timeline is a retimable beat("montage", …). Stable addresses: ${id}-${i}, labels shot-${i} / cross-${i}.
  • Any-aspect media works — each layer uses fit: "cover", so the renderer crops to fill the frame at the source’s aspect (no pre-cropping, no distortion). The Ken Burns keeps scale ≥ 1 with the pan bounded to its slack, so an edge is never revealed.
  • Per-shot overrides: { src, hold?, ken?, volume? } where ken is "in" | "out" | "pan". A video shot plays as a clip from its slot’s start; its audio is muted by default in a montage — set volume (per shot) to include it, or add a scene.audio bed.
  • Seeded + pure (same (shots, opts) → identical IR). Note: image/video sources do not render in reframe player / artifacts — montage ships as mp4. See examples/scenes/video-montage.ts.
  • Assemble from files: reframe assemble <media...> [-o name] [--title "…"] [--bgm <synth>] [--hold s] [--seed N] probes each clip’s real duration (so a video shot’s hold = its actual length, never a freeze) and scaffolds an editable scene .ts wiring photoMontage + an optional title + a bed. The probed numbers are baked in, so the emitted scene is a normal deterministic scene — edit it (reorder, retime, swap a src), then reframe render it.

Titles & lower-thirds (title / lowerThird)

The motion-graphic overlay vocabulary for a media piece — generators that return { nodes, timeline } to compose over a montage (or anything). Stable ids so overlays address them; pure + deterministic.
  • title({ text, id?, x?, y?, fontSize?, fontWeight?, fill?, letterSpacing?, entrance?, exit?, speed?, seed?, hold? }){ nodes, timeline, block }. A kinetic headline built on splitText + textIn (entrance presets: cascade rise bounce typewriter assemble decode). Set exit (a textOut preset) and it plays in, holds holds, then exits. Glyph ids ${id}-${i}; labels ${id}-in / ${id}-out. block is returned so you can add textLoop behaviors or extra tweens.
  • lowerThird({ name, role?, id?, x?, y?, accent?, fill?, subFill?, fontSize?, hold? }){ nodes, timeline }. A name/role strap: an accent bar grows in, the text slides + fades. Ids ${id} (group) / ${id}-bar / ${id}-name / ${id}-role; labels ${id}-in / ${id}-out. Defaults to a bottom-left title-safe position.
See examples/scenes/media-story.ts.

Video clips (video)

Draw a video clip as a layer. It plays on the scene clock — at scene-time t it shows the source frame at clipStart + max(0, t - start) * rate.
  • Props: src (mp4 / mov / webm / m4v / mkv, absolute or scene-relative), width/height, fit ("cover" like the image node), start (scene-time playback begins), rate (speed), clipStart (source in-point s), volume (clip-audio gain, default 1; 0 mutes). Transform/opacity/effects compose as usual.
  • start can be a label (not just a number): start: "shot-2" anchors playback to that timeline label’s time (like beat.at), so the clip ripples when its shot is retimed (by an overlay or AI regen) instead of desyncing. photoMontage does this automatically for video shots.
  • Deterministic by frame extraction: render-cli runs ffmpeg -vf fps=<sceneFps> to pull the clip’s frames, and the renderer draws frame round(t·fps) — no live <video> seek, so it stays byte-identical (same machine).
  • Clip audio: the clip’s own audio track is muxed into the output, placed at start (trimmed from clipStart, sped by rate, scaled by volume), mixed with scene.audio. A clip with no audio stream is skipped; set volume: 0 to drop a clip’s sound.
  • Limitations: all frames are pre-decoded so keep clips short (≤~5s); like images, video sources are not rendered in reframe player / artifacts (mp4 only). See examples/scenes/video-demo.ts.

Track mattes (group({ matte }))

Use one layer’s alpha or luminance to mask another — video-filled text, shape / PNG punch-through, luma wipes. In a matte group the FIRST child is the matte; the remaining children are the masked content.
  • matte: "alpha" keeps content where the matte is opaque; "luma" where it’s bright (animate a gradient/shape as the matte for a wipe). Needs ≥2 children.
  • The group’s transform / opacity / clip apply as usual (a centered group scales about its middle; fading the group fades the masked result). Mattes can nest.
  • Rendered by offscreen compositing (the matte + content draw to separate buffers, combined via destination-in). Deterministic same-machine. See examples/scenes/matte-demo.ts.

Group effects (blur / shadow / blend on a whole group)

The paint effects (blur, shadowColor/shadowBlur/shadowX/shadowY, blend) also work on a group — there they apply to the whole subtree as ONE composite layer, not per child. The classic uses: a depth-of-field focus pull on a multi-node lockup, a single silhouette drop shadow under a multi-shape mark, and a group that blends against the background as one layer (so its own overlaps composite together).
  • Group blur is animatable (tween(group, { blur })); shadow scalars too.
  • Same offscreen compositing as mattes (the subtree renders to a buffer, drawn back once with the effect). It wraps a matte group and nests. The effects are screen-pixel space. See examples/scenes/group-fx-demo.ts.

Device frames (phone / browser / laptop …)

To put a phone, browser, laptop, … on screen, use the preset — don’t hand-draw a device out of rects. devicePreset(name, opts) → NodeIR returns a parametric vector frame (bezel, rounded body, phone notch / dynamic island, browser chrome).
  • devicePreset(name, { id, x, y, scale?, opacity?, orientation?, content }) — names: phone tablet laptop browser watch monitor tv foldable terminal car. There is no "iphone""phone" IS the iOS-style frame (notch + dynamic island). browser/terminal take an address string.
  • content nodes are authored in screen-LOCAL centre coords (0,0 = screen centre) and clipped to the screen. Stable ids ${id}-screen / ${id}-content (overlay/regen addresses) — keep id across rewrites.
  • It’s one node: animate the device group for the float/entrance (tween/ motionPath its x/y/scale/rotation, oscillate for an idle drift).
  • Premium by defaultmaterial:"premium" (the default) gives a gradient body, an ambient screen glow, a soft contact shadow and (glass) a sheen; the style knob picks "glass" (realistic glass/metal, default) or "neon" (flat body + additive accent edge-glow, graphic punch). material:"flat" opts back to clean solid fills. All of this is purely cosmetic — the screen rect, the clip, and the stable ids are identical across materials/styles, so deviceScreen coords and existing content/overlays are unaffected.
  • Auto-varied per instance — each device’s look (bezel, corner, glare angle, neon hue) is derived deterministically from its id, so two devices differ while staying on-model. Pass seed to pin or explore a variation; same seed → identical, different seed → same family. Reproducible (no Math.random).
  • notch?: "island" | "notch" | "punch" | "none" selects the phone front-camera treatment (default "island" — keep it explicit for an iOS vs Android read).
  • See examples/scenes/device-gallery.ts for glass/neon + seed variation.
Pair with cursor + deviceScreenPoint (below) to click UI inside the device.

Cursor (UI demos)

A vector mouse pointer that glides across the scene and clicks things — for app walkthroughs. cursor() returns a node; the moves/clicks return timeline steps. The pointer’s hotspot is the group origin, so a move lands the tip on a target.
  • cursor({ id, x, y, scale?, opacity?, style?, accent? }) → NodeIR — styles arrow (default), dot, ring. Draw it LAST so it sits on top. Carries a hidden ${id}-ripple ring for clicks.
  • cursorTo(id, from, to, { duration?, ease?, arc? }) → TimelineIR — glide along a gentle human arc (arc is the bow, default 0.12). Thread the position: start = the node’s x/y, each to becomes the next from.
  • cursorPath(id, points, opts) — a multi-stop tour through waypoints.
  • cursorClick(id, { press?, ripple?, label? }) / cursorDouble(...) — the pointer taps, a ripple ring expands, and the press node (a button) dips. Pass a unique label when you click more than once in a scene.
  • deviceScreenPoint(name, deviceOpts, [lx, ly]) → [x, y] — map a UI element’s screen-local coords (the coords devicePreset content is authored in) to scene coords, so the cursor clicks on-screen UI precisely (account for the device’s scale at click time and any slot offset).

Audio (optional)

Label-anchored sound design — cues follow retiming and regeneration:
Procedural sfx palette (deterministic; exactly one of sfx/file per cue): Variation — repeats don’t sound the same. Each cue’s seed shifts the sound’s PITCH (a musical step) and texture, and it defaults to the cue’s order, so a run of the same sfx becomes a little phrase instead of a stuck note — no setup needed. Override explicitly with params: { sfx: "blip", params: { seed: 4 } } (pick the variant) or { sfx: "tick", params: { pitch: 1.5 } } (an explicit frequency multiplier; 2 = octave up). params.gainDb trims a single hit. Auto-foley — the motion scores itself. audio: { autoFoley: true } derives sound cues from node motion, no manual cues needed: a fast move → whoosh/swish at the velocity peak, a moving node that settles → thud/knock, a scale-in → pop, each panned by its on-screen x. It’s a pure pass over the compiled motion, so it’s deterministic AND retime/regeneration-safe — retime a step and the sound follows. Manual cues still layer on top (and win). Best for discrete-element scenes; on dense scenes use the options to stay tasteful: autoFoley: { gain?, whoosh?, impact?, pop?, pan?, sensitivity?, maxCues?, nodes?: [ids] } (maxCues keeps the loudest, nodes allowlists). See examples/scenes/auto-foley-demo.ts (zero manual cues). bgm beds: synthesized via bgm.synth (ambient-pad lofi pulse tension uplift), or a file via bgm.file — bundled CC0 music: bgm-song21.mp3 (ambient), bgm-synthwave.mp3 (chill), bgm-piano.mp3 (elegant), bgm-battle.mp3 (energetic), or your own path. Mixing: any cue takes fadeIn/fadeOut (seconds) and pan (-1 left … 0 centre … +1 right). A video clip’s audio takes fadeIn and pan too (clip fade-out isn’t supported yet). The bed auto-ducks under cues (bgm.duck). Recorded samples are a separate layer from the synth palette: use a file: cue to play a CC0 file from assets/sfx/ — keyboard typing (keypress-*.wav, also driven by textTypeCues), footstep_*, and the Kenney UI pack (click_*/confirmation_*/ select_*/…). Six “hero” names (whoosh/rise/shimmer/thud/pop/tick) default to a curated CC0 sample (better fidelity, fixed — no pitch-vary); add params: { synth: 1 } to use the varying synth instead. Every other sfx: name synthesizes. Audition the procedural set with examples/scenes/sfx-showcase.ts and the samples with sample-showcase.ts.

Rules

  • Everything must be a pure function of time: no Math.random() (use wiggle with a seed), no Date, no async. reframe lint --strict enforces this (and flags un-addressable motion); for structural overlays (add/remove/reorder/insert) see the regeneration contract.
  • Node ids must be unique; states/tweens may only reference existing ids and real props of that node type.
  • Overshoot pops are two steps: tween scale past 1 (1.15), then settle to 1.
  • When a node enters by scaling from 0, start it at opacity: 0 too and fade in alongside — a scale-0 shape can still rasterize as a 1px dot at frame 0.
  • Moving text under 3D rotation (a perspective card flip, a tilt) can shimmer slightly — the 2.5D projection re-skews glyph edges each frame. Keep a node rotationally still during the beats where text is read, and render with reframe render --supersample 2 (or frame --supersample 2) for crisp anti-aliasing on any remaining motion.

Worked example A — countdown (3, 2, 1, GO!)

Worked example B — badge pop (overshoot + wiggle + drop)