Writing scenes
The HTML scene authoring contract, with small examples that link a style and render in any of them.
The HTML scene contract, with examples that render in swiss and terminal alike. Agents follow the same contract through the plugin's rapid-scene skill, whose reference files (plugin/skills/rapid-scene/*.md) document every library call.
The libraries, a style's components, and the join's transitions are starting points. Use one as it is, tune it, copy it into the scene and change it, or write your own when the moment needs something else; only the contract below is fixed.
The smallest scene
<!doctype html>
<html>
<head>
<script type="application/json" id="rapid-meta">{"id": "hello", "kind": "html", "dur": 2, "width": 1080, "height": 1920, "fps": 30}</script>
<link rel="stylesheet" href="rapid:style">
<style>
/* Layout only: the look comes from the style. */
#title { position: absolute; left: var(--rapid-margin-x); top: 640px; }
</style>
</head>
<body class="field">
<h1 class="display" id="title" data-el="title" data-role="type">hello.</h1>
<script>
const title = document.getElementById('title')
rapid.frame((t) => {
const p = rapid.ease.outCubic(rapid.at(0.2, 0.8, t))
title.style.visibility = t >= 0.2 ? 'visible' : 'hidden'
title.style.clipPath = `inset(0 0 ${(1 - p) * 100}% 0)`
title.style.transform = `translateY(${(1 - p) * 60}px)`
})
</script>
</body>
</html>
The scene names no colour, face, or type size. <link rel="stylesheet" href="rapid:style"> brings them from the style it renders in, and the kit classes field and display use them. Save it as hello.html and render it in each style:
rapid render hello.html --style swiss --out out/swiss/hello.mp4
rapid render hello.html --style terminal --out out/terminal/hello.mp4
swiss sets the word in a bold grotesque on off-white paper; terminal, in lowercase monospace on a near-black screen. Each command prints one JSON line naming what it wrote: the MP4, with hello.boxes.json and hello.cues.json beside it. No daemon needed.
One frame, or a strip of them, without a full render:
rapid still hello.html --style swiss --t 0.5 --out out/hello-0.5.png
rapid sheet hello.html --style swiss --times 0.2,0.5,1.9 --out out/hello-sheet.png
A scene that links rapid:style needs --style; without one, the render stops with the scene links rapid:style; render it with a style. --style takes a built-in style by name (swiss, terminal), an installed or pinned one, or a folder.
The contract
- One file, one scene. A whole document with a
<head>and a<body>. In a video, its file isscenes/<id>.htmland its id is the storyboard scene's. - The meta block is a
<script type="application/json" id="rapid-meta">holding the scene's meta as JSON. It needsid,dur,width,height, andfps;kindishtml. Takewidth,height, andfpsfrom the format'sformat.json. An HTML scene draws the frame its meta names, sorapid renderrefuses it for a format with another frame (hello.html draws 1080x1920 at 30 fps; the format square is 1080x1080 at 30 fps). Derivedurfrom the scene's own timings, never from the storyboard's estimate. Optional fields are listed under scenes. - The look is linked, never copied. Put
<link rel="stylesheet" href="rapid:style">in<head>, and take the look from the kit's classes and the style's custom properties. Your own<style>sets layout only: position, size, the order of things. - Scripts are inline, plus the pinned libraries:
<script src="rapid:lib/gsap@3.15.0"></script>, rapid's shot libraryrapid:lib/shots@1, andcamera,motion,fields,shapes,charts, andpictures, each@1(the other libraries). Another version, or an unknown name, is refused. - Nothing from the network. An absolute address in the HTML or CSS outside the scene's folder is refused; anything a script builds at run time is blocked. Put images, fonts, and clips beside the scene and use relative addresses. A style's own files are
rapid:style/<path>. - Motion is a function of time. Compute from
t, inrapid.frame, GSAP timelines, or CSS and Web Animations made at load. Never from what the previous frame did. - Elements that matter carry
data-elanddata-roleso the checks can see them. - Cues mark what the sound plays and where the reviewer looks.
- The key frame is
meta.key, in seconds: the hero, complete. In a video it is built and judged first, and the motion is built around it. - The poster is the last frame. End on a frame worth a thumbnail.
The style in the page
When a scene renders in a style, its rapid:style link becomes the style's tokens as custom properties on :root, adapted to the format, followed by the style's own style.css:
| Custom property | Holds |
|---|---|
--rapid-color-<token> | each palette colour, by the name the style gives it (field, ink, accent, paper, cool, …) |
--rapid-font-<role> | each face as a font-family list: the face, its substitutes, then a generic family |
--rapid-weight-<role>, --rapid-style-<role> | each face's weight and style |
--rapid-size-<step> | each type size, scaled by the format's type_k |
--rapid-leading-min, --rapid-leading-max | the range of line heights |
--rapid-margin-x, --rapid-margin-y | the margins for the format |
--rapid-grid-columns, --rapid-grid-gutter, --rapid-grid-col, --rapid-grid-baseline | the style's grid for the format, when it has one; --rapid-grid-col is one column's width |
--rapid-video-<step> | each step of the style's video.steps type scale, scaled by type_k |
--rapid-ease-<name> | each easing as a CSS timing function |
rapid:style/<path> anywhere in the page is one of the style's files, inlined: <img src="rapid:style/vectors/mark.svg">. Scripts read the same values as window.rapidStyle: tokens, motion, format (the format's name), and adaptation (what the style changes for that format).
The runtime
The runtime sets window.rapid before your scripts run:
rapid.t | the current time, in seconds |
rapid.meta | the meta block, parsed, with its defaults |
rapid.frame(fn) | fn(t) runs on every frame, in the order you registered them |
rapid.at(t0, t1, t?) | progress from 0 to 1 across [t0, t1], clamped; t defaults to now |
rapid.ease | linear, outCubic, inOutCubic, outExpo, outBack, outElastic, each taking 0–1 |
rapid.random() | seeded from the scene's id; inside a frame callback, reseeded from the frame's time |
rapid.cue(kind, { key, color, gain, params }) | records a cue at the current time |
Date, performance.now, requestAnimationFrame, the timers, Math.random, and crypto.getRandomValues are all virtual too. Only the motion listed under determinism is safe in any seek order, and a render seeks out of order across chunks.
Video clips
A video clip in the project's assets/ (.mp4, .webm, .mov) plays in a plain <video>:
<video src="../assets/clip.mp4" data-el="clip" data-role="object" data-start="0.5" data-rate="1" loop muted></video>
The runtime owns the video. It never plays on its own; on every frame it shows the clip at the scene's time less data-start (seconds, default 0), times data-rate (default 1). With loop the clip wraps; without it, it holds its last frame. Before data-start it shows its first frame, so hide it until then with opacity from a timeline. Each frame waits until the video shows that moment. Give it data-el and a data-role (object, or texture for a full-bleed ground), size it with CSS (object-fit: cover for full bleed), and move, scale, or mask it like any element. Clips are silent in the render; the mix scores the video.
A scene in a project's scenes/ folder is served from the project, so ../assets/… reaches the project's assets/. A scene anywhere else reaches only its own folder.
A Blender shot plays the same way: its .webm keeps its transparency over the scene's field.
Motion with GSAP
Ask for the pinned GSAP and build timelines at load. The runtime holds GSAP's global timeline and moves it only by seeking.
<!doctype html>
<html>
<head>
<script type="application/json" id="rapid-meta">{"id": "gsap-card", "kind": "html", "dur": 2.4, "width": 1080, "height": 1920, "fps": 30}</script>
<link rel="stylesheet" href="rapid:style">
<script src="rapid:lib/gsap@3.15.0"></script>
<style>
#card { position: absolute; left: var(--rapid-margin-x); top: 760px; width: 760px; height: 220px; visibility: hidden; }
#card .card-tile { position: absolute; left: 36px; top: 36px; width: 112px; height: 148px; }
#card .card-name { position: absolute; left: 184px; top: 52px; }
#card .card-sub { position: absolute; left: 184px; top: 120px; }
</style>
</head>
<body class="field-dark">
<div class="card" id="card" data-el="card" data-role="card">
<div class="card-tile" data-el="card-tile" data-role="object"></div>
<p class="card-name" data-el="card-name" data-role="type">brief.pdf</p>
<p class="card-sub" data-el="card-sub" data-role="type">three directions</p>
</div>
<script>
const card = document.getElementById('card')
const W = rapid.meta.width
gsap.timeline()
.set(card, { visibility: 'visible' }, 0.2)
.fromTo(card, { x: W - card.offsetLeft + 40 }, { x: 0, duration: 0.6, ease: 'power3.out' }, 0.2)
.to(card, { x: -(card.offsetLeft + card.offsetWidth + 40), duration: 0.5, ease: 'power2.in' }, 1.6)
.set(card, { visibility: 'hidden' }, 2.1)
</script>
</body>
</html>
The card arrives from the right edge and leaves off the left. While it moves across the edge, the placement checks leave it alone; only an element at rest is held to the frame. It is hidden before it enters and once it has gone, never parked past the edge. The kit draws the card: in swiss, a figure hung from a heavy rule with a red tile; in terminal, a pane framed by a stroke through its outer cells, with a dotted slot for a block picture.
Motion with CSS
CSS animations and transitions are paused and seeked to each frame. A delay counts from the start of the scene. The style's easings are custom properties, so the motion takes the style's feel too.
<!doctype html>
<html>
<head>
<script type="application/json" id="rapid-meta">{"id": "css-dot", "kind": "html", "dur": 2, "width": 1080, "height": 1920, "fps": 30}</script>
<link rel="stylesheet" href="rapid:style">
<style>
#dot { position: absolute; left: var(--rapid-margin-x); top: 860px; width: 200px; height: 200px; border-radius: 100px;
background: var(--rapid-color-accent);
animation: slide 1.2s var(--rapid-ease-in) 0.3s both; }
@keyframes slide { from { transform: translateX(0) } to { transform: translateX(600px) } }
</style>
</head>
<body class="field">
<div id="dot" data-el="dot" data-role="object"></div>
</body>
</html>
When the kit has no class for something, build it from the custom properties, as the dot's colour is here.
Tracked elements
Give every element that matters a stable data-el id that names the thing, not its position (title, voice-1, card-brief), and a data-role. The renderer records each one's box on every frame, its text, and, for type, its line height.
data-role | What the checks hold it to |
|---|---|
type | inside the frame, inside a readable band, clear of keep-outs that apply to type, inside safe, at least the minimum text size |
card, bubble | inside the frame, clear of keep-outs that apply, inside safe |
object | inside the frame, unless it also has data-macro (a close-up the frame may crop); inside safe |
mark, other | inside safe, clear of keep-outs that apply |
texture | nothing: fields, grain, repeated patterns |
Put the role on the element whose box is the thing: card on the card, type on the text inside it. A full-frame background is texture; as other it would be marked outside the safe area. An element that isn't displayed, or is visibility: hidden, isn't recorded on that frame.
Cues
Cues mark moments: a word landing, a card arriving, an exit. The mix plays an instrument for each, from the style's sound map, and the contact sheet picks its frames at them. A cue has a kind (a key of the style's sound.cues), a time, and an optional key that makes it play once however many frames emit it, a color (a palette token, when colour picks the instrument), and a gain from 0 to 4.
Declare them as elements, which fire on the exact frame of data-cue-at:
<i hidden data-cue="word" data-cue-at="0.4" data-cue-key="a"></i>
data-cue-color and data-cue-gain set the rest, and data-cue-duck dips a track under the cue; without data-cue-key, the element's data-el is the key. Or emit one from a frame callback, which records it at the frame's time:
<!doctype html>
<html>
<head>
<script type="application/json" id="rapid-meta">{"id": "cues", "kind": "html", "dur": 2, "width": 1080, "height": 1920, "fps": 30}</script>
<link rel="stylesheet" href="rapid:style">
<style>
.w { position: absolute; left: var(--rapid-margin-x); visibility: hidden; }
#a { top: 640px; }
#b { top: calc(640px + var(--rapid-size-title) * 1.2); }
</style>
</head>
<body class="field">
<p class="w title" id="a" data-el="word-a" data-role="type">one</p>
<p class="w title accent" id="b" data-el="word-b" data-role="type">two</p>
<i hidden data-cue="word" data-cue-at="0.4" data-cue-key="a"></i>
<script>
const a = document.getElementById('a')
const b = document.getElementById('b')
rapid.frame((t) => {
a.style.visibility = t >= 0.4 ? 'visible' : 'hidden'
b.style.visibility = t >= 0.9 ? 'visible' : 'hidden'
if (t >= 0.9) rapid.cue('land', { key: 'b', color: 'accent' })
})
</script>
</body>
</html>
cues.json:
[
{ "kind": "word", "t": 0.4, "key": "a" },
{ "kind": "land", "t": 0.9, "key": "b", "color": "accent" }
]
A cue emitted from a frame callback needs a key, or it records again on every frame it's emitted. Prefer declared cues: they always land on their exact frame, whichever frames a chunk renders. The second word's place is computed from --rapid-size-title, so it sits under the first in any style's type scale.
Timing from the style
The style's motion.pacing gives the rates every scene times its lines by: words_per_second for speech, chars_per_second for typing, and line_hold, how long a finished line holds. Scripts read them from window.rapidStyle:
<!doctype html>
<html>
<head>
<script type="application/json" id="rapid-meta">{"id": "pacing", "kind": "html", "dur": 2.4, "width": 1080, "height": 1920, "fps": 30}</script>
<link rel="stylesheet" href="rapid:style">
<style>
#line { position: absolute; left: var(--rapid-margin-x); right: var(--rapid-margin-x); top: 700px; }
#aside { position: absolute; left: var(--rapid-margin-x); top: 1100px; }
</style>
</head>
<body class="field">
<h2 class="title" id="line" data-el="line" data-role="type"></h2>
<p class="voice" id="aside" data-el="aside" data-role="type">one idea at a time</p>
<script>
// The video's style's motion.pacing, written in as numbers: a scene's length must not change with the style.
const pacing = { chars_per_second: 38, line_hold: 0.6 }
const text = 'make it out loud'
const start = 0.3
const typedEnd = start + text.length / pacing.chars_per_second
const asideAt = typedEnd + pacing.line_hold
const line = document.getElementById('line')
const aside = document.getElementById('aside')
rapid.frame((t) => {
const n = Math.max(0, Math.min(text.length, Math.floor((t - start) * pacing.chars_per_second)))
line.textContent = text.slice(0, n)
aside.style.visibility = t >= asideAt ? 'visible' : 'hidden'
})
</script>
</body>
</html>
The line types at the style's rate and the aside follows after its hold, so the same scene runs a little faster in terminal than in swiss. In a video, set dur from where the last thing lands plus its hold, in the video's style. A cheap guard is to throw when the meta disagrees with your timings; a throw is a problem, and the render fails saying why:
const END = asideAt + 1.2
if (Math.abs(rapid.meta.dur - END) > 1 / rapid.meta.fps) throw new Error(`dur should be ${END.toFixed(2)}`)
Fonts
Every family the page's text uses (the first of each font-family list) and every @font-face loads before frame 0. A family that doesn't load is a problem that fails the render by name. Generic families (serif, sans-serif, monospace, system-ui, and the rest) are never required. Take faces from the kit classes or var(--rapid-font-<role>), never by naming a family yourself. If you draw text only on a <canvas>, use the family from window.rapidStyle.tokens.fonts and list it in the meta's fonts.
Canvas scenes
A canvas scene is a module whose default export has a meta and a draw(g, t, d) that paints the whole frame from t. d carries the frame's width, height, and fps, d.box(id, rect, role?, text?, facts?) to report an element, and d.cue(kind, t, key?, more?) to emit a cue. An optional fonts list names faces that must load, and an optional init(frame) runs once before any frame. A canvas scene has no page to link a style into, so it names its own colours.
export default {
meta: { id: 'dots', kind: 'canvas', dur: 2 },
draw(g, t, d) {
g.fillStyle = '#F2EFE6'
g.fillRect(0, 0, d.width, d.height)
const x = 220 + 600 * Math.min(1, t / 1.5)
g.fillStyle = '#4C6FD8'
g.beginPath()
g.arc(x, d.height / 2, 80, 0, Math.PI * 2)
g.fill()
d.box('dot', { x: x - 80, y: d.height / 2 - 80, w: 160, h: 160 }, 'object')
if (t >= 1.5) d.cue('settle', 1.5, 'stop')
},
}
A canvas scene takes its frame from the format (reel by default):
rapid render dots.js --format square --out out/dots.mp4
Checking your scene
Run the code checks on a render against a format and a style:
rapid render hello.html --style swiss --out out/hello.mp4
rapid check hello.html --render out/hello --style swiss --out out/hello.checks.json
--render is the render's path without .mp4, and --format defaults to reel. The result is { "pass": true, "marks": [] }, or the marks to fix. Check in every style the video might use: a line that fits one style's type scale can run past the band in another's. See review.
When a render fails
no <script type="application/json" id="rapid-meta"> block: the meta is missing or itsidis misspelled.rapid-meta is not a scene meta: …: a field is missing or out of range.… draws 1080x1920 at 30 fps; the format square is 1080x1080 at 30 fps: the meta's frame isn't the format's; render for the format the scene draws, or change its meta.the scene links rapid:style; render it with a style: pass--style.the style has no file …: arapid:style/<path>names a file the style doesn't hold.scene reaches outside its assets: …: an absolute address; use a relative one beside the scene.rapid:lib/gsap is pinned at 3.15.0, not …(orrapid:lib/shots is pinned at 1, not …): use the pinned version.font family not loaded: …: the face isn't installed; use the style's face or a substitute it lists.callback at 1.033s: frame: Error: …: your frame callback threw at that time.
Every failure is one line. Set RAPID_DEBUG=1 to see the whole error.