# Launch film (45–70 s)

The longer format: a product launch film in the spirit of an Apple keynote video. The demo is the
main part; big, short statements punctuate it; optional footage opens and closes it; optional music
and sound are added last. Start from `assets/film-template.html` + `assets/film-kit.js` (copy both,
plus `gsap.min.js` and `MotionPathPlugin.min.js` from `scripts/node_modules/gsap/dist/`) and fill
its `CONFIG`; it renders with the same `scripts/render.cjs` (use `--fps 60`).

## Structure that worked

| Part | Length | What happens |
|---|---|---|
| Cold open (optional footage) | 3–7 s | one footage cut per beat, each with a one-word label in viewfinder brackets: what the product does, one word each ("Plans", "Writes", "Ships") |
| Title | 3 s | cut to black; the brackets close on the logo mark; the mark rises; the promise in two lines |
| Product hero | 6–10 s | the first screen opens out of the mark (circle reveal); the product, big and centred, used for real (cursor clicks, state changes) |
| Statement → demo, repeated | 1.5 s + 5–8 s each | a full-colour text slide ("It does it for them.") lifts off to reveal the product doing exactly that |
| A rhythmic beat | 3 s | three phrases rolling in place, one per beat, the last completing a sentence ("Every order. / Every refund. / Every review / in one place.") |
| Setup / features | 3.5 s each | big title on top, one live element below (a form typing itself, a list filling in, files dropping in) |
| Closing | 6 s | the last screen contracts into the logo mark, footage (or colour) opens out of it, tagline, the CTA gets clicked |

Rules that came from review rounds:

- **The demo is the point.** Footage and statements are punctuation; keep them to ~20% of runtime.
- **The product centre stage, big.** Show the core interaction at the size a viewer can read: an
  enlarged widget or panel in the middle beats a small UI in a corner. Enlarge with a *static*
  transform or by building it at the target size, never with a slow zoom.
- **Text is for reading.** Titles in the brand's display face, at least ~110 px, two lines at most.
  No grey or low-contrast text on film: ink on paper, white on black or on the accent.
- **Every text slide animates in and out.** A slide that pops away reads as a glitch. Enter with a
  whip, push or curtain; exit by sinking the words and lifting the slide like a curtain off the
  next scene. The only hard cuts are footage cuts on the beat.
- **Captions people can read.** A chat or transcript streaming word by word needs 0.16–0.22 s per
  word without a voiceover, and 1–2 s of hold after the last word before the next transition.
  (With a voiceover the voice sets the pace: see `references/sound.md`.)
- **Opening and closing mirror each other.** If the film opens with labelled footage cuts and a
  mark, close with the mark: contract the last screen into it, open the end card out of it.
- **Real claims, no product name until decided.** Same rules as the short ad; mask anything not
  public yet (an embed snippet's domain becomes `".../embed.js"`).

## Beat grid

Pick 120 BPM unless there is a reason not to: a beat is 0.5 s, a bar 2 s. Every scene length is a
multiple of 0.5 s, every cut and every click lands on a grid line, phrase rolls change on beats,
quick flips (names cycling) use triplet sixteenths (1/12 s) or thirty-seconds (1/16 s). Keep the
scene start times in one table (`T`) derived from scene lengths; the music is composed to the same
table afterwards.

## The page

- One paused GSAP timeline; `window.seek(t)` is **async**: it sets the timeline time, runs every
  per-frame function (footage frame, typing, canvas drawing, exclusive states) and awaits image
  decodes, so `render.cjs` captures complete frames. `film-kit.js` wires this up (`FilmKit.mount`).
- Scenes are full-screen layers with their own background, toggled with `visibility`. Order them
  with z-index: content scenes 1, text slides 3, footage 10, labels 11, end card 12, cursor 13.
- **Real product components** beat screenshots for anything that moves: render the app's own
  markup inside a shadow root with its compiled CSS (replace `:root` with `:root,:host`, drop
  `@font-face` rules that point at the app's media folder, put `@property` rules in the main
  document, load the fonts in the page). Drive their state from the timeline.
- **Canvas visuals that react over time** (an avatar, a waveform) must be deterministic: seed any
  randomness, and simulate at fixed 1/60 s steps from a start time up to the requested frame
  (re-simulate from the start when time goes backwards).

## Transitions (all in `film-kit.js`)

| Helper | Use |
|---|---|
| `rise(sel, t)` / `sink(sel, t)` | lines enter from below their mask; leave upwards (`yPercent -135`) |
| `curtainIn(sel, t)` / `curtainOut(sel, t)` | a slide's colour rises over the scene; lifts off to reveal the next one |
| `whip(from, to, t)` | both scenes slide sideways together, blurred at full speed (0.44 s) |
| `push(from, to, t)` | the next scene shoves the last one up (0.5 s) |
| `openFrom(sel, x, y, t)` / `closeTo(sel, x, y, t, d, r)` | circle reveal out of a point (the logo); contract into it |
| `roll(out, in, t)` | phrases replacing each other in one place: same duration and ease for both, outgoing fades in 0.07 s so descenders never linger |
| `typeLetters(el, t0, per, caret)` | centred text typed letter by letter into pre-laid-out spans (no reflow), caret follows |
| `typeInto(el, text, t0, per)` | a field filling in, left-aligned |
| `exclusive({id: el}, initial)` | one-of-many states (Draft / Saving / Saved) computed per frame |
| `cursor.move/click`, `press(el, t)` | arrow cursor, tip on the target; every click and press is logged in `window.CUES.click` for the sound mix |
| `viewfinder([[id, t], …])` | labels in brackets over footage cuts |

## Bugs that cost a review round each

| Symptom | Cause | Fix |
|---|---|---|
| Text "pixels move up and down" during a zoom | a slow `scale` tween re-rasterizes text every frame and glyphs snap between pixels | no slow scales on anything with text; enlarge statically; keep moves fast (≤0.6 s) or translate-only |
| A hidden scene's content shows over another scene | a descendant with `visibility: visible` overrides a hidden parent | toggle children with `visibility: inherit`, never `visible` |
| Faint grey bands across scenes | a hidden headline waiting below its mask still casts its `text-shadow` | keep layers that hold hidden text `visibility: hidden` until they are used |
| Two status labels on top of each other | competing tweens on the same element: a fade-in outlives the next fade-out | compute exclusive states per frame from a list of changes (`exclusive`) |
| A label is the wrong colour | a class name (`.a`, `.g`) collides with a rule from an embedded mock page | prefix the film's own class names |
| A slide flashes something behind it | a slide revealed by the previous one also ran its own curtain-in | only the first slide of a run enters; later ones are revealed by the lift |
| The closing footage or end card never appears | a slide with a higher z-index is still visible | footage and end card above every scene; hide the last slide when the closing starts |
| `</script>` inside a JS string ends the page script | the HTML parser | write `<\/script>` |
| A phrase swap shows two phrases overlapping | outgoing and incoming tweens with different durations or eases | identical duration and ease for both (`roll`) |
| Words appear "out of nowhere" | a cut to a new screen with the text already placed | text always rises, rolls or types in, and the background animates in first |

## Checks before showing anyone

- `python3 scripts/qa-video.py film.mp4`: the hard cuts must be exactly the planned footage cuts.
- `--hold a b x y w h` over a title or a UI region that should be still: change ≤ ~4 means stable.
- Step through every transition with stills 1–2 frames apart (`render.cjs --stills t1,t2,…`), not
  just mid-scene frames: most bugs live in the 0.3 s around a change.
