# The Reel kit: API reference

The kit (`kit/kit.js`) gives every reel a helper object, **`k`**, and a global, **`Reel`**. The player (`kit/player.js`) plays reels and drives the tools. Sound has its own reference, `kit/SOUND.md`. The worked example is `pieces/_kit-demo.js`: read it alongside this file.

Load order in a page: `kit/kit.js`, `kit/sound.js` (optional: without it everything is silent), `kit/player.js`, then the piece.

---

## 1. A piece in 40 lines

```js
(function () {                       // an IIFE keeps your helpers out of the page's globals
  const INK = '#1d2433';
  Reel.piece({
    id: 'my-reel', title: 'My Reel', medium: 'Chalkboard', family: 'Drawn', theme: 'jazz',
    summary: '…', look: '…', motion: '…', explains: '…',
    facts: [{ text: '…', source: '…', url: 'https://…' }],
    fonts: ['Caveat:wght@700', 'IBM Plex Mono:wght@500'],
    duration: 22, poster: 15, hold: 1.2,
    beats: [{ at: 0, label: 'Hook' }, { at: 6, label: 'Turn' }],

    setup(k) {                        // runs once, after the fonts load: bake everything expensive
      return { paper: k.tex.paper({ seed: 3 }), stroke: k.wobble(k.circle(640, 360, 200), { seed: 2 }) };
    },
    frame(ctx, t, k, s) {             // a pure function of t: paint every pixel, background first
      ctx.drawImage(s.paper, 0, 0);
      ctx.strokeStyle = INK; ctx.lineWidth = 6; ctx.lineCap = 'round';
      k.drawOn(ctx, s.stroke, k.span(t, 1, 3, 'inOutSine'));
      k.text(ctx, 'HELLO', 640, 380, { size: 96, family: 'Caveat', weight: 700, align: 'center', color: INK, alpha: k.span(t, 2, 3) });
      k.vignette(ctx);
      k.grain(ctx, t);               // grain, flicker, dust and shake freeze in the hold by themselves
    },
    sound(cue, k) { cue(1, 'chalk', { dur: 2 }); cue.bed(0, 22, 'calm', { gain: 0.25 }); },
  });
})();
```

### What `tools/check.mjs` enforces
| Check | Rule |
|---|---|
| loads | no console errors or uncaught exceptions |
| fields | every SPEC §4 field present and valid; `id` matches the file name (dev pieces `_*` may have any duration) |
| fonts | every family, weight and style in `fonts` actually loaded |
| determinism | each test time drawn after `t=0`, after a random history, and twice in a row: identical pixels |
| purity | no `Math.random`, `Date` or `performance.now` calls in `setup`, `frame` or `sound` |
| performance | 90 frames spread over the reel: **mean ≤ 12 ms** (else WARN), **p95 ≤ 33 ms** (else FAIL) |
| hold | the frames at `duration − hold + 0.05`, mid-hold and `duration` are identical |
| opaque | every pixel has alpha 255 (paint the background first) |
| isolation | no new `window` globals, and the file runs twice in one page without a `SyntaxError` |
| text audit | forbidden words in strings (`robot`, `AI learn`, `neural`, `cat`, `dog`): warn only |
| sound | `sound(cue, k)` runs, every effect, instrument, bed and ambience name exists, the engine compiles it |

---

## 2. `Reel` (the global)

