# 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](/docs/shots), a style's [components](/docs/style-json#the-components-folder), and the join's [transitions](/docs/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

```html
<!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:

```sh
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:

```sh
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

1. **One file, one scene.** A whole document with a `<head>` and a `<body>`. In a video, its file is `scenes/<id>.html` and its id is the storyboard scene's.
2. **The meta block** is a `<script type="application/json" id="rapid-meta">` holding the scene's meta as JSON. It needs `id`, `dur`, `width`, `height`, and `fps`; `kind` is `html`. Take `width`, `height`, and `fps` from the format's `format.json`. An HTML scene draws the frame its meta names, so `rapid render` refuses it for a format with another frame (`hello.html draws 1080x1920 at 30 fps; the format square is 1080x1080 at 30 fps`). Derive `dur` from the scene's own timings, never from the storyboard's estimate. Optional fields are listed under [scenes](/docs/scenes#the-meta).
3. **The look is linked, never copied.** Put `<link rel="stylesheet" href="rapid:style">` in `<head>`, and take the look from [the kit's classes](/docs/styles-and-formats#the-kit) and the style's custom properties. Your own `<style>` sets layout only: position, size, the order of things.
4. **Scripts are inline**, plus the pinned libraries: `<script src="rapid:lib/gsap@3.15.0"></script>`, rapid's [shot library](/docs/shots) `rapid:lib/shots@1`, and `camera`, `motion`, `fields`, `shapes`, `charts`, and `pictures`, each `@1` ([the other libraries](/docs/shots#the-other-libraries)). Another version, or an unknown name, is refused.
5. **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>`.
6. **Motion is a function of time.** Compute from `t`, in `rapid.frame`, GSAP timelines, or CSS and Web Animations made at load. Never from what the previous frame did.
7. **Elements that matter carry `data-el` and `data-role`** so the checks can see them.
8. **Cues** mark what the sound plays and where the reviewer looks.
9. **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.
10. **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](/docs/scenes#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>`:

```html
<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](/docs/blender-shots) 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.

```html
<!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.

```html
<!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`:

```html
<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](/docs/music) 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:

```html
<!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`:

```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`:

```html
<!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:

```js
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.

```js
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):

```sh
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:

```sh
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](/docs/review#1-code-checks).

## When a render fails

- `no <script type="application/json" id="rapid-meta"> block`: the meta is missing or its `id` is 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 …`: a `rapid: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 …` (or `rapid: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.
