# product-launch-motion

Adopt this skill: https://proskills.md/skills/other/abubakrchan-product-launch-motion

Page: https://proskills.md/skills/other/abubakrchan-product-launch-motion
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: legacy/SKILL.md
size: 17779
sha: 5979971a45011c66a7f9f47f90204669e636a358
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/SKILL.md

````text
---
name: product-launch-motion-v1
description: "Legacy v1 (voiceover-led, GSAP + HyperFrames). Use when making, directing or rescuing a product launch video, promo, teaser, demo reel, sizzle, feature-announcement film or animated product explainer built from code — and when an existing one looks basic, reads as a slideshow, has reveals that miss the voiceover, an invisible cursor, an inaudible sound effect, a clipped or too-quiet mix, or every video coming out looking the same. Covers SaaS, apps, hardware, e-commerce, dev tools, services. Keywords include motion design, storyboard, voiceover sync, kinetic typography, GSAP, HyperFrames, Remotion, film grain, LUFS, ffmpeg."
---

# Product Launch Motion — v1 (legacy)

> This is the original v1 skill, kept for voiceover-led films. The current skill is
> [`../SKILL.md`](../SKILL.md): music-first, one shape never cut, springs, no GSAP or HyperFrames.

A production discipline for making launch films that look like a motion designer made
them — not a slideshow with a voiceover on top.

The output is a rendered MP4. The medium is HTML, CSS and GSAP, rendered frame-by-frame
by a deterministic seek-based renderer (HyperFrames by default). Everything here is
transferable to any renderer that can seek a timeline; the laws are renderer-neutral,
the plumbing examples are HyperFrames.

**This skill exists because green tooling is not the same as good film.** A composition
can lint clean, render without error, and still be a slideshow. The value below is the
craft that separates the two, plus the traps that cost real hours to find — each one
recorded with the measurement that proves it, not the vibe that suggested it.

**It is a discipline, not a template.** The laws below are fixed; the *look* is not, and
deciding it is your job as director. Two films built from this skill should be
unmistakably different from each other — different palette, ground, camera personality,
type, texture, sound and structure — because they are arguing different claims for
different audiences. Before you build anything, write three genuinely different visual
directions and kill two: `references/11-creative-direction.md`. Reaching for the reference
film's look because it is documented here is the one failure mode that makes everything
else worthless.

## When to use this

Use it for: a product launch or feature-announcement film (20–90s), a landing-page hero
video, a demo reel, a conference sizzle, an investor-update film, or a rescue pass on a
video that "looks basic".

Do not use it for: talking-head editing, screen-recording with captions slapped on, or
anything where the deliverable is a live web page rather than a video file.

## The ten laws

Break one and the film reads amateur, however good the individual frames are. Note what
they do **not** cover: none of them tells you what the film should look like.

**1 · Truth first.** Every number, logo, face, screenshot and testimonial on screen is
real and sourced. Before animating anything, write down the approved figures — the exact
list of numerals allowed to appear — and refuse every other number. Invented metrics and
stock faces are the fastest way to make a real product look fake. → `references/02-story-and-truth.md`

**2 · Word-locked sync.** Every reveal is cued to the measured start time of the word it
illustrates. Not to a beat grid, not to an estimate — to a word-level transcript of the
actual voiceover file. A frame at t=0 shows only what the voice is saying at t=0.
→ `references/03-word-locked-sync.md`

**3 · Determinism.** No `Math.random`, no `Date.now`, no CSS transitions, no
`repeat`/`yoyo`. Any scattered or noisy value is derived from an index or the playhead.
A seek to time T must reproduce byte-identical output, or your render is a lottery.
→ `references/01-renderer-contract.md`

**4 · One camera, two nodes.** A whole-shot rotation and a mid-shot scale must live on
*different* elements. On the same element they fight over one transform matrix and the
move judders. Outer node = dolly (scale), inner node = 3D turn (rotation + perspective).
→ `references/05-camera-3d-cursor.md`

**5 · The cursor is a stage prop.** A life-size pointer (~32px at 1920) disappears into
any saturated control. Draw it at ~44×54 with a heavy stroke and a deep shadow, put it
*inside* the node the camera moves, and put the zoom origin exactly on the control it
clicks — that point becomes a fixed point of the transform and the aim survives the push
with no compensating maths. → `references/05-camera-3d-cursor.md`

**6 · Sound is arithmetic, not taste.** A cue's volume cannot rescue a quiet source file.
If the asset sits 20 dB under the narration bed, raising its volume moves the mix by
tenths of a dB — measured, not guessed. Level the *asset*, then verify the cue landed in
the delivered file. → `references/07-sound-and-master.md`

**7 · Nothing ends moving.** Every shot resolves. Motion is cued to meaning; a shot still
drifting when it cuts reads as a screensaver, and a shot where everything drifts
independently reads as one too. *Scope:* this governs **resolution, not stillness**. A
single-take film whose camera never stops satisfies it by decelerating to rest before the
end; a shot that holds a deliberate slow move while its content is settled satisfies it
too. What it forbids is a shot cutting while the thing it was saying is still arriving.
→ `references/04-motion-grammar.md`

**8 · Ration the accent.** Two grounds (one for the argument, one for the product), one
accent colour or gradient, and a written budget for where it may appear — typically:
one emphasis word per headline, stat numerals, the active step, state flips, the CTA.
A third hue is almost always a mistake. → `references/06-look-and-grade.md`

**9 · Green gates are necessary, not sufficient.** Lint, layout and contrast passes catch
none of the things that make a film bad. After every render, extract frames from the
**delivered file** at the beats you changed, look at them, and write down what is wrong
before anyone else does. → `references/08-qa-and-direction.md`

**10 · No house style.** The look is derived from *this* product's claim, brand, audience
and category — never inherited from the last film or from the examples in this repo. Write
three different directions, pick one, and give the film a signature move that no other film
has. A film that could be a competitor's film with the logo swapped has no direction.
→ `references/11-creative-direction.md`

## Red flags

These are the thoughts that precede every skipped step. Each one is a signal to stop and
do the thing you are about to talk yourself out of.

| The thought | What it actually means |
|---|---|
| "I'll estimate the timings and fix them later" | You will not. Every cue will be wrong, and re-deriving them all costs more than doing it once now |
| "Three directions is overkill for this one" | You have one idea and no basis for believing it is good. Two of the three are meant to die |
| "The brand is purple, so this palette is fine" | Check whether you derived it or inherited it from the examples here |
| "It sounds fine to me" | You are listening on the machine that rendered it, at a volume you chose, knowing what to listen for. Measure it |
| "The change is in the source, so it's in the film" | Two documented bugs in this repo's history were exactly that. Sample the delivered file |
| "The gates passed" | Gates catch broken, not bad. They have never once caught a boring film |
| "It's a small tweak, no need to re-verify" | Small tweaks are how a −14 LUFS master became a clipped one |
| "I'll just overwrite the last render" | Then you cannot prove the fix worked, and the version the director preferred is gone |
| "They said make it 3D, so I'll add rotation" | Take the note as a symptom, not a spec. "Make it 3D" meant a camera rig, a cursor rebuild and a coordinate refactor |
| "I'll add the camera move at the end" | A camera changes what every coordinate means. Retrofitting a rig means redoing every position in the shot |
| "It's close enough to on-the-word" | 200ms off reads as lag to a viewer who cannot name why |

Violating the letter of a law is violating the law. "Mostly deterministic" is
non-deterministic; "roughly on the word" is off the word.

## The pipeline

Fourteen steps. Do not skip 1, 2, 5 or 13 — they are the ones people skip, and they are
the ones that decide whether the film is any good.

Steps 1–3 are a **loop, not a sequence**: the direction depends on which real assets exist,
and the truth pass often kills a direction outright (you cannot shoot a raking-light
product film for a product that has not been manufactured). Cycle them until they agree,
then go forward.

