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 within/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
| Flag | What it does | Cost |
|---|---|---|
--check | Validate the spec, warn on dead air. No render. | ~0.05s |
--at <t> | One PNG frame at that time. | ~0.9s |
--preview | Labelled contact sheet of six frames. | seconds |
--emit-html | Write 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.