Skip to main content

The scene DSL

A scene is JSON. The compiler turns it into deterministic, seekable HTML — you never write the runtime.

{
"size": [1920, 1080],
"duration": 10,
"bg": "$palette.bg",
"layers": [
{
"id": "h1",
"type": "text",
"text": "MAKE IT MOVE",
"at": { "x": 118, "y": 208 },
"style": { "size": 104, "weight": 900, "color": "$palette.ink", "uppercase": true },
"in": { "at": 0.45, "dur": 0.75, "from": "rise", "ease": "outCubic" }
},
{
"id": "cta",
"type": "text",
"text": "Get started",
"anchor": "bottom-left",
"safe": true,
"style": { "bg": "$palette.accent", "pad": [18, 34], "radius": 999 },
"in": { "at": 2.1, "dur": 0.5, "from": "pop" },
"idle": { "kind": "breathe", "amp": 0.02 }
}
]
}

Layers

text, image, rect, counter, bar, svg, video and stack. Position with at: {x, y} or an anchor plus margin; set safe: true to inset a layer into the platform-safe region. fit: {maxWidth, maxHeight} shrinks type at runtime so it cannot overflow.

Video layers

type: "video" puts footage on the same timeline as everything else. The runtime never plays the element — it seeks currentTime once per frame — so a scene containing video is still a pure function of time, and still fully seekable.

{
"id": "shotA", "type": "video", "src": "clips/street.mp4",
"size": { "w": 1920, "h": 1080 },
"clipStart": 4.2,
"clipSpeed": 1,
"in": { "at": 0.0, "dur": 0.08 },
"out": { "at": 3.1, "dur": 0.08 }
}

Clip time is clipStart + (t - in.at) * clipSpeed, and layers default to object-fit: cover.

Cuts are layer windows: a hard cut is one layer's out.at meeting the next layer's in.at with a very short dur, a cross-dissolve overlaps the two windows, and a jump cut is the same clip twice with different clipStart. Clips are only sought while on screen, so overlapping windows cost real decode time — keep dissolves short.

Entrances and exits

fade · rise / drop · slide-left / slide-right / slide-up / slide-down · pop · grow-x · wipe · stamp · reveal (masked line reveal) · shrink-fade for exits.

Idle motion

Fills a hold so nothing sits dead on screen: bob (position), breathe (scale), drift (ambient wander), pulse (opacity — note that it dims the element, so keep it off a CTA that must stay solid).

Easing

18 named eases — linear, the quad/cubic/quart/quint families, outExpo, outCirc, inOutSine, outBack, outElastic, outBounce — or CSS-style cubic-bezier control points as a four-number array [x1, y1, x2, y2], accepted anywhere an ease is.

Beyond entrances

  • tracks — per-layer keyframes for x, y, rotate, scale (absolute) and opacity (multiplied), composing with in / out / idle.
  • camera — scene-level look-at and zoom keys; the compiler splits them per property.
  • tickers — a slot that cycles through content sets while the rest of the scene holds, so a single linear play reads as a loop.
  • path — set text along an SVG path for badges, arcs and orbits.
  • mask — gradient, image or SVG (including text-as-mask) per layer.

The iteration loop

FlagWhat it doesCost
--checkValidate the spec, warn on dead air. No render.~0.05s
--at <t>One PNG frame at that time.~0.9s
--previewLabelled contact sheet of six frames.seconds
--emit-htmlWrite the compiled HTML instead of rendering.instant

Full grammar and a worked example live in docs/scene-spec.md in the repository, with a JSON Schema beside it.