⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

product-launch-motion

@abubakrchan⭐ 378 stars

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).

Choose how to use this skill

You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.

1. Native installer

This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.

Do not guess an installer command or replace an existing version without reviewing the diff.

2. Complete package recommended

Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.

No complete ProSkills package is published for this listing yet.

3. Prompt-only

Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.

Need only the instruction file?

Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.

No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.

—/10

// RATINGS

⭐GitHub Stars
⭐⭐⭐ 378 on GitHubGitHub ↗

Popular

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

// README

Product Launch Motion — a Claude Code Skill

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

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

No Claude Code? Get the film made for you: a polished launch video in 30 minutes, from $20 → 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.

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/.

🔊 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) to watch it as intended.

30 s, made for 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 (refractive 3D shapes floating through every scene).

▶ One shape, never cut (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.

Hoopoe Club launch film: several readers' cursors like, repost and reply to a post, then vote in a poll

▶ Hoopoe Club · 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 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

PathWhat it is
SKILL.mdThe creative direction and the process Claude follows
references/prompts.mdThe original prompts and every review note, verbatim. Swap the product and reuse them
references/technique.mdSprings, camera, cursor, odometer, audio and render code patterns
legacy/The v1 skill: voiceover-led films with GSAP + HyperFrames, 34 traps

Requirements

NeedFor
Claude Code with Opus 5.5Directing and building the film. Smaller models follow the rules but miss the detail
Node ≥ 20 + PlaywrightRendering
FFmpegMotion blur, encoding, loudness checks
Python 3 + numpyBeat grid and synthesized SFX
A ~120 BPM royalty-free songOptional. 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
TimingLocked to voiceover wordsLocked to the music's measured beat grid
MotionGSAP timelinesClosed-form springs, pure seek(t)
RendererHyperFramesPlaywright subframes + ffmpeg tmix motion blur
StructureShots and transitionsOne shape that never cuts
SoundLibrary SFX, levelledSynthesized SFX placed by measured peak
DepthCSS camera rigCamera springs + optional WebGL glass

v1 is still the better choice for a narrated explainer. It lives on in 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:
    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. Made a film with this skill? Tag me on X and I'll share it.

Follow on X [LinkedIn](https://www.linkedin.com/in/muhamma

// HOW IT'S BUILT

// FILES

42 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.

  • legacy/SKILL.md17 KB5979971a4501upstream ↗
    ---
    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.
    
  • SKILL.md8.9 KB2e0f5fb42c19upstream ↗
    ---
    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.
    
  • README.md8.2 KB85a3c4469ba8upstream ↗
    # 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).
    
  • references/prompts.md6.8 KB88bcfcda54f6upstream ↗
    # 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.
    
  • references/technique.md5.5 KB07e25ce4decdupstream ↗
    # 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.
    
  • .github/FUNDING.yml20 B84a0b6301ea1upstream ↗
    github: AbubakrChan
    
  • .gitignore50 B3122e74e755aupstream ↗
    node_modules/
    renders/
    snapshots/
    .DS_Store
    *.log
    
  • examples/film-preview.webp17.5 MBf255d29dc3fdupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/hoopoe-preview.webp2.1 MB0333b76897cbupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • examples/one-shape-loop.webp3.2 MB48dadd0bf4acupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/assets/BRIEF.md3.0 KBb9c292e854c5upstream ↗
    # 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.
    
  • legacy/assets/cues.example.json3.2 KB7dad42e8ff65upstream ↗
    {
      "_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" }
        ]
      }
    }
    
  • legacy/assets/film.example.json3.4 KBced9e2fd4bd8upstream ↗
    {
      "_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
        }
      ]
    }
    
  • legacy/assets/frame-skeleton.html17 KBdf89e68de226upstream ↗
    <!--
      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>
    
  • legacy/assets/STORYBOARD.md2.7 KBc196fe6906d6upstream ↗
    ---
    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
    
  • legacy/CASE-STUDY.md13 KB4cd2bfa0b728upstream ↗
    # 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.
    
  • legacy/evals/BASELINE.md7.5 KB1b2bd88416b7upstream ↗
    # 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.
    
  • legacy/evals/evals.json5.3 KB2c3b82716826upstream ↗
    {
      "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": []
        }
      ]
    }
    
  • legacy/film-preview.webp976 KB84e08dcfeac3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/01-renderer-contract.md15 KB459fdc704d55upstream ↗
    # 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.
    
  • legacy/references/02-story-and-truth.md21 KB3293aa3dfb3bupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/03-word-locked-sync.md9.2 KB5c2a032a4f02upstream ↗
    # 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
    
  • legacy/references/04-motion-grammar.md20 KB5e9bbc1a969cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/05-camera-3d-cursor.md12 KB94ac0478139cupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/06-look-and-grade.md18 KB8e0feaac9df6upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/07-sound-and-master.md10 KB4f6d7c5f1233upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/08-qa-and-direction.md12 KBbaf57cd590a6upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/09-shot-catalog.md39 KB158d37611f76upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/10-traps.md15 KB4e1841bbc712upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/11-creative-direction.md17 KB90cf1120ec40upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/references/12-deliverables.md7.1 KBe08299f150f3upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/assemble.mjs9.3 KB3e0aa8642ed2upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/level-sfx.mjs8.5 KBc77fd8725894upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/master.sh3.8 KB8abe5f1a1168upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/transitions.mjs7.2 KB6423cb9196eaupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/verify-cue.sh3.4 KBf0019fdaeaadupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/wire-audio.mjs5.3 KB480b5d329541upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/wire-grade.mjs9.7 KB54cada2fd1c7upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/scripts/word-timings.mjs4.6 KB5ce4c8bb5b15upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • legacy/still-1.jpg17 KB0813cdb95d1eupstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

  • LICENSE1.0 KB7f003f7cda76upstream ↗
    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.
    
  • NOTICE3.5 KB667c90624d17upstream ↗

    Contents are not inlined in the catalog. Open the upstream file.

Plain-text copy, no login: /skill-bundles/abubakrchan-product-launch-motion.md

// REPO STATS

378 stars