| # | Step | Output | Reference |
|---|---|---|---|
| 1 | **Intake** — the product, the audience, the ONE claim, the angle (usually the product's own headline) | `BRIEF.md` | `assets/BRIEF.md` |
| 2 | **Direction** — three genuinely different visual directions, judged and cut to one, plus a signature move | direction block | `references/11-creative-direction.md` |
| 3 | **Truth pass** — collect real assets, real screens, real customers, and fix the approved-figures list | `assets/`, figures list | `references/02-story-and-truth.md` |
| 4 | **Script** — 40–60s written for the ear; one clause per beat; the claim lands in the first 10s | `SCRIPT.md` | `references/02-story-and-truth.md` |
| 5 | **Voiceover** — render or record the VO *before* building anything; frame durations come from real VO length | `assets/voice/*.wav` | `references/03-word-locked-sync.md` |
| 6 | **Word timings** — transcribe with word-level timestamps into a cue table | `audio_meta.json` | `scripts/word-timings.mjs` |
| 7 | **Storyboard** — frames, durations, the register map from your direction, and a shot per beat | `STORYBOARD.md` | `references/09-shot-catalog.md` |
| 8 | **Build frames** — one HTML file per frame; camera rig and cursor from the skeleton; every tween cued to a word | `compositions/frames/*.html` | `references/04-motion-grammar.md` |
| 9 | **Assemble** — index, transitions, audio, grade — in that order, every time | `index.html` | `references/01-renderer-contract.md` |
| 10 | **Gates** — lint/runtime/layout/motion with the grade on; contrast with the grade off | 0 errors | `references/08-qa-and-direction.md` |
| 11 | **Render + master** — two-pass loudnorm, limiter, re-encode | `renders/*.mp4` | `scripts/master.sh` |
| 12 | **Verify in the delivered file** — frames at the changed beats, cue levels measured | evidence | `scripts/verify-cue.sh` |
| 13 | **Direct** — watch it, list the defects yourself, fix, re-render as a NEW file | v2, v3, … | `references/08-qa-and-direction.md` |
| 14 | **Ship the set** — vertical cut, captions, poster, loop, stills, licences | `deliverables/` | `references/12-deliverables.md` |

### Build order (step 9) is not negotiable

The index is generated, so anything hand-added to it is destroyed on re-assemble and must
be re-injected. Every wiring script here is idempotent (it replaces its own marked block),
so the safe move is always to re-run the whole chain:

```bash
node scripts/assemble.mjs        # film.json + measured VO → index.html
node scripts/transitions.mjs     # the crossings between frames
node scripts/wire-audio.mjs      # music bed + word-locked SFX cues
node scripts/wire-grade.mjs      # grain + vignette + specular sweeps
```

`assemble.mjs` reads `film.json` (`assets/film.example.json` is a filled-in one) and
**measures** every frame from its voiceover with `ffprobe`, so you never type a duration.
It applies the arithmetic each downstream cue depends on: `hold = VO + pad` — the breath
after the last word, because a shot that cuts on the last syllable reads as rushed — and
`duration = hold + the outgoing transition`. The next frame starts at `hold`, **not** at
`duration`; the difference is the overlap the transition lives in.

## Reference map

Load only what the current step needs.

| File | What's in it |
|---|---|
| `references/11-creative-direction.md` | **Read this first.** What is fixed and what is free, the direction dials, deriving a look from the product, diverging to three directions, the signature move, five worked directions, and the anti-sameness checks |
| `references/01-renderer-contract.md` | How HTML becomes video: timed tracks, the seekable timeline, determinism rules, build order, and what to do on Remotion instead |
| `references/02-story-and-truth.md` | Angle, arc, the register map, the approved-figures discipline, honesty rules, and how to source real assets for *any* product |
| `references/03-word-locked-sync.md` | Getting word timings, writing the cue table, pacing, holds, and why VO comes first |
| `references/04-motion-grammar.md` | Easing, entrances, staggers, anticipation, speed ramps, kinetic type, and the two failure modes (slideshow / screensaver) |
| `references/05-camera-3d-cursor.md` | The two-node camera rig, turntable, dolly, zoom-to-detail, rack focus, z-parallax, and the full fake-cursor spec |
| `references/06-look-and-grade.md` | Palette rationing, glass borders without `backdrop-filter`, film grain, vignette, specular sweeps, and the blend-mode trap |
| `references/07-sound-and-master.md` | SFX design, sub-bass, silence before the hero beat, the volume-arithmetic law, asset levelling, and the mastering chain |
| `references/08-qa-and-direction.md` | The gates, snapshot review, verifying the delivered file, and the director loop that turns a v1 into a v7 |
| `references/09-shot-catalog.md` | 14 shot *constructions* to adapt — prompt/composer, results list, workspace, pipeline tracker, stat count-up, testimonial, comparison, hardware hero, terminal, map, chart, end card. Never a running order to copy |
| `references/10-traps.md` | Every trap we hit, each with the measurement that proves it. Read this before debugging anything |
| `references/12-deliverables.md` | The set that actually ships: vertical re-layouts, platform safe areas, captions from your existing word timings, poster frame, loops, naming and handoff |

## Scripts

All are dependency-light Node/bash, all idempotent, all safe to re-run.

| Script | Does |
|---|---|
| `scripts/assemble.mjs` | Writes `index.html` from `film.json`, measuring every frame's length from its voiceover instead of letting you guess it |
| `scripts/transitions.mjs` | Injects the between-frame crossings — dissolve, zoom-through, push, cut — at the instant each frame's narration ends |
| `scripts/wire-audio.mjs` | Mounts a music bed + a cue table of SFX into the assembled index, resolving frame-relative cue times against real frame starts |
| `scripts/wire-grade.mjs` | Injects the film grade: playhead-seeded grain, vignette, and specular sweeps at times you pass (`--sweeps 7.9:0.9,…`; no sweeps unless you ask). Each treatment is independently switchable; `--off` removes the whole block for the contrast gate |
| `scripts/level-sfx.mjs` | Fixes the quiet-source problem: cuts from the first transient, applies a fixed `--gain` through a limiter, and prints before/after measurements. It reports; it deliberately does not declare a cue "audible" |
| `scripts/word-timings.mjs` | Turns a word-level transcript into the cue table the frames read |
| `scripts/master.sh` | Two-pass loudnorm → true-peak limiter → `-crf 19 -tune film` re-encode → verification readout |
| `scripts/verify-cue.sh` | Measures a time window in the *delivered* MP4, so you can prove a cue is audible instead of assuming it |

## Templates

| Template | Use |
|---|---|
| `assets/BRIEF.md` | Intake: product, audience, claim, angle, constraints, approved figures |
| `assets/STORYBOARD.md` | Frame-by-frame plan with durations, register, shot type and VO line |
| `assets/film.example.json` | The assemble manifest: frames, voiceover, pads and transitions — the reference film's real one |
| `assets/cues.example.json` | The SFX cue table format, annotated |
| `assets/frame-skeleton.html` | A working frame: camera rig, glass borders, cursor with the full click stack, and a word-cued timeline. Copy this, don't retype it |

## Definition of done

A launch film is finished when all of these are true. Anything less, say so plainly
rather than shipping quietly.

- [ ] Three directions were written and two were killed; the chosen one is recorded
- [ ] The film has a signature move you can name in one sentence
- [ ] It does not look like the examples in this repo, or like the last film you made
- [ ] Every on-screen number is on the approved-figures list
- [ ] Every reveal sits on its word (spot-check five at random against the transcript)
- [ ] Gates: 0 errors with the grade on; contrast passes with the grade off
- [ ] No frame is still moving when it cuts
- [ ] The delivered MP4 has been sampled at every changed beat and looked at
- [ ] Loudness measured on the delivered file: −14 LUFS ±0.5, true peak ≤ −1 dBTP
- [ ] Every SFX cue you added is measurably present in the delivered file
- [ ] Renders are versioned (`video-v3.mp4`), never overwritten
- [ ] The deliverable set exists, not just the master — vertical cut, captions, poster
- [ ] Third-party licences (music, SFX, fonts, photography) recorded with the handoff
- [ ] You have written down what you would fix next

## Worked example

`CASE-STUDY.md` walks the real film this skill was extracted from: a 44.2s
B2B SaaS launch film, 11 frames, seven rounds of notes from "this is very basic" to
delivered — with the specific note, the specific fix, and the measurement for each pass.
Read it when you want to see the loop in step 12 actually running.
````

## File: SKILL.md
size: 9088
sha: 2e0f5fb42c1929f2e206cdd98baa63bce4828081
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/SKILL.md

```text
---
name: product-launch-motion
description: Use when asked to make a launch video, product film, motion design piece, UI motion reel, Dribbble-style UI animation or animated brand/logo sting — anything rendered to MP4 from HTML with a cursor, morphing UI, springs, beat-synced music and SFX. Carries Chan's full creative direction (one shape never cut, blur content swaps, springs, real data, logo slams, no creative limits) and the proven build/render pipeline (pure seek(t), closed-form springs, Playwright subframes, ffmpeg tmix, numpy beat grid).
---

# Motion design launch videos

This is how Chan wants launch films made. It is the distilled direction from every prompt and review
that produced "The Spark" (v1) and the real-data cut (v2) for Cheerful.
The prompts themselves, verbatim and in order, are in `references/prompts.md` — read them before
starting; they are the spec. The code patterns are in `references/technique.md`.
Prior films demonstrate the technique; compose this product's own story from its customer's
problem, current brand assets and actual behavior. Preserve the one-shape continuity throughout.

## The stance: you are the director

- **No constraints on creativity.** Chan says "I want you to be the director", "full creative control",
  "think out of the box", "no limits on shapes or buttons". Do not ask permission for creative choices;
  make them, log them as DECISIONs, and list them at the end so he can overturn any in one reply.
- **Original, never a template, never a copy.** Do not lift the product website's copy, UI, stats or
  images wholesale. Invent the scenes. "Anything that looks like a template" is banned.
- **Launch-film energy.** Modern, futuristic, premium — the way top motion designers do it (Linear,
  Apple, Arc). Something happens on every beat; there is no dead time.
- **Attention to detail is the product.** Micro-interactions (button press, knob stretch, name pill
  lag, click ripple, odometer settle, sticker tilt) are what make it read as expensive.
- **Make it easy to follow.** One clear takeaway per scene, short prompts and answers, and enough
  settled time to read the result. Keep the beat alive through secondary motion and sound while
  the main idea holds; simplify the content or add beats rather than rushing the viewer.

## Non-negotiable motion rules

1. **One shape, never cut.** Every state is the same element morphing its size, radius and colour
   while its content swaps with a short blur. Cuts are not encouraged; transitions are morphs,
   camera moves, zooms into an element that becomes the next scene (a tile opens into an inbox,
   a reply card becomes the order, the cursor becomes the logo star).
2. **Content swaps with a short blur**, and text inside a morphing container gets its own enter and
   exit timing — exit short and early — or old and new text overlap.
3. **Springs everywhere, a tiny overshoot at most.** No bouncy easing. Closed-form damped spring step
   responses only (see technique).
4. **Leading/trailing edges on different springs** so things stretch: tab indicators, toggle knobs,
   liquid pills — the leading edge runs ahead of the trailing one.
5. **A cursor drives every change with real clicks and drags.** Drags are direct manipulation: while
   held, the value is computed from the cursor position; on release it springs back from wherever it
   was. The product's agent can be its own cursor (a gradient multiplayer cursor with a name pill, a
   short motion trail, lean with velocity, press scale + ripple on clicks) handing off from the user's
   cursor ("You").
6. **The camera zooms so each state fills the frame.** Empty frames are a bug; frame the content, then
   push, whip or pull back. Pull-backs reveal scale (one email → a grid of hundreds; one post → a wall).
7. **Music-first timing.** Choose the soundtrack before choreographing: energetic, brand-appropriate,
   with a build, a clear drop and a satisfying ending. BPM alone is not energy; do not default to a
   repetitive synthesized loop. Use a licensed track or an original score with that musical arc.
   Aim for ~120 BPM, analyse the song with numpy for the beat grid, start on a
   downbeat, cut the song window so the drop lands on the product's big moment. Something happens on
   every beat; typing rides 32nds on a snare build; slams accelerate beats → 8ths → 16ths.
8. **Every UI sound is synthesized and placed by its measured peak** on the kick-peak grid.
   Treat sound design as seriously as the visuals: original, layered sound effects that make the
   film feel rich and alive (the Hoopoe Club film is one example). Be creative with it.
9. **Loops:** when the piece loops, the last frame is the first frame — cursor position and speed
   included — or the loop stutters.

## Look

- **Palette:** the brand's own design system (for Cheerful: warm near-white canvas, brand gradient
  #B04ADC → #E35C91 → #FF7247 → #FFB21E, lilac/peach light pools, ink #111827). Not "black plus one
  purple". One clean UI font.
- **Banned (from the original brief, still binding unless Chan overrides):** bouncy easing, particle
  bursts, glows, gradients on UI chrome, mismatched icon strokes, dead time, template looks.

## Content

- **Use real data.** Chan: "You have access to the database. You can be creative and not just pick
  images." Pull real, defensible numbers read-only (creators reached, gifts sent, posts/views
  tracked) and show them as rolling odometers. Invent the micro-story (creator, brand, messages) but
  keep the big numbers true, and say which is which in the report.
- **Customer logos one by one, boom boom boom** — never a website-style "trusted by" row. One shape
  re-sized/re-coloured on every hit, only brands that really ran campaigns.
- **Licensed media only.** Stock video/stills from free-licence sources, checked per item (Mixkit has
  Restricted items mixed in). Never the product's own marketing images as a shortcut.
- **Keep what he liked:** for Cheerful the headline intro, the typed brief, the discovery search wall
  and the wordmark ending tested well — keep strong beats between versions and iterate the rest.

## Process

1. **Ask for inputs first** (unless already given or he says "you decide"): 8–12 states the shape
   should become, palette (B/W, one accent, or the brand system), and a royalty-free ~120 BPM song.
   Offer "Director's cut / Director picks" options — he usually picks those.
2. **Direct first, implement second.** Finalize a concise creative plan before writing animation code:
   the product story and payoff, brand treatment, soundtrack and musical arc, then a beat-by-beat
   scene plan covering framing, key interactions, transitions and sound cues. Show the plan, resolve
   gaps as the director, and only then implement it. Do not wait for approval unless requested.
   Give each scene a clear takeaway and reading window, and carry a recognizable customer example
   through the demo. Simplify real product behavior for film without inventing actions or outcomes.
3. **Build** one HTML file where every style is computed from time inside `seek(t)` — no CSS
   transitions, no timers, no state between frames.
4. **Render one frame per beat before the full render**, contact-sheet it, and fix anything off the
   grid, cramped, empty, hard to read or overlapping. Then check transitions at ±0.1 s. Review a
   low-resolution moving draft at normal speed before the full render: verify readable holds,
   cursor actions and continuous morphs. Still frames alone cannot prove the timing works.
5. **Full render** with Playwright subframes → ffmpeg tmix motion blur → 60 fps H.264 + mix.
6. **Verify the MP4 itself** with a 1-per-second storyboard and a frame-difference scan for seams
   and glitches. Review its pacing and transitions at normal speed, check for flicker, and verify audio cue sync
   against the picture. Listen when playback is available; distinguish listening from measurements.
   Serve it over the tailnet, and report: link, concept, scene list, real-vs-invented data,
   DECISIONs, caveats (whether audio was heard or only measured; logo/likeness permissions).

## Gotchas that cost hours

- Never put `will-change` on anything the camera scales — text renders blurry.
- Springs start at their event: a target that must *arrive* at time T is issued one settle-lead early.
- `hide()` that zeroes opacity must be undone by the draw function, or elements stay invisible.
- Proportional digits jump in odometers: size each slot to its final glyph, clip only vertically.
- On the VPS: disk is tight (render in 4 s chunks and delete PNGs), Chromium is the first thing
  earlyoom kills (auto-retry, relaunch, hard-exit when done), a stray `/tmp/inspect.py` shadows the
  stdlib (run Python from the project dir), PreToolUse hooks block some ffmpeg glob strings.

## Output

Deliverables in a `deliver/` folder served with `serve <dir>` (tailnet URL, never localhost):
the MP4, a 1-per-second storyboard, key stills, and the previous version for comparison.
```

## File: README.md
size: 8350
sha: 85a3c4469ba81bc3e8a8519f871856eff12ba62e
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/README.md

````text
# Product Launch Motion — a Claude Code Skill

### Paste your website URL. Get a product launch video. That's it.

```bash
npx skills add AbubakrChan/product-launch-motion
```

Already installed it? Get the latest version with `npx skills update`.

[![The launch film: a typed brief, a search wall of creators, a personal email, an inbox on autopilot, a 432,014,696 views odometer, customer logos on the beat and the wordmark](examples/film-preview.webp)](https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/launch-film-1080p60.mp4)

**No Claude Code? Get the film made for you: a polished launch video in 30 minutes, from $20 → [launchvideos.app](https://launchvideos.app)**

Then, in Claude Code:

> Make a launch video for https://your-website.com

Claude reads your site, writes the story, directs every scene and renders a 60 fps MP4 with
music, synced sound effects and real motion blur. No editing, no After Effects, no templates.
The film above started the same way: a URL, then a few rounds of notes.

<details>
<summary><b>v2 is here.</b> Use it with Claude Opus 5.5 to get results like these films.</summary>

This skill has been rebuilt on a newer, simpler stack: plain HTML and closed-form springs
instead of GSAP and HyperFrames, music-first timing instead of voiceover, and Playwright
subframes with ffmpeg motion blur. **Use it with Claude Opus 5.5 to get results like these
films.** The original v1 (190 ⭐, thank you) is kept in [`legacy/`](legacy/).

</details>

> 🔊 **Download it and turn the sound on.** The preview above is silent. The real film is cut to the beat
> of its music, and every click, whoosh and number roll lands on a drum hit.
> **[Download the film (1080p, 60 fps, with sound)](https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/launch-film-1080p60.mp4)**
> to watch it as intended.

30 s, made for [cheerful.ai](https://www.cheerful.ai). No After Effects, no stock templates,
no AI video model: every frame is HTML computed from time. Bonus: the same film with a
[WebGL glass pass](https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/launch-film-glass-1080p60.mp4)
(refractive 3D shapes floating through every scene).

<table><tr>
<td width="45%"><a href="https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/one-shape-loop-1440.mp4"><img src="examples/one-shape-loop.webp" alt="One shape, never cut: a button becomes a loader, a check, a creator reel, a rate slider, a toggle, tabs, a chart, a command palette and a toast, then loops"></a></td>
<td>

**[▶ One shape, never cut](https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/one-shape-loop-1440.mp4)**
(14 s seamless loop, 1440×1440, with sound)

The idea the whole skill is built on. One element morphs through eleven UI states:
button → loader → check → dynamic island → video reel → slider → toggle → tabs → chart →
⌘K palette → toast → button. Its content swaps with a short blur, the cursor does every
click and drag, and the last frame is the first frame.

</td></tr></table>

[![Hoopoe Club launch film: several readers' cursors like, repost and reply to a post, then vote in a poll](examples/hoopoe-preview.webp)](https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/hoopoe-club-1080p60.mp4)

**[▶ Hoopoe Club](https://github.com/AbubakrChan/product-launch-motion/releases/download/v2.0.0/hoopoe-club-1080p60.mp4)** · 43 s, Arabic, with sound

---

## What makes it look expensive

These are the rules the skill enforces. Each one came from a review note on a real film
([`references/prompts.md`](references/prompts.md) has the prompts and notes, verbatim).

- **One shape, never cut.** Every state is the same element morphing size, radius and
  colour. Transitions are morphs, camera pushes, or zooms into an element that *becomes*
  the next scene.
- **Blur content swaps.** Text inside a morphing container exits short and early, then the
  new text springs in. Nothing overlaps.
- **Springs, a tiny overshoot at most.** Closed-form damped springs, never bouncy easing.
  Toggle knobs and tab indicators stretch because their two edges ride different springs.
- **A cursor drives everything.** Real clicks with press-scale and ripple, real drags that
  spring back on release. The product's own agent can take over as a second cursor.
- **The camera frames every state.** An empty frame is a bug. Pull-backs reveal scale: one
  email becomes a wall of hundreds.
- **Music first.** The beat grid is measured with numpy, the drop lands on the big moment,
  and every synthesized UI sound is placed by its own measured peak, so it hits the beat.
- **Real data, original scenes.** Real numbers on odometers, customer logos slammed one per
  beat, and nothing copied from the product's website.
- **You get a director.** It makes the creative calls itself and logs each one as a
  `DECISION:` line you can overturn in one reply.

## What's in the box

| Path | What it is |
|---|---|
| [`SKILL.md`](SKILL.md) | The creative direction and the process Claude follows |
| [`references/prompts.md`](references/prompts.md) | The original prompts and every review note, verbatim. Swap the product and reuse them |
| [`references/technique.md`](references/technique.md) | Springs, camera, cursor, odometer, audio and render code patterns |
| [`legacy/`](legacy/) | The v1 skill: voiceover-led films with GSAP + HyperFrames, 34 traps |

## Requirements

| Need | For |
|---|---|
| [Claude Code](https://claude.com/claude-code) with **Opus 5.5** | Directing and building the film. Smaller models follow the rules but miss the detail |
| Node ≥ 20 + Playwright | Rendering |
| FFmpeg | Motion blur, encoding, loudness checks |
| Python 3 + numpy | Beat grid and synthesized SFX |
| A ~120 BPM royalty-free song | Optional. Check each track's licence; some free libraries mix in restricted items |

Everything runs locally and nothing costs money.

## How it works

```
brief ──► state list on the beat grid ──► one HTML film, every pixel = f(t) in seek(t)
      ──► one frame per beat, contact sheet, fix ──► Playwright: 4 subframes per frame
      ──► ffmpeg tmix (motion blur) ──► 60 fps H.264 + mix ──► storyboard + seam scan
```

Because every frame is a pure function of time, renders are deterministic: seek to any
moment and you get the same pixels, which is what makes per-beat QA and parallel rendering
possible.

## v1 → v2

| | v1 (legacy) | v2 |
|---|---|---|
| Timing | Locked to voiceover words | Locked to the music's measured beat grid |
| Motion | GSAP timelines | Closed-form springs, pure `seek(t)` |
| Renderer | HyperFrames | Playwright subframes + ffmpeg `tmix` motion blur |
| Structure | Shots and transitions | One shape that never cuts |
| Sound | Library SFX, levelled | Synthesized SFX placed by measured peak |
| Depth | CSS camera rig | Camera springs + optional WebGL glass |

v1 is still the better choice for a narrated explainer. It lives on in [`legacy/`](legacy/).

### Updating

- **Installed v1 (before 13 October 2026)?** Run `npx skills update`. Your `product-launch-motion` skill is
  replaced by v2.
- **Installed v2 on 13 or 14 October 2026?** It was briefly named `motion-launch-video`, so `update` can't
  find it. Reinstall once:
  ```bash
  npx skills remove motion-launch-video
  npx skills add AbubakrChan/product-launch-motion
  ```
- **Want to keep v1 for narrated explainers?** Install it alongside v2:
  `npx skills add AbubakrChan/product-launch-motion/legacy`

## Made by

Muhammad Abubakr, who builds [Cheerful](https://www.cheerful.ai). Made a film with this skill?
Tag me on X and I'll share it.

[![Follow on X](https://img.shields.io/badge/@Muhamma77800831-000000?style=for-the-badge&logo=x&logoColor=white)](https://x.com/Muhamma77800831)
[![LinkedIn](https://img.shields.io/badge/Muhammad_Abubakar-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/muhammad-abubakar-05173b20a/)

## License

MIT for the skill and references — see [`LICENSE`](LICENSE).

**The sample films do not travel.** They were made for Cheerful and are theirs; see
[`NOTICE`](NOTICE) for that and for third-party credits (music, footage, fonts).
````

## File: references/prompts.md
size: 6923
sha: 88bcfcda54f6b9381b62c63dda154d54452add5b
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/references/prompts.md

````text
# Chan's prompts, verbatim and in order

These produced the Cheerful launch films (Sept 2026). Treat them as the spec: every later message
adds to or overrides the earlier ones; nothing earlier is dropped unless a later one reverses it.

---

## 1. The brief (first message)

```
<inputs> Ask me for: 8 to 12 UI states I want the shape to become (e.g. button, loader, player, slider, toggle, tabs, chart, command palette, toast), pure black and white or one accent color, and a royalty-free song around 120 BPM (e.g. Mixkit, free for commercial use). </inputs>
<direction> Dribbble-level UI motion. One shape, never cut: every state is the same element morphing its size, radius and color while its content swaps with a short blur. A cursor drives every change with real clicks and drags. Light warm-gray canvas, black and white components, one clean UI font (Geist). Springs everywhere, a tiny overshoot at most. The camera zooms so each state fills the frame. The last frame is the first frame, so it loops. Banned: bouncy easing, particle bursts, glows, gradients on UI chrome, mismatched icon strokes, dead time, anything that looks like a template. </direction>
<structure> 120 BPM, 7 bars, something happens on every beat. "Find creators" button → loader searching 350M+ profiles → check → dynamic island "Creator matched: @mayamoves · 184K" → creator reel preview with a play/pause morph → scrub the reel's progress bar → it becomes a rate slider ($ per reel) that stretches when dragged past max → an "Autopilot" toggle flips on the beat → the knob becomes a liquid tab indicator across Outreach · Negotiation · Deal → the tabs open into a performance chart that draws itself, with a tooltip on hover → it collapses into ⌘K "Ask Cheerful…" → type to filter → enter → toast "Deal signed · @mayamoves" → back to the button. </structure>
<build> 1. One HTML file, square 1440x1440. Every style is computed from time inside seek(t): no CSS transitions, no timers, no state carried between frames. 2. Springs are closed-form step responses. A value that changes target many times is the sum of one spring per change, so it stays a pure function of time. 3. The tab indicator's two edges ride different springs, so the leading edge stretches ahead of the trailing one. Same trick for the toggle knob. 4. Drags are direct manipulation: while the cursor is held, the value is computed from its position. On release it springs back from wherever it was. 5. Analyze the song with numpy for the beat grid and start on a downbeat. Place every UI sound by its measured peak. 6. Render with Playwright: 4 subframes per frame, blended with ffmpeg tmix for motion blur at 60fps. 7. Render one frame per beat before the full render. Fix anything off the grid, cramped or hard to read. </build>
<gotchas> Never put will-change on anything the camera scales or the text renders blurry. Text that swaps inside a morphing container needs its own enter and exit timing or it overlaps. Make the last frame identical to the first, cursor position and speed included, or the loop stutters. </gotchas>
<start> Ask me for the inputs, then show me the state list on the beat grid before you write any code. </start>
```

Added in the same message:

> use the theme to create the video of Cheerful (https://www.cheerful.ai/) --- I want you to be the
> director and I want you to compose the whole video, motion design video and I want you to put
> attention to detail. I want you to think out of the box, no constraints on and full creative control

Answers to the input questions: palette = "cheerful design.md" (the brand's design system);
song = "Director picks"; states = "Director's cut".

## 2. Build it — one shape, no cuts

> Okay, go ahead and implement it. Make sure you follow the principles I told in that form. Make sure
> you focus on that wherever like we can cut the scenes or let's actually not cut the scenes We should
> keep one shape every state in the same element morphing its size radius color While its content swaps
> with a short blur something like that so there is consist consistency among this cuts are not
> encouraged

## 3. Be creative — a launch video in the brand design

> Dude, I want you to be creative. I want you to use the cheerful design. I don't want you to use the
> black and this color. I want You're not limited to use any kind of shape or buttons, you know, as I
> told you in the first form. I just want you to Bring up with something your own like a launch video or
> something, you know, so go ahead

## 4. Review of v1 — original content, real data, no star cursor, logos one by one

> Okay, so I'll review the video which you created in the last session. First of all, I really like the
> starting. That's really good. And I really like how you search on it. I think we can speed it up bit,
> but it still looks good. And what I really doesn't like is that you copied it all from the website,
> you know, the content itself. You have access to the database. You can be creative and not just pick
> image. I just want you to... no constraints in the creativity so process of looking at all it is not I
> really like the star more attention to detail into stuff but I don't like the star, that's what I
> meant. I don't like the star cursor that you had. I think we can do a better job of creating a more
> modern, you know, launch video. And at the end that we show the brands we launched the campaign for,
> we can do a better job instead of just copying the UI we have on the website and the ending is also
> good. Maybe for the brands that we show the logo for, for whom we launch the campaign for, we can just
> instead of copying the UI of the website, we can show it one by one, boom, boom, boom.

What this meant in practice: keep the intro, the search (faster) and the ending; replace the sparkle
cursor; rebuild the middle from original scenes driven by real prod numbers; slam real customer
logos one per beat instead of a "trusted by" row.

## 5. This skill

> create me a skill in that skill I want you to mention all of my prompts that we use all of my
> instructions that we use to create really awesome motion design launch videos and make sure you don't
> miss any instruction, you know things like no cuts Same elements, morphing into different sizes, blurs
> and each and every effect we talked about you know what I mean and how there are no constraints in the
> creativity stuff like that which would pro produce really high quality video launch videos.

## Standing working agreements that applied throughout

- Autonomy: don't stop for non-blocking choices; decide, log `DECISION:` lines, report them at the end.
- Hand over tailnet URLs (`serve <dir>`), never localhost or VPS paths.
- Report honestly: audio was never heard (numbers only), which data is real vs invented, which
  third-party logos/likenesses need permission.
````

## File: references/technique.md
size: 5625
sha: 07e25ce4decd36f51823d251eba9fab48a49d205
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/references/technique.md

````text
# Technique: how the films are built

Reference implementation: `~/code/cheerful-motion/launch2/` (`index.html`, `film.js`,
`audio.py`, `events.mjs`, `render.mjs`, `render_chunks.sh`, `snap.mjs`). Shared SFX synth:
`~/code/cheerful-motion/audio.py`. Beat analysis: `~/code/cheerful-motion/song/tempo.py`, `grid.py`.
Contact sheets: `~/code/cheerful-motion/sheet.py <dir> <out.png> <cols> <w> [h]`.
Serve locally for Playwright: `python3 -m http.server 8765 --bind 127.0.0.1` from `~/code/cheerful-motion`.

## Pure time

Every frame is `seek(t)`. Layers: `#bg` (brand bloom), `#thr` (canvas: cursor
trail), `#world` (camera-transformed scene), `#hud` (screen-space overlays, cursors, wordmark).
Camera: `#world { transform: translate(960px,540px) scale(z) translate(-cx,-cy) }` with x, y and
**log-zoom** on springs (zooming linearly in z feels wrong).

## Springs

```js
function stepR(tau, {w, z}) {            // closed-form damped step response, 0 -> 1
  if (tau <= 0) return 0;
  if (z >= 1) { const x = w * tau; return 1 - Math.exp(-x) * (1 + x); }
  const wd = w * Math.sqrt(1 - z * z);
  return 1 - Math.exp(-z * w * tau) * (Math.cos(wd * tau) + (z * w / wd) * Math.sin(wd * tau));
}
class Track {                            // many target changes = sum of one spring per change
  constructor(v0) { this.v0 = v0; this.cur = v0; this.ev = []; }
  to(t, v, sp) { if (v !== this.cur) { this.ev.push([t, v - this.cur, sp]); this.cur = v; } return this; }
  at(t) { let v = this.v0; for (const [ti, d, sp] of this.ev) v += d * stepR(t - ti, sp); return v; }
}
```
Damping 0.72–0.9 for a tiny overshoot, 1.0 for camera and text. A target that must *arrive* at T is
issued ~0.3 s early (depends on w). For looping films, sum the k = -1, -2 periods too (ΣΔ = 0).

## Content swap with blur

```js
function vis(t, tin, tout, dOut = 0.12) {   // enter on a spring, exit short and early
  const e = stepR(t - tin, {w: 26, z: 1}), x = tout == null ? 0 : clamp((t - tout) / dOut), xo = x * x;
  return { o: e * (1 - xo), b: (1 - e) * 10 + xo * 10, dy: (1 - e) * 16 - xo * 10, e };
}
```
Apply opacity, `filter: blur(b)` and a translateY. Morph containers (`cardAt(e, x, y, w, h, s, r, v, q)`)
spring their width/height/radius; their children get their own `vis` windows.

## Edge-split stretch (toggle knob, tab indicator)

Leading edge on a stiff spring, trailing edge on a soft one; width = R − L.
```js
T.tgL = new Track(4).to(TOG, 44, {w: 19, z: 0.9});   // trailing
T.tgR = new Track(44).to(TOG, 84, {w: 34, z: 0.9});  // leading
```

## Cursor

- Agent cursor: gradient arrow + gradient name pill ("Cheerful"), world-space Tracks AX/AY.
  Lean = clamp(−vx·0.0045, ±14°); pill trails by −v·0.012 px (clamped); press scale
  `1 − 0.16·exp(−((t−tc)/0.045)²)`; ripple ring 0.4 s; born with a scale spring at the drop.
- Trail: sample the cursor's screen position at t − k·9 ms (k ≤ 22) — pure, because position is a
  function of t — draw tapered gradient segments, alpha ∝ (speed − 500)/2500.
- Writing: cursor targets follow the end of streamed words (`offsetLeft/Top` measured at build).
- The human cursor ("You" pill) does the typing/clicking in the intro; handoff at the drop.

## Odometer (real numbers)

Per-digit strips 0–9,0 in slots sized to each final glyph (`clip-path: inset(0 -80px)`, clip
vertically only). Digit p: `floor(v/10^p) % 10 + max(0, (v % 10^p) − (10^p − 1))` so a digit only moves
while the one below carries; ease v with outQ to the exact integer. Leading slots fade/scale in as
log10(v) crosses p; gradient text continuous across slots via background-size/position.

## Audio

- numpy: spectral flux + autocorrelation for tempo, kick-band onsets and peaks for the grid;
  estimate key (chroma) so tonal SFX sit in key (pentatonic of the key).
- Song window chosen so the drop lands on the film's big beat; start 3 ms before a bar's kick onset.
- `cue(g) = g · PERIOD/0.5 + PEAK_OFF` maps film time to the measured kick-peak time (drift included);
  `put()` shifts every sound so **its own peak** lands on the cue.
- Cues are exported from the film itself (`M.cues` → `events.mjs` → `events.json`) — one source of truth.
- Synth: click (press/release pair), tick, key, note, chime, thump, whoosh, band noise, blip, shimmer,
  riser, tape. Pen/drag sounds follow the measured speed of the element.
- Mix: song·0.5 + sfx·0.62, one static gain to 0.93 peak (no limiter — keeps loops seamless).
  Measure with `ffmpeg -af ebur128=peak=true` (≈ −15 LUFS, < −0.5 dBTP).

## Render

- Playwright Chromium headless, 1920×1080 (or square), 4 subframes per frame centred on the frame
  time: `subT(i) = floor(i/4)/60 + ((i%4) − 1.5)/240`.
- `ffmpeg -framerate 240 -i s%05d.png -vf "tmix=frames=4:weights='1 1 1 1',select='eq(mod(n\,4)\,3)',setpts=N/(60*TB),scale=out_color_matrix=bt709:out_range=tv,format=yuv420p" -r 60 -c:v libx264 -preset slow -crf 15 -tune film` + BT.709 tags.
- Chunked (`render_chunks.sh`): 960 subframes → segment → delete PNGs → concat with `-c copy` → mux AAC.
- Resilience: retry a subframe with a fresh page; relaunch the browser if disconnected; restart it if
  the page never becomes READY; `process.exit(0)` at the end (close() can hang after a crash). 3 workers.

## QA

- Snapshot `snap.mjs <dir> t1 t2 …`; one frame per beat (t = k·0.5 + 0.12) as a contact sheet;
  transitions at ±0.1 s; full-res looks at text-heavy frames.
- After the render: 1-per-second storyboard from the MP4, and a frame-difference scan (big jumps
  should only be intended hits) to catch chunk seams and crashed-retry frames.
````

## File: .github/FUNDING.yml
size: 20
sha: 84a0b6301ea1e2f0f7dde84e4e41d4d1c3514ca5
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/.github/FUNDING.yml

```text
github: AbubakrChan
```

## File: .gitignore
size: 50
sha: 3122e74e755a4120881ff92440d8c17344b2ade7
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/.gitignore

```text
node_modules/
renders/
snapshots/
.DS_Store
*.log
```

## File: examples/film-preview.webp
size: 18350620
sha: f255d29dc3fd686eddad3e829e103db8e841c68d
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/examples/film-preview.webp

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/examples/film-preview.webp

## File: examples/hoopoe-preview.webp
size: 2175514
sha: 0333b76897cb77560933bd63feede9eb394fb352
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/examples/hoopoe-preview.webp

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/examples/hoopoe-preview.webp

## File: examples/one-shape-loop.webp
size: 3325266
sha: 48dadd0bf4ac69a6b7186e6ef32a803ae6b22412
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/examples/one-shape-loop.webp

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/examples/one-shape-loop.webp

## File: legacy/assets/BRIEF.md
size: 3065
sha: b9c292e854c5b26bc45fd543ed08eb068b8f9fa2
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/assets/BRIEF.md

```text
# BRIEF — <product name>

Fill this before writing a single line of script. It is short on purpose: if a section is
hard to fill, the film is not ready to build, and building anyway is how you end up with a
beautiful video about nothing.

## The product

- **What it is** (one sentence, no adjectives):
- **Category** (SaaS / app / hardware / e-commerce / dev tool / service):
- **What it replaces** (the tool, the agency, the spreadsheet, the manual process):

## The audience

- **Who is watching** (role, not demographic):
- **What they currently do instead**:
- **What they are sceptical about**:
- **Where this plays** (landing page hero / X / YouTube pre-roll / conference / sales deck):
  Note the consequence: autoplay-muted placements need the claim on screen as type, not
  only in the voiceover.

## The ONE claim

> <seven words or fewer>

The test: if a viewer remembers exactly one sentence, this is the one. Usually the
product's own headline, a line a real customer said, or the single before/after.

- **Why it is true** (the evidence you will show):
- **What you are NOT claiming** (protects you from scope creep in the storyboard):

## Approved figures

The exact list of numerals allowed to appear on screen. Everything else is refused,
including plausible filler. Each needs a source.

| Figure | Means | Source |
|---|---|---|
| e.g. `20,521` | creators contacted | customer dashboard, 2026-07-12 |
| | | |

If the product has no real numbers yet: **ship zero numbers.** A film with no figures beats
a film with impressive fake ones, and the second kind cannot be un-shipped.

## Real assets

| Asset | Where it lives | Cleared for use? |
|---|---|---|
| Logo (SVG) | | |
| Brand tokens / design system | | |
| Product screens | | |
| Product photography | | |
| Customer logos | | |
| Customer photo(s) | | |
| Fonts (self-hosted files) | | |

## The negative list

What may never appear. Start from these and add your own:

- No stock photography, and no invented faces
- No invented metric, ever
- No competitor named on screen
- No UI state the product cannot actually produce
- No implied speed the product does not actually deliver
- <your additions>

## Constraints

- **Runtime target**: 45s (20–90 is the sane band)
- **Aspect / resolution**: 1920×1080 @ 30fps
- **Voice**: local TTS / commercial TTS / human — and who
- **Music**: bed or none; licence
- **Deadline**:
- **Who signs it off**:

## Register map

Two grounds, alternating by act. Every register change is a story turn and carries a
transition.

- **Argument register** (the problem and the thesis): e.g. dark `#100E19`
- **Product register** (the demo and the evidence): e.g. light `#FAFAFB`
- **Accent**: one colour or gradient
- **Accent budget** (where it may appear — five jobs maximum):
  1. one emphasis word per headline
  2. stat numerals
  3. the active step in a sequence
  4. state flips
  5. the CTA

## Definition of done

Copy the checklist from `references/08-qa-and-direction.md` when you get there. Do not
declare the film finished without it.
```

## File: legacy/assets/cues.example.json
size: 3266
sha: 7dad42e8ff65949ff4daac2be06df0313f3d6895
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/assets/cues.example.json

```text
{
  "_readme": [
    "The SFX cue table. `scripts/wire-audio.mjs` reads this and mounts it into the",
    "assembled index, resolving each frame-relative `at` against that frame's real start.",
    "",
    "Author cue times FRAME-RELATIVE. It is the only way they survive a re-order or a",
    "re-cut upstream: absolute times all move, frame-relative times do not.",
    "",
    "Always fill `word`. When the script is re-cut every second changes and no word does,",
    "so a table annotated with words can be re-derived and a table of bare numbers cannot.",
    "",
    "Before trusting a cue, prove it landed in the delivered file:",
    "  ./scripts/verify-cue.sh renders/video-v1.mp4 <absolute start> <dur>",
    "If raising `vol` barely moves the delivered peak, the SOURCE is too quiet — level the",
    "asset with scripts/level-sfx.mjs. See references/07-sound-and-master.md."
  ],

  "bed": {
    "src": "audio/music/bed.mp3",
    "volume": 0.1
  },

  "sfxDir": "audio/sfx",

  "cues": {
    "01-hook": [
      { "sfx": "type-short", "at": 0.11, "dur": 0.45, "vol": 0.3, "word": "Find" },
      { "sfx": "type-short", "at": 1.16, "dur": 0.45, "vol": 0.3, "word": "email" },
      { "sfx": "riser-build", "at": 2.85, "dur": 0.85, "vol": 0.18, "word": "into the impact on 'post.'" }
    ],

    "02-thesis": [
      { "sfx": "bass-drop", "at": 0.02, "dur": 1.7, "vol": 0.22, "word": "Hire — sub-bass on the hero reveal" },
      { "sfx": "ux-chime", "at": 0.05, "dur": 0.57, "vol": 0.3, "word": "Hire" },
      { "sfx": "swish", "at": 1.05, "dur": 0.45, "vol": 0.28, "word": "not" }
    ],

    "03-prompt": [
      { "sfx": "mouse-click", "at": 0.04, "dur": 0.36, "vol": 0.45, "word": "Ask — click into the field" },
      {
        "sfx": "keyboard-typing-loud",
        "at": 0.27,
        "dur": 0.76,
        "vol": 0.55,
        "word": "plain English — the query types",
        "_note": "the -loud variant, not the raw asset: the raw sits at -37 dB mean under a -17 dB bed and no cue volume can lift it. See scripts/level-sfx.mjs."
      },
      { "sfx": "mouse-click", "at": 1.3, "dur": 0.36, "vol": 0.5, "word": "it goes — submit" },
      { "sfx": "ux-chime", "at": 1.55, "dur": 0.34, "vol": 0.3, "word": "to work" }
    ],

    "04-results": [
      { "sfx": "whoosh-slide", "at": 0.0, "dur": 0.31, "vol": 0.32, "word": "the surface arrives" },
      { "sfx": "chime-soft", "at": 0.16, "dur": 0.8, "vol": 0.22, "word": "searches — the scan sweep" },
      { "sfx": "pop", "at": 1.47, "dur": 0.27, "vol": 0.2, "word": "million — filter chips" },
      { "sfx": "pop", "at": 1.53, "dur": 0.27, "vol": 0.18, "word": "(chip 2)" },
      { "sfx": "swish", "at": 2.34, "dur": 0.4, "vol": 0.15, "word": "the camera pushes in" },
      { "sfx": "click", "at": 2.84, "dur": 0.27, "vol": 0.4, "word": "finds — the cursor click" }
    ],

    "05-cta": [
      { "sfx": "whoosh", "at": 0.0, "dur": 0.72, "vol": 0.3, "word": "the end card arrives" },
      { "sfx": "ux-chime", "at": 1.29, "dur": 0.57, "vol": 0.3, "word": "the brand word" },
      { "sfx": "bass-drop", "at": 1.29, "dur": 1.8, "vol": 0.2, "word": "sub under the brand word" },
      { "sfx": "chime", "at": 2.56, "dur": 0.84, "vol": 0.25, "word": "the URL" }
    ]
  }
}
```

## File: legacy/assets/film.example.json
size: 3518
sha: ced9e2fd4bd8e33c53c6ed6254bb5b04c7c11034
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/assets/film.example.json

```text
{
  "_note": [
    "The reference film's real manifest. `node scripts/assemble.mjs` turns this plus the",
    "eleven voiceover files into index.html, and lands on 44.189s against the shipped cut's",
    "44.2 — the 11ms is the hand-rounding in the original, which is the point: measure once",
    "in a script and the number stops being something you maintain.",
    "",
    "You write: which frame, which VO file, how it leaves. You never write a duration —",
    "assemble.mjs measures each frame from its narration with ffprobe.",
    "",
    "`pad` is the breath after the last word, before the transition starts. 0.25s is the",
    "default and most frames just take it. The two that don't are decisions: 03-turn is the",
    "pivot of the whole film and 11-cta is the end card, and both are held longer on purpose.",
    "That is what a pad is for — rhythm, not slack.",
    "",
    "`out.type` is one of dissolve, zoom-through, push, push-right, cut. Note that this film",
    "uses four of them and groups them by meaning: zoom-through where one thing causes the",
    "next, push through the product sequence, dissolve between unrelated beats. A film where",
    "every crossing is the same move reads as a slideshow.",
    "",
    "The last frame has no `out` — nothing follows it to cross into."
  ],

  "width": 1920,
  "height": 1080,
  "background": "#FAFAFB",
  "gsap": "vendor/gsap.min.js",
  "pad": 0.25,

  "frames": [
    {
      "id": "01-hook",
      "src": "compositions/frames/01-hook.html",
      "vo": "assets/voice/01.wav",
      "out": { "type": "dissolve", "duration": 0.5 }
    },
    {
      "id": "02-pain",
      "src": "compositions/frames/02-pain.html",
      "vo": "assets/voice/02.wav",
      "pad": 0.3,
      "out": { "type": "zoom-through", "duration": 0.4 }
    },
    {
      "id": "03-turn",
      "src": "compositions/frames/03-turn.html",
      "vo": "assets/voice/03.wav",
      "pad": 0.58,
      "out": { "type": "zoom-through", "duration": 0.4 }
    },
    {
      "id": "04-prompt",
      "src": "compositions/frames/04-prompt.html",
      "vo": "assets/voice/04.wav",
      "pad": 0.21,
      "out": { "type": "push", "duration": 0.5 }
    },
    {
      "id": "05-discovery",
      "src": "compositions/frames/05-discovery.html",
      "vo": "assets/voice/05.wav",
      "out": { "type": "push", "duration": 0.5 }
    },
    {
      "id": "06-outreach",
      "src": "compositions/frames/06-outreach.html",
      "vo": "assets/voice/06.wav",
      "out": { "type": "push", "duration": 0.5 }
    },
    {
      "id": "07-gifting-detection",
      "src": "compositions/frames/07-gifting-detection.html",
      "vo": "assets/voice/07.wav",
      "pad": 0.31,
      "out": { "type": "dissolve", "duration": 0.5 }
    },
    {
      "id": "08-proof-numbers",
      "src": "compositions/frames/08-proof-numbers.html",
      "vo": "assets/voice/08.wav",
      "pad": 0.33,
      "out": { "type": "dissolve", "duration": 0.5 }
    },
    {
      "id": "09-proof-quote",
      "src": "compositions/frames/09-proof-quote.html",
      "vo": "assets/voice/09.wav",
      "pad": 0.35,
      "out": { "type": "zoom-through", "duration": 0.4 }
    },
    {
      "id": "10-contrast",
      "src": "compositions/frames/10-contrast.html",
      "vo": "assets/voice/10.wav",
      "out": { "type": "dissolve", "duration": 0.5 }
    },
    {
      "id": "11-cta",
      "src": "compositions/frames/11-cta.html",
      "vo": "assets/voice/11.wav",
      "pad": 0.65
    }
  ]
}
```

## File: legacy/assets/frame-skeleton.html
size: 17731
sha: df89e68de226d0894505015372f0dd8e9d6b52d1
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/assets/frame-skeleton.html

```text
<!--
  frame-skeleton.html — a working frame. Copy this; don't retype it.

  What is already built in here, because these are the parts people get wrong:
    · the two-node camera rig (dolly node + 3D turn node)          Law 4
    · a cursor at prop scale with the full three-layer click stack Law 5
    · glass borders that render in a per-track compositing renderer
    · a word-cued timeline with the cue table in a comment          Law 2
    · a locked hold at the end — nothing is moving at the cut       Law 7

  Everything the camera moves lives INSIDE #stage, in stage-local coordinates. That is what
  keeps the cursor glued to its target through the turn and the push. If you add an element
  as a canvas-space sibling of the rig, you have broken the shot.

  Replace: the id prefix (f01-), the geometry, the copy, and the cue times.
  Keep: the structure, the comments explaining WHY, and the hold at the end.
-->
<template>
  <script src="vendor/gsap.min.js"></script>

  <style>
    /* Self-hosted fonts only — a render should need no network at all. */
    @font-face {
      font-family: "YourSans";
      src: url("fonts/YourSans-Variable.woff2") format("woff2-variations");
      font-weight: 100 900;
      font-display: block;
    }

    /* The frame root. Style #root, never a class on the composition element. */
    #root {
      position: relative;
      width: 1920px;
      height: 1080px;
      overflow: hidden;
      font-family: "YourSans", sans-serif;
      -webkit-font-smoothing: antialiased;
      color: #2c2c2c;
    }

    /* ── ground ──────────────────────────────────────────────────────────────
       Full-bleed ground rides on a clip layer, never on #root. Depth from light:
       a base colour plus two soft brand radials. No blurred screenshot. */
    .f01-ground {
      position: absolute;
      inset: 0;
      background:
        radial-gradient(ellipse 56% 46% at 16% -8%, rgba(80, 60, 200, 0.12), rgba(80, 60, 200, 0) 64%),
        radial-gradient(ellipse 42% 38% at 92% 6%, rgba(255, 120, 70, 0.07), rgba(255, 120, 70, 0) 68%),
        #fafafb;
    }

    /* ── THE CAMERA RIG ──────────────────────────────────────────────────────
       #f01-cam    owns the DOLLY (scale) only. Its origin is the control the
                   camera pushes toward, which makes that control a fixed point
                   of the transform — so cursor coordinates survive the push.
       #f01-stage  owns the 3D TURN (perspective + rotation) only.

       Two nodes, not one: a whole-shot rotation and a mid-shot scale on the same
       element fight over one transform matrix and the move judders. */
    .f01-cam,
    .f01-stage {
      position: absolute;
      left: 0;
      top: 0;
      width: 1400px;
      height: 700px;
    }
    .f01-cam {
      left: 260px;  /* rig origin in canvas space */
      top: 148px;
    }
    .f01-stage {
      transform-style: preserve-3d;  /* lets children carry real z for parallax */
    }

    /* ── the surface ─────────────────────────────────────────────────────── */
    .f01-card {
      position: absolute;
      inset: 0;
      border-radius: 22px;
      background: #ffffff;
      border: 1px solid rgba(16, 14, 25, 0.08);
      box-shadow:
        0 1px 2px rgba(16, 14, 25, 0.04),
        0 40px 90px -40px rgba(16, 14, 25, 0.28);
      will-change: transform, opacity;
    }

    /* ── glass borders ───────────────────────────────────────────────────────
       `backdrop-filter` is useless here: the renderer composites each .clip track
       on its own, so there is no backdrop to blur. Fake it honestly with a 14px
       frosted band and a 1.5px gradient hairline, both built from the
       padding + mask-composite ring trick. Keep them INSIDE the card box so a
       camera push can't shove a plate edge into the bottom keep-out band. */
    .f01-glassband,
    .f01-rim {
      position: absolute;
      inset: 0;
      border-radius: 22px;
      -webkit-mask:
        linear-gradient(#000 0 0) content-box,
        linear-gradient(#000 0 0);
      -webkit-mask-composite: xor;
      mask-composite: exclude;
      pointer-events: none;
      opacity: 0;
    }
    .f01-glassband {
      padding: 14px;
      background: linear-gradient(
        135deg,
        rgba(80, 60, 200, 0.10) 0%,
        rgba(255, 255, 255, 0.60) 42%,
        rgba(255, 120, 70, 0.09) 100%
      );
      z-index: 5;
    }
    .f01-rim {
      padding: 1.5px;
      background: linear-gradient(
        135deg,
        rgba(80, 60, 200, 0.50) 0%,
        rgba(255, 255, 255, 0.85) 46%,
        rgba(255, 120, 70, 0.45) 100%
      );
      z-index: 7;
    }

    /* ── content (replace all of this) ───────────────────────────────────── */
    .f01-title {
      position: absolute;
      left: 56px;
      top: 52px;
      font-size: 34px;
      font-weight: 600;
      letter-spacing: -0.01em;
      white-space: nowrap;
      will-change: transform, opacity;
    }
    .f01-row {
      position: absolute;
      left: 56px;
      width: 700px;
      height: 92px;
      border-radius: 14px;
      background: rgba(16, 14, 25, 0.025);
      border: 1px solid rgba(16, 14, 25, 0.07);
      will-change: transform, opacity;
    }
    .f01-btn {
      position: absolute;
      left: 1080px;
      top: 520px;
      width: 154px;
      height: 52px;
      border-radius: 100px;
      background: linear-gradient(90deg, #5a3cc8 13%, #ff7846 90%);
      box-shadow: 0 10px 24px -12px rgba(90, 60, 200, 0.55);
      display: grid;
      place-items: center;
      transform-origin: 50% 50%;
      will-change: transform;
    }
    .f01-btn span {
      font-size: 22px;
      font-weight: 600;
      color: #fff;
    }

    /* ── the cursor ──────────────────────────────────────────────────────────
       44×54, not life-size: a true-to-life ~32px pointer vanishes into a
       saturated control. Position by the TIP: left/top = click point minus the
       tip offset, which is ≈ (6, 3) for this path at this size. */
    #f01-cursor {
      position: absolute;
      left: 1151px;  /* click point 1157 − 6 */
      top: 543px;    /* click point  546 − 3 */
      width: 44px;
      height: 54px;
      opacity: 0;
      transform-origin: 14% 6%;
      pointer-events: none;
      z-index: 60;
      filter: drop-shadow(0 6px 16px rgba(16, 14, 25, 0.42));
      will-change: transform;
    }
    /* Three-layer click feedback: soft bloom → hard ring → the arrow's own press.
       Negative margins mean left/top ARE the click centre. */
    .f01-bloom,
    .f01-ripple {
      position: absolute;
      border-radius: 50%;
      opacity: 0;
      transform-origin: 50% 50%;
      pointer-events: none;
    }
    .f01-bloom {
      width: 148px;
      height: 148px;
      margin-left: -74px;
      margin-top: -74px;
      background: radial-gradient(
        circle at 50% 50%,
        rgba(80, 60, 200, 0.34) 0%,
        rgba(255, 120, 70, 0.16) 46%,
        rgba(255, 120, 70, 0) 70%
      );
      z-index: 39;
    }
    .f01-ripple {
      width: 104px;
      height: 104px;
      margin-left: -52px;
      margin-top: -52px;
      border: 2px solid rgba(80, 60, 200, 0.55);
      z-index: 40;
    }
    #f01-bloom-1,
    #f01-ripple-1 {
      left: 1157px;  /* the button's centre, in stage-local coordinates */
      top: 546px;
    }
  </style>

  <div
    id="root"
    data-composition-id="01-example"
    data-width="1920"
    data-height="1080"
    data-duration="4.20"
  >
    <!-- track 0 · ground -->
    <div class="clip f01-ground" data-start="0" data-duration="4.20" data-track-index="0"></div>

    <!-- track 1 · the camera rig; everything the camera moves lives in here -->
    <div class="clip f01-cam" id="f01-cam" data-start="0" data-duration="4.20" data-track-index="1">
      <div class="f01-stage" id="f01-stage">
        <div class="f01-card" id="f01-card"></div>

        <div class="f01-title" id="f01-title">Your headline here</div>
        <div class="f01-row" id="f01-row-0" style="top: 140px"></div>
        <div class="f01-row" id="f01-row-1" style="top: 252px"></div>
        <div class="f01-row" id="f01-row-2" style="top: 364px"></div>
        <div class="f01-btn" id="f01-btn"><span>Accept</span></div>

        <!-- glass borders sit over the card's own content -->
        <div class="f01-glassband" id="f01-glassband"></div>
        <div class="f01-rim" id="f01-rim"></div>

        <!-- the cursor and its click feedback, on the surface -->
        <div class="f01-bloom" id="f01-bloom-1"></div>
        <div class="f01-ripple" id="f01-ripple-1"></div>
        <div id="f01-cursor">
          <svg viewBox="0 0 24 30" width="44" height="54" xmlns="http://www.w3.org/2000/svg">
            <path
              d="M3.4 1.6 L3.4 25.4 L9.7 19.4 L13.7 29.2 L17.9 27.3 L14 17.7 L21.9 17.1 Z"
              fill="#FFFFFF"
              stroke="rgba(16,14,25,0.8)"
              stroke-width="1.6"
              stroke-linejoin="round"
            />
          </svg>
        </div>
      </div>
    </div>
  </div>

  <script>
    (function () {
      /* ═══════════════════════════════════════════════════════════════════════
         VO: "Your line of narration goes here, and each word has a time."
         "Your"@0.03   "line"@0.31   "narration"@0.88   "each"@1.90
         "word"@2.24   "time."@2.86                     duration 3.40s

         Every cue below carries the word it lands on. When the script is re-cut
         all the seconds move and none of the words do, so a table annotated with
         words survives a re-cut and a table of bare numbers does not.
      ═══════════════════════════════════════════════════════════════════════ */

      window.__timelines = window.__timelines || {};
      var tl = gsap.timeline({ paused: true });
      var DUR = 3.40; // animated span; the frame holds to its data-duration

      var cam = document.getElementById("f01-cam");
      var stage = document.getElementById("f01-stage");

      /* ── camera · the turn (whole shot) ──────────────────────────────────
         The surface starts angled away and squares up as the shot resolves.
         Never resolve to exactly 0 — a hair of angle keeps the depth. */
      gsap.set(stage, { transformPerspective: 2600, transformOrigin: "50% 52%" });
      tl.fromTo(
        stage,
        { rotationY: -7.2, rotationX: 2.1 },
        { rotationY: -1.3, rotationX: 0.5, duration: DUR, ease: "power1.inOut" },
        0,
      );

      /* ── depth ───────────────────────────────────────────────────────────
         A few pixels of z on the elements the story touches, so they parallax
         against the card under the turn. Static: the turn supplies the motion. */
      gsap.set("#f01-btn", { z: 22 });
      gsap.set("#f01-row-0", { z: 12 });

      /* ── camera · the dolly (back half) ──────────────────────────────────
         Origin = the button's own centre, in percentages of the rig box:
           (1157 / 1400) = 82.6%   ·   (546 / 700) = 78.0%
         That makes the button a fixed point of the scale, so the cursor stays
         aimed with no compensating maths.

         Check the ceiling before choosing the scale:
           bottom_after = bottom + (bottom − origin_y) · (scale − 1)
         Run it on all four edges against your keep-out band. */
      gsap.set(cam, { transformOrigin: "82.6% 78.0%" });
      tl.to(cam, { scale: 1.10, duration: 0.36, ease: "power2.inOut" }, 1.60);
      tl.to(cam, { scale: 1.00, duration: 0.36, ease: "power2.inOut" }, 2.86);

      /* ── entrances ───────────────────────────────────────────────────────
         fromTo, not to, so a seek reproduces the start state. 0.24–0.38s,
         power3.out, opacity plus a small translate — never opacity alone. */
      tl.fromTo("#f01-card", { opacity: 0, scale: 0.99 },
                             { opacity: 1, scale: 1, duration: 0.36, ease: "power3.out" }, 0);
      tl.to(["#f01-glassband", "#f01-rim"], { opacity: 1, duration: 0.50, ease: "power2.out" }, 0.08);

      // "Your"@0.03 — the headline
      tl.fromTo("#f01-title", { opacity: 0, y: 10 },
                              { opacity: 1, y: 0, duration: 0.28, ease: "power3.out" }, 0.03);

      // "line"@0.31 — the rows cascade. 0.06 stagger reads; much more outruns the voice.
      tl.fromTo(["#f01-row-0", "#f01-row-1", "#f01-row-2"],
                { opacity: 0, x: -18 },
                { opacity: 1, x: 0, duration: 0.28, ease: "power3.out", stagger: 0.06 }, 0.31);

      // "narration"@0.88 — the control the shot is about
      tl.fromTo("#f01-btn", { opacity: 0, y: 10 },
                            { opacity: 1, y: 0, duration: 0.30, ease: "power3.out" }, 0.88);

      /* ── the click ───────────────────────────────────────────────────────
         Shared routine so every click in the film feels identical. */
      function click(n, at) {
        var bloom = "#f01-bloom-" + n;
        var ring = "#f01-ripple-" + n;
        // the bloom LEADS the press by 0.1s — this is what makes a click read as
        // intentional rather than as a shape appearing
        tl.fromTo(bloom, { opacity: 0, scale: 0.3 },
                         { opacity: 1, scale: 0.62, duration: 0.12, ease: "power2.out" }, at - 0.1);
        tl.to("#f01-cursor", { scale: 0.86, duration: 0.06, ease: "power2.in" }, at);
        tl.to("#f01-cursor", { scale: 1, duration: 0.26, ease: "back.out(2.4)" }, at + 0.06);
        tl.to(bloom, { opacity: 0, scale: 1.15, duration: 0.34, ease: "power2.out" }, at);
        // immediateRender:false — otherwise GSAP paints the "from" state on frame 0
        tl.fromTo(ring, { opacity: 0.75, scale: 0.34 },
                        { opacity: 0, scale: 1.5, duration: 0.42, ease: "power2.out",
                          immediateRender: false }, at);
      }

      /* Cursor travel: enter off-target, a 0.05s anticipation pull-back, then a
         BOWED path — x and y on different eases and durations, so the move curves
         instead of sliding on a ruler line. Start early; land the CLICK on the
         word. Never butt-join tweens on one property (a move ending at 1.38 and
         the next starting at 1.38 is reported as an overlap). */
      tl.set("#f01-cursor", { x: -150, y: 120 }, 1.60);
      tl.to("#f01-cursor", { opacity: 1, duration: 0.14, ease: "none" }, 1.62);
      tl.to("#f01-cursor", { x: -158, y: 128, duration: 0.05, ease: "power1.out" }, 1.64);
      tl.to("#f01-cursor", { x: 0, duration: 0.20, ease: "power2.inOut" }, 1.70);
      tl.to("#f01-cursor", { y: 0, duration: 0.24, ease: "power1.inOut" }, 1.70);

      // "each"@1.90 — the click lands exactly on its word
      click(1, 1.90);

      /* The control must REACT. A cursor pressing a button that does not move is
         uncanny: depress it on the click frame, spring it back. */
      tl.to("#f01-btn", { scale: 0.94, duration: 0.09, ease: "power1.in" }, 1.90);
      tl.to("#f01-btn", { scale: 1, duration: 0.30, ease: "back.out(1.8)" }, 1.99);

      /* ── a deterministic count-up ────────────────────────────────────────
         A proxy object with snap + onUpdate is a pure function of timeline time,
         so it is identical under a reverse seek. Never write to the DOM from a
         wall clock. Give the element `font-variant-numeric: tabular-nums` so the
         numerals do not reflow, and grow it with transform, never font-size. */
      // var COUNT = { v: 0 }, el = document.getElementById("f01-num");
      // tl.to(COUNT, { v: 852, duration: 0.72, ease: "power2.out", snap: { v: 1 },
      //                onUpdate: function () { el.textContent = Math.round(COUNT.v); } }, 2.24);

      /* ── the cursor leaves, and the frame locks ──────────────────────────
         Law 7: nothing is moving at the cut. The last tween ends at 2.86 + 0.36
         = 3.22, inside DUR, and the remaining time is a true hold. Extract the
         last frame of the shot and confirm it is static. */
      tl.to("#f01-cursor", { opacity: 0, duration: 0.20, ease: "none" }, 2.60);

      window.__timelines["01-example"] = tl;
    })();
  </script>
</template>
```

## File: legacy/assets/STORYBOARD.md
size: 2805
sha: c196fe6906d6cfa2a23c6c90dd5bda0a8783cae3
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/assets/STORYBOARD.md

```text
---
format: 1920x1080
fps: 30
duration: <derived from real VO length — fill AFTER the voiceover exists>
claim: "<the ONE claim from BRIEF.md>"
arc: problem → agitate → thesis → product → proof → contrast → act
---

# STORYBOARD — <product name>

## Video direction

The rules this film is held to. Write them once here; every frame answers to them, and a
reviewer can check a frame against this section without reading the code.

**Register map.** <argument ground> for the argument (frames …), <product ground> for the
product and its evidence (frames …). Every register change is a story turn and carries a
transition.

**Palette.** One accent, rationed to the five jobs listed in `BRIEF.md`. No third hue.
Small accent-coloured text uses the AA-safe darker variant.

**Motion grammar.** One camera per shot. Entrances 0.24–0.38s, `power3.out`, `fromTo`
always. Overshoot only on state flips and confirmations. Reveals are VO-cued: at t=0 a
frame carries only what the voice is saying at t=0. No `repeat`/`yoyo`, no `Math.random`,
no `Date.now`. Nothing is moving when a frame cuts.

**Rhythm.** Frames <n> and <n> are the deliberate breathers — one restrained move, then a
still read. They sit either side of the busiest stretch.

**Type.** <family> by role. Numerals `tabular-nums`. Headlines −0.025em.

**Sound.** Bed at ~0.1. SFX named per frame in `audio/cues.json`, each annotated with the
word it punctuates. Sub-bass on the thesis and the CTA only. Silence in front of both.

**Negative list.** <copy from BRIEF.md>

## Frames

Duration is derived from real narration length plus a tail hold plus the transition pad —
fill it after the voiceover exists, never from an estimate.

### Frame 1 — <name>

- **scene**: <what the viewer sees, one sentence>
- **voiceover**: "<the exact line>"
- **duration**: <s>
- **register**: argument | product
- **shot**: <from references/09-shot-catalog.md>
- **camera**: locked | turn | turn + dolly to <control>
- **transition_in**: cut | crossfade | zoom-through
- **cues**: <word>@<t> → <what lands>; <word>@<t> → <what lands>
- **sfx**: <asset>@<word>
- **src**: compositions/frames/01-<name>.html

### Frame 2 — <name>

- **scene**:
- **voiceover**: "…"
- **duration**:
- **register**:
- **shot**:
- **camera**:
- **transition_in**:
- **cues**:
- **sfx**:
- **src**:

<!-- repeat per frame -->

## Composition check

Before building, read the frame list top to bottom and confirm:

- [ ] The ONE claim lands inside the first 10 seconds
- [ ] No two adjacent frames use the same camera move
- [ ] Busy and still alternate; no three dense shots in a row
- [ ] Every register flip is a genuine story turn
- [ ] Every number planned for screen is on the approved-figures list
- [ ] Total runtime is within the target band
```

## File: legacy/CASE-STUDY.md
size: 13071
sha: 4cd2bfa0b728430565144d3ae71ee9f670f6f739
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/CASE-STUDY.md

````text
# Case study — Cheerful, v1 → v7

This skill was extracted from one film: the launch video for
[Cheerful](https://www.cheerful.ai). It is worth reading the revision history rather than
just the finished frames, because **every technique in this repo exists as the answer to a
specific director's note** — and the notes are the part you cannot get from a tutorial.

Read it as *one direction executed well*, not as the shape a launch film should take. The
two-register flip, the purple-to-orange accent and the reconstructed inbox are answers to
this product's claim and this brand's palette. Yours should look nothing like it —
`references/11-creative-direction.md` is about making sure of that.

- **Product**: Cheerful — an AI-native influencer-marketing platform (SaaS, B2B, sales-led)
- **Angle**: the product's own headline — *"Hire the agent, not the agency"*
- **Delivered**: 44.2s · 1920×1080 @ 30fps · 11 frames · 30 MB · −14.0 LUFS / −0.8 dBTP
- **Built with**: HTML + CSS + GSAP, rendered by HyperFrames; local TTS narration; a frozen
  local SFX kit; ffmpeg mastering
- **Render time**: 51s (measured). **Master time**: 30s (measured). The direction loop
  took days — which is the honest ratio: the machine time is nothing, the deciding is
  everything.

## The arc

| # | Frame | Beat |
|---|---|---|
| 1 | Hook | Four verbs of the manual work, stacking up |
| 2 | Pain | Seven lifecycle stages crowd in, and the cost of doing it by hand |
| 3 | Thesis | The claim, held still |
| 4 | Prompt | Ask in plain English; the agent goes to work |
| 5 | Discovery | The search runs and finds the contact |
| 6 | Outreach | Drafts, replies, negotiates |
| 7 | Fulfilment | Ships the product, catches every post |
| 8 | Proof (numbers) | The customer's real figures |
| 9 | Proof (human) | One person did the work of a whole team |
| 10 | Contrast | Point tools / agencies / the product |
| 11 | CTA | The lockup |

Frames 1–3 and 10–11 sit on the dark argument register; 4–9 on the light product register.
Every flip is a story turn and carries a transition.

![The thesis, held still on the argument register](still-1.jpg)

*The one frame reproduced here. The rest of the film is linked from the README rather
than cut into stills: its shots carry a client's brand marks, product photography and
photographs of real people, none of which this repository's MIT licence covers.*

## The revisions

### v1 → v2 · "You have not put attention to detail in it"

The full note was *"everything should be synced for what the user is saying"* — and it was
correct. Reveals were near their words, not on them, which reads as lag even when a viewer
cannot name it.

**What changed.** Every cue in all eleven frames was re-derived from a word-level
transcript of the actual narration files. Frames 5 and 7 were rebuilt natively instead of
using cropped screenshots. A custom cursor was added to the three UI-demo shots.

Also in this note: *"the line cuts the 'by hand' text — it does not go below it, but on
it."* A marker underline was being drawn across the word. The fix exposed a real trap: the
word sits under a camera-scaled ancestor, so `getBoundingClientRect` returns numbers in the
wrong space. It had to be measured with `offsetWidth` / `offsetLeft` / `offsetTop`.

→ `references/03-word-locked-sync.md`, trap 5 in `references/10-traps.md`

### v2 → v3 · "We may have sped it up a little more than we should"

The v2 re-cut fixed the drag and introduced the opposite problem: shots were cutting within
a couple of frames of their last motion, so there was no time to read the state.

**What changed.** Tail holds on every frame — the film went from 40.3s back to 44.2s, but
*faster in the clauses and slower at the ends* than either previous cut. Pace is a
direction note, not a constant.

This pass also added the film grade (grain, vignette, three specular sweeps on named story
beats) and sub-bass under the two hero reveals. Both had complications worth recording:

- The first grade attempt used `mix-blend-mode` and **rendered the entire film white**,
  because each track composites as its own layer and a `multiply` over transparency paints
  its own source colour.
- Per-frame grain took the render from **9.3 MB to 67.3 MB** and read as electronic sizzle. The
  fix was a 12 Hz re-seed and a `-crf 19 -tune film` re-encode.
- A full-canvas grade broke the automated contrast gate, which reported a nonsense
  **1.06:1**. The pass got an `--off` switch so contrast can be gated with the grade
  removed.

→ `references/06-look-and-grade.md`, traps 1, 24 in `references/10-traps.md`

The same pass moved the proof frame's count-up to start on the word "contacted" rather than
1.3 seconds later — the shot had been opening on an almost empty frame, which is a pacing
bug that only shows up when you watch the film instead of the frame.

### v3 → v4 · runtimes, and a bake-off

Five motion tools were raced, one beat each, to find out what was worth adopting. The
verdicts are in the README's stack table; the short version is that GSAP earns its place,
Lottie is good for a logo draw-on with one serious trap, Rive can be mounted but not
authored by an agent, Three.js is a one-shot tool, and Remotion is a genuine peer whose
laws are the same.

### v4 → v5 · "I don't like the ripple effect behind the logo"

The end card had two separate elements that both read as a ripple — a Lottie burst and
three concentric rings. Removing one would not have satisfied the note; the note was about
the *impression*, not the element.

Two more notes in the same pass, both about a shot doing too little:

- *"The creator search — when you click, it should show a skeleton loading and then
  results."* Four finished rows appearing is a slide. Rows that land as skeletons and
  resolve in sequence read as a query being answered.
- *"The 'hire the agent' background can be improved, or removed if difficult."* It was a
  Rive layer that was not rendering reliably. Replaced with an aurora gradient mesh — three
  broad brand-hued pools with one slow drift, which never crosses the type.

### v5 → v6 · "The building-list part can be improved"

One beat — the moment the agent starts working — was carried by a text label on a hairline
progress bar. Two thin slabs in an empty frame.

**What changed.** It became an agent run panel: a header with a live state pill and three
steps that complete in sequence with rings flipping to checks. The last step is deliberately
left *running* when the frame cuts, because the next frame is what completes it — the shot
does not claim a result the film has not shown yet.

Caught during this pass: a DOM splice closed at the wrong `</div>` and left orphaned markup
rendering at the canvas origin. Invisible in code review, obvious in a still.

→ trap 8 in `references/10-traps.md`

### v6 → v7 · the cursor, the camera, and the people

The final note was six notes, and they are the reason half this repo exists.

**"Add a typing sound."** There already was one. It was mathematically inaudible: the source
was a distant room recording at −37.4 dB mean, against a narration stem at −21.3 dB
(the mastered *window* read −16.7 dB — flattering, and the wrong thing to measure). Raising the
cue volume from 0.35 to 0.85 moved the delivered mix by **0.1 dB** — measured. The fix was
to level the *asset* (+22 dB through a limiter), after which the delivered window's peak
went from **−4.6 dB to −1.4 dB**. This is where Law 6 comes from.

**"Make it 3D, do the camera rotation, do the cursor zoom in."** The inbox shot was rebuilt
on a two-node camera rig. Its card and contents had been separate canvas-space siblings, so
they *could not* move as one object — the first structural fix was converting the whole
shot to rig-local coordinates. The camera now turns across the shot and pushes 1.12 onto the
Accept button, with the zoom origin on the button itself so the cursor stays aimed.

**"Focus on the cursor."** The pointer was life-size and vanished into a gradient button.
It went to 44×54 with a heavy stroke, a deep shadow, a three-layer click and bowed travel.

**"Have actual conversations on the left side."** The rail listed four names against the
words "Draft ready" four times. It now carries each thread's last real message — and row
one's line changes to the reply, with an unread dot, at the moment the reply lands.

**"The fulfilment left side can be improved."** An eyebrow, a pill and three checks floating
on white. It became the order itself: the real product photograph as a line item and the
creator it ships to — the same creator the search found two frames earlier, which turned
three unrelated demo shots into one continuous story.

**"Show Dan's picture."** The testimonial was attributed to a logo. His real photograph
from the customer's own case study was already sitting in the marketing repo. A quote
attributed to a face reads as a person; the same quote attributed to a logo reads as copy.

Two mastering bugs surfaced in this pass and are now permanent parts of `scripts/master.sh`:
the louder keystrokes pushed the master to **+1.1 dBFS** (clipped), and the limiter added to
fix that silently applied makeup gain and produced **−13.0 LUFS / −0.0 dBFS** until
`level=disabled` was passed.

### v7 → v8 · the round nobody asked for

The first seven rounds came from a director watching the film. The eighth came from
reading it — an audit of every claim the repository made, run before publishing it.
Nobody had watched anything. All three findings were real.

**The typing sound started 0.435s late.** Not missing, not quiet — late. The levelled
asset had been cut from the top of a field recording, so it opened with room tone, and by
the time the first keystroke landed 56% of the typing animation had already played in
silence. A window measurement passed it: the peak inside the window was a healthy −1.4 dB,
it was just in the wrong half. Only a per-100ms envelope shows it:

```
before   10.72: -4.6   10.82: -6.3   10.92: -6.0   11.02: -4.7   11.12: -1.6
after    10.72: -1.1   10.82: -1.3   10.92: -1.2   11.02: -1.5   11.12: -0.9
```

`scripts/level-sfx.mjs` now detects the first transient and trims the head by default, and
`verify-cue.sh` prints the envelope rather than a single number — the tool that missed the
bug was changed, not just the file.

**The testimonial was a paraphrase.** The card read *"One person did the work of a whole
team"*, under a named person's real photograph and his employer's wordmark. His published
sentence is *"Cheerful turned our 1-person team into a 10-person operation."* Close enough
to feel harmless, and it is not: attributing words to a real person that they did not say
is the one failure law 1 exists to prevent, and it had survived seven rounds because it
reads well.

**Four real people's photographs were wearing invented names.** The creator rows showed
photographs lifted from the campaign's own detected posts, relabelled "Sam Kim ·
@lifewithsam · 391K followers" and three more like it. Law 1 warns that a stock face makes
a real product look fake; a *real* face under an invented name is worse, and the law did
not say so because the case had not come up. The rows are now initial avatars, tinted from
the film's own accent gradient so they cost no new colour — and they read better, because
a product UI showing initials is a thing that genuinely happens.

The lesson is the uncomfortable one: **watching the film would never have found any of
these.** Two of the three are only visible by measuring, and the third by checking a
sentence against its source. A director's loop and an auditor's loop catch different
defects, and a film that has had only one of them has not been finished.

## What the loop actually taught

1. **Almost every note was about meaning, not polish.** "This is very basic" meant a shot
   was carrying a beat with two rectangles. No linter reports that.
2. **The measurable bugs hid behind aesthetic notes.** "Add a typing sound" surfaced an
   arithmetic problem. "Improve the background" surfaced a runtime that was not rendering.
   Take the note at face value, then measure.
3. **The reviewer was right every time, and imprecise every time.** "The ripple" was two
   elements. "3D" meant a camera rig, a cursor rebuild and a coordinate refactor. Translate
   the note into the mechanism; do not implement it literally.
4. **Continuity is free and nobody asks for it.** Carrying one creator across four frames
   cost nothing and did more for the film than any single effect.
5. **Version every render.** The typing fix was only provable by measuring the same window
   in v6 and v7.

## Reading the frames

The finished film's frame sources are not redistributed here (they are full of one
company's brand assets and customer data). What is portable is in `assets/frame-skeleton.html`
— the camera rig, the cursor, the glass borders and the word-cued timeline, with the
reasoning in comments.
````

## File: legacy/evals/BASELINE.md
size: 7721
sha: 1b2bd88416b7493bb2f7bbf0cb0f070392f7ae6d
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/evals/BASELINE.md

```text
# Baseline evaluation — RED / GREEN

The skill was tested the way its own Law 9 demands: run the scenario **without** it, record
what a capable model does unaided, then run the same scenario **with** it and compare. Every
change listed at the bottom came out of this, not out of taste.

Four runs, two scenarios, one fresh context each. Same model, same prompts except for the
instruction to read the skill.

**What is not here:** the raw transcripts. What follows is a write-up, not a machine-checked
artifact, and every finding below should be read as "this is what the runs surfaced and what
was changed as a result" — not as a reproducible benchmark. `evals/evals.json` is likewise a
set of *specifications*, not a test report: nothing in this repo records a pass/fail for
them.

---

## Scenario 1 — "the typing sound is inaudible"

A real defect in a real film: a keystroke cue nobody could hear. The agent could measure but
not modify.

### RED (no skill)

Diagnosed it correctly and thoroughly, including the arithmetic — a −37.4 dB source under a
−16.7 dB bed cannot be rescued by cue volume. It also independently found something the
project team had missed: **the levelled asset opens with 0.40s of room tone, so the sound
lands 400ms after the animation it punctuates.**

The unaided run was strong. This matters, and the honest read is that the skill's value on
this scenario is not "the model could not do it" — it is that the model did it here because
it happened to check the envelope, which nothing was requiring it to do.

### GREEN (with skill)

Reached the same two causes, with tighter numbers (onset measured at 0.435s at 20ms
resolution) and a per-100ms envelope table across both masters showing the cue arriving at
11.155s against an animation starting at 10.74s. It attributed the instinct that found it
to Law 9:

> Reading `wire-audio.mjs` showed a levelled asset at a sensible volume with a thorough
> comment explaining the arithmetic, and the honest-looking move was to report "this is
> already fixed, re-watch v7." Measuring the delivered file anyway is the only reason
> Cause B surfaced.

It then reported six defects **in the skill itself**, three of which were bugs in the
scripts. That is the run's real output.

---

## Scenario 2 — two products, two directions

Direct the launch films for a B2B accounts-payable platform and a DTC moisturiser. The test
is whether they come out the same.

### RED (no skill)

Produced two genuinely unalike surfaces — flat ledger-paper white with no camera and a
mechanical 120bpm foley grid, versus high-chroma riso silkscreen with continuous drift and
an analogue synth swell. Then audited its own pair and found **twelve structural
convergences**, including:

- the same three-act skeleton, turn at the same proportional point
- **the same ending, beat for beat** — drain to the lightest value, shrink to a wordmark,
  one short line, final five seconds
- the same rhetorical device (many collapsing into one) and the same climax mechanic
- the same sound dramaturgy and the same copy placement
- both refusing a camera, both flat 2D, both with no human presence
- **both breaking their category cliché in the same direction**

### GREEN (with skill)

All eleven direction dials differed between the two films, against a requirement of four.
The dial mechanism worked, and the run said so:

> The dial table … is the single most useful mechanism in the file — it converted "make
> them different" into a checkable list, and it's why B has no voiceover and A has no
> perspective.

And then reached the same verdict as the RED run, from the other side:

> **the two films look nothing alike and are structured almost exactly alike.** The skill's
> right column (free) worked; its left column plus the arc/shot defaults produced one shared
> chassis.

Two independent runs, one with the skill and one without, converged on the same finding:
**sameness survives in structure after it has been eliminated from surface.** That was the
single largest gap in the skill and it is now `references/11-creative-direction.md` §
"Sameness hides in structure, not in surface".

The GREEN run also caught the skill teaching its own defaults:

> My honest first instinct for both was to write those two entries back out with new hexes,
> and they would have passed every anti-sameness check in the file. **Nothing in the skill
> catches inheritance from its own worked examples.**

---

## What changed as a result

| Finding | Source | Change |
|---|---|---|
| Sameness lives in structure, not surface | both runs | New section in `11` with nine structural axes and a two-film test |
| Inheritance from the skill's own worked directions is uncaught | GREEN 2 | A worked direction matching your brief is now automatically one of the three you write **and** one of the two you kill |
| A silent head makes a levelled cue land late | RED 1, GREEN 1 | `level-sfx.mjs` detects the first transient and cuts from there by default; reports the head; trap 21 |
| A window measurement averages the defect away | GREEN 1 | `verify-cue.sh` now prints a per-100ms envelope; it is the check that separates a fix from a half-fix |
| `--bed` compared asset peak to bed mean — a false pass on the very file the docs are about | GREEN 1 | Rewritten to report peak-vs-peak and mean-vs-mean and to **stop issuing a verdict**, because masking is not predictable from levels |
| The bed figure mixed pre- and post-master | GREEN 1 | Documented: measure the isolated stem, not the mastered window (4.6 dB error in the flattering direction) |
| A no-voiceover film has no defined pipeline, though the skill recommends one | GREEN 2 | Cueing without narration added to `03` |
| Law 7 read as forbidding a continuous-camera film | GREEN 2 | Law 7 scoped to resolution, not stillness |
| Truth pass assumes the product already ships | GREEN 2 | Pre-launch sourcing added to `02` |
| Steps 1–3 are a loop, not a sequence | GREEN 2 | Stated in `SKILL.md` |
| Composition rules in `09` only hold for cut-based films | GREEN 2 | Scoped |
| Truncation seam when a cue is shorter than its asset | GREEN 1 | Documented in `07` |

## Honest limitations

- One run per cell. These are not statistically meaningful; they are a bug-finding
  instrument, and they found bugs.
- Both scenarios are diagnostic and planning tasks. Neither built and rendered a film end to
  end, so the build mechanics are covered only by the fact that the reference film exists.
- The RED runs were strong. On scenario 1 the unaided model reached the same diagnosis; the
  skill's contribution was making the check that found the second cause **required** rather
  than lucky. Anyone claiming a skill turns a bad answer into a good one should show the
  bad answer, and here there wasn't one.
- Scenario 2's two products share an emotional shape (relief from drudgery), which the GREEN
  run identified as the real convergence hazard. A pair with different shapes would be an
  easier test.

## Re-running this

The scenarios are worth re-running after any substantial edit, particularly scenario 2 —
it is the one that catches the skill drifting back into being a template.

Ask a fresh agent, once without the skill and once with it:

1. *"The typing sound in this film is inaudible. Diagnose and plan the fix. Measure; do not
   modify or re-render."*
2. *"Direct two 45-second launch films for [two unrelated products]. Give palette, ground,
   type, camera, texture, shot list, sound, transitions for each. Then audit honestly how
   similar your two answers are."*

The second prompt's final clause is the whole test. A model that cannot see its own
convergence will not fix it.
```

## File: legacy/evals/evals.json
size: 5410
sha: 2c3b8271682691876588ab528cec0b9a88049f0e
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/evals/evals.json

```text
{
  "skill_name": "product-launch-motion",
  "_readme": [
    "Trigger and behaviour evaluations. The first group checks that the skill activates on",
    "substantive requests it should own (and stays out of ones it should not). The second",
    "group checks that, once active, it enforces the laws rather than just describing them.",
    "",
    "Assertions are plain strings — the criteria a run is judged against. These are",
    "SPECIFICATIONS, not a test report: this file records nothing about whether any",
    "particular run passed. The two scenarios that HAVE been run, with and without the",
    "skill, are written up in evals/BASELINE.md, findings and all."
  ],
  "evals": [
    {
      "id": 1,
      "prompt": "We're launching our API monitoring tool next Thursday. Can you put together a 45-second launch video for the landing page hero?",
      "expected_output": "Activates the skill. Starts with intake (BRIEF), asks for or gathers real brand assets and the approved-figures list, and does NOT begin building frames before the voiceover exists.",
      "assertions": [
        "Produces or requests a BRIEF before any composition work",
        "States that the voiceover must be rendered before frame durations are set",
        "Refuses to invent metrics for the product"
      ],
      "files": []
    },
    {
      "id": 2,
      "prompt": "Here's our product video: renders/v1.mp4. It looks basic and I can't say why. Fix it.",
      "expected_output": "Activates the skill for a rescue pass. Samples the delivered file at multiple beats, produces a written defect list against the ten laws before editing, and diagnoses specific causes (reveals off-word, no camera, cursor too small, nothing holding at cuts).",
      "assertions": [
        "Extracts frames from the delivered file rather than reasoning only from source",
        "Produces a written defect list before making changes",
        "Names at least two specific laws the film violates"
      ],
      "files": ["renders/v1.mp4"]
    },
    {
      "id": 3,
      "prompt": "The typing sound effect in our demo video is too quiet. I bumped the volume to 0.9 and it's still inaudible.",
      "expected_output": "Recognises the volume-arithmetic law. Measures the asset and the narration window rather than adjusting the cue again, then levels the asset and verifies the change in the delivered file.",
      "assertions": [
        "Measures the source asset level with ffmpeg before changing anything",
        "Explains that cue volume cannot rescue a quiet source, with the arithmetic",
        "Levels the asset and verifies the delivered-file window afterwards"
      ],
      "files": []
    },
    {
      "id": 4,
      "prompt": "Add a camera push-in on the Save button for the settings shot, and have the cursor click it.",
      "expected_output": "Uses the two-node rig, puts the zoom origin on the button's own centre, keeps the cursor inside the rig, checks the keep-out arithmetic before choosing the scale, and cues the click to a word.",
      "assertions": [
        "Rotation and scale are placed on different elements",
        "The zoom origin is the clicked control's centre",
        "The keep-out ceiling is computed before the scale is chosen"
      ],
      "files": []
    },
    {
      "id": 5,
      "prompt": "Our mastered launch film measures -13 LUFS and peaks at 0.0 dBFS. What happened?",
      "expected_output": "Identifies the alimiter makeup-gain trap (missing level=disabled) and the loudnorm linear=true clipping trap, and re-masters with verification against the delivered file.",
      "assertions": [
        "Names level=disabled as the cause of the ~1 dB overshoot",
        "Re-measures the delivered file with ebur128 rather than trusting the filter graph"
      ],
      "files": []
    },
    {
      "id": 6,
      "prompt": "Make a 30-second video showing off our new espresso machine — it's a physical product, no software UI at all.",
      "expected_output": "Activates and selects hardware-appropriate shots (hero on a floating plane, exploded view, unboxing) rather than SaaS shots, applies the same laws, and warns about bloom over near-white product photography.",
      "assertions": [
        "Selects shots from the hardware entries in the catalog, not the UI entries",
        "Applies the truth pass to product photography and claims",
        "Does not propose a fake cursor for a product with no UI"
      ],
      "files": []
    },
    {
      "id": 7,
      "prompt": "Trim 4 seconds off the middle of the film — the product section drags.",
      "expected_output": "Recognises that re-cutting narration invalidates every word-locked cue, and either re-transcribes or explains the uniform-tempo rescale rule, rather than nudging timings by ear.",
      "assertions": [
        "States that cues must be re-derived after a re-cut",
        "Does not adjust individual cue times by estimate"
      ],
      "files": []
    },
    {
      "id": 8,
      "prompt": "Can you add captions to this recording of our CEO's keynote?",
      "expected_output": "Does NOT activate this skill — captioning existing talking-head footage is outside its scope, and the skill says so explicitly.",
      "assertions": [
        "Declines to apply the launch-film pipeline",
        "Points to caption tooling instead"
      ],
      "files": []
    }
  ]
}
```

## File: legacy/film-preview.webp
size: 999152
sha: 84e08dcfeac38c4745eb8a68aa391b9d4bcb2eb9
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/film-preview.webp

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/film-preview.webp

## File: legacy/references/01-renderer-contract.md
size: 15108
sha: 459fdc704d55fa161770d6d9063741d156f68c24
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/01-renderer-contract.md

````text
# 01 · The renderer contract

How an HTML file becomes a deterministic video file, what the DOM has to say for that to
work, and which rules survive a move to another renderer. The plumbing here is HyperFrames;
the laws are not — anything that seeks a timeline and screenshots the result obeys the same
physics. A Remotion translation is at the bottom.

## The mental model

A composition is an HTML file whose **DOM declares timing**. The renderer does not play it:
it sets the playhead to a time, waits for layout, screenshots the viewport, moves the
playhead, screenshots again. Frame 300 of a 30 fps render is whatever the page looks like at
t = 10.000s, arrived at cold. Three consequences follow, and every rule below is one:

1. **Nothing runs on a wall clock.** No `requestAnimationFrame` loop, no `setTimeout`, no
   CSS transition. Those advance by elapsed real time, which the renderer never spends.
2. **Frames can be sampled out of order, or in parallel.** Anything that accumulates state
   ("this is the 4th call, so advance one character") desyncs.
3. **Frame N must be a pure function of N.** If it is not, your render is a lottery — it
   will differ between machines, between runs, and between preview and delivery.

## The structural contract

### Root — one per file, carrying frame size and render length

The assembled film's own root is the same shape with `data-composition-id="main"`.

```html
<div id="root" data-composition-id="09-proof"
     data-width="1920" data-height="1080" data-duration="4.5">
```

| Attribute | On | Means |
|---|---|---|
| `data-composition-id` | root | Identity. Must equal the `window.__timelines` key, and — for a sub-composition — the host slot's id, exactly, with no `-host`/`-slot` suffix |
| `data-width` / `data-height` | root | Pixel frame size |
| `data-duration` | root | Render length. Read once, before scripts run; a script that rewrites it later is ignored |
| `class="clip"` | timed child | Marks the element as framework-timed. The framework owns its visibility — never animate it yourself |
| `data-start` | clip | Seconds from the start of *this* composition |
| `data-duration` | clip | Seconds. Re-read from the live DOM, unlike the root's |
| `data-track-index` | clip | Temporal lane (see *Tracks*) |
| `data-volume` | `<audio>` / `<video>` | 0–1 |

A sub-composition wraps its root in `<template>`; the top-level `index.html` must **not**. The
runtime clones only the template's contents, so put `<style>` and `<script>` inside it — a
sub-composition's `<head>` is discarded.

### One paused timeline per composition

```js
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// … tweens, each positioned with an absolute time argument …
window.__timelines["09-proof"] = tl;
```

Build it **synchronously at page load** — not in a promise, an event handler, or after a
`fetch`. The renderer can sample before an async build finishes and will happily screenshot
an empty frame. Never call `tl.play()`: playing is a preview convenience, not the output.

### Media is discovered by `id`

```html
<audio id="sfx-f04-click-12" class="clip" src="audio/sfx/click.mp3"
       data-start="10.490" data-duration="0.360" data-track-index="912" data-volume="0.45"></audio>
```

The renderer finds `<audio>` / `<video>` by id and mixes them into the output, so **an
`<audio>` element without an `id` renders silent, with no error and no warning** — and the
preview plays it correctly, so you only find out in the delivered file. Every generator that
emits audio must mint an id (`sfx-<frame>-<name>-<nn>` is enough).

## Determinism: the bans and what to use instead

| Banned | Why it breaks | Use instead |
|---|---|---|
| `Math.random()` | A different value on every seek; the same element lands somewhere new each frame | Derive from the element's index |
| `Date.now()`, `performance.now()`, `new Date()` | Render-time clock; unrelated to the playhead | Read the playhead |
| CSS `transition` | Advances on elapsed real time, which the renderer never spends | A tween on the timeline |
| CSS `@keyframes` with `animation-iteration-count: infinite` | No finite length for the renderer to infer | Finite count, or a tween |
| `repeat: -1`, `yoyo` on a loop | Unbounded; the phase at time T is undefined | A finite `repeat`, computed with `floor` — or just hold still |
| Hover / scroll / focus state | The renderer dispatches no input events | Static state, or a tween |
| Fetching an asset at render time | Frame 0 can land before the response arrives | Inline or vendor it |
| Two timelines tweening one property | GSAP's overwrite order can flip between runs | One owner per property |

### Index-derived scatter

Anything that should look random is a function of the element's index. This depth-scatter
flies seven chips in from a sphere, byte-identical on every render:

```js
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
const scatter = chips.map(function (el, i) {
  const a = i * GOLDEN;                       // golden angle — even, non-obvious spacing
  const depthT = n > 1 ? i / (n - 1) : 0;     // stepped depth, near → far
  return {
    x: Math.cos(a) * RADIUS,  y: Math.sin(a) * RADIUS * Y_SQUASH,
    z: Z_NEAR - depthT * (Z_NEAR - Z_FAR),
    rotationX: Math.sin(a) * TUMBLE,  rotationY: Math.cos(a) * TUMBLE,
  };
});
```

### Playhead-derived noise

Film grain has to move or it reads as a dirty lens. Seed it from the playhead, re-seeded at
a *low* rate:

```js
var clock = { t: 0 }, lastSeed = -1;
tl.to(clock, {
  t: TOTAL, duration: TOTAL, ease: "none",
  onUpdate: function () {
    var seed = Math.floor(clock.t * 12) % 12;   // pure function of the playhead
    if (seed !== lastSeed) { lastSeed = seed; turb.setAttribute("seed", String(seed)); }
  },
}, 0);
```

The `* 12` is measured, not stylistic. Re-seeding **every** frame reads as electronic sizzle
rather than film, and it destroys inter-frame compression: the reference film's 30 Hz grain
encoded to **85 MB against ~9 MB ungrained**. Real 24 fps grain is also held across more than
one frame. See `references/06-look-and-grade.md`.

### The proxy-object tween — counters, typewriters, anything discrete

The canonical pattern for state that is not a CSS property. Tween a plain object, `snap` it
to integers, write the DOM in `onUpdate`. The written value is a function of timeline time
only, so a reverse seek reproduces it exactly.

```js
// count-up: 0 → 400, snapped, settled before the next spoken word
var COUNT = { v: 0 };
tl.to(COUNT, {
  v: 400, duration: 0.72, ease: "power2.out", snap: { v: 1 },
  onUpdate: function () { numEl.textContent = Math.round(COUNT.v); },
}, 1.73);

// typewriter: one tween on a snapped index; slice(0, i) is a pure function of driver time
var typer = { i: 0 };
tl.to(typer, {
  i: QUERY.length, duration: 0.71, ease: "none", snap: { i: 1 },
  onUpdate: function () { textEl.textContent = QUERY.slice(0, Math.round(typer.i)); },
}, 0.29);
```

Give a rolling numeral `font-variant-numeric: tabular-nums` or it reflows as it counts, and
grow it with `transform: scale`, never `font-size`.

## Tracks and layering

`data-track-index` is a **temporal lane, not a z-order**. Two clips on the same track must
not overlap in time; the linter fails if they do. Visual stacking is CSS `z-index`. That is
why an assembled film alternates frames between two tracks: a 0.5s cross-dissolve needs the
outgoing and incoming frame alive at once, which is illegal on one track.

Give each kind of clip its own band of indices so a new cue can never collide with an
existing one. The reference film uses 0–1 for frame hosts (alternating), 10 for narration,
900 for the music bed, 910+ for one SFX cue each, 985/990 for a root-level canvas layer and
the grade. The numbers are arbitrary; the separation is not.

**The consequence that costs you a day:** each `.clip` composites as its own layer, so a
`mix-blend-mode` in an overlay clip has **no frame content as its backdrop** — it blends
against transparency and paints its own source colour. A `multiply` vignette over nothing
rendered an entire film as a white wash on the first attempt. Grade layers use plain alpha;
full treatment in `references/06-look-and-grade.md`.

## Build order and idempotent injection

**The index is generated**, so anything hand-added to it is destroyed on the next assemble.
Every wiring step is therefore a script that injects a *marked* block and replaces its own
previous output:

```js
const MARK_OPEN = "<!-- wire-grade:start -->";
const MARK_CLOSE = "<!-- wire-grade:end -->";

let html = readFileSync(INDEX, "utf8");
html = html.replace(new RegExp(`\\n?[ \\t]*${MARK_OPEN}[\\s\\S]*?${MARK_CLOSE}`, "g"), "");
// … build `block`, wrapped in the two markers …
const anchor = html.lastIndexOf("</body>");
writeFileSync(INDEX, html.slice(0, anchor) + block + "\n  " + html.slice(anchor));
```

An injector that needs film-time positions reads them off the assembled DOM rather than
trusting a config — the frame wrappers are the source of truth for both frame starts and
total length:

```js
let total = 0;
for (const m of html.matchAll(/data-composition-src="compositions\/frames\/[^"]+\.html"[^>]*>/g)) {
  const start = Number(m[0].match(/data-start="([\d.]+)"/)?.[1] ?? NaN);
  const dur   = Number(m[0].match(/data-duration="([\d.]+)"/)?.[1] ?? NaN);
  if (Number.isFinite(start) && Number.isFinite(dur)) total = Math.max(total, start + dur);
}
if (!total) throw new Error("no frame wrappers found — assemble first");
```

**After any re-assemble, re-run the entire chain, in order.** Each step depends on the one
before it: SFX cues resolve against frame starts that exist only *after* transitions are
injected, and the grade's span is the total those wrappers imply.

```bash
node scripts/assemble.mjs        # writes index.html from film.json + the measured VO
node scripts/transitions.mjs     # the crossings; shifts nothing else
node scripts/wire-audio.mjs      # music bed + word-locked SFX, resolved against real frame starts
node scripts/wire-grade.mjs      # grain + vignette + specular sweeps, spanning the real total
```

## Frame durations come from the voiceover

Never estimate a frame length. Render the narration first, measure it, derive the frame from
it — `references/03-word-locked-sync.md` covers why this ordering is not optional. The
relationship has two parts:

- **`hold` = the measured VO length + a pad.** The pad — ~0.25s — is the breath after the
  last word. Without it the cut lands on the final syllable and the shot reads as rushed
  even when every tween inside it was right. Raise it on a beat you want to land.
- **The next frame starts at `hold`**, not when the clip ends.
- **A frame's `data-duration` = `hold` + the outgoing transition's duration.** That second
  surplus is the overlap the dissolve or zoom-through lives in.

Example, from the reference film: frame 08's VO file measures 5.968s; a 0.332s pad makes
its hold 6.3; a 0.5s dissolve makes its `data-duration` 6.8 against a `data-start` of 24.6;
and frame 09 starts at 30.9 — exactly where the dissolve tween is injected.

`scripts/assemble.mjs` does all of this from `film.json`, calling `ffprobe` for the measured
length, so the only numbers you ever type are the pad and the transition duration — the two
that are actually creative decisions.

For a separate reason, a frame's own timeline should also finish **before** its
`data-duration` runs out, so the shot lands and holds instead of cutting mid-move: in the
proof frame the last cued tween ends at ~3.32s inside a 4.5s clip; the rest is deliberate
stillness (law 7).

## On another renderer

### Remotion

The determinism law is identical and Remotion makes it the default mental model rather than
a rule you must not break. What changes is the plumbing.

| HyperFrames | Remotion |
|---|---|
| Renderer seeks `window.__timelines[id]` | `useCurrentFrame()` inside the component |
| `.clip` + `data-start` / `data-duration` | `<Sequence from={…} durationInFrames={…}>` |
| Root `data-duration` / `data-width` / `data-height` | `<Composition durationInFrames fps width height>` |
| GSAP eases, `back.out` to fake a settle | `spring({ frame, fps, config })` — real damped physics |
| Hand-rolled CSS/SVG grain, vignette, sweeps | `<HtmlInCanvas>` → a WebGL fragment shader over live DOM |
| `<audio id>` clips discovered by id | `<Audio src>` inside a `<Sequence>` |

**Ports cleanly:** the storyboard, the word-locked cue times, the palette and type, and every
determinism rule — no `Math.random`, no `Date.now`, index-derived scatter, frame-seeded grain.

**Does not port:** the timeline itself. Per-element React composition replaces one global
timeline, so a 40s+ multi-scene film means re-solving audio and word-sync in a different
shape. What you gain, measured: `spring()` produces a true damped oscillation — a mark ring
went **95 → 107px peak → 91px undershoot → 95px settle**, which `back.out` only approximates
— and the shader route buys bright-pass bloom, radial chromatic aberration and grain in ~40
lines over live DOM. What it costs: `<HtmlInCanvas>` needs Chrome 149+ with
`canvas-draw-element`, WebGL needs `--gl=angle`, and grain is expensive in H.264 either way
(that proof clip: **30 MB** raw, **9.1 MB** after the same `-crf 19 -tune film` master, 204
frames at 1920×1080). Verdict from the bake-off: keep the seek-based host for the long film,
reach for Remotion for a hero title beat wanting a GPU post pass or a real spring.

### The other runtimes

**Lottie** is worth mounting for vector stroke draw-on and for moving a gradient along a
path, which CSS cannot do cleanly. One trap, found live: an animated **multi-dimensional
layer transform silently blanks the whole layer** in `lottie-web` — it plays in preview and
renders empty. Keep layer transforms static and drive motion from GSAP on the container.
Cue by shifting keyframe times, not by the layer's start-time field, which also blanks it.

**Rive**'s runtime is genuinely seek-deterministic — 150 frames rendered twice produced 150
identical md5s — but **authoring is GUI-only**. A `.riv` is a compiled binary, and the one
programmatic generator available produced files that render blank or crash the WASM: an
agent cannot author one. Mount and seek a human-authored file, or skip it — Rive's real
differentiators (state machines, data binding) are interactivity primitives a linear film
never uses.

**Three.js** is the only route to a real camera and real lights without a GPU renderer:
actual perspective, image-based reflections, specular that slides across a surface as the
camera moves. Drive it from the same one paused timeline (`tl.time(t)` → apply → render) and
it stays deterministic. One rule: **tone-map before bloom** — an `UnrealBloomPass` over a
near-white product screenshot blows out to an unreadable white cloud, and a tone-mapped
screen plus a tight, low-strength halo is the fix. Spend it on one hero or lockup shot, not
on flat UI-tour frames.
````

## File: legacy/references/02-story-and-truth.md
size: 21191
sha: 3293aa3dfb3b15f83862381787ee52652b8733ed
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/02-story-and-truth.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/02-story-and-truth.md

## File: legacy/references/03-word-locked-sync.md
size: 9461
sha: 5c2a032a4f02c9d4efd811f71b53118f9625dcd7
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/03-word-locked-sync.md

````text
# Word-locked sync

Owns Law 2. This is the difference between animation that illustrates narration and
animation that merely accompanies it.

The rule: **every reveal is cued to the measured start time of the word it illustrates.**
Not to a beat grid, not to a stopwatch estimate, not to "roughly when he says it" — to a
word-level transcript of the actual voiceover file you are shipping.

Viewers cannot articulate this, but they feel it instantly. A chip that appears 200ms
after the word that names it reads as lag. The same chip on the word reads as the voice
causing it.

## Contents

- [Voiceover first](#voiceover-first)
- [Getting word timings](#getting-word-timings)
- [The cue table](#the-cue-table)
- [Cueing in practice](#cueing-in-practice)
- [Deriving frame durations](#deriving-frame-durations)
- [Pacing and holds](#pacing-and-holds)
- [Re-cutting the narration](#re-cutting-the-narration)
- [Choosing a voice](#choosing-a-voice)

## Voiceover first

Render or record the voiceover **before building a single frame.** Frame durations are
derived from real narration length; if you build to estimates you will rebuild every
timeline when the real audio comes in half a second longer.

This inverts the instinct to storyboard visually first. Storyboard the *shots* first, yes —
but the moment you need a number, that number comes from the wav.

Order of operations:

1. Write the script (`references/02-story-and-truth.md`)
2. Render one audio file per narration line
3. Transcribe with word-level timestamps
4. Derive each frame's duration from its line's length, plus a tail hold
5. Only now, build frames

## Getting word timings

Any transcriber that returns word-level timestamps works. Whisper locally is the usual
choice because it is free, offline and accurate enough:

```bash
whisper assets/voice/01.wav --model small --word_timestamps True --output_format json
```

Normalise whatever you get into one table the frames can read:

```bash
node scripts/word-timings.mjs --transcript transcript.json --out audio_meta.json
```

```json
{
  "01-hook": {
    "duration": 4.02,
    "words": [
      { "w": "Find",   "start": 0.11, "end": 0.42 },
      { "w": "the",    "start": 0.42, "end": 0.55 },
      { "w": "creators","start": 0.55, "end": 1.08 }
    ]
  }
}
```

Two practical notes:

- **Timings are per frame, frame-relative.** A cue inside a frame is expressed relative to
  that frame's start, so re-ordering frames does not invalidate the cues.
- **Spot-check the first and last word of every line.** Transcribers clip leading
  consonants and occasionally merge two short words; a wrong start on the first word
  offsets an entire shot.

## The cue table

Write the timings you actually use into a comment table at the top of the frame, then cue
against it. This is the artefact that makes a frame reviewable — a reader can check the
sync without opening an audio editor.

```js
/* VO: "It searches three hundred and fifty million creators and finds the contact."
   "searches"@0.16  "350"@0.77  "million"@1.47  "creators"@2.02
   "and"@2.68  "finds"@2.84  "the"@3.15  "contact."@3.33                        */

tl.to(glow, { opacity: 1, duration: 0.22, ease: "power2.out" }, 0.16);   // "searches"
tl.to(count, { opacity: 1, duration: 0.22, ease: "power2.out" }, 2.02);  // "creators"
click(1, 2.84);                                                          // "finds"
```

Every cue carries the word in a trailing comment. When someone later asks "why 2.84?", the
answer is in the file.

## Cueing in practice

**Cue the meaning, not the noun.** The word to lock to is the one that makes the visual
make sense — usually a verb or the emphasis word, not the subject.

**Lead by a hair when the eye needs travel time.** An element that must be *read* can start
0.02–0.06s early so that it is legible on the word. An element that is a *hit* (a click, a
state flip, a stat landing) fires exactly on the word.

**Lead the travel, land the beat.** A cursor crossing a card needs 0.2–0.3s of eased
travel. Start the move early; put the *click* on the word. The word-locked event is the
one the viewer connects to the sentence.

**Count-ups start on the word that names them and finish before the next clause.** A
counter still spinning while the voice has moved on reads as a stall.

**Weight reveals to the back half of a shot.** At t=0 a frame carries only what the voice is
saying at t=0. Everything else arrives as it is spoken. This single habit is what stops a
shot from being a slide that fades in complete.

**One beat can carry two cues, never three.** If three things must land on one word, you
have a storyboard problem, not a timing problem.

## Deriving frame durations

```
frame duration = narration length + tail hold + transition pad
```

The tail hold is the breath after the line — 0.2–0.5s, longer before a hero beat because
silence is what makes the next hit land (`references/07-sound-and-master.md`). The
transition pad is whatever the outgoing transition needs to overlap the next frame.

Set the root's `data-duration` and every timed child to the same padded value. Mismatched
durations between a frame root and its clips is a common source of a frame that goes blank
just before the cut.

## Pacing and holds

Pace is a direction note, not a constant. The reference film was re-cut 10% faster after a
first pass that dragged, and then given tail holds after a second pass that was too
clipped — the final film is *faster in the clauses and slower at the ends* than either.

Practical guidance:

- If the voice finishes a clause and the visuals are still resolving, the shot is too slow.
- If a shot cuts within ~0.2s of its last motion, it is too fast — the viewer has no time
  to read the state you just built.
- Put a genuine breather on either side of the busiest stretch. Two consecutive dense
  product shots exhaust an audience faster than one long one.

## Re-cutting the narration

If you speed up or re-render the VO, **every cue in the film is now wrong.** Two options:

1. Re-transcribe and re-derive all timings (correct, and what you should do).
2. If you applied a uniform tempo change (`atempo=1.1`), rescale the existing word table by
   the same factor (`start / 1.1`) — valid only for a uniform change, and worth
   spot-checking against the new wav at three points before trusting it.

Changing voice, speaker or model is never a uniform change. Re-transcribe.

## When there is no voiceover

A silent film is a legitimate and often correct choice — feed placements autoplay muted, and
a narrated skincare film is the category default you may want to break. But Law 2 as written
assumes words to lock to, and "cue to a beat grid instead" is exactly what it forbids. So
the law generalises rather than lapses:

> **Every reveal is cued to the thing that motivates it.** With narration, that is the word.
> Without narration, it is a physical event or the moment a viewer has finished reading.

Three sources of truth replace the transcript:

1. **Sound-design events.** If the film is sound-led, the cue grid is the SFX transients, and
   you build it the other way round: place the sound events first, measure their onsets
   exactly as you would words (`volumedetect` in 100ms slices, or read them off the asset),
   and cue the visuals to those. The discipline is identical — measured onsets, never
   estimates.
2. **Reading time.** A title card cannot be cued; it must be *held*. Budget a minimum of
   ~0.35s per word plus 0.3s of settle, never less than 1.2s for any card, and longer for a
   card carrying a number. Read it aloud at a natural pace and time yourself — if you can
   finish comfortably, a viewer can.
3. **The action itself.** In a demonstration, the motivating event is on screen: the lid
   lifts, the row resolves, the value lands. Cue everything else to that.

Two things do not change. Durations still come from measured content rather than estimates —
you are measuring reading time and sound onsets instead of speech. And nothing is arbitrary:
if you cannot say what a reveal is cued *to*, it is cued to nothing, which is the definition
of a slideshow.

## Choosing a voice

| Option | Cost | Trade-off |
|---|---|---|
| **Local neural TTS** (e.g. Kokoro-82M) | Free, offline, CPU | Fully deterministic and re-runnable, but audibly synthetic — acceptable for internal and iterative work, noticeable on a public launch |
| **Commercial TTS** | Per-character | Best quality-per-effort for a public film; pin the voice and model version, because a model update will change your timings |
| **Human recording** | Time or money | Still the best, and the only option that can carry irony. Record before the storyboard is fixed, not after |

Whichever you choose, the pipeline is identical: a wav plus word timings. That is the whole
interface, which is what makes the voice swappable at all.

One caveat learned the hard way: some TTS drivers silently ignore a `--speed` flag. If
tempo matters, verify the output length rather than trusting the parameter, and apply tempo
changes with ffmpeg (`atempo`) where you can measure them.

## Related

- `references/02-story-and-truth.md` — writing the script the voice will read
- `references/04-motion-grammar.md` — how long a cued entrance should take
- `references/07-sound-and-master.md` — SFX cues on the same word grid
- `scripts/word-timings.mjs` — transcript → cue table
````

## File: legacy/references/04-motion-grammar.md
size: 20027
sha: 5e9bbc1a969c4ce70aaa30377d62a625df81191e
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/04-motion-grammar.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/04-motion-grammar.md

## File: legacy/references/05-camera-3d-cursor.md
size: 12724
sha: 94ac0478139c4e7a1d68f33978e15646968afec6
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/05-camera-3d-cursor.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/05-camera-3d-cursor.md

## File: legacy/references/06-look-and-grade.md
size: 18729
sha: 8e0feaac9df6def6a74d483445f945ba2abeb911
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/06-look-and-grade.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/06-look-and-grade.md

## File: legacy/references/07-sound-and-master.md
size: 10514
sha: 4f6d7c5f1233568e792bf38537c2d5165ac010e1
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/07-sound-and-master.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/07-sound-and-master.md

## File: legacy/references/08-qa-and-direction.md
size: 11984
sha: baf57cd590a6dd41cc806aa486512206ca8e18e6
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/08-qa-and-direction.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/08-qa-and-direction.md

## File: legacy/references/09-shot-catalog.md
size: 39820
sha: 158d37611f7659a39019d61edeb6ed398c786099
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/09-shot-catalog.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/09-shot-catalog.md

## File: legacy/references/10-traps.md
size: 14957
sha: 4e1841bbc712089e474431ff3fd910eab9655261
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/10-traps.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/10-traps.md

## File: legacy/references/11-creative-direction.md
size: 17826
sha: 90cf1120ec408378194c5df5bc4e07224f897104
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/11-creative-direction.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/11-creative-direction.md

## File: legacy/references/12-deliverables.md
size: 7285
sha: e08299f150f34a2cba082dacca33adee6483787d
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/12-deliverables.md

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/references/12-deliverables.md

## File: legacy/scripts/assemble.mjs
size: 9484
sha: 3e0aa8642ed24d911f90a431bfe605b44a4bb1b0
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/assemble.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/assemble.mjs

## File: legacy/scripts/level-sfx.mjs
size: 8673
sha: c77fd8725894ba464f680cf2a5076842f72b8e56
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/level-sfx.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/level-sfx.mjs

## File: legacy/scripts/master.sh
size: 3923
sha: 8abe5f1a1168602b8c2ed3f188cc3c88c767888b
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/master.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/master.sh

## File: legacy/scripts/transitions.mjs
size: 7369
sha: 6423cb9196eabb50238304d5b15e632a19c3aa33
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/transitions.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/transitions.mjs

## File: legacy/scripts/verify-cue.sh
size: 3523
sha: f0019fdaeaad00207a42cb56e32cf0e9b91285be
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/verify-cue.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/verify-cue.sh

## File: legacy/scripts/wire-audio.mjs
size: 5434
sha: 480b5d3295412961ad26a7cb0a27d5d97b712ec3
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/wire-audio.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/wire-audio.mjs

## File: legacy/scripts/wire-grade.mjs
size: 9965
sha: 54cada2fd1c7acbc7ef7b62f27f06804524b919a
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/wire-grade.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/wire-grade.mjs

## File: legacy/scripts/word-timings.mjs
size: 4698
sha: 5ce4c8bb5b154c2d5830ef7f68beb5c53b196987
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/word-timings.mjs

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/scripts/word-timings.mjs

## File: legacy/still-1.jpg
size: 17336
sha: 0813cdb95d1e2d2262d7050ac3d9af85f2328cce
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/still-1.jpg

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/legacy/still-1.jpg

## File: LICENSE
size: 1069
sha: 7f003f7cda767331f4ce51b7f034426d8a80d926
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/LICENSE

```text
MIT License

Copyright (c) 2026 Abubakr Chan

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

## File: NOTICE
size: 3578
sha: 667c90624d172a140b61863e51e5931a9c2fa233
upstream: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/NOTICE

Content is not inlined in the catalog. Read the upstream file: https://github.com/abubakrchan/product-launch-motion/blob/HEAD/NOTICE