| | |
|---|---|
| `Reel.version` | `'1.0.0'` |
| `Reel.piece(def)` | Validates (warns in the console on errors), fills the defaults `hold: 1.2`, `readsPixels: false`, `credits: ''`, stores it in `Reel.pieces[def.id]`, sets `Reel.last` and fires `window` event `reel:piece` with `detail: def`. |
| `Reel.load(id, base = Reel.base)` → `Promise<def>` | Injects `<script src="{base}{id}.js">` once and resolves when it has registered. `Reel.base` is `pieces/` resolved next to `kit/kit.js`, so it works from any page. |
| `Reel.make(def)` → `inst` | `inst.k` (this piece's kit), `inst.ready` (Promise: fonts, then `setup`; sets `inst.state`), `inst.draw(ctx, t)`, `inst.duration`, `inst.hold`, `inst.holdFrom`. `draw` resets the context, clamps `t` to `[0, duration]` and calls `frame` inside `save()/restore()`. |
| `Reel.fonts.load(specs)` → `Promise<report>` | One css2 `<link>` for all the specs (`display=block`), then `document.fonts.load()` for every face named, including the Latin-extended, Greek and symbol subsets. Gives up quietly after 5 s. The report lists `{family, weight, style, loaded}`. |
| `Reel.fonts.report(specs)` | The same report, synchronously, for the current state. |
| `Reel.validate(def)` → `{errors, warnings}` | The field rules `check.mjs` uses. |
| `Reel.kit(def)` | A kit for a def without an instance, for example for `Reel.sound.compile(def, k)`. |
| `Reel.caps` | `{offscreen, filter, letterSpacing}`, as detected. Set `Reel.caps.filter = false` or `Reel.caps.letterSpacing = false` before drawing to preview the fallbacks (Safari without `ctx.filter`). The dev pages take `?nofilter` and `?nospacing`. |
| `Reel.loadQuery` | A string appended to `Reel.load` URLs (the dev viewer uses `?v=<time>` to defeat caching). |

A font spec is `'Family Name'` (weight 400) or a css2 spec, for example `'Fraunces:ital,wght@0,400;0,700;1,400'` or `'Inter:wght@100..900'`. Axes and tuples are sorted for you. **Declare every weight and style you draw with.** Otherwise the canvas silently falls back to a system font, and the fonts check can't catch it.

---

## 3. `k`: constants, math, time

`k` is frozen. Keep your state in the object `setup` returns, and treat it as **read-only in `frame`**: mutating it makes frames depend on history, which fails determinism.

| | |
|---|---|
| `k.W`, `k.H` | 1280, 720 |
| `k.TAU` | 2π |
| `k.duration`, `k.hold`, `k.holdFrom` | the piece's timing; `holdFrom = duration − hold` |

### Math
| Signature | Notes / example |
|---|---|
| `k.clamp(x, lo = 0, hi = 1)` | |
| `k.lerp(a, b, t)` | unclamped |
| `k.unlerp(a, b, x)` | 0 when `a === b` |
| `k.remap(x, a, b, c, d, clamp = true)` | `k.remap(t, 2, 4, 0, 360)` |
| `k.smoothstep(e0, e1, x)` | cubic, clamped |
| `k.fract(x)` | `x − floor(x)` |
| `k.wrap(x, lo = 0, hi = 1)` | wraps a number into `[lo, hi)`. **Overloaded:** `k.wrap(ctx, …)` wraps text (§7). Unambiguous aliases are `k.wrapNum` and `k.wrapText`. |
| `k.dist(x1, y1, x2, y2)` | |
| `k.angle(x1, y1, x2, y2)` | radians, `atan2` |
| `k.rotate([x, y], a, [cx, cy] = [0, 0])` → `[x, y]` | |

### Time
| Signature | Notes / example |
|---|---|
| `k.span(t, t0, t1, ease = 'linear')` → 0..1 | clamped, then eased: `k.span(t, 2, 3, 'outCubic')` |
| `k.keys(t, frames)` | `frames` = `[[time, value, ease?], …]`, sorted by time. The value is a number, an array of numbers or a colour string (interpolated in RGB, returned as `rgb()`). `ease` is the easing *into* that key. It holds the first value before the first key and the last after the last. Two keys at the same time make a jump. `k.keys(t, [[0, 0], [1.5, 300, 'outBack'], [4, 300], [5, 0, 'inCubic']])` |
| `k.pulse(t, at, rise = 0.15, fall = 0.4)` → 0..1 | smoothstep up to 1 at `at`, then down: flashes and bumps |
| `k.stagger(t, i, n, t0, t1, overlap = 0.6, ease = 'outCubic')` | item `i` of `n` across `[t0, t1]`. Each item lasts `d = (t1−t0) / ((1−overlap)(n−1) + 1)` and starts `i·d·(1−overlap)` after `t0`. `overlap` 0 is one after another; 1 is all together. |
| `k.step(t, fps)` | `t` quantised to frames: `k.step(t, 12)` animates on twos |
| `k.ambient(t)` | `min(t, holdFrom)`. **Drive all ambient motion with it** (drift, breathing, flicker, twinkle, rotation), so the hold is still. |
| `k.ease.<name>(x)` | `linear inQuad outQuad inOutQuad inCubic outCubic inOutCubic inQuart outQuart inOutQuart inQuint outQuint inOutQuint inSine outSine inOutSine inExpo outExpo inOutExpo inCirc outCirc inOutCirc inBack outBack inOutBack outElastic outBounce`, plus `inBounce`, `smooth` (smoothstep) and `smoother`. They expect 0..1; Back, Elastic and Bounce overshoot. An unknown name warns once and falls back to linear. |
| `k.spring(x, bounce = 0.3)` → 0..1 | a damped spring from rest. It is exactly 0 at 0 and exactly 1 at 1. Typical use: `k.spring(k.span(t, 3, 4))` |

`k.spring` overshoot by `bounce` (the peak value, where it peaks, and when it is within 2% for good):

| bounce | 0 | 0.15 | 0.3 | 0.45 | 0.6 | 0.8 |
|---|---|---|---|---|---|---|
| peak | 1.000 (no overshoot) | 1.026 at 0.40 | 1.098 at 0.38 | 1.205 at 0.36 | 1.348 at 0.34 | 1.613 at 0.31 |
| settled within 2% by | 0.65 | 0.47 | 0.57 | 0.82 | 0.84 | 0.99 |

---

## 4. Randomness and noise (all deterministic)

| Signature | Notes |
|---|---|
| `k.rng(seed = 1)` → `r` | sfc32 seeded through splitmix32; `seed` may be a number or string. `r()` returns [0, 1). Also `r.range(a, b)`, `r.int(a, b)` (inclusive), `r.pick(arr)`, `r.chance(p = 0.5)`, `r.gauss(mean = 0, sd = 1)` and `r.shuffle(arr)` (returns a copy). **Make generators in `setup`** and consume them there: a generator used in `frame` depends on how many calls came before it. |
| `k.hash(...args)` → [0, 1) | Stateless. Takes numbers (floats too) and strings. Use it for per-element, per-frame randomness in `frame`: `k.hash(i, 7)`, or `k.hash(i, k.step(t, 12))` for boil. About 6 ns a call. |
| `k.noise(seed = 1)` → `{n1, n2, n3, fbm2, fbm3}` | Seeded Perlin gradient noise in [−1, 1] (n2 reaches about ±0.97). `fbm2(x, y, oct = 4, lac = 2, gain = 0.5)` and `fbm3(x, y, z, …)` sum octaves, normalised (typically within ±0.6). The same seed returns the same cached object. Costs: n2 about 5 ns, fbm2 at 4 octaves about 16 ns. |

---

## 5. Colour

Inputs accept `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()`, `transparent`, named colours and `[r, g, b, a]` arrays. Outputs are `rgb(r,g,b)` or `rgba(r,g,b,a)` strings. Parses are cached, so they're cheap in `frame` (`k.mix` costs about 55 ns).

| Signature | Notes |
|---|---|
| `k.rgb(c)` → `[r, g, b, a]` | a fresh array (0–255 and 0–1) |
| `k.css([r, g, b, a])` | rounds the channels |
| `k.mix(c1, c2, t)` | RGB lerp. It interpolates premultiplied when the alphas differ, so `k.mix('#f00', 'transparent', .5)` stays red. |
| `k.alpha(c, a)` | **multiplies** `c`'s alpha by `a` (an opaque colour gets alpha `a`) |
| `k.lighten(c, amt)`, `k.darken(c, amt)` | mixes towards white or black by `amt` (0..1) |
| `k.hsl(h, s, l, a = 1)` | h in degrees; s and l in 0..1 |

---

## 6. Paths and geometry

Points are `[x, y]`. Closed shapes (`k.circle`, `k.rect`, `k.spline(…, true)`) **repeat the first point at the end**, so lengths and draw-on cover the whole loop.

| Signature | Notes / example |
|---|---|
| `k.rr(ctx, x, y, w, h, r = 0)` | **Adds** a rounded-rect subpath, like `ctx.rect`: call `ctx.beginPath()` first. `r` is a number or `[tl, tr, br, bl]`; radii scale down to fit, as in CSS. |
| `k.path(ctx, pts, close = false)` | adds a moveTo/lineTo subpath |
| `k.smoothPath(ctx, pts, close = false, tension = 0.5)` | adds a Catmull-Rom curve through the points, as beziers (0.5 is standard Catmull-Rom; 0 gives straight lines) |
| `k.spline(pts, samples = 12, close = false)` → pts | the same curve sampled `samples` times per segment: smooth input for `drawOn`, `along` and `wobble` |
| `k.len(pts)` | polyline length |
| `k.along(pts, u)` → `{x, y, angle}` | the point at fraction `u` of the length, and the segment angle: move things along paths |
| `k.partial(pts, u0, u1)` → pts | the sub-polyline between two length fractions |
| `k.drawOn(ctx, pts, u, u0 = 0)` | **Begins its own path** and strokes from `u0` to `u` with the current stroke style. A full closed loop joins cleanly. Use `u0` for a travelling dash. |
| `k.wobble(pts, {amp = 1.5, freq = 0.04, seed = 1})` → pts | A hand-drawn copy: resampled to about 6 px, then offset by smooth noise along the length. Closed loops stay closed. `freq` is per pixel of length. |
| `k.resample(pts, spacing)` → pts | evenly spaced points, including the end |
| `k.circle(cx, cy, r, n = 64)` | `n + 1` points (closed) |
| `k.arc(cx, cy, r, a0, a1, n = 48)` | `n + 1` points |
| `k.rect(x, y, w, h)` | 5 points (closed) |
| `k.iso(x, y, z = 0, {scale = 1, angle = π/6, ox = 640, oy = 360})` → `[sx, sy]` | *Extension.* Isometric projection: x runs down-right, y down-left, z up. |
| `k.project([x, y, z], {f = 900, cx = 640, cy = 360, cam = [0, 0, 0], yaw, pitch, roll, near = 1})` → `[sx, sy, scale, depth]` or `null` | *Extension.* Perspective; y is up and z is forward. `null` means the point is behind the near plane. |

---

## 7. Text

`k.text` and `k.glyphs` use `save()/restore()`. They inherit whatever else is set on the context: shadows, filters, the composite operation, the transform.

| Signature | Notes / example |
|---|---|
| `k.font(size, family = 'sans-serif', weight = 400, style = 'normal')` | `'normal 400 48px "Bebas Neue"'`; quotes each family in a stack |
| `k.text(ctx, str, x, y, opts)` → drawn width | `opts`: `font` (or `size = 16`, `family`, `weight = 400`, `style = 'normal'`), `color = '#000'` (`null` or `'none'` = no fill), `align = 'left'` (`left`, `center`, `right`), `baseline = 'alphabetic'`, `spacing = 0` (letter-spacing in px; negative works), `maxWidth` (squeezes horizontally to fit), `stroke` (a colour), `strokeWidth = 1`, `strokeUnder = false` (stroke beneath the fill: a clean outline), `alpha = 1`. Example: `k.text(ctx, 'SEPTEMBER', 640, 300, { size: 72, family: 'Oswald', weight: 600, spacing: 8, align: 'center', color: '#ffd23f' })` |
| `k.measure(ctx, str, font, spacing = 0)` | width with `spacing` between the glyphs (no trailing space) |
| `k.wrap(ctx, str, maxWidth, font, spacing = 0)` → lines | greedy word wrap; respects `\n` |
| `k.typed(str, t, t0, cps = 18)` | the prefix typed by time `t` (Unicode-safe) |
| `k.glyphs(ctx, str, x, y, opts, fn)` → width | Lays the string out per character (kerning kept) and calls `fn(i, ch, gx, gy, gw, n)` for each glyph, inside `save()/restore()`, with the font, fill and baseline set and `textAlign = 'left'`. `(gx, gy)` is the glyph's left end on the baseline; `gw` is its advance. **Return** `{dx, dy, rot, scale, sx, sy, alpha, color, stroke}` and the kit draws the glyph transformed about its centre. Return `true` to draw it plainly, or `false`/nothing if you drew it yourself (or want it hidden). `opts` are as for `k.text` (no `maxWidth`). |

**Letter-spacing** uses `ctx.letterSpacing` where it exists, and otherwise positions each glyph itself. Both paths put every glyph in the same place, kerning included (verified pixel for pixel in Chrome). Measurements are cached per font and string; the cache clears when fonts finish loading.

**Per-letter animation:**
```js
k.glyphs(ctx, 'SWING', 640, 400, { size: 140, family: 'Monoton', align: 'center', color: '#ff3df0' }, (i, ch, gx, gy, gw, n) => {
  const p = k.stagger(t, i, n, 1.0, 2.2, 0.5, 'linear');
  if (p <= 0) return false;                      // not yet
  const s = k.spring(p, 0.45);
  return { dy: (1 - s) * -80, scale: 0.6 + 0.4 * s, rot: (1 - s) * 0.4, alpha: k.clamp(p * 4) };
});
```

---

## 8. Canvases, layers, caching, camera

| Signature | Notes |
|---|---|
| `k.canvas(w, h, {willRead = false})` → canvas with `.ctx` | an OffscreenCanvas where available; `willRead` makes it CPU-backed for fast `getImageData` |
| `k.layer(key, w = 1280, h = 720, {clear = true, willRead})` | *Extension.* A per-piece scratch canvas, reused every frame: it's reset and cleared each time you fetch it, so it can't leak state between frames. Use one key per buffer in use at once. Ideal for per-frame masks, glow sources and low-res buffers. |
| `k.memo(key, fn)` | Memoises `fn()` per piece instance (non-string keys are JSON-stringified). Don't key it on raw `t`, or the cache grows without limit; quantise (`k.step`) or bake in `setup`. |
| `k.camera(ctx, {x = 640, y = 360, zoom = 1, rotate = 0, cx = 640, cy = 360}, draw)` | Puts world point `(x, y)` at screen point `(cx, cy)`, zoomed and rotated, calls `draw()` and restores. Nest it, or animate it with `k.keys`. |
| `k.shake(t, {amp = 4, freq = 14, seed = 1})` → `{x, y, r}` | Smooth noise jitter in px and radians (`r` ≈ amp × 0.0025). Frozen in the hold. For an impact, multiply by an envelope: `const h = k.pulse(t, 6.2, 0.02, 0.4)`, then `x: 640 + sh.x * h`. |
| `k.reset(ctx)` | *Extension.* The same reset the player does before each frame. |

---

## 9. Textures (memoised by their arguments; deterministic)

> **Shared canvases: never draw into one.** `k.tex.*` results are memoised and shared by every piece on the page with the same arguments. Drawing into one changes it for all of them, and breaks determinism. To modify a texture, copy it first (`const c = k.canvas(w, h); c.ctx.drawImage(tex, 0, 0)`), then draw into the copy.

Bake them in `setup`. Same arguments, same canvas, across pieces and runs.

| Signature | Notes |
|---|---|
| `k.tex.grain({size = 256, seed = 1, contrast = 1, soft = 0.35})` | A tileable mono noise tile, mid-grey ± about 64 levels, slightly softened so it reads as film. About 8 ms to bake. |
| `k.tex.paper({w = 1280, h = 720, base = '#efe6d2', fibers = 0.5, mottle = 0.5, tooth = 0.5, seed = 1})` | A paper sheet: two scales of mottling, tooth, light and dark fibres and specks. About 20 ms at 1280×720. |
| `k.tex.noise({w = 1280, h = 720, scale = 0.004, oct = 4, lac = 2, gain = 0.5, seed = 1, from = '#000', to = '#fff'})` | An fbm colour ramp, **normalised** so the darkest point is `from` and the brightest `to`. Computed on a coarse grid when the detail allows, then smoothly upscaled: about 6 ms at the defaults. |
| `k.tex.dots({cell = 8, radius = 0.45, angle = 15, color = '#000', w = 1280, h = 720})` | A uniform halftone screen: dot radius `radius × cell` on a grid rotated `angle`°. About 4 ms. For dots that vary with tone, use `k.halftone`. |

---

## 10. Finishing passes

Each one draws onto `ctx` in device pixels (it saves the context, sets an identity transform and restores) and sizes itself to `ctx.canvas`, so it works on layers too. Grain, flicker, dust and shake step on `k.ambient(t)`, so they freeze in the hold on their own.

| Signature | Notes |
|---|---|
| `k.grain(ctx, t, {amount = 0.06, fps = 24, seed = 1, blend = 'overlay', size = 256, contrast = 1})` | Blits a pre-tiled grain sheet at a whole-pixel offset that changes `fps` times a second. `amount` is its alpha; try `'soft-light'` for gentler or `'multiply'` for print. |
| `k.vignette(ctx, {strength = 0.35, radius = 0.72, color = '#000'})` | A cached elliptical gradient. Darkening ramps from `radius − 0.4` to `radius + 0.35`, as fractions of the half-diagonal (the corners are at 1). **It respects `ctx.globalAlpha`**: to fade it, set `globalAlpha`, not `strength` (each distinct strength bakes a new cached canvas). |
| `k.bloom(ctx, {strength = 0.5, radius = 18, threshold = 0, src, x = 0, y = 0, blend = 'lighter', fast = false})` | Downscales (1/8 for radius ≥ 16, 1/4 for ≥ 6, else 1/2), optionally thresholds, blurs and adds the glow back. `threshold` (0..1) keeps only what's brighter, rescaled: `max(0, v − th) / (1 − th)` per channel. `src` takes the glow from another canvas (selective bloom), placed at `(x, y)` at its own size. `strength` above 1 adds more passes. `fast` uses a cheaper final upscale (bilinear to half size, then a nearest 2× step): about 1 ms saved, very slightly softer. |
| `k.blur(src, radius, out?)` → canvas | Gaussian blur with standard deviation `radius` (the same as CSS `blur()`). It uses `ctx.filter` where supported, otherwise a calibrated downscale, shift and upscale fallback that matches it to within a few percent in σ. Pass `out` (a canvas of the same size) to reuse a buffer instead of allocating one. |
| `k.scanlines(ctx, {gap = 3, alpha = 0.18, color = '#000', offset = 0})` | cosine-profile lines every `gap` px; `offset` (whole px) rolls them |
| `k.chroma(ctx, {offset = 1.5})` | red shifted right, blue left, green in place. **The offset is rounded to whole pixels** (a fractional shift costs about 2 ms more per channel), so 1.5 becomes 2. |
| `k.flicker(t, {seed = 1, amount = 0.04, fps = 24})` → about `1 ± amount` | a brightness multiplier: per-frame jitter plus a slow drift, frozen in the hold |
| `k.dust(ctx, t, {count = 14, seed = 1, color = '#fff', alpha = 0.5})` | specks, curly hairs and blobs that change 24 times a second, plus hairline scratches that last about 5 frames |

### Measured cost (headless Chrome 151 on Apple M5 Max, CPU raster, 1280×720 target, mean of 80 runs)
| Pass | ms | | Pass | ms |
|---|---|---|---|---|
| `k.grain` | **0.64** | | `k.scanlines` | 0.34 |
| `k.vignette` | **0.18** | | `k.chroma` | 2.0 |
| `k.bloom` r18 | **2.4** (2.5 with a threshold) | | `k.dust` (14) | 0.02 |
| `k.bloom` r8 (¼ scale) | 2.9 | | `k.halftone` 640×360, cell 10 | 1.9 |
| `k.bloom` r18 `fast` | 1.6 | | full-frame `drawImage` | 0.2 |
| `k.blur` full frame r8 / r24 | 3.3 / 3.9 (filter), 2.7 / 2.6 (fallback) | | `getImageData` full frame | 0.7 |

Bake costs (once, in `setup`): paper 20 ms, grain tile 8 ms, noise 6 ms, dots 4 ms. The demo piece runs at a mean of 5.3 ms a frame (p95 10.3 ms) with every pass on.

---

## 11. Pixels and halftone

| Signature | Notes |
|---|---|
| `k.pixels(ctx, x, y, w, h, fn)` | `getImageData`, then `fn(data, w, h)`, then `putImageData`. Slow: keep regions small, or work on a small `k.layer(…, {willRead: true})` and scale it up. Reading the **main** canvas needs `readsPixels: true` on the piece. |
| `k.halftone(ctx, src, {cell = 8, angle = 45, color = '#000', x = 0, y = 0, w, h, sx, sy, sw, sh, gain = 1, invert = false, min = 0.25})` | *Extension.* Variable-size dots: it samples `src` (optionally its `sx, sy, sw, sh` rectangle), and each screen cell gets a dot whose **area** follows the ink (darkness × alpha × `gain`; `invert` makes light the ink), drawn at `(x, y)` over `w × h`. Dots under `min` px radius are skipped. Bake it in `setup` when the source is static. |

---

## 12. Recipes

**Halftone photo or shading.** Draw the tone (gradients, shapes, a blurred render) into a canvas, then dot it:
```js
// setup
const tone = k.canvas(800, 500);
const g = tone.ctx.createLinearGradient(0, 0, 0, 500); g.addColorStop(0, '#fff'); g.addColorStop(1, '#333');
tone.ctx.fillStyle = g; tone.ctx.fillRect(0, 0, 800, 500);
s.photo = k.canvas(800, 500); k.halftone(s.photo.ctx, tone, { cell: 6, angle: 45, color: '#111' });
// frame: ctx.drawImage(s.photo, 240, 110) (multiply it over paper for ink on paper)
```
For CMYK-style print, run it once per plate with different angles (15°, 75°, 0°, 45°) and colours, offset each plate by 1–2 px (misregistration), and draw the plates with `'multiply'`.

**Thresholded bloom** (neon, CRT, practical lights). Draw the scene, then `k.bloom(ctx, { threshold: 0.6, radius: 18, strength: 0.9 })`: only channels above 0.6 glow. On a bright background (paper), use **selective bloom** so the background doesn't glow:
```js
const glow = k.layer('glow');                       // cleared each frame
drawTubes(glow.ctx);                                 // only the emissive parts
ctx.drawImage(glow, 0, 0);
k.bloom(ctx, { src: glow, radius: 22, strength: 1.2 });
```
**Neon tube:** stroke the path three times, wide and dim (glow colour, low alpha), medium (saturated colour) and thin (near-white core, `#fff7f0`), then bloom. Tube flicker: `const f = k.flicker(t, { amount: 0.08, seed: i })`, or an outage with `k.hash(i, k.step(k.ambient(t), 12)) < 0.03`.

**Draw-on strokes.** Precompute `pts` in `setup` (wobble them there once). In `frame`, set `lineCap = 'round'` and call `k.drawOn(ctx, s.pts, k.span(t, t0, t1, 'inOutSine'))`. For a pen tip, use `k.along(s.pts, u)`. For a stroke that travels, `k.drawOn(ctx, pts, u, u - 0.2)`.

**Hand-drawn boil** (clay, chalk, cartoons): re-wobble a few times a second with a stepped seed. `k.wobble(pts, { amp: 1.2, seed: Math.floor(k.ambient(t) * 12) })` keeps the shape but changes the line 12 times a second, and freezes in the hold. It's cheap for a few hundred points; for heavy art, bake 3–4 boil variants in `setup` and pick one with `Math.floor(k.ambient(t) * 12) % 4`.

**Stop-motion stepping:** compute everything from `const ts = k.step(t, 12)` (on twos), and keep camera and finishing moves on `t` if you want them smooth. Add per-frame jitter with `k.hash(i, ts)`.

**Freezing ambience in the hold:** anything that moves without a story reason (drift, bob, twinkle, rotating beams, scrolling grids, the camera's breath) takes `k.ambient(t)`, not `t`. `k.grain`, `k.flicker`, `k.dust` and `k.shake` already do. Story motion must have *finished* by `holdFrom`. `check.mjs` compares three frames inside the hold.

**Phosphor persistence and motion trails** (no frame feedback allowed): draw the moving thing at a few earlier times with fading alpha, `for (let j = 4; j >= 0; j--) drawAt(t - j * 0.03, alpha * (1 - j / 5))`. For a CRT, render text into a `k.layer`, then draw that layer blurred (`k.blur(layer, 1.2, s.tmp)`) under the sharp copy, then add scanlines and bloom.

**Pixel art:** draw into `const lo = k.layer('lo', 320, 180)` (with `lo.ctx.imageSmoothingEnabled = false` for crisp sprites), then `ctx.imageSmoothingEnabled = false; ctx.drawImage(lo, 0, 0, 1280, 720)`. Snap positions with `Math.round`. Pixel fonts: draw text into the low-res buffer at the font's native size.

**2.5D and voxels:** project with `k.iso(x, y, z, { scale: 24 })`. Painter's sort (by `x + y + z`, ascending) once in `setup`, and draw the three visible faces of each cube with fixed shades (top light, left mid, right dark). Bake static tiers into canvases in `setup` and build them up by revealing with clip rects, not per-voxel work. For perspective, use `k.project(p, { cam, yaw, pitch })`, sort by the returned depth, and skip `null`s.

**Chalk and pencil texture:** draw the strokes or text into a `k.layer`, then knock out the tooth with `destination-out`. The knock-out source must have **varying alpha**: `k.tex.grain` and `k.tex.noise` are opaque, so they erase evenly and leave no texture. Bake an alpha-noise tile in `setup` instead: copy a grain tile, then turn its brightness into alpha with `k.pixels` (e.g. `a = clamp((v - 110) * 3)`, so bright specks become holes). Draw that at alpha 0.5–0.8, then composite the layer onto the board.

**Ink wash and bleeding edges:** paint shapes into a layer, `k.blur` it (bake it if static) and draw it with `'multiply'` at a low alpha under a sharper copy. Dry brush: `destination-out` with a stretched noise texture along the stroke.

**Misregistration** (woodblock, linocut, risograph): draw each colour plate into its own baked canvas, then composite with `'multiply'` at small fixed offsets, e.g. `(1.5, −1)`.

**Thermal and false colour:** compute a scalar field on a small `k.layer(…, 160, 90, { willRead: true })` (blurred shapes in greyscale), map it through a 256-entry palette with `k.pixels`, then draw it scaled up with smoothing on. Keep the buffer small: 160×90 costs well under 1 ms.

**Split-flap:** each tile's flip times come from `k.hash(row, col)`. The character shown is a pure function of `t`: find how many flips have completed by `t` and index the flap sequence. Draw the falling half with a vertical `scale(1, cos θ)` and a darker shade.

**Camera with purpose:** `const cam = { x: k.keys(t, …), y: …, zoom: k.keys(t, …) }`, then `k.camera(ctx, cam, () => drawWorld())`. Ease in and out of every move, and keep a slow breath on `k.ambient(t)`. **If you zoom into a raster layer, render that layer at the zoom resolution** (the demo draws its card at 1.5× and pushes in 1.5×); otherwise it goes soft.

---

## 13. Performance and gotchas

- **Budget:** 12 ms mean, 33 ms p95, measured by `check.mjs`. On this headless Chrome the canvas is CPU-rasterised: cost scales with pixels touched and blend complexity.
- **Cheap:** `drawImage` of a canvas at whole-pixel offsets (0.2 ms full frame), `fillRect`, paths with a few thousand segments, `k.hash`, `k.noise`, `k.mix`, `k.text`.
- **Costly:** pattern fills (about 3× a blit), scaled or fractional-offset full-frame draws (about 2 ms each), `ctx.filter` blurs on big canvases (3–4 ms), `shadowBlur` on large shapes (bake shadows in `setup` with `k.blur`), `getImageData` of large areas, and allocating canvases in `frame` (use `k.layer` or the `out` argument of `k.blur`).
- **Bake** textures, wobbled paths, halftones, shadows and static artwork in `setup`. Memory: each 1280×720 canvas is 3.7 MB; keep setup caches under about 150 MB.
- **Never** read wall-clock time or `Math.random`, keep state in module variables that `frame` changes, mutate `s`, or depend on the previous frame.
- **Isolation:** wrap everything in an IIFE. Top-level `const`, `let`, `class` or `function` breaks the gallery, which loads many pieces into one page.
- **Fonts:** list every weight and style you use. Symbols outside the font fall back silently; check close-ups with `frames.mjs`.
  - Google Fonts' Latin subsets usually lack **≈ ≤ ≥ →** and other math or arrow signs, so the canvas draws them in a system font. Draw such signs as paths, or pick a font that has them, and check close-ups.
  - Latin, Latin-ext, Greek and Cyrillic subsets are preloaded (`FONT_SAMPLE`). Other scripts need a font that ships them.
- **Partial reads of a reused layer leak.** When `drawImage` reads only part of a reused `k.layer(…, {clear: false})`, smoothing samples one pixel past the region's edge. Stale pixels from earlier frames bleed in and break determinism. Keep a cleared 1-px border around the region, or use `{clear: true}`.
- `k.layer` keeps one canvas per requested size for the life of the page. Reuse a few fixed sizes rather than asking for new ones every frame.
- **Full-frame `destination-out`** or pattern-masking passes cost about 4–5 ms. Bake combined masks into one canvas in `setup`.
- **Glow text:** `shadowBlur` on text costs about 3 ms a frame. Bake glowing labels into sprites in `setup` (draw with the shadow once, then blit).
- **Scaled `drawImage` smoothing:** `imageSmoothingQuality = 'high'` can differ by 1 px, one level, depending on what was drawn before, which fails the determinism check. `'medium'` on a large scaled draw roughly doubles its cost. Use the default (`'low'`) for scaled draws in `frame`.
- **Full-frame `soft-light` or `overlay` fills** cost several ms on this CPU rasteriser. Prefer `multiply` or `screen`, or bake the result.
- `k.measure` isn't cached. For text measured every frame, memoise the widths with `k.memo`.
- `k.span` and `k.stagger` return exactly 0 before their start and exactly 1 after their end, for every ease. (Before 1.0.1, `outBack` gave 2.2e-16 at 0.)
- `k.rr`, `k.path` and `k.smoothPath` **add** to the current path (call `ctx.beginPath()`); `k.drawOn` begins its own.
- `k.vignette`: animate it with `globalAlpha`, not `strength`. `k.chroma`: whole-pixel offsets.
- `k.blur` without `out` allocates a new canvas each call: fine in `setup`, wasteful in `frame`.
- The player resets transform, alpha, composite, filter, shadows, smoothing, letter-spacing, line and text state, and the path before every frame, and wraps `frame` in `save()/restore()`, so even an unbalanced `save()` can't leak a clip into the next frame (`check.mjs` still warns about it).
- `readsPixels: true` makes the main canvas CPU-backed (`willReadFrequently`). Only set it if `frame` reads the main canvas.

---

## 14. The player (`Reel.player`)

| | |
|---|---|
| `Reel.player.mount(el, idOrDef, opts)` → controller | `opts`: `autoplay = false`, `loop = true`, `controls = true`, `sound = false`, `startAt = 0`, `onTime(t)`, `fit = 'width'` (`'contain'` letterboxes inside a sized `el`), `soundOnPlay = false` (the big play button's first play turns sound on, and with it the narration), `narration = true` (`false` skips the narration lookup). The controller has `play()`, `pause()`, `toggle()`, `seek(t)`, `setSound(on)`, `setLoop(on)`, `setNarration(on)`, `setCaptions(on)`, `destroy()`, `canvas`, `duration`, `time`, `playing`, `started`, `sound`, `narration`, `captions`, `voice`, `def`, `el`, `ready` and `voiceReady` (Promises). `play({ gesture: true })` is a play the visitor asked for: with `soundOnPlay`, the first one turns sound on. |
| `Reel.player.preview(el, idOrDef, {poster, force = false, fps = 30})` | For gallery cards. It shows `poster` (default `posters/<id>-sm.jpg`, or an `<img>` already inside `el`), and on hover or keyboard focus lazy-loads the piece and plays it muted and looping at 30 fps on one shared canvas. Only one preview plays at a time; it pauses when off-screen or when the tab is hidden. It respects `prefers-reduced-motion` unless `force`. Returns `{play, pause, destroy, playing, el}`. |
| `Reel.player.still(idOrDef, t = poster, canvas?)` → `Promise<canvas>` | renders one 1280×720 frame |
| `Reel.player.auto` | Set to `false` to stop auto-mount. **Auto-mount** runs once the DOM is ready: on a page with `[data-reel-mount]`, or with no `#app`, the last registered piece mounts there (full-window if there's no mount element). Pages marked `data-reel-headless`, and pieces fetched through `Reel.load`, never auto-mount. |

**Controls:** Space or K plays and pauses. ←/→ moves 1 s; Shift+←/→ moves one frame (at 30 fps). Home/End go to the start and end. M toggles sound, N narration, C captions, L loop, F fullscreen. The scrub bar is frame-accurate (it snaps to the 30 fps grid) and redraws live while dragged; hover shows the time and beat. While sound is on, the clock is `Reel.sound.time()`, so picture and sound can't drift; otherwise it's `performance.now()`.

### Narration

A narrated reel has a recorded voiceover: `voice/<id>.mp3` and `voice/<id>.json` (`{ voice, engine, lines: [{ at, end, text }] }`), listed in `voice/index.json` (`{ "<id>": { voice, file, meta } }`). `tools/voice.mjs` writes all three. How it sits in the mix is in `kit/SOUND.md` (Narration).

| | |
|---|---|
| `Reel.voiceBase` | `voice/` resolved next to the kit, like `Reel.base`. An inlined kit (a downloaded page, the Make page's sandbox) has none, and so no narration; only same-origin http(s) is fetched. A page may set it. |
| `Reel.player.voiceIndex()` → `Promise<object>` | `voice/index.json`, fetched once per page. A 404 gives `{}`. |
| `Reel.player.narration(id)` → `Promise<{ id, voice, engine, model, duration, lines, url } \| null>` | The piece's lines (sorted) and its MP3's URL. |

In a mounted player (with controls) on a narrated reel:
- The bar gains a **Narration** toggle (a speech bubble) and a **CC** toggle. Narration defaults to on and captions to off; both are remembered on the device (`localStorage` `reel-narration`, `reel-captions`).
- Narration plays only while sound is on. The MP3 loads the first time sound and narration are both on (or as the pointer reaches the big play button, when `soundOnPlay` is set), with a spinner on the toggle while it decodes. Then the audio restarts at the current `t` with the voice in, the same path a seek takes; toggling narration while playing does the same.
- **Captions** show the current line's text over the bottom of the picture (white on 70% black, IBM Plex Sans, about 2.2% of the player's height, at most two lines), timed from `lines[].at/end` and the player's `t`, so they work with sound off too.
- Gallery previews (`Reel.player.preview`) are always silent.

**Headless hook** (`window.__reel`, used by the tools): `load(id)` → `Promise<info>`, `ready`, `seek(t)` → `Promise<{t, ms}>`, `draw(t)` → ms (synchronous, including a raster flush), `duration`, `canvas`, `ctx`, `def`, `compileSound()` → summary and the cue list, `renderSound({ narration = true })` → `Promise<base64 WAV | null>` (a narrated reel's voice mixed in, unless `narration: false`; `lastSound` then says what went in), `narration()` → the piece's narration or null, `audit` (purity counters; on with `?audit=1`).

---

## 15. Tools

Run from the repo root (Node 26). Each tool serves the repo on a free port and drives headless Chrome; Google Fonts are cached on disk between runs.

```bash
node tools/sheet.mjs <id> [--n 12] [--out file.png] [--cols 4] [--tile 480]  # contact sheet → out/sheets/<id>.png
node tools/frames.mjs <id> --at 0,2.5,poster,hold,end [--out dir] [--scale 1] # PNGs → out/frames/<id>/
node tools/check.mjs <id>          # or: all
node tools/posters.mjs <id>        # → posters/<id>.jpg and posters/<id>-sm.jpg at piece.poster
node tools/video.mjs <id> out.mp4 [--fps 30] [--size 1920x1080] [--no-audio] [--no-narration] [--from s] [--to s] [--jobs 3]
node tools/catalog.mjs             # → pieces/index.json (skips _* files)
```

- `--at` accepts seconds and the keywords `start`, `poster`, `hold` (the first held frame), `end`, `beats` (each beat + 0.4 s) and ranges `a-b@step`.
- Typical times: `sheet` 2 s, `check` 1–3 s per piece, a 24 s 1080p video about 15 s.
- To watch a piece: `python3 -m http.server 8770 --bind 127.0.0.1`, then open `http://127.0.0.1:8770/dev/view.html?id=<id>` (`&t=12.5`, `&autoplay`, `&sound`, `&full`, `&nofilter`, `&nospacing`).
