Back to Skills
heygen-com/hyperframesReview behavior before running

SKILL DETAIL

music-to-video

heygen-com/hyperframes/music-to-video

This skill turns a music track into a beat-synced HyperFrames video. You analyze the track once, lay out the frames, fill in a per-frame plan, and build each frame as a composition. The input is a music track plus optional user images or videos — there is no narration and no website capture. Typography and templates are the floor (a complete video needs zero assets); any media the user supplies is cut in on the same beat grid. You are the orchestrator. Work in `videos/<project>/`. Run the steps in order and pass each Gate before moving on. Two steps need the user: Step 3 (plan approval) and Step 6 (render approval) — both are checkpoint gates. Do every step yourself except Step 4, where you dispatch one sub-agent per frame. Keep design and motion rules out of this file — they live in `references/` and the `frame-worker` sub-agent.

Installs · 1,162View source

Installation

npx skills add https://github.com/heygen-com/hyperframes --skill music-to-video

Skill files

SKILL.md

Last synced · Aug 29, 2026

references/frame-skeleton.md
# Frame skeleton (Step 2) — read the music, lay out the frames

At Step 2 **you (the orchestrator)** read `audiomap.json` and write the **skeleton** of
`STORYBOARD.md` directly: cut the track into **frames** (one frame = one composition file =
one scene), and for each frame set its **span**, its **pacing** (does this stretch want hard
beat-cuts, or calm phrase/energy flow?), its **mood**, and a one-line **feel** note.

You **classify and lay out the spine only.** You do **not** pick templates, write copy, choose
colors/fonts, or decide a frame's groups — those are Step 3 (the plan fills each frame in
place). Leave every frame's `### Groups` as `TBD (Step 3)` and the frontmatter `style` blank.

There is **no intermediate JSON** — the skeleton _is_ the start of `STORYBOARD.md`. Step 3
edits the same file.

## The trust boundary (read this first)

`audiomap.json` is one analyzer's output. Some fields are robust on **any** music; some are
reliable only when the music is **actually rhythmic**. This decides each frame's `pacing`:

| Field                                                                                                                                                                                               | Trust                                                                                                                                                                                                                        |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `energy_phases[]` (level / energy / density / feel), `events[]` + `onset_rate`, `rolls[]` (and their **absence**), `silences[]`, `hard_stops[]`, `key_moments[]`, `phrases[]`, `audio.duration_sec` | **Always** — robust measurements                                                                                                                                                                                             |
| `tempo.bpm`, `grid.beats_sec` / `downbeats_sec` **precision**                                                                                                                                       | **Only when the music is rhythmic.** On calm / sparse material the beat grid is a metronome the tracker _imposes_ (often octave-doubled) — usually **more grid beats than real onsets**. Do **not** anchor cuts to it there. |

- **Grid is reliable** when: rolls present, and/or dense phases, and/or high `onset_rate` with a steady grid.
- **Grid is fictional** when: `rolls`≈0, mostly `sparse` phases, low `onset_rate` → pace by `phrases[]` + `energy_phases[]`, not beats.

## How to lay out frames (run in order)

1. **Gestalt.** From `summary` / `tempo` / `audio.duration_sec` + the roll count, write the
   frontmatter `compositionId`, `duration_s` (== `audio.duration_sec`), `canvas`, and a
   one-line read of the track's density arc.
2. **Cut into frames.** Walk `energy_phases[]` and split where the music genuinely changes
   state — at `hard_stops[]`, `SURGE` / `DROP` `key_moments`, the start/end of a `rolls[]` run,
   an **onset desert** (a long gap in `events[]`), or a big energy-level jump. Collapse adjacent
   phases that are one gesture. Expect **~1–6 frames**; a short clip may be one.
   **Snap every boundary to an audiomap anchor**, then re-snap to the nearest `beats_sec`
   (tolerance ≤ ½ beat) **only when the grid is reliable**; on calm material snap to
   `phrases[]` / `energy_phases[]` edges instead. Frames **tile the track** (first at 0, last
   at `duration_s`, no gaps/overlaps).
3. **Per frame, set `pacing`** — the trust-boundary call:
   - **`beat_cut`** — genuinely rhythmic: a roll present, **or** dense, **or** (clearly high `onset_rate` **and** a steady grid). Hard cuts / per-onset reveals may anchor to beats.
   - **`phrase_flow`** — calm / sparse: `rolls`≈0, mostly sparse, low `onset_rate`. Do **not** anchor hard cuts to the grid; pace by `phrases[]` + the energy envelope (slow crossfades, long holds).
4. **Per frame, tag `mood`** (1–3 of: `warm` · `dark` · `hype` · `elegant` · `glitch` ·
   `cinematic` · `playful` · `tense` · `dreamy` · `aggressive`) from `energy_phases[].feel` +
   energy + any genre cue in the brief/title.
5. **Per frame, write a one-line `feel`** — the plain-language music situation Step 3 matches a
   template against (e.g. "accelerating onset stream into a held downbeat", "calm held pad, one
   onset desert", "fast sustained-fill roll, no readable message"). This is what the planner
   reads against the catalog's **Reach for it when** — keep it concrete, drawn from the robust
   fields, never invented.

## What the skeleton looks like

A valid `STORYBOARD.md` with the spine set and every frame's treatment left for Step 3
(full syntax in [`storyboard-format.md`](storyboard-format.md)):

```markdown
---
compositionId: bgm
duration_s: 30.0 # == audiomap.audio.duration_sec
canvas: { w: 1920, h: 1080, fps: 30 }
style: # blank — Step 3 fills it from the chosen frame.md preset
build_notes: ["one paused timeline per frame", "no remote assets"]
---

## Frame 1 — f1

- src: compositions/frames/01-f1.html
- duration: 7.198s # = span length; assembler sums these for cumulative data-start
- span_sec: [0.0, 7.198] # track seconds; frames tile the track
- pacing: beat_cut
- mood: [hype]
- feel: accelerating onset stream building into a held downbeat

### Groups

- TBD (Step 3)

## Frame 2 — f2

- src: compositions/frames/02-f2.html
- duration: 10.4s
- span_sec: [7.198, 17.598]
- pacing: phrase_flow
- mood: [warm, cinematic]
- feel: calm held pad, one long onset desert

### Groups

- TBD (Step 3)
```

## Self-check

- `duration_s == audiomap.audio.duration_sec`; frames tile the track gap-free (first at 0, last at `duration_s`).
- Every frame has `src` + `span_sec` + `duration` + `pacing` + `mood` + a one-line `feel`.
- `pacing` was set from the **robust** fields (energy / density / rolls / onset_rate), never from `bpm` / `beats_sec` alone.
- No frame boundary sits inside a `rolls[]` run or leaves a sub-1-bar fragment.
- Every frame's `### Groups` is `TBD (Step 3)`; `style` is blank. **No template, copy, color, or font anywhere.**
references/montage.md
# Asset treatments — weaving user media onto the beat spine

When the user supplies images/videos, a group can be an **asset treatment** instead of a
typographic template/free-compose. Assets are an **additive ingredient on the same beat
spine** — never a separate pipeline. Typography/templates stay the floor: if no asset fits a
group, fall back to a template/free group (a complete video needs zero assets).

The planner (Step 3) picks the treatment and the clips + anchors (WHAT); the frame-worker
realizes it inside the frame file (HOW). **Obey the frame's `pacing`.**

## The three treatments

### `beat_cut` — one clip per anchor (only on a `beat_cut` frame)

The asset-driven analogue of a per-onset typographic group: cut to a new clip on each anchor
(the frame's beats/onsets from the audiomap). Each clip is a `class="clip"` element
(`<img>` for a photo, **muted** `<video>` for a motion clip) placed at its anchor with
`data-start`/`data-duration`/`data-track-index` per the core clip contract. Between clips,
crossfade the outgoing content to `opacity:0` ending **at** the next anchor, then immediately
**hard-kill** with a `tl.set(..., {opacity:0}, anchor)` — this pair is required (the
`gsap_exit_missing_hard_kill` lint rule; non-linear seeking otherwise bleeds stale frames).
Cut on the **strong** anchors; land a hero clip on a `key_moment`/downbeat.

### `ken_burns` — slow push on one clip (fits a `phrase_flow` frame)

For calm frames: one clip held over the span with a slow scale/translate push (e.g. scale
1.0→1.08 + a small drift) eased across the whole `span_sec` — paced by the frame, not by
beats. No hard cuts. Crossfade in/out at the frame edges. This is the right asset treatment
when the beat grid is unreliable (calm music).

### `bg_under_text` — clip dimmed behind a template/free group

A full-bleed clip dimmed ~30–50% as the background of a group whose foreground is a template
or free-compose typographic treatment. The text rides on the same anchors; the clip is the
bed. Use when the user wants their footage present but the message must stay readable.

## Rules

- **`pacing` decides the treatment**: `beat_cut` only on a `beat_cut` frame; on a
  `phrase_flow` frame use `ken_burns` or a slow crossfade — **never** per-onset hard cuts on
  the (unreliable) calm grid.
- **Clips are muted; the root owns audio.** Mount each `<video class="clip">` **muted**, as a
  direct child of the frame root (never nested in another timed element, or the renderer
  freezes it). The BGM is the only audio in v1.
- **Crossfades animate `opacity`/`autoAlpha`**, never `visibility`/`display` on a `.clip`
  (the framework owns clip visibility — that trips `gsap_animates_clip_element`).
- **Backgrounds dim ~30–50%** so any foreground text stays legible.
- Anchors are **track seconds from `audiomap.json`**; the worker subtracts the frame start
  to get frame-local time.
- Local staged assets only (`assets/` via `stage-assets.mjs`); never remote URLs.

## Deferred hook (not v1)

A clip that should play **its own sound** (interview cut, lyric clip) needs a sibling
`<audio>` mounted at the **root** by the assembler, with the BGM ducked under it (timeline
volume automation: `tl.to("#el-bgm",{volume:0.15,…},clipStart)` … `tl.to(…,{volume:0.9,…},
clipEnd)`). The frame-worker mounts no audio. Keep clips muted in v1; wire clip-audio +
ducking only when the user asks.
references/motion-primitive-catalog.md
# Motion-primitive catalog — the free-compose menu

The atomic layer: one anchor → one micro-move. When no template fits a group, free-compose by
naming primitives from here. Scan **anchor** + **best span** + **what it does**, then pick the
smallest set that carries the group.

## Timing & latency (applies to every primitive)

- **Hard hits are 0ms.** Cuts, palette flips, content swaps, freezes are `tl.set(...)` with no duration — the percussion _is_ the motion. Easing a hit kills it.
- **Lead the anchor.** A move that must _land_ on a beat (a wipe covering the frame, a count-up locking, two blocks colliding) starts **~40–190ms early** so it completes ON the anchor. Reactive entrances (something appearing _because_ of the hit) fire 0–45ms after.
- **Eased entrances: 300–500ms** (scale punch, slides, camera pushes). **Macro builds: 800–2000ms** spanning a whole roll / silence.
- **Per-bar caps:** one accumulating element per hit (not a burst); a camera move at most once per phrase, never per beat; a dense flip/strobe system runs ≤2–3s.
- **Tension-builds lock.** A count-up / sequential build / morph must _resolve on_ a downbeat or hard_stop, never trail off mid-bar.
- **Best span means active motion.** The catalog's span guidance is not a license to stretch one primitive over a whole frame. If a free-composed group runs longer than the listed span, add a hold / bed / next primitive, or split the frame into another group at the next musical anchor.

## Catalog

| id                    | anchor                           | best span         | what it does                                                              |
| --------------------- | -------------------------------- | ----------------- | ------------------------------------------------------------------------- |
| `hypercut-whip`       | beat / hard_stop                 | 0.18-0.45s        | fast whip-pan hard cut between frames                                     |
| `kinetic-letter-in`   | downbeat / phrase                | 0.4-1.2s          | per-letter kinetic entrance                                               |
| `braam-punch`         | drop / surge                     | 0.2-0.9s active   | big impact: scale + weight slam                                           |
| `chromatic-split`     | snare / glitch / surge           | 0.1-0.6s          | RGB channel split / glitch on a word                                      |
| `mask-reveal`         | section_start / downbeat         | 0.5-1.2s          | clip-path mask wipe reveal                                                |
| `screen-shake`        | drop / crash / kick              | 0.1-0.5s          | camera / screen shake jitter                                              |
| `binary-decrypt`      | roll / build                     | 0.8-2.5s          | scramble→decode text (binary → word)                                      |
| `dolly-zoom`          | phrase / build                   | 1.2-2.5s          | vertigo dolly-zoom (scale vs perspective)                                 |
| `iris-open`           | section_start / reveal           | 0.6-1.2s          | circular iris-open reveal                                                 |
| `electric-arc`        | accent / glitch                  | 0.1-0.6s          | electric arc / lightning accent                                           |
| `neon-flicker`        | hold / texture                   | 0.5-2.5s          | neon-sign flicker                                                         |
| `chrome-sweep`        | downbeat / reveal                | 0.6-1.4s          | metallic specular sweep across text                                       |
| `slot-machine-reveal` | roll → downbeat                  | 0.8-2.0s          | slot-machine spin-to-land character reveal                                |
| `liquid-morph`        | phrase / transition              | 1.0-2.5s          | liquid / blob morph                                                       |
| `gooey-metaball`      | build / drop                     | 1.5-3.0s          | gooey metaball merge field                                                |
| `3d-card-flip`        | downbeat / swap                  | 0.8-1.6s          | 3D card flip (rotateY)                                                    |
| `crash-zoom-in`       | drop / surge                     | 0.2-0.8s          | violent crash zoom-in                                                     |
| `spotlight-sweep`     | reveal / hold                    | 0.8-2.0s          | spotlight / gradient sweep over text                                      |
| `outline-to-fill`     | downbeat / reveal                | 0.8-1.8s          | stroke outline → solid fill                                               |
| `counting-punch`      | roll → downbeat                  | 1.0-2.5s          | number count-up that punches & locks                                      |
| `particle-burst`      | drop / crash                     | 0.2-1.2s          | particle explosion burst                                                  |
| `radial-burst-lines`  | drop / surge                     | 0.2-0.8s          | radial speed-lines burst                                                  |
| `pixel-dissolve`      | transition / hard_stop           | 0.5-1.5s          | pixelated dissolve                                                        |
| `datamosh-smear`      | glitch / transition              | 0.4-1.2s          | datamosh / motion smear                                                   |
| `text-wave-distort`   | hold / texture                   | 1.0-2.5s          | wavy text distortion                                                      |
| `bg-flow-field`       | energy / whole span (bed)        | 4-12s bed         | generative curl-noise background bed; compose any foreground move over it |
| `blur-resolve`        | stop / final hold                | 0.7-2.0s          | blur-in to crisp focus, then blur-out on the cut                          |
| `chromatic-pressure`  | snare / glitch                   | 0.1-0.5s          | RGB split / digital tension on a transient                                |
| `color-grid-shuffle`  | onset                            | 0ms hits; ≤2s run | grid of cells recolored by a deterministic index per onset                |
| `content-swap`        | beat                             | 0ms hits; ≤3s run | 0ms swap of stacked nodes: the workhorse percussive move                  |
| `directional-fill`    | beat / reveal                    | 0.3-1.0s each     | directional wipe-fill (scaleX) sweeping across bars                       |
| `flash-cut`           | drop / crash                     | 0-0.6s            | full-frame flash masking a word / color state change                      |
| `freeze-hold`         | hard_stop                        | 0ms in; 0.5-2s    | freeze the moving system and hold it                                      |
| `hard-cut`            | beat / hard_stop                 | 0ms in; 0.3-2s    | sample-accurate color-block + word cut                                    |
| `mosaic-pack`         | beat / build                     | 1.5-3.5s          | scattered tiles fly in and pack into a grid                               |
| `negative-space-hold` | silence / hard_stop / final hold | 1-6s hold         | kill busy layers, hold one readable mark in empty space                   |
| `overlay-pop`         | accent                           | 0.2-0.6s in       | badge / lower-third overlay pops in over a base                           |
| `palette-flip`        | section change                   | 0ms flip; 0.5-4s  | same layout re-skins via 0ms palette-variable flips                       |
| `staggered-exit`      | phrase / transition              | 0.4-1.2s          | ordered cascade-out clearing the frame                                    |
| `staggered-reveal`    | build                            | 0.8-2.5s          | ordered cascade-in of a stack / list                                      |
| `system-replace`      | drop / regime change             | 0ms cut           | hard-cut the entire visual system, then boot the new one                  |
| `text-spectral-rays`  | phrase / sweep (hero text)       | 2.5-5s            | volumetric light-rays cast by a wordmark toward a sweeping light cursor   |
| `tile-mosaic`         | build / reveal                   | 1.5-3.5s          | grid of tiles revealed in a diagonal sweep, assembling a poster           |
| `typewriter-reveal`   | roll / build                     | 1.0-3.0s          | character / word type-on with caret                                       |
| `value-counter`       | roll → downbeat                  | 1.0-2.5s          | count-up that locks on a downbeat / hard_stop                             |
| `word-grid-burst`     | onsets → downbeat                | 1.8-3.2s          | grid of words revealed per onset, refocus one on a downbeat               |

## How to combine

- One dominant system per group; layer at most one texture primitive over one structural primitive.
- Structure on strong beats (cuts, camera, `system-replace` → downbeat / phrase / section_start); texture on weak / syncopated hits (`content-swap`, typewriter letters, chromatic accents).
- A roll is an accumulation container — build during it, hard-cut to a clean layout on the downbeat that ends it.
- `drop` ≠ `downbeat`: a downbeat is a cut within the regime; a drop is a regime change (`system-replace`, total clear, element-count jump).
- Let silence remove density (`negative-space-hold`).
- Background beds are a layer, not a move: one bed at a time, under foreground primitives.
- `text-spectral-rays` is the hero wordmark treatment; do not stack another visible copy of the same word on top.
references/motion-primitives/3d-card-flip/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>18 · 3d_card_flip</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .card-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        perspective: 2400px;
      }
      .card {
        position: relative;
        width: 1000px;
        height: 580px;
        transform-style: preserve-3d;
        will-change: transform;
      }
      .face {
        position: absolute;
        inset: 0;
        backface-visibility: hidden;
        display: flex;
        align-items: center;
        justify-content: center;
        font-weight: 900;
        font-size: 200px;
        letter-spacing: -0.04em;
        border-radius: 24px;
        box-shadow:
          0 40px 80px rgba(0, 0, 0, 0.5),
          0 0 0 1px rgba(255, 255, 255, 0.08) inset;
      }
      .face.front {
        background: linear-gradient(135deg, #1e1b3a 0%, #0a0a0f 100%);
        color: var(--hg-fg);
      }
      .face.back {
        background: linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #d946ef 100%);
        color: var(--hg-fg);
        transform: rotateY(180deg);
      }
      .face .meta {
        position: absolute;
        top: 36px;
        left: 40px;
        font-family: "JetBrains Mono", monospace;
        font-weight: 600;
        font-size: 18px;
        letter-spacing: 0.16em;
        opacity: 0.6;
      }
      .card-shadow {
        position: absolute;
        left: 50%;
        bottom: 22%;
        width: 1000px;
        height: 80px;
        background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.55) 0%, transparent 70%);
        transform: translateX(-50%);
        filter: blur(12px);
        will-change: opacity, transform;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="card-shadow"></div>
        <div class="card-stage">
          <div class="card" id="card">
            <div class="face front">
              <div class="meta">v4 · CURRENT</div>
              PRODUCT
            </div>
            <div class="face back">
              <div class="meta">v5 · LAUNCH</div>
              v5.0
            </div>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.fromTo(
        "#card",
        { rotateY: 0, scale: 0.92, opacity: 0 },
        { rotateY: 0, scale: 1, opacity: 1, duration: 0.45, ease: "power3.out" },
        0.15,
      );

      // Hold then flip
      tl.to("#card", { rotateY: 180, duration: 1.0, ease: "power3.inOut" }, 0.7);

      // Shadow squishes mid-flip
      tl.to(
        ".card-shadow",
        { scaleX: 0.6, opacity: 0.4, duration: 0.5, ease: "power2.inOut" },
        0.7,
      );
      tl.to(".card-shadow", { scaleX: 1, opacity: 0.7, duration: 0.5, ease: "power2.inOut" }, 1.2);

      // Tiny landing settle
      tl.to("#card", { scale: 1.02, duration: 0.12, ease: "power2.out" }, 1.68);
      tl.to("#card", { scale: 1, duration: 0.3, ease: "elastic.out(1, 0.5)" }, 1.8);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/assets/gsap.min.js
/*!
 * GSAP 3.15.0
 * https://gsap.com
 * 
 * @license Copyright 2026, GreenSock. All rights reserved.
 * Subject to the terms at https://gsap.com/standard-license.
 * @author: Jack Doyle, [email protected]
 */

!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function R(t){return(i=bt(t,ht))&&Fe}function S(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function T(t,e){return!e&&console.warn(t)}function U(t,e){return t&&(ht[t]=e)&&i&&(i[t]=e)||ht}function V(){return 0}function ga(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=yt.length;r--&&!yt[r].targetTest(i););e=yt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Xt(t[r],e)))||t.splice(r,1);return t}function ha(t){return t._gsap||ga(Pt(t))[0]._gsap}function ia(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ja(t,e){return(t=t.split(",")).forEach(e)||t}function ka(t){return Math.round(1e5*t)/1e5||0}function la(t){return Math.round(1e7*t)/1e7||0}function ma(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function na(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++i<r;);return i<r}function oa(){var t,e,r=pt.length,i=pt.slice(0);for(_t={},t=pt.length=0;t<r;t++)(e=i[t])&&e._lazy&&(e.render(e._lazy[0],e._lazy[1],!0)._lazy=0)}function pa(t){return!!(t._initted||t._startAt||t.add)}function qa(t,e,r,i){pt.length&&!I&&oa(),t.render(e,r,i||!!(I&&e<0&&pa(t))),pt.length&&!I&&oa()}function ra(t){var e=parseFloat(t);return(e||0===e)&&(t+"").match(ot).length<2?e:r(t)?t.trim():t}function sa(t){return t}function ta(t,e){for(var r in e)r in t||(t[r]=e[r]);return t}function wa(t,e){for(var r in e)"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(t[r]=v(e[r])?wa(t[r]||(t[r]={}),e[r]):e[r]);return t}function xa(t,e){var r,i={};for(r in t)r in e||(i[r]=t[r]);return i}function ya(t){var e=t.parent||L,r=t.keyframes?function _setKeyframeDefaults(i){return function(t,e){for(var r in e)r in t||"duration"===r&&i||"ease"===r||(t[r]=e[r])}}(K(t.keyframes)):ta;if(w(t.inherit))for(;e;)r(t,e.vars.defaults),e=e.parent||e._dp;return t}function Aa(t,e,r,i,n){void 0===r&&(r="_first"),void 0===i&&(i="_last");var a,s=t[i];if(n)for(a=e[n];s&&s[n]>a;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function Ba(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function Ca(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Da(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Fa(t,e,r,i){return t._startAt&&(I?t._startAt.revert(ft):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Ha(t){return t._repeat?wt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ja(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ka(t){return t._end=la(t._start+(t._tDur/Math.abs(t._ts||t._rts||q)||0))}function La(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=la(r._time-(0<t._ts?e/t._ts:((t._dirty?t.totalDuration():t._tDur)-e)/-t._ts)),Ka(t),r._dirty||Da(r,t)),t}function Ma(t,e){var r;if((e._time||!e._dur&&e._initted||e._start<t._time&&(e._dur||!e.add))&&(r=Ja(t.rawTime(),e),(!e._dur||Mt(0,e.totalDuration(),r)-e._tTime>q)&&e.render(r,!0)),Da(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur<t.duration())for(r=t;r._dp;)0<=r.rawTime()&&r.totalTime(r._tTime),r=r._dp;t._zTime=-q}}function Na(e,r,i,n){return r.parent&&Ca(r),r._start=la((t(i)?i:i||e!==L?Ot(e,i,r):e._time)+r._delay),r._end=la(r._start+(r.totalDuration()/Math.abs(r.timeScale())||0)),Aa(e,r,"_first","_last",e._sort?"_start":0),xt(r)||(e._recent=r),n||Ma(e,r),e._ts<0&&La(e,e._tTime),e}function Oa(t,e){return(ht.ScrollTrigger||S("scrollTrigger",e))&&ht.ScrollTrigger.create(e,t)}function Pa(t,e,r,i,n){return Ht(t,e,n),t._initted?!r&&t._pt&&!I&&(t._dur&&!1!==t.vars.lazy||!t._dur&&t.vars.lazy)&&f!==It.frame?(pt.push(t),t._lazy=[n,i],1):void 0:1}function Ua(t,e,r,i){var n=t._repeat,a=la(e)||0,s=t._tTime/t._tDur;return s&&!i&&(t._time*=a/t._dur),t._dur=a,t._tDur=n?n<0?1e10:la(a*(n+1)+t._rDelay*n):a,0<s&&!i&&La(t,t._tTime=t._tDur*s),t.parent&&Ka(t),r||Da(t.parent,t),t}function Va(t){return t instanceof Gt?Da(t):Ua(t,t._dur)}function Ya(e,r,i){var n,a,s=t(r[1]),o=(s?2:1)+(e<2?0:1),u=r[o];if(s&&(u.duration=r[1]),u.parent=i,e){for(n=u,a=i;a&&!("immediateRender"in n);)n=a.vars.defaults||{},a=w(a.vars.inherit)&&a.parent;u.immediateRender=w(n.immediateRender),e<2?u.runBackwards=1:u.startAt=r[o-1]}return new te(r[0],u,r[1+o])}function Za(t,e){return t||0===t?e(t):e}function _a(t,e){return r(t)&&(e=ut.exec(t))?e[1]:""}function cb(t,e){return t&&v(t)&&"length"in t&&(!e&&!t.length||t.length-1 in t&&v(t[0]))&&!t.nodeType&&t!==h}function fb(r){return r=Pt(r)[0]||T("Invalid scope")||{},function(t){var e=r.current||r.nativeElement||r;return Pt(t,e.querySelectorAll?e:e===r?T("Invalid scope")||a.createElement("div"):r)}}function gb(t){return t.sort(function(){return.5-Math.random()})}function hb(t){if(s(t))return t;var p=v(t)?t:{each:t},_=jt(p.ease),m=p.from||0,g=parseFloat(p.base)||0,y={},e=0<m&&m<1,T=isNaN(m)||e,b=p.axis,w=m,x=m;return r(m)?w=x={center:.5,edges:.5,end:1}[m]||0:!e&&T&&(w=m[0],x=m[1]),function(t,e,r){var i,n,a,s,o,u,h,l,f,c=(r||p).length,d=y[c];if(!d){if(!(f="auto"===p.grid?0:(p.grid||[1,X])[1])){for(h=-X;h<(h=r[f++].getBoundingClientRect().left)&&f<c;);f<c&&f--}for(d=y[c]=[],i=T?Math.min(f,c)*w-.5:m%f,n=f===X?0:T?c*x/f-.5:m/f|0,l=X,u=h=0;u<c;u++)a=u%f-i,s=n-(u/f|0),d[u]=o=b?Math.abs("y"===b?s:a):$(a*a+s*s),h<o&&(h=o),o<l&&(l=o);"random"===m&&gb(d),d.max=h-l,d.min=l,d.v=c=(parseFloat(p.amount)||parseFloat(p.each)*(c<f?c-1:b?"y"===b?c/f:f:Math.max(f,c/f))||0)*("edges"===m?-1:1),d.b=c<0?g-c:g,d.u=_a(p.amount||p.each)||0,_=_&&c<0?Yt(_):_}return c=(d[t]-d.min)/d.max||0,la(d.b+(_?_(c):c)*d.v)+d.u}}function ib(i){var n=Math.pow(10,((i+"").split(".")[1]||"").length);return function(e){var r=la(Math.round(parseFloat(e)/i)*i*n);return(r-r%1)/n+(t(e)?0:_a(e))}}function jb(h,e){var l,f,r=K(h);return!r&&v(h)&&(l=r=h.radius||X,h.values?(h=Pt(h.values),(f=!t(h[0]))&&(l*=l)):h=ib(h.increment)),Za(e,r?s(h)?function(t){return f=h(t),Math.abs(f-t)<=l?f:t}:function(e){for(var r,i,n=parseFloat(f?e.x:e),a=parseFloat(f?e.y:0),s=X,o=0,u=h.length;u--;)(r=f?(r=h[u].x-n)*r+(i=h[u].y-a)*i:Math.abs(h[u]-n))<s&&(s=r,o=u);return o=!l||s<=l?h[o]:e,f||o===e||t(e)?o:o+_a(e)}:ib(h))}function kb(t,e,r,i){return Za(K(t)?!e:!0===r?!!(r=0):!i,function(){return K(t)?t[~~(Math.random()*t.length)]:(r=r||1e-5)&&(i=r<1?Math.pow(10,(r+"").length-2):1)&&Math.floor(Math.round((t-r/2+Math.random()*(e-t+.99*r))/r)*r*i)/i})}function ob(e,r,t){return Za(t,function(t){return e[~~r(t)]})}function rb(t){return t.replace(tt,function(t){var e=t.indexOf("[")+1,r=t.substring(e||7,e?t.indexOf("]"):t.length-1).split(et);return kb(e?r:+r[0],e?0:+r[1],+r[2]||1e-5)})}function ub(t,e,r){var i,n,a,s=t.labels,o=X;for(i in s)(n=s[i]-e)<0==!!r&&n&&o>(n=Math.abs(n))&&(a=i,o=n);return a}function wb(t){return Ca(t),t.scrollTrigger&&t.scrollTrigger.kill(!!I),t.progress()<1&&At(t,"onInterrupt"),t}function zb(t){if(t)if(t=!t.name&&t.default||t,x()||t.headless){var e=t.name,r=s(t),i=e&&!r&&t.init?function(){this._props=[]}:t,n={init:V,render:_e,add:$t,kill:Te,modifier:ve,rawVars:0},a={targetTest:0,get:0,getSetter:ue,aliases:{},register:0};if(Lt(),t!==i){if(mt[e])return;ta(i,ta(xa(t,n),a)),bt(i.prototype,bt(n,xa(t,a))),mt[i.prop=e]=i,t.targetTest&&(yt.push(i),dt[e]=1),e=("css"===e?"CSS":e.charAt(0).toUpperCase()+e.substr(1))+"Plugin"}U(e,i),t.register&&t.register(Fe,i,we)}else Dt.push(t)}function Cb(t,e,r){return(6*(t+=t<0?1:1<t?-1:0)<1?e+(r-e)*t*6:t<.5?r:3*t<2?e+(r-e)*(2/3-t)*6:e)*zt+.5|0}function Db(e,r,i){var n,a,s,o,u,h,l,f,c,d,p=e?t(e)?[e>>16,e>>8&zt,e&zt]:0:Rt.black;if(!p){if(","===e.substr(-1)&&(e=e.substr(0,e.length-1)),Rt[e])p=Rt[e];else if("#"===e.charAt(0)){if(e.length<6&&(e="#"+(n=e.charAt(1))+n+(a=e.charAt(2))+a+(s=e.charAt(3))+s+(5===e.length?e.charAt(4)+e.charAt(4):"")),9===e.length)return[(p=parseInt(e.substr(1,6),16))>>16,p>>8&zt,p&zt,parseInt(e.substr(7),16)/255];p=[(e=parseInt(e.substr(1),16))>>16,e>>8&zt,e&zt]}else if("hsl"===e.substr(0,3))if(p=d=e.match(rt),r){if(~e.indexOf("="))return p=e.match(it),i&&p.length<4&&(p[3]=1),p}else o=+p[0]%360/360,u=p[1]/100,n=2*(h=p[2]/100)-(a=h<=.5?h*(u+1):h+u-h*u),3<p.length&&(p[3]*=1),p[0]=Cb(o+1/3,n,a),p[1]=Cb(o,n,a),p[2]=Cb(o-1/3,n,a);else p=e.match(rt)||Rt.transparent;p=p.map(Number)}return r&&!d&&(n=p[0]/zt,a=p[1]/zt,s=p[2]/zt,h=((l=Math.max(n,a,s))+(f=Math.min(n,a,s)))/2,l===f?o=u=0:(c=l-f,u=.5<h?c/(2-l-f):c/(l+f),o=l===n?(a-s)/c+(a<s?6:0):l===a?(s-n)/c+2:(n-a)/c+4,o*=60),p[0]=~~(o+.5),p[1]=~~(100*u+.5),p[2]=~~(100*h+.5)),i&&p.length<4&&(p[3]=1),p}function Eb(t){var r=[],i=[],n=-1;return t.split(Et).forEach(function(t){var e=t.match(nt)||[];r.push.apply(r,e),i.push(n+=e.length+1)}),r.c=i,r}function Fb(t,e,r){var i,n,a,s,o="",u=(t+o).match(Et),h=e?"hsla(":"rgba(",l=0;if(!u)return t;if(u=u.map(function(t){return(t=Db(t,e,1))&&h+(e?t[0]+","+t[1]+"%,"+t[2]+"%,"+t[3]:t.join(","))+")"}),r&&(a=Eb(t),(i=r.c).join(o)!==a.c.join(o)))for(s=(n=t.replace(Et,"1").split(nt)).length-1;l<s;l++)o+=n[l]+(~i.indexOf(l)?u.shift()||h+"0,0,0,0)":(a.length?a:u.length?u:r).shift());if(!n)for(s=(n=t.split(Et)).length-1;l<s;l++)o+=n[l]+u[l];return o+n[s]}function Ib(t){var e,r=t.join(" ");if(Et.lastIndex=0,Et.test(r))return e=Ft.test(r),t[1]=Fb(t[1],e),t[0]=Fb(t[0],e,Eb(t[1])),!0}function Rb(t){var e=(t+"").split("("),r=Bt[e[0]];return r&&1<e.length&&r.config?r.config.apply(null,~t.indexOf("{")?[function _parseObjectInString(t){for(var e,r,i,n={},a=t.substr(1,t.length-3).split(":"),s=a[0],o=1,u=a.length;o<u;o++)r=a[o],e=o!==u-1?r.lastIndexOf(","):r.length,i=r.substr(0,e),n[s]=isNaN(i)?i.replace(Ut,"").trim():+i,s=r.substr(e+1).trim();return n}(e[1])]:function _valueInParentheses(t){var e=t.indexOf("(")+1,r=t.indexOf(")"),i=t.indexOf("(",e);return t.substring(e,~i&&i<r?t.indexOf(")",r+1):r)}(t).split(",").map(ra)):Bt._CE&&Nt.test(t)?Bt._CE("",t):r}function Ub(t,e,r,i){void 0===r&&(r=function easeOut(t){return 1-e(1-t)}),void 0===i&&(i=function easeInOut(t){return t<.5?e(2*t)/2:1-e(2*(1-t))/2});var n,a={easeIn:e,easeOut:r,easeInOut:i};return ja(t,function(t){for(var e in Bt[t]=ht[t]=a,Bt[n=t.toLowerCase()]=r,a)Bt[n+("easeIn"===e?".in":"easeOut"===e?".out":".inOut")]=Bt[t+"."+e]=a[e]}),a}function Vb(e){return function(t){return t<.5?(1-e(1-2*t))/2:.5+e(2*(t-.5))/2}}function Wb(r,t,e){function Gm(t){return 1===t?1:i*Math.pow(2,-10*t)*Q((t-a)*n)+1}var i=1<=t?t:1,n=(e||(r?.3:.45))/(t<1?t:1),a=n/G*(Math.asin(1/i)||0),s="out"===r?Gm:"in"===r?function(t){return 1-Gm(1-t)}:Vb(Gm);return n=G/n,s.config=function(t,e){return Wb(r,t,e)},s}function Xb(e,r){function Om(t){return t?--t*t*((r+1)*t+r)+1:0}void 0===r&&(r=1.70158);var t="out"===e?Om:"in"===e?function(t){return 1-Om(1-t)}:Vb(Om);return t.config=function(t){return Xb(e,t)},t}var F,I,l,L,h,n,a,i,o,f,c,d,p,_,m,g,b,k,O,M,C,P,A,D,z,E,B,N,Y={autoSleep:120,force3D:"auto",nullTargetWarn:1,units:{lineHeight:""}},j={duration:.5,overwrite:!1,delay:0},X=1e8,q=1/X,G=2*Math.PI,Z=G/4,W=0,$=Math.sqrt,H=Math.cos,Q=Math.sin,J="function"==typeof ArrayBuffer&&ArrayBuffer.isView||function(){},K=Array.isArray,tt=/random\([^)]+\)/g,et=/,\s*/g,rt=/(?:-?\.?\d|\.)+/gi,it=/[-+=.]*\d+[.e\-+]*\d*[e\-+]*\d*/g,nt=/[-+=.]*\d+[.e-]*\d*[a-z%]*/g,at=/[-+=.]*\d+\.?\d*(?:e-|e\+)?\d*/gi,st=/[+-]=-?[.\d]+/,ot=/[^,'"\[\]\s]+/gi,ut=/^[+\-=e\s\d]*\d+[.\d]*([a-z]*|%)\s*$/i,ht={},lt={suppressEvents:!0,isStart:!0,kill:!1},ft={suppressEvents:!0,kill:!1},ct={suppressEvents:!0},dt={},pt=[],_t={},mt={},gt={},vt=30,yt=[],Tt="",bt=function _merge(t,e){for(var r in e)t[r]=e[r];return t},wt=function _animationCycle(t,e){var r=Math.floor(t=la(t/e));return t&&r===t?r-1:r},xt=function _isFromOrFromStart(t){var e=t.data;return"isFromStart"===e||"isStart"===e},kt={_start:0,endTime:V,totalDuration:V},Ot=function _parsePosition(t,e,i){var n,a,s,o=t.labels,u=t._recent||kt,h=t.duration()>=X?u.endTime(!1):t._dur;return r(e)&&(isNaN(e)||e in o)?(a=e.charAt(0),s="%"===e.substr(-1),n=e.indexOf("="),"<"===a||">"===a?(0<=n&&(e=e.replace(/=/,"")),("<"===a?u._start:u.endTime(0<=u._repeat))+(parseFloat(e.substr(1))||0)*(s?(n<0?u:i).totalDuration()/100:1)):n<0?(e in o||(o[e]=h),o[e]):(a=parseFloat(e.charAt(n-1)+e.substr(n+1)),s&&i&&(a=a/100*(K(i)?i[0]:i).totalDuration()),1<n?_parsePosition(t,e.substr(0,n-1),i)+a:h+a)):null==e?h:+e},Mt=function _clamp(t,e,r){return r<t?t:e<r?e:r},Ct=[].slice,Pt=function toArray(t,e,i){return l&&!e&&l.selector?l.selector(t):!r(t)||i||!n&&Lt()?K(t)?function _flatten(t,e,i){return void 0===i&&(i=[]),t.forEach(function(t){return r(t)&&!e||cb(t,1)?i.push.apply(i,Pt(t)):i.push(t)})||i}(t,i):cb(t)?Ct.call(t,0):t?[t]:[]:Ct.call((e||a).querySelectorAll(t),0)},St=function mapRange(e,t,r,i,n){var a=t-e,s=i-r;return Za(n,function(t){return r+((t-e)/a*s||0)})},At=function _callback(t,e,r){var i,n,a,s=t.vars,o=s[e],u=l,h=t._ctx;if(o)return i=s[e+"Params"],n=s.callbackScope||t,r&&pt.length&&oa(),h&&(l=h),a=i?o.apply(n,i):o.call(n),l=u,a},Dt=[],zt=255,Rt={aqua:[0,zt,zt],lime:[0,zt,0],silver:[192,192,192],black:[0,0,0],maroon:[128,0,0],teal:[0,128,128],blue:[0,0,zt],navy:[0,0,128],white:[zt,zt,zt],olive:[128,128,0],yellow:[zt,zt,0],orange:[zt,165,0],gray:[128,128,128],purple:[128,0,128],green:[0,128,0],red:[zt,0,0],pink:[zt,192,203],cyan:[0,zt,zt],transparent:[zt,zt,zt,0]},Et=function(){var t,e="(?:\\b(?:(?:rgb|rgba|hsl|hsla)\\(.+?\\))|\\B#(?:[0-9a-f]{3,4}){1,2}\\b";for(t in Rt)e+="|"+t+"\\b";return new RegExp(e+")","gi")}(),Ft=/hsl[a]?\(/,It=(O=Date.now,M=500,C=33,P=O(),A=P,z=D=1e3/240,g={time:0,frame:0,tick:function tick(){zl(!0)},deltaRatio:function deltaRatio(t){return b/(1e3/(t||60))},wake:function wake(){o&&(!n&&x()&&(h=n=window,a=h.document||{},ht.gsap=Fe,(h.gsapVersions||(h.gsapVersions=[])).push(Fe.version),R(i||h.GreenSockGlobals||!h.gsap&&h||{}),Dt.forEach(zb)),m="undefined"!=typeof requestAnimationFrame&&requestAnimationFrame,p&&g.sleep(),_=m||function(t){return setTimeout(t,z-1e3*g.time+1|0)},d=1,zl(2))},sleep:function sleep(){(m?cancelAnimationFrame:clearTimeout)(p),d=0,_=V},lagSmoothing:function lagSmoothing(t,e){M=t||1/0,C=Math.min(e||33,M)},fps:function fps(t){D=1e3/(t||240),z=1e3*g.time+D},add:function add(n,t,e){var a=t?function(t,e,r,i){n(t,e,r,i),g.remove(a)}:n;return g.remove(n),E[e?"unshift":"push"](a),Lt(),a},remove:function remove(t,e){~(e=E.indexOf(t))&&E.splice(e,1)&&e<=k&&k--},_listeners:E=[]}),Lt=function _wake(){return!d&&It.wake()},Bt={},Nt=/^[\d.\-M][\d.\-,\s]/,Ut=/["']/g,Yt=function _invertEase(e){return function(t){return 1-e(1-t)}},jt=function _parseEase(t,e){return t&&(s(t)?t:Bt[t]||Rb(t))||e};function zl(t){var e,r,i,n,a=O()-A,s=!0===t;if((M<a||a<0)&&(P+=a-C),(0<(e=(i=(A+=a)-P)-z)||s)&&(n=++g.frame,b=i-1e3*g.time,g.time=i/=1e3,z+=e+(D<=e?4:D-e),r=1),s||(p=_(zl)),r)for(k=0;k<E.length;k++)E[k](i,b,n,t)}function dn(t){return t<N?B*t*t:t<.7272727272727273?B*Math.pow(t-1.5/2.75,2)+.75:t<.9090909090909092?B*(t-=2.25/2.75)*t+.9375:B*Math.pow(t-2.625/2.75,2)+.984375}ja("Linear,Quad,Cubic,Quart,Quint,Strong",function(t,e){var r=e<5?e+1:e;Ub(t+",Power"+(r-1),e?function(t){return Math.pow(t,r)}:function(t){return t},function(t){return 1-Math.pow(1-t,r)},function(t){return t<.5?Math.pow(2*t,r)/2:1-Math.pow(2*(1-t),r)/2})}),Bt.Linear.easeNone=Bt.none=Bt.Linear.easeIn,Ub("Elastic",Wb("in"),Wb("out"),Wb()),B=7.5625,N=1/2.75,Ub("Bounce",function(t){return 1-dn(1-t)},dn),Ub("Expo",function(t){return Math.pow(2,10*(t-1))*t+t*t*t*t*t*t*(1-t)}),Ub("Circ",function(t){return-($(1-t*t)-1)}),Ub("Sine",function(t){return 1===t?1:1-H(t*Z)}),Ub("Back",Xb("in"),Xb("out"),Xb()),Bt.SteppedEase=Bt.steps=ht.SteppedEase={config:function config(t,e){void 0===t&&(t=1);var r=1/t,i=t+(e?0:1),n=e?1:0;return function(t){return((i*Mt(0,.99999999,t)|0)+n)*r}}},j.ease=Bt["quad.out"],ja("onComplete,onUpdate,onStart,onRepeat,onReverseComplete,onInterrupt",function(t){return Tt+=t+","+t+"Params,"});var Vt,Xt=function GSCache(t,e){this.id=W++,(t._gsap=this).target=t,this.harness=e,this.get=e?e.get:ia,this.set=e?e.getSetter:ue},qt=((Vt=Animation.prototype).delay=function delay(t){return t||0===t?(this.parent&&this.parent.smoothChildTiming&&this.startTime(this._start+t-this._delay),this._delay=t,this):this._delay},Vt.duration=function duration(t){return arguments.length?this.totalDuration(0<this._repeat?t+(t+this._rDelay)*this._repeat:t):this.totalDuration()&&this._dur},Vt.totalDuration=function totalDuration(t){return arguments.length?(this._dirty=0,Ua(this,this._repeat<0?t:(t-this._repeat*this._rDelay)/(this._repeat+1))):this._tDur},Vt.totalTime=function totalTime(t,e){if(Lt(),!arguments.length)return this._tTime;var r=this._dp;if(r&&r.smoothChildTiming&&this._ts){for(La(this,t),!r._dp||r.parent||Ma(r,this);r&&r.parent;)r.parent._time!==r._start+(0<=r._ts?r._tTime/r._ts:(r.totalDuration()-r._tTime)/-r._ts)&&r.totalTime(r._tTime,!0),r=r.parent;!this.parent&&this._dp.autoRemoveChildren&&(0<this._ts&&t<this._tDur||this._ts<0&&0<t||!this._tDur&&!t)&&Na(this._dp,this,this._start-this._delay)}return(this._tTime!==t||!this._dur&&!e||this._initted&&Math.abs(this._zTime)===q||!this._initted&&this._dur&&t||!t&&!this._initted&&(this.add||this._ptLookup))&&(this._ts||(this._pTime=t),qa(this,t,e)),this},Vt.time=function time(t,e){return arguments.length?this.totalTime(Math.min(this.totalDuration(),t+Ha(this))%(this._dur+this._rDelay)||(t?this._dur:0),e):this._time},Vt.totalProgress=function totalProgress(t,e){return arguments.length?this.totalTime(this.totalDuration()*t,e):this.totalDuration()?Math.min(1,this._tTime/this._tDur):0<=this.rawTime()&&this._initted?1:0},Vt.progress=function progress(t,e){return arguments.length?this.totalTime(this.duration()*(!this._yoyo||1&this.iteration()?t:1-t)+Ha(this),e):this.duration()?Math.min(1,this._time/this._dur):0<this.rawTime()?1:0},Vt.iteration=function iteration(t,e){var r=this.duration()+this._rDelay;return arguments.length?this.totalTime(this._time+(t-1)*r,e):this._repeat?wt(this._tTime,r)+1:1},Vt.timeScale=function timeScale(t,e){if(!arguments.length)return this._rts===-q?0:this._rts;if(this._rts===t)return this;var r=this.parent&&this._ts?Ja(this.parent._time,this):this._tTime;return this._rts=+t||0,this._ts=this._ps||t===-q?0:this._rts,this.totalTime(Mt(-Math.abs(this._delay),this.totalDuration(),r),!1!==e),Ka(this),function _recacheAncestors(t){for(var e=t.parent;e&&e.parent;)e._dirty=1,e.totalDuration(),e=e.parent;return t}(this)},Vt.paused=function paused(t){return arguments.length?(this._ps!==t&&((this._ps=t)?(this._pTime=this._tTime||Math.max(-this._delay,this.rawTime()),this._ts=this._act=0):(Lt(),this._ts=this._rts,this.totalTime(this.parent&&!this.parent.smoothChildTiming?this.rawTime():this._tTime||this._pTime,1===this.progress()&&Math.abs(this._zTime)!==q&&(this._tTime-=q)))),this):this._ps},Vt.startTime=function startTime(t){if(arguments.length){this._start=la(t);var e=this.parent||this._dp;return!e||!e._sort&&this.parent||Na(e,this,this._start-this._delay),this}return this._start},Vt.endTime=function endTime(t){return this._start+(w(t)?this.totalDuration():this.duration())/Math.abs(this._ts||1)},Vt.rawTime=function rawTime(t){var e=this.parent||this._dp;return e?t&&(!this._ts||this._repeat&&this._time&&this.totalProgress()<1)?this._tTime%(this._dur+this._rDelay):this._ts?Ja(e.rawTime(t),this):this._tTime:this._tTime},Vt.revert=function revert(t){void 0===t&&(t=ct);var e=I;return I=t,pa(this)&&(this.timeline&&this.timeline.revert(t),this.totalTime(-.01,t.suppressEvents)),"nested"!==this.data&&!1!==t.kill&&this.kill(),I=e,this},Vt.globalTime=function globalTime(t){for(var e=this,r=arguments.length?t:e.rawTime();e;)r=e._start+r/(Math.abs(e._ts)||1),e=e._dp;return!this.parent&&this._sat?this._sat.globalTime(t):r},Vt.repeat=function repeat(t){return arguments.length?(this._repeat=t===1/0?-2:t,Va(this)):-2===this._repeat?1/0:this._repeat},Vt.repeatDelay=function repeatDelay(t){if(arguments.length){var e=this._time;return this._rDelay=t,Va(this),e?this.time(e):this}return this._rDelay},Vt.yoyo=function yoyo(t){return arguments.length?(this._yoyo=t,this):this._yoyo},Vt.seek=function seek(t,e){return this.totalTime(Ot(this,t),w(e))},Vt.restart=function restart(t,e){return this.play().totalTime(t?-this._delay:0,w(e)),this._dur||(this._zTime=-q),this},Vt.play=function play(t,e){return null!=t&&this.seek(t,e),this.reversed(!1).paused(!1)},Vt.reverse=function reverse(t,e){return null!=t&&this.seek(t||this.totalDuration(),e),this.reversed(!0).paused(!1)},Vt.pause=function pause(t,e){return null!=t&&this.seek(t,e),this.paused(!0)},Vt.resume=function resume(){return this.paused(!1)},Vt.reversed=function reversed(t){return arguments.length?(!!t!==this.reversed()&&this.timeScale(-this._rts||(t?-q:0)),this):this._rts<0},Vt.invalidate=function invalidate(){return this._initted=this._act=0,this._zTime=-q,this},Vt.isActive=function isActive(){var t,e=this.parent||this._dp,r=this._start;return!(e&&!(this._ts&&this._initted&&e.isActive()&&(t=e.rawTime(!0))>=r&&t<this.endTime(!0)-q))},Vt.eventCallback=function eventCallback(t,e,r){var i=this.vars;return 1<arguments.length?(e?(i[t]=e,r&&(i[t+"Params"]=r),"onUpdate"===t&&(this._onUpdate=e)):delete i[t],this):i[t]},Vt.then=function then(t){var i=this,n=i._prom;return new Promise(function(e){function Ao(){var t=i.then;i.then=null,n&&n(),s(r)&&(r=r(i))&&(r.then||r===i)&&(i.then=t),e(r),i.then=t}var r=s(t)?t:sa;i._initted&&1===i.totalProgress()&&0<=i._ts||!i._tTime&&i._ts<0?Ao():i._prom=Ao})},Vt.kill=function kill(){wb(this)},Animation);function Animation(t){this.vars=t,this._delay=+t.delay||0,(this._repeat=t.repeat===1/0?-2:t.repeat||0)&&(this._rDelay=t.repeatDelay||0,this._yoyo=!!t.yoyo||!!t.yoyoEase),this._ts=1,Ua(this,+t.duration,1,1),this.data=t.data,l&&(this._ctx=l).data.push(this),d||It.wake()}ta(qt.prototype,{_time:0,_start:0,_end:0,_tTime:0,_tDur:0,_dirty:0,_repeat:0,_yoyo:!1,parent:null,_initted:!1,_rDelay:0,_ts:1,_dp:0,ratio:0,_zTime:-q,_prom:0,_ps:!1,_rts:1});var Gt=function(i){function Timeline(t,e){var r;return void 0===t&&(t={}),(r=i.call(this,t)||this).labels={},r.smoothChildTiming=!!t.smoothChildTiming,r.autoRemoveChildren=!!t.autoRemoveChildren,r._sort=w(t.sortChildren),L&&Na(t.parent||L,_assertThisInitialized(r),e),t.reversed&&r.reverse(),t.paused&&r.paused(!0),t.scrollTrigger&&Oa(_assertThisInitialized(r),t.scrollTrigger),r}_inheritsLoose(Timeline,i);var e=Timeline.prototype;return e.to=function to(t,e,r){return Ya(0,arguments,this),this},e.from=function from(t,e,r){return Ya(1,arguments,this),this},e.fromTo=function fromTo(t,e,r,i){return Ya(2,arguments,this),this},e.set=function set(t,e,r){return e.duration=0,e.parent=this,ya(e).repeatDelay||(e.repeat=0),e.immediateRender=!!e.immediateRender,new te(t,e,Ot(this,r),1),this},e.call=function call(t,e,r){return Na(this,te.delayedCall(0,t,e),r)},e.staggerTo=function staggerTo(t,e,r,i,n,a,s){return r.duration=e,r.stagger=r.stagger||i,r.onComplete=a,r.onCompleteParams=s,r.parent=this,new te(t,r,Ot(this,n)),this},e.staggerFrom=function staggerFrom(t,e,r,i,n,a,s){return r.runBackwards=1,ya(r).immediateRender=w(r.immediateRender),this.staggerTo(t,e,r,i,n,a,s)},e.staggerFromTo=function staggerFromTo(t,e,r,i,n,a,s,o){return i.startAt=r,ya(i).immediateRender=w(i.immediateRender),this.staggerTo(t,e,i,n,a,s,o)},e.render=function render(t,e,r){var i,n,a,s,o,u,h,l,f,c,d,p,_=this._time,m=this._dirty?this.totalDuration():this._tDur,g=this._dur,v=t<=0?0:la(t),y=this._zTime<0!=t<0&&(this._initted||!g);if(this!==L&&m<v&&0<=t&&(v=m),v!==this._tTime||r||y){if(_!==this._time&&g&&(v+=this._time-_,t+=this._time-_),i=v,f=this._start,u=!(l=this._ts),y&&(g||(_=this._zTime),!t&&e||(this._zTime=t)),this._repeat){if(d=this._yoyo,o=g+this._rDelay,this._repeat<-1&&t<0)return this.totalTime(100*o+t,e,r);if(i=la(v%o),v===m?(s=this._repeat,i=g):((s=~~(c=la(v/o)))&&s===c&&(i=g,s--),g<i&&(i=g)),c=wt(this._tTime,o),!_&&this._tTime&&c!==s&&this._tTime-c*o-this._dur<=0&&(c=s),d&&1&s&&(i=g-i,p=1),s!==c&&!this._lock){var T=d&&1&c,b=T===(d&&1&s);if(s<c&&(T=!T),_=T?0:v%g?g:v,this._lock=1,this.render(_||(p?0:la(s*o)),e,!g)._lock=0,this._tTime=v,!e&&this.parent&&At(this,"onRepeat"),this.vars.repeatRefresh&&!p&&(this.invalidate()._lock=1,c=s),_&&_!==this._time||u!=!this._ts||this.vars.onRepeat&&!this.parent&&!this._act)return this;if(g=this._dur,m=this._tDur,b&&(this._lock=2,_=T?g:-1e-4,this.render(_,!0),this.vars.repeatRefresh&&!p&&this.invalidate()),this._lock=0,!this._ts&&!u)return this}}if(this._hasPause&&!this._forcing&&this._lock<2&&(h=function _findNextPauseTween(t,e,r){var i;if(e<r)for(i=t._first;i&&i._start<=r;){if("isPause"===i.data&&i._start>e)return i;i=i._next}else for(i=t._last;i&&i._start>=r;){if("isPause"===i.data&&i._start<e)return i;i=i._prev}}(this,la(_),la(i)))&&(v-=i-(i=h._start)),this._tTime=v,this._time=i,this._act=!!l,this._initted||(this._onUpdate=this.vars.onUpdate,this._initted=1,this._zTime=t,_=0),!_&&v&&g&&!e&&!c&&(At(this,"onStart"),this._tTime!==v))return this;if(_<=i&&0<=t)for(n=this._first;n;){if(a=n._next,(n._act||i>=n._start)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0<n._ts?(i-n._start)*n._ts:(n._dirty?n.totalDuration():n._tDur)+(i-n._start)*n._ts,e,r),i!==this._time||!this._ts&&!u){h=0,a&&(v+=this._zTime=-q);break}}n=a}else{n=this._last;for(var w=t<0?t:i;n;){if(a=n._prev,(n._act||w<=n._end)&&n._ts&&h!==n){if(n.parent!==this)return this.render(t,e,r);if(n.render(0<n._ts?(w-n._start)*n._ts:(n._dirty?n.totalDuration():n._tDur)+(w-n._start)*n._ts,e,r||I&&pa(n)),i!==this._time||!this._ts&&!u){h=0,a&&(v+=this._zTime=w?-q:q);break}}n=a}}if(h&&!e&&(this.pause(),h.render(_<=i?0:-q)._zTime=_<=i?1:-1,this._ts))return this._start=f,Ka(this),this.render(t,e,r);this._onUpdate&&!e&&At(this,"onUpdate",!0),(v===m&&this._tTime>=this.totalDuration()||!v&&_)&&(f!==this._start&&Math.abs(l)===Math.abs(this._ts)||this._lock||(!t&&g||!(v===m&&0<this._ts||!v&&this._ts<0)||Ca(this,1),e||t<0&&!_||!v&&!_&&m||(At(this,v===m&&0<=t?"onComplete":"onReverseComplete",!0),!this._prom||v<m&&0<this.timeScale()||this._prom())))}return this},e.add=function add(e,i){var n=this;if(t(i)||(i=Ot(this,i,e)),!(e instanceof qt)){if(K(e))return e.forEach(function(t){return n.add(t,i)}),this;if(r(e))return this.addLabel(e,i);if(!s(e))return this;e=te.delayedCall(0,e)}return this!==e?Na(this,e,i):this},e.getChildren=function getChildren(t,e,r,i){void 0===t&&(t=!0),void 0===e&&(e=!0),void 0===r&&(r=!0),void 0===i&&(i=-X);for(var n=[],a=this._first;a;)a._start>=i&&(a instanceof te?e&&n.push(a):(r&&n.push(a),t&&n.push.apply(n,a.getChildren(!0,e,r)))),a=a._next;return n},e.getById=function getById(t){for(var e=this.getChildren(1,1,1),r=e.length;r--;)if(e[r].vars.id===t)return e[r]},e.remove=function remove(t){return r(t)?this.removeLabel(t):s(t)?this.killTweensOf(t):(t.parent===this&&Ba(this,t),t===this._recent&&(this._recent=this._last),Da(this))},e.totalTime=function totalTime(t,e){return arguments.length?(this._forcing=1,!this._dp&&this._ts&&(this._start=la(It.time-(0<this._ts?t/this._ts:(this.totalDuration()-t)/-this._ts))),i.prototype.totalTime.call(this,t,e),this._forcing=0,this):this._tTime},e.addLabel=function addLabel(t,e){return this.labels[t]=Ot(this,e),this},e.removeLabel=function removeLabel(t){return delete this.labels[t],this},e.addPause=function addPause(t,e,r){var i=te.delayedCall(0,e||V,r);return i.data="isPause",this._hasPause=1,Na(this,i,Ot(this,t))},e.removePause=function removePause(t){var e=this._first;for(t=Ot(this,t);e;)e._start===t&&"isPause"===e.data&&Ca(e),e=e._next},e.killTweensOf=function killTweensOf(t,e,r){for(var i=this.getTweensOf(t,r),n=i.length;n--;)Zt!==i[n]&&i[n].kill(t,e);return this},e.getTweensOf=function getTweensOf(e,r){for(var i,n=[],a=Pt(e),s=this._first,o=t(r);s;)s instanceof te?na(s._targets,a)&&(o?(!Zt||s._initted&&s._ts)&&s.globalTime(0)<=r&&s.globalTime(s.totalDuration())>r:!r||s.isActive())&&n.push(s):(i=s.getTweensOf(a,r)).length&&n.push.apply(n,i),s=s._next;return n},e.tweenTo=function tweenTo(t,e){e=e||{};var r,i=this,n=Ot(i,t),a=e.startAt,s=e.onStart,o=e.onStartParams,u=e.immediateRender,h=te.to(i,ta({ease:e.ease||"none",lazy:!1,immediateRender:!1,time:n,overwrite:"auto",duration:e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale())||q,onStart:function onStart(){if(i.pause(),!r){var t=e.duration||Math.abs((n-(a&&"time"in a?a.time:i._time))/i.timeScale());h._dur!==t&&Ua(h,t,0,1).render(h._time,!0,!0),r=1}s&&s.apply(h,o||[])}},e));return u?h.render(0):h},e.tweenFromTo=function tweenFromTo(t,e,r){return this.tweenTo(e,ta({startAt:{time:Ot(this,t)}},r))},e.recent=function recent(){return this._recent},e.nextLabel=function nextLabel(t){return void 0===t&&(t=this._time),ub(this,Ot(this,t))},e.previousLabel=function previousLabel(t){return void 0===t&&(t=this._time),ub(this,Ot(this,t),1)},e.currentLabel=function currentLabel(t){return arguments.length?this.seek(t,!0):this.previousLabel(this._time+q)},e.shiftChildren=function shiftChildren(t,e,r){void 0===r&&(r=0);var i,n=this._first,a=this.labels;for(t=la(t);n;)n._start>=r&&(n._start+=t,n._end+=t),n=n._next;if(e)for(i in a)a[i]>=r&&(a[i]+=t);return Da(this)},e.invalidate=function invalidate(t){var e=this._first;for(this._lock=0;e;)e.invalidate(t),e=e._next;return i.prototype.invalidate.call(this,t)},e.clear=function clear(t){void 0===t&&(t=!0);for(var e,r=this._first;r;)e=r._next,this.remove(r),r=e;return this._dp&&(this._time=this._tTime=this._pTime=0),t&&(this.labels={}),Da(this)},e.totalDuration=function totalDuration(t){var e,r,i,n=0,a=this,s=a._last,o=X;if(arguments.length)return a.timeScale((a._repeat<0?a.duration():a.totalDuration())/(a.reversed()?-t:t));if(a._dirty){for(i=a.parent;s;)e=s._prev,s._dirty&&s.totalDuration(),o<(r=s._start)&&a._sort&&s._ts&&!a._lock?(a._lock=1,Na(a,s,r-s._delay,1)._lock=0):o=r,r<0&&s._ts&&(n-=r,(!i&&!a._dp||i&&i.smoothChildTiming)&&(a._start+=la(r/a._ts),a._time-=r,a._tTime-=r),a.shiftChildren(-r,!1,-Infinity),o=0),s._end>n&&s._ts&&(n=s._end),s=e;Ua(a,a===L&&a._time>n?a._time:n,1,1),a._dirty=0}return a._tDur},Timeline.updateRoot=function updateRoot(t){if(L._ts&&(qa(L,Ja(t,L)),f=It.frame),It.frame>=vt){vt+=Y.autoSleep||120;var e=L._first;if((!e||!e._ts)&&Y.autoSleep&&It._listeners.length<2){for(;e&&!e._ts;)e=e._next;e||It.sleep()}}},Timeline}(qt);ta(Gt.prototype,{_lock:0,_hasPause:0,_forcing:0});function cc(t,e,i,n,a,o){var u,h,l,f;if(mt[t]&&!1!==(u=new mt[t]).init(a,u.rawVars?e[t]:function _processVars(t,e,i,n,a){if(s(t)&&(t=Qt(t,a,e,i,n)),!v(t)||t.style&&t.nodeType||K(t)||J(t))return r(t)?Qt(t,a,e,i,n):t;var o,u={};for(o in t)u[o]=Qt(t[o],a,e,i,n);return u}(e[t],n,a,o,i),i,n,o)&&(i._pt=h=new we(i._pt,a,t,0,1,u.render,u,0,u.priority),i!==c))for(l=i._ptLookup[i._targets.indexOf(a)],f=u._props.length;f--;)l[u._props[f]]=h;return u}function ic(t,r,e,i){var n,a,s=r.ease||i||"power1.inOut";if(K(r))a=e[t]||(e[t]=[]),r.forEach(function(t,e){return a.push({t:e/(r.length-1)*100,v:t,e:s})});else for(n in r)a=e[n]||(e[n]=[]),"ease"===n||a.push({t:parseFloat(t),v:r[n],e:s})}var Zt,Wt,$t=function _addPropTween(t,e,i,n,a,o,u,h,l,f){s(n)&&(n=n(a||0,t,o));var c,d=t[e],p="get"!==i?i:s(d)?l?t[e.indexOf("set")||!s(t["get"+e.substr(3)])?e:"get"+e.substr(3)](l):t[e]():d,_=s(d)?l?se:ae:ie;if(r(n)&&(~n.indexOf("random(")&&(n=rb(n)),"="===n.charAt(1)&&(!(c=ma(p,n)+(_a(p)||0))&&0!==c||(n=c))),!f||p!==n||Wt)return isNaN(p*n)||""===n?(d||e in t||S(e,n),function _addComplexStringPropTween(t,e,r,i,n,a,s){var o,u,h,l,f,c,d,p,_=new we(this._pt,t,e,0,1,pe,null,n),m=0,g=0;for(_.b=r,_.e=i,r+="",(d=~(i+="").indexOf("random("))&&(i=rb(i)),a&&(a(p=[r,i],t,e),r=p[0],i=p[1]),u=r.match(at)||[];o=at.exec(i);)l=o[0],f=i.substring(m,o.index),h?h=(h+1)%5:"rgba("===f.substr(-5)&&(h=1),l!==u[g++]&&(c=parseFloat(u[g-1])||0,_._pt={_next:_._pt,p:f||1===g?f:",",s:c,c:"="===l.charAt(1)?ma(c,l)-c:parseFloat(l)-c,m:h&&h<4?Math.round:0},m=at.lastIndex);return _.c=m<i.length?i.substring(m,i.length):"",_.fp=s,(st.test(i)||d)&&(_.e=0),this._pt=_}.call(this,t,e,p,n,_,h||Y.stringFilter,l)):(c=new we(this._pt,t,e,+p||0,n-(p||0),"boolean"==typeof d?de:fe,0,_),l&&(c.fp=l),u&&c.modifier(u,this,t),this._pt=c)},Ht=function _initTween(t,e,r){var i,n,a,s,o,u,h,l,f,c,d,p,_,m=t.vars,g=m.ease,v=m.startAt,y=m.immediateRender,T=m.lazy,b=m.onUpdate,x=m.runBackwards,k=m.yoyoEase,O=m.keyframes,M=m.autoRevert,C=t._dur,P=t._startAt,S=t._targets,A=t.parent,D=A&&"nested"===A.data?A.vars.targets:S,z="auto"===t._overwrite&&!F,R=t.timeline,E=m.easeReverse||k;if(!R||O&&g||(g="none"),t._ease=jt(g,j.ease),t._rEase=E&&(jt(E)||t._ease),t._from=!R&&!!m.runBackwards,t._from&&(t.ratio=1),!R||O&&!m.stagger){if(p=(l=S[0]?ha(S[0]).harness:0)&&m[l.prop],i=xa(m,dt),P&&(P._zTime<0&&P.progress(1),e<0&&x&&y&&!M?P.render(-1,!0):P.revert(x&&C?ft:lt),P._lazy=0),v){if(Ca(t._startAt=te.set(S,ta({data:"isStart",overwrite:!1,parent:A,immediateRender:!0,lazy:!P&&w(T),startAt:null,delay:0,onUpdate:b&&function(){return At(t,"onUpdate")},stagger:0},v))),t._startAt._dp=0,t._startAt._sat=t,e<0&&(I||!y&&!M)&&t._startAt.revert(ft),y&&C&&e<=0&&r<=0)return void(e&&(t._zTime=e))}else if(x&&C&&!P)if(e&&(y=!1),a=ta({overwrite:!1,data:"isFromStart",lazy:y&&!P&&w(T),immediateRender:y,stagger:0,parent:A},i),p&&(a[l.prop]=p),Ca(t._startAt=te.set(S,a)),t._startAt._dp=0,t._startAt._sat=t,e<0&&(I?t._startAt.revert(ft):t._startAt.render(-1,!0)),t._zTime=e,y){if(!e)return}else _initTween(t._startAt,q,q);for(t._pt=t._ptCache=0,T=C&&w(T)||T&&!C,n=0;n<S.length;n++){if(h=(o=S[n])._gsap||ga(S)[n]._gsap,t._ptLookup[n]=c={},_t[h.id]&&pt.length&&oa(),d=D===S?n:D.indexOf(o),l&&!1!==(f=new l).init(o,p||i,t,d,D)&&(t._pt=s=new we(t._pt,o,f.name,0,1,f.render,f,0,f.priority),f._props.forEach(function(t){c[t]=s}),f.priority&&(u=1)),!l||p)for(a in i)mt[a]&&(f=cc(a,i,t,d,o,D))?f.priority&&(u=1):c[a]=s=$t.call(t,o,a,"get",i[a],d,D,0,m.stringFilter);t._op&&t._op[n]&&t.kill(o,t._op[n]),z&&t._pt&&(Zt=t,L.killTweensOf(o,c,t.globalTime(e)),_=!t.parent,Zt=0),t._pt&&T&&(_t[h.id]=1)}u&&be(t),t._onInit&&t._onInit(t)}t._onUpdate=b,t._initted=(!t._op||t._pt)&&!_,O&&e<=0&&R.render(X,!0,!0)},Qt=function _parseFuncOrString(t,e,i,n,a){return s(t)?t.call(e,i,n,a):r(t)&&~t.indexOf("random(")?rb(t):t},Jt=Tt+"repeat,repeatDelay,yoyo,repeatRefresh,yoyoEase,easeReverse,autoRevert",Kt={};ja(Jt+",id,stagger,delay,duration,paused,scrollTrigger",function(t){return Kt[t]=1});var te=function(E){function Tween(e,r,i,n){var a;"number"==typeof r&&(i.duration=r,r=i,i=null);var s,o,u,h,l,f,c,d,p=(a=E.call(this,n?r:ya(r))||this).vars,_=p.duration,m=p.delay,g=p.immediateRender,b=p.stagger,x=p.overwrite,k=p.keyframes,O=p.defaults,M=p.scrollTrigger,C=r.parent||L,P=(K(e)||J(e)?t(e[0]):"length"in r)?[e]:Pt(e);if(a._targets=P.length?ga(P):T("GSAP target "+e+" not found. https://gsap.com",!Y.nullTargetWarn)||[],a._ptLookup=[],a._overwrite=x,k||b||y(_)||y(m)){var S=(r=a.vars).easeReverse||r.yoyoEase;if((s=a.timeline=new Gt({data:"nested",defaults:O||{},targets:C&&"nested"===C.data?C.vars.targets:P})).kill(),s.parent=s._dp=_assertThisInitialized(a),s._start=0,b||y(_)||y(m)){if(h=P.length,c=b&&hb(b),v(b))for(l in b)~Jt.indexOf(l)&&((d=d||{})[l]=b[l]);for(o=0;o<h;o++)(u=xa(r,Kt)).stagger=0,S&&(u.easeReverse=S),d&&bt(u,d),f=P[o],u.duration=+Qt(_,_assertThisInitialized(a),o,f,P),u.delay=(+Qt(m,_assertThisInitialized(a),o,f,P)||0)-a._delay,!b&&1===h&&u.delay&&(a._delay=m=u.delay,a._start+=m,u.delay=0),s.to(f,u,c?c(o,f,P):0),s._ease=Bt.none;s.duration()?_=m=0:a.timeline=0}else if(k){ya(ta(s.vars.defaults,{ease:"none"})),s._ease=jt(k.ease||r.ease||"none");var A,D,z,R=0;if(K(k))k.forEach(function(t){return s.to(P,t,">")}),s.duration();else{for(l in u={},k)"ease"===l||"easeEach"===l||ic(l,k[l],u,k.easeEach);for(l in u)for(A=u[l].sort(function(t,e){return t.t-e.t}),o=R=0;o<A.length;o++)(z={ease:(D=A[o]).e,duration:(D.t-(o?A[o-1].t:0))/100*_})[l]=D.v,s.to(P,z,R),R+=z.duration;s.duration()<_&&s.to({},{duration:_-s.duration()})}}_||a.duration(_=s.duration())}else a.timeline=0;return!0!==x||F||(Zt=_assertThisInitialized(a),L.killTweensOf(P),Zt=0),Na(C,_assertThisInitialized(a),i),r.reversed&&a.reverse(),r.paused&&a.paused(!0),(g||!_&&!k&&a._start===la(C._time)&&w(g)&&function _hasNoPausedAncestors(t){return!t||t._ts&&_hasNoPausedAncestors(t.parent)}(_assertThisInitialized(a))&&"nested"!==C.data)&&(a._tTime=-q,a.render(Math.max(0,-m)||0)),M&&Oa(_assertThisInitialized(a),M),a}_inheritsLoose(Tween,E);var e=Tween.prototype;return e.render=function render(t,e,r){var i,n,a,s,o,u,h,l,f=this._time,c=this._tDur,d=this._dur,p=t<0,_=c-q<t&&!p?c:t<q?0:t;if(d){if(_!==this._tTime||!t||r||!this._initted&&this._tTime||this._startAt&&this._zTime<0!=p||this._lazy){if(i=_,l=this.timeline,this._repeat){if(s=d+this._rDelay,this._repeat<-1&&p)return this.totalTime(100*s+t,e,r);if(i=la(_%s),_===c?(a=this._repeat,i=d):(a=~~(o=la(_/s)))&&a===o?(i=d,a--):d<i&&(i=d),(u=this._yoyo&&1&a)&&(i=d-i),o=wt(this._tTime,s),i===f&&!r&&this._initted&&a===o)return this._tTime=_,this;a!==o&&this.vars.repeatRefresh&&!u&&!this._lock&&i!==s&&this._initted&&(this._lock=r=1,this.render(la(s*a),!0).invalidate()._lock=0)}if(!this._initted){if(Pa(this,p?t:i,r,e,_))return this._tTime=0,this;if(!(f===this._time||r&&this.vars.repeatRefresh&&a!==o))return this;if(d!==this._dur)return this.render(t,e,r)}if(this._rEase){var m=i<f;if(m!==this._inv){var g=m?f:d-f;this._inv=m,this._from&&(this.ratio=1-this.ratio),this._invRatio=this.ratio,this._invTime=f,this._invRecip=g?(m?-1:1)/g:0,this._invScale=m?-this.ratio:1-this.ratio,this._invEase=m?this._rEase:this._ease}this.ratio=h=this._invRatio+this._invScale*this._invEase((i-this._invTime)*this._invRecip)}else this.ratio=h=this._ease(i/d);if(this._from&&(this.ratio=h=1-h),this._tTime=_,this._time=i,!this._act&&this._ts&&(this._act=1,this._lazy=0),!f&&_&&!e&&!o&&(At(this,"onStart"),this._tTime!==_))return this;for(n=this._pt;n;)n.r(h,n.d),n=n._next;l&&l.render(t<0?t:l._dur*l._ease(i/this._dur),e,r)||this._startAt&&(this._zTime=t),this._onUpdate&&!e&&(p&&Fa(this,t,0,r),At(this,"onUpdate")),this._repeat&&a!==o&&this.vars.onRepeat&&!e&&this.parent&&At(this,"onRepeat"),_!==this._tDur&&_||this._tTime!==_||(p&&!this._onUpdate&&Fa(this,t,0,!0),!t&&d||!(_===this._tDur&&0<this._ts||!_&&this._ts<0)||Ca(this,1),e||p&&!f||!(_||f||u)||(At(this,_===c?"onComplete":"onReverseComplete",!0),!this._prom||_<c&&0<this.timeScale()||this._prom()))}}else!function _renderZeroDurationTween(t,e,r,i){var n,a,s,o=t.ratio,u=e<0||!e&&(!t._start&&function _parentPlayheadIsBeforeStart(t){var e=t.parent;return e&&e._ts&&e._initted&&!e._lock&&(e.rawTime()<0||_parentPlayheadIsBeforeStart(e))}(t)&&(t._initted||!xt(t))||(t._ts<0||t._dp._ts<0)&&!xt(t))?0:1,h=t._rDelay,l=0;if(h&&t._repeat&&(l=Mt(0,t._tDur,e),a=wt(l,h),t._yoyo&&1&a&&(u=1-u),a!==wt(t._tTime,h)&&(o=1-u,t.vars.repeatRefresh&&t._initted&&t.invalidate())),u!==o||I||i||t._zTime===q||!e&&t._zTime){if(!t._initted&&Pa(t,e,i,r,l))return;for(s=t._zTime,t._zTime=e||(r?q:0),r=r||e&&!s,t.ratio=u,t._from&&(u=1-u),t._time=0,t._tTime=l,n=t._pt;n;)n.r(u,n.d),n=n._next;e<0&&Fa(t,e,0,!0),t._onUpdate&&!r&&At(t,"onUpdate"),l&&t._repeat&&!r&&t.parent&&At(t,"onRepeat"),(e>=t._tDur||e<0)&&t.ratio===u&&(u&&Ca(t,1),r||I||(At(t,u?"onComplete":"onReverseComplete",!0),t._prom&&t._prom()))}else t._zTime||(t._zTime=e)}(this,t,e,r);return this},e.targets=function targets(){return this._targets},e.invalidate=function invalidate(t){return t&&this.vars.runBackwards||(this._startAt=0),this._pt=this._op=this._onUpdate=this._lazy=this.ratio=0,this._ptLookup=[],this.timeline&&this.timeline.invalidate(t),E.prototype.invalidate.call(this,t)},e.resetTo=function resetTo(t,e,r,i,n){d||It.wake(),this._ts||this.play();var a,s=Math.min(this._dur,(this._dp._time-this._start)*this._ts);return this._initted||Ht(this,s),a=this._ease(s/this._dur),function _updatePropTweens(t,e,r,i,n,a,s,o){var u,h,l,f,c=(t._pt&&t._ptCache||(t._ptCache={}))[e];if(!c)for(c=t._ptCache[e]=[],l=t._ptLookup,f=t._targets.length;f--;){if((u=l[f][e])&&u.d&&u.d._pt)for(u=u.d._pt;u&&u.p!==e&&u.fp!==e;)u=u._next;if(!u)return Wt=1,t.vars[e]="+=0",Ht(t,s),Wt=0,o?T(e+" not eligible for reset. Try splitting into individual properties"):1;c.push(u)}for(f=c.length;f--;)(u=(h=c[f])._pt||h).s=!i&&0!==i||n?u.s+(i||0)+a*u.c:i,u.c=r-u.s,h.e&&(h.e=ka(r)+_a(h.e)),h.b&&(h.b=u.s+_a(h.b))}(this,t,e,r,i,a,s,n)?this.resetTo(t,e,r,i,1):(La(this,0),this.parent||Aa(this._dp,this,"_first","_last",this._dp._sort?"_start":0),this.render(0))},e.kill=function kill(t,e){if(void 0===e&&(e="all"),!(t||e&&"all"!==e))return this._lazy=this._pt=0,this.parent?wb(this):this.scrollTrigger&&this.scrollTrigger.kill(!!I),this;if(this.timeline){var i=this.timeline.totalDuration();return this.timeline.killTweensOf(t,e,Zt&&!0!==Zt.vars.overwrite)._first||wb(this),this.parent&&i!==this.timeline.totalDuration()&&Ua(this,this._dur*this.timeline._tDur/i,0,1),this}var n,a,s,o,u,h,l,f=this._targets,c=t?Pt(t):f,d=this._ptLookup,p=this._pt;if((!e||"all"===e)&&function _arraysMatch(t,e){for(var r=t.length,i=r===e.length;i&&r--&&t[r]===e[r];);return r<0}(f,c))return"all"===e&&(this._pt=0),wb(this);for(n=this._op=this._op||[],"all"!==e&&(r(e)&&(u={},ja(e,function(t){return u[t]=1}),e=u),e=function _addAliasesToVars(t,e){var r,i,n,a,s=t[0]?ha(t[0]).harness:0,o=s&&s.aliases;if(!o)return e;for(i in r=bt({},e),o)if(i in r)for(n=(a=o[i].split(",")).length;n--;)r[a[n]]=r[i];return r}(f,e)),l=f.length;l--;)if(~c.indexOf(f[l]))for(u in a=d[l],"all"===e?(n[l]=e,o=a,s={}):(s=n[l]=n[l]||{},o=e),o)(h=a&&a[u])&&("kill"in h.d&&!0!==h.d.kill(u)||Ba(this,h,"_pt"),delete a[u]),"all"!==s&&(s[u]=1);return this._initted&&!this._pt&&p&&wb(this),this},Tween.to=function to(t,e,r){return new Tween(t,e,r)},Tween.from=function from(t,e){return Ya(1,arguments)},Tween.delayedCall=function delayedCall(t,e,r,i){return new Tween(e,0,{immediateRender:!1,lazy:!1,overwrite:!1,delay:t,onComplete:e,onReverseComplete:e,onCompleteParams:r,onReverseCompleteParams:r,callbackScope:i})},Tween.fromTo=function fromTo(t,e,r){return Ya(2,arguments)},Tween.set=function set(t,e){return e.duration=0,e.repeatDelay||(e.repeat=0),new Tween(t,e)},Tween.killTweensOf=function killTweensOf(t,e,r){return L.killTweensOf(t,e,r)},Tween}(qt);ta(te.prototype,{_targets:[],_lazy:0,_startAt:0,_op:0,_onInit:0}),ja("staggerTo,staggerFrom,staggerFromTo",function(r){te[r]=function(){var t=new Gt,e=Ct.call(arguments,0);return e.splice("staggerFromTo"===r?5:4,0,0),t[r].apply(t,e)}});function qc(t,e,r){return t.setAttribute(e,r)}function yc(t,e,r,i){i.mSet(t,e,i.m.call(i.tween,r,i.mt),i)}var ie=function _setterPlain(t,e,r){return t[e]=r},ae=function _setterFunc(t,e,r){return t[e](r)},se=function _setterFuncWithParam(t,e,r,i){return t[e](i.fp,r)},ue=function _getSetter(t,e){return s(t[e])?ae:u(t[e])&&t.setAttribute?qc:ie},fe=function _renderPlain(t,e){return e.set(e.t,e.p,Math.round(1e6*(e.s+e.c*t))/1e6,e)},de=function _renderBoolean(t,e){return e.set(e.t,e.p,!!(e.s+e.c*t),e)},pe=function _renderComplexString(t,e){var r=e._pt,i="";if(!t&&e.b)i=e.b;else if(1===t&&e.e)i=e.e;else{for(;r;)i=r.p+(r.m?r.m(r.s+r.c*t):Math.round(1e4*(r.s+r.c*t))/1e4)+i,r=r._next;i+=e.c}e.set(e.t,e.p,i,e)},_e=function _renderPropTweens(t,e){for(var r=e._pt;r;)r.r(t,r.d),r=r._next},ve=function _addPluginModifier(t,e,r,i){for(var n,a=this._pt;a;)n=a._next,a.p===i&&a.modifier(t,e,r),a=n},Te=function _killPropTweensOf(t){for(var e,r,i=this._pt;i;)r=i._next,i.p===t&&!i.op||i.op===t?Ba(this,i,"_pt"):i.dep||(e=1),i=r;return!e},be=function _sortPropTweensByPriority(t){for(var e,r,i,n,a=t._pt;a;){for(e=a._next,r=i;r&&r.pr>a.pr;)r=r._next;(a._prev=r?r._prev:n)?a._prev._next=a:i=a,(a._next=r)?r._prev=a:n=a,a=e}t._pt=i},we=(PropTween.prototype.modifier=function modifier(t,e,r){this.mSet=this.mSet||this.set,this.set=yc,this.m=t,this.mt=r,this.tween=e},PropTween);function PropTween(t,e,r,i,n,a,s,o,u){this.t=e,this.s=i,this.c=n,this.p=r,this.r=a||fe,this.d=s||this,this.set=o||ie,this.pr=u||0,(this._next=t)&&(t._prev=this)}ja(Tt+"parent,duration,ease,delay,overwrite,runBackwards,startAt,yoyo,immediateRender,repeat,repeatDelay,data,paused,reversed,lazy,callbackScope,stringFilter,id,yoyoEase,stagger,inherit,repeatRefresh,keyframes,autoRevert,scrollTrigger,easeReverse",function(t){return dt[t]=1}),ht.TweenMax=ht.TweenLite=te,ht.TimelineLite=ht.TimelineMax=Gt,L=new Gt({sortChildren:!1,defaults:j,autoRemoveChildren:!0,id:"root",smoothChildTiming:!0}),Y.stringFilter=Ib;function Gc(t){return(Oe[t]||Me).map(function(t){return t()})}function Hc(){var t=Date.now(),o=[];2<t-Ce&&(Gc("matchMediaInit"),ke.forEach(function(t){var e,r,i,n,a=t.queries,s=t.conditions;for(r in a)(e=h.matchMedia(a[r]).matches)&&(i=1),e!==s[r]&&(s[r]=e,n=1);n&&(t.revert(),i&&o.push(t))}),Gc("matchMediaRevert"),o.forEach(function(e){return e.onMatch(e,function(t){return e.add(null,t)})}),Ce=t,Gc("matchMedia"))}var xe,ke=[],Oe={},Me=[],Ce=0,Pe=0,Se=((xe=Context.prototype).add=function add(t,i,n){function Gw(){var t,e=l,r=a.selector;return e&&e!==a&&e.data.push(a),n&&(a.selector=fb(n)),l=a,t=i.apply(a,arguments),s(t)&&a._r.push(t),l=e,a.selector=r,a.isReverted=!1,t}s(t)&&(n=i,i=t,t=s);var a=this;return a.last=Gw,t===s?Gw(a,function(t){return a.add(null,t)}):t?a[t]=Gw:Gw},xe.ignore=function ignore(t){var e=l;l=null,t(this),l=e},xe.getTweens=function getTweens(){var e=[];return this.data.forEach(function(t){return t instanceof Context?e.push.apply(e,t.getTweens()):t instanceof te&&!(t.parent&&"nested"===t.parent.data)&&e.push(t)}),e},xe.clear=function clear(){this._r.length=this.data.length=0},xe.kill=function kill(i,t){var n=this;if(i?function(){for(var t,e=n.getTweens(),r=n.data.length;r--;)"isFlip"===(t=n.data[r]).data&&(t.revert(),t.getChildren(!0,!0,!1).forEach(function(t){return e.splice(e.indexOf(t),1)}));for(e.map(function(t){return{g:t._dur||t._delay||t._sat&&!t._sat.vars.immediateRender?t.globalTime(0):-1/0,t:t}}).sort(function(t,e){return e.g-t.g||-1/0}).forEach(function(t){return t.t.revert(i)}),r=n.data.length;r--;)(t=n.data[r])instanceof Gt?"nested"!==t.data&&(t.scrollTrigger&&t.scrollTrigger.revert(),t.kill()):t instanceof te||!t.revert||t.revert(i);n._r.forEach(function(t){return t(i,n)}),n.isReverted=!0}():this.data.forEach(function(t){return t.kill&&t.kill()}),this.clear(),t)for(var e=ke.length;e--;)ke[e].id===this.id&&ke.splice(e,1)},xe.revert=function revert(t){this.kill(t||{})},Context);function Context(t,e){this.selector=e&&fb(e),this.data=[],this._r=[],this.isReverted=!1,this.id=Pe++,t&&this.add(t)}var De,Re=((De=MatchMedia.prototype).add=function add(t,e,r){v(t)||(t={matches:t});var i,n,a,s=new Se(0,r||this.scope),o=s.conditions={};for(n in l&&!s.selector&&(s.selector=l.selector),this.contexts.push(s),e=s.add("onMatch",e),s.queries=t)"all"===n?a=1:(i=h.matchMedia(t[n]))&&(ke.indexOf(s)<0&&ke.push(s),(o[n]=i.matches)&&(a=1),i.addListener?i.addListener(Hc):i.addEventListener("change",Hc));return a&&e(s,function(t){return s.add(null,t)}),this},De.revert=function revert(t){this.kill(t||{})},De.kill=function kill(e){this.contexts.forEach(function(t){return t.kill(e,!0)})},MatchMedia);function MatchMedia(t){this.contexts=[],this.scope=t,l&&l.data.push(this)}var Ee={registerPlugin:function registerPlugin(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];e.forEach(function(t){return zb(t)})},timeline:function timeline(t){return new Gt(t)},getTweensOf:function getTweensOf(t,e){return L.getTweensOf(t,e)},getProperty:function getProperty(i,t,e,n){r(i)&&(i=Pt(i)[0]);var a=ha(i||{}).get,s=e?sa:ra;return"native"===e&&(e=""),i?t?s((mt[t]&&mt[t].get||a)(i,t,e,n)):function(t,e,r){return s((mt[t]&&mt[t].get||a)(i,t,e,r))}:i},quickSetter:function quickSetter(r,e,i){if(1<(r=Pt(r)).length){var n=r.map(function(t){return Fe.quickSetter(t,e,i)}),a=n.length;return function(t){for(var e=a;e--;)n[e](t)}}r=r[0]||{};var s=mt[e],o=ha(r),u=o.harness&&(o.harness.aliases||{})[e]||e,h=s?function(t){var e=new s;c._pt=0,e.init(r,i?t+i:t,c,0,[r]),e.render(1,e),c._pt&&_e(1,c)}:o.set(r,u);return s?h:function(t){return h(r,u,i?t+i:t,o,1)}},quickTo:function quickTo(t,i,e){function $x(t,e,r){return n.resetTo(i,t,e,r)}var r,n=Fe.to(t,ta(((r={})[i]="+=0.1",r.paused=!0,r.stagger=0,r),e||{}));return $x.tween=n,$x},isTweening:function isTweening(t){return 0<L.getTweensOf(t,!0).length},defaults:function defaults(t){return t&&t.ease&&(t.ease=jt(t.ease,j.ease)),wa(j,t||{})},config:function config(t){return wa(Y,t||{})},registerEffect:function registerEffect(t){var i=t.name,n=t.effect,e=t.plugins,a=t.defaults,r=t.extendTimeline;(e||"").split(",").forEach(function(t){return t&&!mt[t]&&!ht[t]&&T(i+" effect requires "+t+" plugin.")}),gt[i]=function(t,e,r){return n(Pt(t),ta(e||{},a),r)},r&&(Gt.prototype[i]=function(t,e,r){return this.add(gt[i](t,v(e)?e:(r=e)&&{},this),r)})},registerEase:function registerEase(t,e){Bt[t]=jt(e)},parseEase:function parseEase(t,e){return arguments.length?jt(t,e):Bt},getById:function getById(t){return L.getById(t)},exportRoot:function exportRoot(t,e){void 0===t&&(t={});var r,i,n=new Gt(t);for(n.smoothChildTiming=w(t.smoothChildTiming),L.remove(n),n._dp=0,n._time=n._tTime=L._time,r=L._first;r;)i=r._next,!e&&!r._dur&&r instanceof te&&r.vars.onComplete===r._targets[0]||Na(n,r,r._start-r._delay),r=i;return Na(L,n,0),n},context:function context(t,e){return t?new Se(t,e):l},matchMedia:function matchMedia(t){return new Re(t)},matchMediaRefresh:function matchMediaRefresh(){return ke.forEach(function(t){var e,r,i=t.conditions;for(r in i)i[r]&&(i[r]=!1,e=1);e&&t.revert()})||Hc()},addEventListener:function addEventListener(t,e){var r=Oe[t]||(Oe[t]=[]);~r.indexOf(e)||r.push(e)},removeEventListener:function removeEventListener(t,e){var r=Oe[t],i=r&&r.indexOf(e);0<=i&&r.splice(i,1)},utils:{wrap:function wrap(e,t,r){var i=t-e;return K(e)?ob(e,wrap(0,e.length),t):Za(r,function(t){return(i+(t-e)%i)%i+e})},wrapYoyo:function wrapYoyo(e,t,r){var i=t-e,n=2*i;return K(e)?ob(e,wrapYoyo(0,e.length-1),t):Za(r,function(t){return e+(i<(t=(n+(t-e)%n)%n||0)?n-t:t)})},distribute:hb,random:kb,snap:jb,normalize:function normalize(t,e,r){return St(t,e,0,1,r)},getUnit:_a,clamp:function clamp(e,r,t){return Za(t,function(t){return Mt(e,r,t)})},splitColor:Db,toArray:Pt,selector:fb,mapRange:St,pipe:function pipe(){for(var t=arguments.length,e=new Array(t),r=0;r<t;r++)e[r]=arguments[r];return function(t){return e.reduce(function(t,e){return e(t)},t)}},unitize:function unitize(e,r){return function(t){return e(parseFloat(t))+(r||_a(t))}},interpolate:function interpolate(e,i,t,n){var a=isNaN(e+i)?0:function(t){return(1-t)*e+t*i};if(!a){var s,o,u,h,l,f=r(e),c={};if(!0===t&&(n=1)&&(t=null),f)e={p:e},i={p:i};else if(K(e)&&!K(i)){for(u=[],h=e.length,l=h-2,o=1;o<h;o++)u.push(interpolate(e[o-1],e[o]));h--,a=function func(t){t*=h;var e=Math.min(l,~~t);return u[e](t-e)},t=i}else n||(e=bt(K(e)?[]:{},e));if(!u){for(s in i)$t.call(c,e,s,"get",i[s]);a=function func(t){return _e(t,c)||(f?e.p:e)}}}return Za(t,a)},shuffle:gb},install:R,effects:gt,ticker:It,updateRoot:Gt.updateRoot,plugins:mt,globalTimeline:L,core:{PropTween:we,globals:U,Tween:te,Timeline:Gt,Animation:qt,getCache:ha,_removeLinkedListItem:Ba,reverting:function reverting(){return I},context:function context(t){return t&&l&&(l.data.push(t),t._ctx=l),l},suppressOverwrites:function suppressOverwrites(t){return F=t}}};ja("to,from,fromTo,delayedCall,set,killTweensOf",function(t){return Ee[t]=te[t]}),It.add(Gt.updateRoot),c=Ee.to({},{duration:0});function Lc(t,e){for(var r=t._pt;r&&r.p!==e&&r.op!==e&&r.fp!==e;)r=r._next;return r}function Nc(t,a){return{name:t,headless:1,rawVars:1,init:function init(t,n,e){e._onInit=function(t){var e,i;if(r(n)&&(e={},ja(n,function(t){return e[t]=1}),n=e),a){for(i in e={},n)e[i]=a(n[i]);n=e}!function _addModifiers(t,e){var r,i,n,a=t._targets;for(r in e)for(i=a.length;i--;)(n=(n=t._ptLookup[i][r])&&n.d)&&(n._pt&&(n=Lc(n,r)),n&&n.modifier&&n.modifier(e[r],t,a[i],r))}(t,n)}}}}var Fe=Ee.registerPlugin({name:"attr",init:function init(t,e,r,i,n){var a,s,o;for(a in this.tween=r,e)o=t.getAttribute(a)||"",(s=this.add(t,"setAttribute",(o||0)+"",e[a],i,n,0,0,a)).op=a,s.b=o,this._props.push(a)},render:function render(t,e){for(var r=e._pt;r;)I?r.set(r.t,r.p,r.b,r):r.r(t,r.d),r=r._next}},{name:"endArray",headless:1,init:function init(t,e){for(var r=e.length;r--;)this.add(t,r,t[r]||0,e[r],0,0,0,0,0,1)}},Nc("roundProps",ib),Nc("modifiers"),Nc("snap",jb))||Ee;te.version=Gt.version=Fe.version="3.15.0",o=1,x()&&Lt();function xd(t,e){return e.set(e.t,e.p,Math.round(1e4*(e.s+e.c*t))/1e4+e.u,e)}function yd(t,e){return e.set(e.t,e.p,1===t?e.e:Math.round(1e4*(e.s+e.c*t))/1e4+e.u,e)}function zd(t,e){return e.set(e.t,e.p,t?Math.round(1e4*(e.s+e.c*t))/1e4+e.u:e.b,e)}function Ad(t,e){return e.set(e.t,e.p,1===t?e.e:t?Math.round(1e4*(e.s+e.c*t))/1e4+e.u:e.b,e)}function Bd(t,e){var r=e.s+e.c*t;e.set(e.t,e.p,~~(r+(r<0?-.5:.5))+e.u,e)}function Cd(t,e){return e.set(e.t,e.p,t?e.e:e.b,e)}function Dd(t,e){return e.set(e.t,e.p,1!==t?e.b:e.e,e)}function Ed(t,e,r){return t.style[e]=r}function Fd(t,e,r){return t.style.setProperty(e,r)}function Gd(t,e,r){return t._gsap[e]=r}function Hd(t,e,r){return t._gsap.scaleX=t._gsap.scaleY=r}function Id(t,e,r,i,n){var a=t._gsap;a.scaleX=a.scaleY=r,a.renderTransform(n,a)}function Jd(t,e,r,i,n){var a=t._gsap;a[e]=r,a.renderTransform(n,a)}function Md(t,e){var r=this,i=this.target,n=i.style,a=i._gsap;if(t in ur&&n){if(this.tfm=this.tfm||{},"transform"===t)return _r.transform.split(",").forEach(function(t){return Md.call(r,t,e)});if(~(t=_r[t]||t).indexOf(",")?t.split(",").forEach(function(t){return r.tfm[t]=wr(i,t)}):this.tfm[t]=a.x?a[t]:wr(i,t),t===gr&&(this.tfm.zOrigin=a.zOrigin),0<=this.props.indexOf(mr))return;a.svg&&(this.svgo=i.getAttribute("data-svg-origin"),this.props.push(gr,e,"")),t=mr}(n||e)&&this.props.push(t,e,n[t])}function Nd(t){t.translate&&(t.removeProperty("translate"),t.removeProperty("scale"),t.removeProperty("rotate"))}function Od(){var t,e,r=this.props,i=this.target,n=i.style,a=i._gsap;for(t=0;t<r.length;t+=3)r[t+1]?2===r[t+1]?i[r[t]](r[t+2]):i[r[t]]=r[t+2]:r[t+2]?n[r[t]]=r[t+2]:n.removeProperty("--"===r[t].substr(0,2)?r[t]:r[t].replace(cr,"-$1").toLowerCase());if(this.tfm){for(e in this.tfm)a[e]=this.tfm[e];a.svg&&(a.renderTransform(),i.setAttribute("data-svg-origin",this.svgo||"")),(t=je())&&t.isStart||n[mr]||(Nd(n),a.zOrigin&&n[gr]&&(n[gr]+=" "+a.zOrigin+"px",a.zOrigin=0,a.renderTransform()),a.uncache=1)}}function Pd(t,e){var r={target:t,props:[],revert:Od,save:Md};return t._gsap||Fe.core.getCache(t),e&&t.style&&t.nodeType&&e.split(",").forEach(function(t){return r.save(t)}),r}function Rd(t,e){var r=Le.createElementNS?Le.createElementNS((e||"http://www.w3.org/1999/xhtml").replace(/^https/,"http"),t):Le.createElement(t);return r&&r.style?r:Le.createElement(t)}function Sd(t,e,r){var i=getComputedStyle(t);return i[e]||i.getPropertyValue(e.replace(cr,"-$1").toLowerCase())||i.getPropertyValue(e)||!r&&Sd(t,yr(e)||e,1)||""}function Vd(){(function _windowExists(){return"undefined"!=typeof window})()&&window.document&&(Ie=window,Le=Ie.document,Be=Le.documentElement,Ue=Rd("div")||{style:{}},Rd("div"),mr=yr(mr),gr=mr+"Origin",Ue.style.cssText="border-width:0;line-height:0;position:absolute;padding:0",Ve=!!yr("perspective"),je=Fe.core.reverting,Ne=1)}function Wd(t){var e,r=t.ownerSVGElement,i=Rd("svg",r&&r.getAttribute("xmlns")||"http://www.w3.org/2000/svg"),n=t.cloneNode(!0);n.style.display="block",i.appendChild(n),Be.appendChild(i);try{e=n.getBBox()}catch(t){}return i.removeChild(n),Be.removeChild(i),e}function Xd(t,e){for(var r=e.length;r--;)if(t.hasAttribute(e[r]))return t.getAttribute(e[r])}function Yd(e){var r,i;try{r=e.getBBox()}catch(t){r=Wd(e),i=1}return r&&(r.width||r.height)||i||(r=Wd(e)),!r||r.width||r.x||r.y?r:{x:+Xd(e,["x","cx","x1"])||0,y:+Xd(e,["y","cy","y1"])||0,width:0,height:0}}function Zd(t){return!(!t.getCTM||t.parentNode&&!t.ownerSVGElement||!Yd(t))}function $d(t,e){if(e){var r,i=t.style;e in ur&&e!==gr&&(e=mr),i.removeProperty?("ms"!==(r=e.substr(0,2))&&"webkit"!==e.substr(0,6)||(e="-"+e),i.removeProperty("--"===r?e:e.replace(cr,"-$1").toLowerCase())):i.removeAttribute(e)}}function _d(t,e,r,i,n,a){var s=new we(t._pt,e,r,0,1,a?Dd:Cd);return(t._pt=s).b=i,s.e=n,t._props.push(r),s}function ce(t,e,r,i){var n,a,s,o,u=parseFloat(r)||0,h=(r+"").trim().substr((u+"").length)||"px",l=Ue.style,f=dr.test(e),c="svg"===t.tagName.toLowerCase(),d=(c?"client":"offset")+(f?"Width":"Height"),p="px"===i,_="%"===i;if(i===h||!u||Tr[i]||Tr[h])return u;if("px"===h||p||(u=ce(t,e,r,"px")),o=t.getCTM&&Zd(t),(_||"%"===h)&&(ur[e]||~e.indexOf("adius")))return n=o?t.getBBox()[f?"width":"height"]:t[d],ka(_?u/n*100:u/100*n);if(l[f?"width":"height"]=100+(p?h:i),a="rem"!==i&&~e.indexOf("adius")||"em"===i&&t.appendChild&&!c?t:t.parentNode,o&&(a=(t.ownerSVGElement||{}).parentNode),a&&a!==Le&&a.appendChild||(a=Le.body),(s=a._gsap)&&_&&s.width&&f&&s.time===It.time&&!s.uncache)return ka(u/s.width*100);if(!_||"height"!==e&&"width"!==e)!_&&"%"!==h||br[Sd(a,"display")]||(l.position=Sd(t,"position")),a===t&&(l.position="static"),a.appendChild(Ue),n=Ue[d],a.removeChild(Ue),l.position="absolute";else{var m=t.style[e];t.style[e]=100+i,n=t[d],m?t.style[e]=m:$d(t,e)}return f&&_&&((s=ha(a)).time=It.time,s.width=a[d]),ka(p?n*u/100:n&&u?100/n*u:0)}function ee(t,e,r,i){if(!r||"none"===r){var n=yr(e,t,1),a=n&&Sd(t,n,1);a&&a!==r?(e=n,r=a):"borderColor"===e&&(r=Sd(t,"borderTopColor"))}var s,o,u,h,l,f,c,d,p,_,m,g=new we(this._pt,t.style,e,0,1,pe),v=0,y=0;if(g.b=r,g.e=i,r+="","var(--"===(i+="").substring(0,6)&&(i=Sd(t,i.substring(4,i.indexOf(")")))),"auto"===i&&(f=t.style[e],t.style[e]=i,i=Sd(t,e)||i,f?t.style[e]=f:$d(t,e)),Ib(s=[r,i]),i=s[1],u=(r=s[0]).match(nt)||[],(i.match(nt)||[]).length){for(;o=nt.exec(i);)c=o[0],p=i.substring(v,o.index),l?l=(l+1)%5:"rgba("!==p.substr(-5)&&"hsla("!==p.substr(-5)||(l=1),c!==(f=u[y++]||"")&&(h=parseFloat(f)||0,m=f.substr((h+"").length),"="===c.charAt(1)&&(c=ma(h,c)+m),d=parseFloat(c),_=c.substr((d+"").length),v=nt.lastIndex-_.length,_||(_=_||Y.units[e]||m,v===i.length&&(i+=_,g.e+=_)),m!==_&&(h=ce(t,e,f,_)||0),g._pt={_next:g._pt,p:p||1===y?p:",",s:h,c:d-h,m:l&&l<4||"zIndex"===e?Math.round:0});g.c=v<i.length?i.substring(v,i.length):""}else g.r="display"===e&&"none"===i?Dd:Cd;return st.test(i)&&(g.e=0),this._pt=g}function ge(t){var e=t.split(" "),r=e[0],i=e[1]||"50%";return"top"!==r&&"bottom"!==r&&"left"!==i&&"right"!==i||(t=r,r=i,i=t),e[0]=xr[r]||r,e[1]=xr[i]||i,e.join(" ")}function he(t,e){if(e.tween&&e.tween._time===e.tween._dur){var r,i,n,a=e.t,s=a.style,o=e.u,u=a._gsap;if("all"===o||!0===o)s.cssText="",i=1;else for(n=(o=o.split(",")).length;-1<--n;)r=o[n],ur[r]&&(i=1,r="transformOrigin"===r?gr:mr),$d(a,r);i&&($d(a,mr),u&&(u.svg&&a.removeAttribute("transform"),s.scale=s.rotate=s.translate="none",Cr(a,1),u.uncache=1,Nd(s)))}}function le(t){return"matrix(1, 0, 0, 1, 0, 0)"===t||"none"===t||!t}function me(t){var e=Sd(t,mr);return le(e)?Or:e.substr(7).match(it).map(ka)}function ne(t,e){var r,i,n,a,s=t._gsap||ha(t),o=t.style,u=me(t);return s.svg&&t.getAttribute("transform")?"1,0,0,1,0,0"===(u=[(n=t.transform.baseVal.consolidate().matrix).a,n.b,n.c,n.d,n.e,n.f]).join(",")?Or:u:(u!==Or||t.offsetParent||t===Be||s.svg||(n=o.display,o.display="block",(r=t.parentNode)&&(t.offsetParent||t.getBoundingClientRect().width)||(a=1,i=t.nextElementSibling,Be.appendChild(t)),u=me(t),n?o.display=n:$d(t,"display"),a&&(i?r.insertBefore(t,i):r?r.appendChild(t):Be.removeChild(t))),e&&6<u.length?[u[0],u[1],u[4],u[5],u[12],u[13]]:u)}function oe(t,e,r,i,n,a){var s,o,u,h=t._gsap,l=n||ne(t,!0),f=h.xOrigin||0,c=h.yOrigin||0,d=h.xOffset||0,p=h.yOffset||0,_=l[0],m=l[1],g=l[2],v=l[3],y=l[4],T=l[5],b=e.split(" "),w=parseFloat(b[0])||0,x=parseFloat(b[1])||0;r?l!==Or&&(o=_*v-m*g)&&(u=w*(-m/o)+x*(_/o)-(_*T-m*y)/o,w=w*(v/o)+x*(-g/o)+(g*T-v*y)/o,x=u):(w=(s=Yd(t)).x+(~b[0].indexOf("%")?w/100*s.width:w),x=s.y+(~(b[1]||b[0]).indexOf("%")?x/100*s.height:x)),i||!1!==i&&h.smooth?(y=w-f,T=x-c,h.xOffset=d+(y*_+T*g)-y,h.yOffset=p+(y*m+T*v)-T):h.xOffset=h.yOffset=0,h.xOrigin=w,h.yOrigin=x,h.smooth=!!i,h.origin=e,h.originIsAbsolute=!!r,t.style[gr]="0px 0px",a&&(_d(a,h,"xOrigin",f,w),_d(a,h,"yOrigin",c,x),_d(a,h,"xOffset",d,h.xOffset),_d(a,h,"yOffset",p,h.yOffset)),t.setAttribute("data-svg-origin",w+" "+x)}function re(t,e,r){var i=_a(e);return ka(parseFloat(e)+parseFloat(ce(t,"x",r+"px",i)))+i}function ye(t,e,i,n,a){var s,o,u=360,h=r(a),l=parseFloat(a)*(h&&~a.indexOf("rad")?hr:1)-n,f=n+l+"deg";return h&&("short"===(s=a.split("_")[1])&&(l%=u)!==l%180&&(l+=l<0?u:-u),"cw"===s&&l<0?l=(l+36e9)%u-~~(l/u)*u:"ccw"===s&&0<l&&(l=(l-36e9)%u-~~(l/u)*u)),t._pt=o=new we(t._pt,e,i,n,l,yd),o.e=f,o.u="deg",t._props.push(i),o}function ze(t,e){for(var r in e)t[r]=e[r];return t}function Ae(t,e,r){var i,n,a,s,o,u,h,l=ze({},r._gsap),f=r.style;for(n in l.svg?(a=r.getAttribute("transform"),r.setAttribute("transform",""),f[mr]=e,i=Cr(r,1),$d(r,mr),r.setAttribute("transform",a)):(a=getComputedStyle(r)[mr],f[mr]=e,i=Cr(r,1),f[mr]=a),ur)(a=l[n])!==(s=i[n])&&"perspective,force3D,transformOrigin,svgOrigin".indexOf(n)<0&&(o=_a(a)!==(h=_a(s))?ce(r,n,a,h):parseFloat(a),u=parseFloat(s),t._pt=new we(t._pt,i,n,o,u-o,xd),t._pt.u=h||0,t._props.push(n));ze(i,l)}var Ie,Le,Be,Ne,Ue,Ye,je,Ve,Xe=Bt.Power0,qe=Bt.Power1,Ge=Bt.Power2,Ze=Bt.Power3,We=Bt.Power4,$e=Bt.Linear,He=Bt.Quad,Qe=Bt.Cubic,Je=Bt.Quart,Ke=Bt.Quint,tr=Bt.Strong,er=Bt.Elastic,rr=Bt.Back,ir=Bt.SteppedEase,nr=Bt.Bounce,ar=Bt.Sine,sr=Bt.Expo,or=Bt.Circ,ur={},hr=180/Math.PI,lr=Math.PI/180,fr=Math.atan2,cr=/([A-Z])/g,dr=/(left|right|width|margin|padding|x)/i,pr=/[\s,\(]\S/,_r={autoAlpha:"opacity,visibility",scale:"scaleX,scaleY",alpha:"opacity"},mr="transform",gr=mr+"Origin",vr="O,Moz,ms,Ms,Webkit".split(","),yr=function _checkPropPrefix(t,e,r){var i=(e||Ue).style,n=5;if(t in i&&!r)return t;for(t=t.charAt(0).toUpperCase()+t.substr(1);n--&&!(vr[n]+t in i););return n<0?null:(3===n?"ms":0<=n?vr[n]:"")+t},Tr={deg:1,rad:1,turn:1},br={grid:1,flex:1},wr=function _get(t,e,r,i){var n;return Ne||Vd(),e in _r&&"transform"!==e&&~(e=_r[e]).indexOf(",")&&(e=e.split(",")[0]),ur[e]&&"transform"!==e?(n=Cr(t,i),n="transformOrigin"!==e?n[e]:n.svg?n.origin:Pr(Sd(t,gr))+" "+n.zOrigin+"px"):(n=t.style[e])&&"auto"!==n&&!i&&!~(n+"").indexOf("calc(")||(n=kr[e]&&kr[e](t,e,r)||Sd(t,e)||ia(t,e)||("opacity"===e?1:0)),r&&!~(n+"").trim().indexOf(" ")?ce(t,e,n,r)+r:n},xr={top:"0%",bottom:"100%",left:"0%",right:"100%",center:"50%"},kr={clearProps:function clearProps(t,e,r,i,n){if("isFromStart"!==n.data){var a=t._pt=new we(t._pt,e,r,0,0,he);return a.u=i,a.pr=-10,a.tween=n,t._props.push(r),1}}},Or=[1,0,0,1,0,0],Mr={},Cr=function _parseTransform(t,e){var r=t._gsap||new Xt(t);if("x"in r&&!e&&!r.uncache)return r;var i,n,a,s,o,u,h,l,f,c,d,p,_,m,g,v,y,T,b,w,x,k,O,M,C,P,S,A,D,z,R,E,F=t.style,I=r.scaleX<0,L="deg",B=getComputedStyle(t),N=Sd(t,gr)||"0";return i=n=a=u=h=l=f=c=d=0,s=o=1,r.svg=!(!t.getCTM||!Zd(t)),B.translate&&("none"===B.translate&&"none"===B.scale&&"none"===B.rotate||(F[mr]=("none"!==B.translate?"translate3d("+(B.translate+" 0 0").split(" ").slice(0,3).join(", ")+") ":"")+("none"!==B.rotate?"rotate("+B.rotate+") ":"")+("none"!==B.scale?"scale("+B.scale.split(" ").join(",")+") ":"")+("none"!==B[mr]?B[mr]:"")),F.scale=F.rotate=F.translate="none"),m=ne(t,r.svg),r.svg&&(M=r.uncache?(C=t.getBBox(),N=r.xOrigin-C.x+"px "+(r.yOrigin-C.y)+"px",""):!e&&t.getAttribute("data-svg-origin"),oe(t,M||N,!!M||r.originIsAbsolute,!1!==r.smooth,m)),p=r.xOrigin||0,_=r.yOrigin||0,m!==Or&&(T=m[0],b=m[1],w=m[2],x=m[3],i=k=m[4],n=O=m[5],6===m.length?(s=Math.sqrt(T*T+b*b),o=Math.sqrt(x*x+w*w),u=T||b?fr(b,T)*hr:0,(f=w||x?fr(w,x)*hr+u:0)&&(o*=Math.abs(Math.cos(f*lr))),r.svg&&(i-=p-(p*T+_*w),n-=_-(p*b+_*x))):(E=m[6],z=m[7],S=m[8],A=m[9],D=m[10],R=m[11],i=m[12],n=m[13],a=m[14],h=(g=fr(E,D))*hr,g&&(M=k*(v=Math.cos(-g))+S*(y=Math.sin(-g)),C=O*v+A*y,P=E*v+D*y,S=k*-y+S*v,A=O*-y+A*v,D=E*-y+D*v,R=z*-y+R*v,k=M,O=C,E=P),l=(g=fr(-w,D))*hr,g&&(v=Math.cos(-g),R=x*(y=Math.sin(-g))+R*v,T=M=T*v-S*y,b=C=b*v-A*y,w=P=w*v-D*y),u=(g=fr(b,T))*hr,g&&(M=T*(v=Math.cos(g))+b*(y=Math.sin(g)),C=k*v+O*y,b=b*v-T*y,O=O*v-k*y,T=M,k=C),h&&359.9<Math.abs(h)+Math.abs(u)&&(h=u=0,l=180-l),s=ka(Math.sqrt(T*T+b*b+w*w)),o=ka(Math.sqrt(O*O+E*E)),g=fr(k,O),f=2e-4<Math.abs(g)?g*hr:0,d=R?1/(R<0?-R:R):0),r.svg&&(M=t.getAttribute("transform"),r.forceCSS=t.setAttribute("transform","")||!le(Sd(t,mr)),M&&t.setAttribute("transform",M))),90<Math.abs(f)&&Math.abs(f)<270&&(I?(s*=-1,f+=u<=0?180:-180,u+=u<=0?180:-180):(o*=-1,f+=f<=0?180:-180)),e=e||r.uncache,r.x=i-((r.xPercent=i&&(!e&&r.xPercent||(Math.round(t.offsetWidth/2)===Math.round(-i)?-50:0)))?t.offsetWidth*r.xPercent/100:0)+"px",r.y=n-((r.yPercent=n&&(!e&&r.yPercent||(Math.round(t.offsetHeight/2)===Math.round(-n)?-50:0)))?t.offsetHeight*r.yPercent/100:0)+"px",r.z=a+"px",r.scaleX=ka(s),r.scaleY=ka(o),r.rotation=ka(u)+L,r.rotationX=ka(h)+L,r.rotationY=ka(l)+L,r.skewX=f+L,r.skewY=c+L,r.transformPerspective=d+"px",(r.zOrigin=parseFloat(N.split(" ")[2])||!e&&r.zOrigin||0)&&(F[gr]=Pr(N)),r.xOffset=r.yOffset=0,r.force3D=Y.force3D,r.renderTransform=r.svg?Er:Ve?Rr:Sr,r.uncache=0,r},Pr=function _firstTwoOnly(t){return(t=t.split(" "))[0]+" "+t[1]},Sr=function _renderNon3DTransforms(t,e){e.z="0px",e.rotationY=e.rotationX="0deg",e.force3D=0,Rr(t,e)},Ar="0deg",Dr="0px",zr=") ",Rr=function _renderCSSTransforms(t,e){var r=e||this,i=r.xPercent,n=r.yPercent,a=r.x,s=r.y,o=r.z,u=r.rotation,h=r.rotationY,l=r.rotationX,f=r.skewX,c=r.skewY,d=r.scaleX,p=r.scaleY,_=r.transformPerspective,m=r.force3D,g=r.target,v=r.zOrigin,y="",T="auto"===m&&t&&1!==t||!0===m;if(v&&(l!==Ar||h!==Ar)){var b,w=parseFloat(h)*lr,x=Math.sin(w),k=Math.cos(w);w=parseFloat(l)*lr,b=Math.cos(w),a=re(g,a,x*b*-v),s=re(g,s,-Math.sin(w)*-v),o=re(g,o,k*b*-v+v)}_!==Dr&&(y+="perspective("+_+zr),(i||n)&&(y+="translate("+i+"%, "+n+"%) "),!T&&a===Dr&&s===Dr&&o===Dr||(y+=o!==Dr||T?"translate3d("+a+", "+s+", "+o+") ":"translate("+a+", "+s+zr),u!==Ar&&(y+="rotate("+u+zr),h!==Ar&&(y+="rotateY("+h+zr),l!==Ar&&(y+="rotateX("+l+zr),f===Ar&&c===Ar||(y+="skew("+f+", "+c+zr),1===d&&1===p||(y+="scale("+d+", "+p+zr),g.style[mr]=y||"translate(0, 0)"},Er=function _renderSVGTransforms(t,e){var r,i,n,a,s,o=e||this,u=o.xPercent,h=o.yPercent,l=o.x,f=o.y,c=o.rotation,d=o.skewX,p=o.skewY,_=o.scaleX,m=o.scaleY,g=o.target,v=o.xOrigin,y=o.yOrigin,T=o.xOffset,b=o.yOffset,w=o.forceCSS,x=parseFloat(l),k=parseFloat(f);c=parseFloat(c),d=parseFloat(d),(p=parseFloat(p))&&(d+=p=parseFloat(p),c+=p),c||d?(c*=lr,d*=lr,r=Math.cos(c)*_,i=Math.sin(c)*_,n=Math.sin(c-d)*-m,a=Math.cos(c-d)*m,d&&(p*=lr,s=Math.tan(d-p),n*=s=Math.sqrt(1+s*s),a*=s,p&&(s=Math.tan(p),r*=s=Math.sqrt(1+s*s),i*=s)),r=ka(r),i=ka(i),n=ka(n),a=ka(a)):(r=_,a=m,i=n=0),(x&&!~(l+"").indexOf("px")||k&&!~(f+"").indexOf("px"))&&(x=ce(g,"x",l,"px"),k=ce(g,"y",f,"px")),(v||y||T||b)&&(x=ka(x+v-(v*r+y*n)+T),k=ka(k+y-(v*i+y*a)+b)),(u||h)&&(s=g.getBBox(),x=ka(x+u/100*s.width),k=ka(k+h/100*s.height)),s="matrix("+r+","+i+","+n+","+a+","+x+","+k+")",g.setAttribute("transform",s),w&&(g.style[mr]=s)};ja("padding,margin,Width,Radius",function(e,r){var t="Right",i="Bottom",n="Left",o=(r<3?["Top",t,i,n]:["Top"+n,"Top"+t,i+t,i+n]).map(function(t){return r<2?e+t:"border"+t+e});kr[1<r?"border"+e:e]=function(e,t,r,i,n){var a,s;if(arguments.length<4)return a=o.map(function(t){return wr(e,t,r)}),5===(s=a.join(" ")).split(a[0]).length?a[0]:s;a=(i+"").split(" "),s={},o.forEach(function(t,e){return s[t]=a[e]=a[e]||a[(e-1)/2|0]}),e.init(t,s,n)}});var Fr,Ir,Lr,Br={name:"css",register:Vd,targetTest:function targetTest(t){return t.style&&t.nodeType},init:function init(t,e,i,n,a){var s,o,u,h,l,f,c,d,p,_,m,g,v,y,T,b,w,x=this._props,k=t.style,O=i.vars.startAt;for(c in Ne||Vd(),this.styles=this.styles||Pd(t),b=this.styles.props,this.tween=i,e)if("autoRound"!==c&&(o=e[c],!mt[c]||!cc(c,e,i,n,t,a)))if(l=typeof o,f=kr[c],"function"===l&&(l=typeof(o=o.call(i,n,t,a))),"string"===l&&~o.indexOf("random(")&&(o=rb(o)),f)f(this,t,c,o,i)&&(T=1);else if("--"===c.substr(0,2))s=(getComputedStyle(t).getPropertyValue(c)+"").trim(),o+="",Et.lastIndex=0,Et.test(s)||(d=_a(s),(p=_a(o))?d!==p&&(s=ce(t,c,s,p)+p):d&&(o+=d)),this.add(k,"setProperty",s,o,n,a,0,0,c),x.push(c),b.push(c,0,k[c]);else if("undefined"!==l){if(O&&c in O?(s="function"==typeof O[c]?O[c].call(i,n,t,a):O[c],r(s)&&~s.indexOf("random(")&&(s=rb(s)),_a(s+"")||"auto"===s||(s+=Y.units[c]||_a(wr(t,c))||""),"="===(s+"").charAt(1)&&(s=wr(t,c))):s=wr(t,c),h=parseFloat(s),(_="string"===l&&"="===o.charAt(1)&&o.substr(0,2))&&(o=o.substr(2)),u=parseFloat(o),c in _r&&("autoAlpha"===c&&(1===h&&"hidden"===wr(t,"visibility")&&u&&(h=0),b.push("visibility",0,k.visibility),_d(this,k,"visibility",h?"inherit":"hidden",u?"inherit":"hidden",!u)),"scale"!==c&&"transform"!==c&&~(c=_r[c]).indexOf(",")&&(c=c.split(",")[0])),m=c in ur){if(this.styles.save(c),w=o,"string"===l&&"var(--"===o.substring(0,6)){if("calc("===(o=Sd(t,o.substring(4,o.indexOf(")")))).substring(0,5)){var M=t.style.perspective;t.style.perspective=o,o=Sd(t,"perspective"),M?t.style.perspective=M:$d(t,"perspective")}u=parseFloat(o)}if(g||((v=t._gsap).renderTransform&&!e.parseTransform||Cr(t,e.parseTransform),y=!1!==e.smoothOrigin&&v.smooth,(g=this._pt=new we(this._pt,k,mr,0,1,v.renderTransform,v,0,-1)).dep=1),"scale"===c)this._pt=new we(this._pt,v,"scaleY",v.scaleY,(_?ma(v.scaleY,_+u):u)-v.scaleY||0,xd),this._pt.u=0,x.push("scaleY",c),c+="X";else{if("transformOrigin"===c){b.push(gr,0,k[gr]),o=ge(o),v.svg?oe(t,o,0,y,0,this):((p=parseFloat(o.split(" ")[2])||0)!==v.zOrigin&&_d(this,v,"zOrigin",v.zOrigin,p),_d(this,k,c,Pr(s),Pr(o)));continue}if("svgOrigin"===c){oe(t,o,1,y,0,this);continue}if(c in Mr){ye(this,v,c,h,_?ma(h,_+o):o);continue}if("smoothOrigin"===c){_d(this,v,"smooth",v.smooth,o);continue}if("force3D"===c){v[c]=o;continue}if("transform"===c){Ae(this,o,t);continue}}}else c in k||(c=yr(c)||c);if(m||(u||0===u)&&(h||0===h)&&!pr.test(o)&&c in k)u=u||0,(d=(s+"").substr((h+"").length))!==(p=_a(o)||(c in Y.units?Y.units[c]:d))&&(h=ce(t,c,s,p)),this._pt=new we(this._pt,m?v:k,c,h,(_?ma(h,_+u):u)-h,m||"px"!==p&&"zIndex"!==c||!1===e.autoRound?xd:Bd),this._pt.u=p||0,m&&w!==o?(this._pt.b=s,this._pt.e=w,this._pt.r=Ad):d!==p&&"%"!==p&&(this._pt.b=s,this._pt.r=zd);else if(c in k)ee.call(this,t,c,s,_?_+o:o);else if(c in t)this.add(t,c,s||t[c],_?_+o:o,n,a);else if("parseTransform"!==c){S(c,o);continue}m||(c in k?b.push(c,0,k[c]):"function"==typeof t[c]?b.push(c,2,t[c]()):b.push(c,1,s||t[c])),x.push(c)}T&&be(this)},render:function render(t,e){if(e.tween._time||!je())for(var r=e._pt;r;)r.r(t,r.d),r=r._next;else e.styles.revert()},get:wr,aliases:_r,getSetter:function getSetter(t,e,r){var i=_r[e];return i&&i.indexOf(",")<0&&(e=i),e in ur&&e!==gr&&(t._gsap.x||wr(t,"x"))?r&&Ye===r?"scale"===e?Hd:Gd:(Ye=r||{})&&("scale"===e?Id:Jd):t.style&&!u(t.style[e])?Ed:~e.indexOf("-")?Fd:ue(t,e)},core:{_removeProperty:$d,_getMatrix:ne}};Fe.utils.checkPrefix=yr,Fe.core.getStyleSaver=Pd,Lr=ja((Fr="x,y,z,scale,scaleX,scaleY,xPercent,yPercent")+","+(Ir="rotation,rotationX,rotationY,skewX,skewY")+",transform,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective",function(t){ur[t]=1}),ja(Ir,function(t){Y.units[t]="deg",Mr[t]=1}),_r[Lr[13]]=Fr+","+Ir,ja("0:translateX,1:translateY,2:translateZ,8:rotate,8:rotationZ,8:rotateZ,9:rotateX,10:rotateY",function(t){var e=t.split(":");_r[e[1]]=Lr[e[0]]}),ja("x,y,z,top,right,bottom,left,width,height,fontSize,padding,margin,perspective",function(t){Y.units[t]="px"}),Fe.registerPlugin(Br);var Nr=Fe.registerPlugin(Br)||Fe,Ur=Nr.core.Tween;e.Back=rr,e.Bounce=nr,e.CSSPlugin=Br,e.Circ=or,e.Cubic=Qe,e.Elastic=er,e.Expo=sr,e.Linear=$e,e.Power0=Xe,e.Power1=qe,e.Power2=Ge,e.Power3=Ze,e.Power4=We,e.Quad=He,e.Quart=Je,e.Quint=Ke,e.Sine=ar,e.SteppedEase=ir,e.Strong=tr,e.TimelineLite=Gt,e.TimelineMax=Gt,e.TweenLite=te,e.TweenMax=Ur,e.default=Nr,e.gsap=Nr;if (typeof(window)==="undefined"||window!==e){Object.defineProperty(e,"__esModule",{value:!0})} else {delete e.default}});

references/motion-primitives/bg-flow-field/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor",    "type":"color",  "label":"Page background",       "default":"#050507"},
    {"id":"colorLow",   "type":"color",  "label":"Flow — shadow tone",    "default":"#0a1430"},
    {"id":"colorMid",   "type":"color",  "label":"Flow — mid tone",       "default":"#6a6f9e"},
    {"id":"colorHigh",  "type":"color",  "label":"Flow — highlight tone", "default":"#e8f0ff"},
    {"id":"accentColor","type":"color",  "label":"Core / glow accent",    "default":"#aee4ff"},
    {"id":"flowSpeed",  "type":"number", "label":"Flow speed", "default":1, "min":0.1, "max":3, "step":0.1}
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>bg_flow_field</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #050507;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .field {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        display: block;
      }
      .label {
        position: absolute;
        left: 48px;
        bottom: 40px;
        font-size: 22px;
        font-weight: 600;
        letter-spacing: 0.18em;
        text-transform: uppercase;
        color: rgba(255, 255, 255, 0.7);
        mix-blend-mode: screen;
        pointer-events: none;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="4"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="4" data-track-index="1">
        <canvas class="field" width="1920" height="1080"></canvas>
        <div class="label">bg_flow_field</div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // ── Variables (palette + speed); falls back to defaults standalone ──
      const defaults = {
        bgColor: "#050507",
        colorLow: "#0a1430",
        colorMid: "#6a6f9e",
        colorHigh: "#e8f0ff",
        accentColor: "#aee4ff",
        flowSpeed: 1,
      };
      const vars = Object.assign(
        {},
        defaults,
        window.__hyperframes && window.__hyperframes.getVariables
          ? window.__hyperframes.getVariables()
          : {},
      );

      // sRGB hex -> linear-light [r,g,b] (shader works in linear, gamma-encodes at end)
      function hexLin(hex) {
        let h = String(hex).replace("#", "").trim();
        if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
        const r = parseInt(h.substr(0, 2), 16) / 255;
        const g = parseInt(h.substr(2, 2), 16) / 255;
        const b = parseInt(h.substr(4, 2), 16) / 255;
        const lin = (c) => Math.pow(c, 2.2);
        return [lin(r), lin(g), lin(b)];
      }

      const canvas = document.querySelector(".field");
      const gl = canvas.getContext("webgl", {
        antialias: false,
        alpha: false,
        preserveDrawingBuffer: true,
      });

      const VERT = [
        "attribute vec2 position;",
        "varying vec2 vUv;",
        "void main(){",
        "  vUv = position * 0.5 + 0.5;",
        "  gl_Position = vec4(position, 0.0, 1.0);",
        "}",
      ].join("\n");

      // Curl-noise flow field — ported verbatim from the held-message-living-field
      // template's "living field" background. All visible state comes from uniforms.
      const FRAG = [
        "precision highp float;",
        "varying vec2 vUv;",
        "uniform float uTime;",
        "uniform float uPhase;",
        "uniform float uReveal;",
        "uniform float uChromAb;",
        "uniform float uVignette;",
        "uniform float uIntensity;",
        "uniform float uSpeed;",
        "uniform vec2  uResolution;",
        "uniform vec3  uColLow;",
        "uniform vec3  uColMid;",
        "uniform vec3  uColHigh;",
        "uniform vec3  uAccent;",
        "float hash21(vec2 p){",
        "  p = fract(p * vec2(123.34, 456.21));",
        "  p += dot(p, p + 45.32);",
        "  return fract(p.x * p.y);",
        "}",
        "float noise2(vec2 p){",
        "  vec2 i = floor(p);",
        "  vec2 f = fract(p);",
        "  vec2 u = f * f * (3.0 - 2.0 * f);",
        "  float a = hash21(i);",
        "  float b = hash21(i + vec2(1.0, 0.0));",
        "  float c = hash21(i + vec2(0.0, 1.0));",
        "  float d = hash21(i + vec2(1.0, 1.0));",
        "  return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);",
        "}",
        "float fbm(vec2 p){",
        "  float v = 0.0;",
        "  float a = 0.5;",
        "  for(int i=0;i<5;i++){",
        "    v += a * noise2(p);",
        "    p *= 2.02;",
        "    a *= 0.5;",
        "  }",
        "  return v;",
        "}",
        "vec2 curl(vec2 p, float t){",
        "  float e = 0.6;",
        "  float n1 = fbm(p + vec2(0.0,  e) + t*0.18);",
        "  float n2 = fbm(p + vec2(0.0, -e) + t*0.18);",
        "  float n3 = fbm(p + vec2( e, 0.0) + t*0.18);",
        "  float n4 = fbm(p + vec2(-e, 0.0) + t*0.18);",
        "  return vec2(n1 - n2, -(n3 - n4));",
        "}",
        "vec3 palette(float t){",
        "  t = clamp(t, 0.0, 1.0);",
        "  vec3 lowMid = mix(uColLow, uColMid, smoothstep(0.0, 0.5, t));",
        "  return mix(lowMid, uColHigh, smoothstep(0.5, 1.0, t));",
        "}",
        "vec3 sampleField(vec2 uv, float t, float energy){",
        "  vec2 p = (uv - 0.5) * vec2(uResolution.x/uResolution.y, 1.0) * 2.4;",
        "  vec2 c = curl(p * 0.9, t);",
        "  vec2 q = p + c * (0.45 + 0.55 * energy);",
        "  float flow = fbm(q * 1.6 + vec2(t * 0.11, -t * 0.07));",
        "  float bands = sin(flow * 14.0 + t * 1.1);",
        "  bands = smoothstep(0.0, 1.0, 0.5 + 0.5 * bands);",
        "  float r = length(p);",
        "  float core = smoothstep(1.5, 0.05, r) * (0.6 + 0.4 * energy);",
        "  float glow = exp(-r * 1.6) * (0.55 + 0.45 * energy);",
        "  float k = clamp(flow * 0.85 + 0.15 * bands + 0.25 * core + 0.6 * glow, 0.0, 1.4);",
        "  vec3 col = palette(k * 0.9 + 0.1 * sin(t * 0.4));",
        "  col *= 0.7 + 0.6 * energy;",
        "  col += uAccent * core * 0.55;",
        "  return col;",
        "}",
        "void main(){",
        "  vec2 uv = vUv;",
        "  float t = uTime * uSpeed;",
        "  float e = uIntensity;",
        "  vec2 dir = normalize(uv - 0.5 + 1e-6);",
        "  float ca = uChromAb * (0.004 + 0.012 * length(uv - 0.5));",
        "  vec3 cR = sampleField(uv + dir * ca, t, e);",
        "  vec3 cG = sampleField(uv,            t, e);",
        "  vec3 cB = sampleField(uv - dir * ca, t, e);",
        "  vec3 col = vec3(cR.r, cG.g, cB.b);",
        "  float lum = dot(col, vec3(0.299, 0.587, 0.114));",
        "  col = mix(vec3(lum), col, 1.05);",
        "  float rev = uReveal;",
        "  float sweep = smoothstep(0.0, 0.6, rev) * (1.0 - smoothstep(0.6, 1.0, rev));",
        "  col += uColHigh * sweep * 0.35;",
        "  vec3 phaseTint = mix(vec3(0.85, 0.92, 1.05), vec3(1.05, 0.95, 0.85), uPhase);",
        "  col *= phaseTint;",
        "  float v = length(uv - 0.5);",
        "  col *= 1.0 - smoothstep(0.45, 0.95, v) * uVignette;",
        "  col = col / (col + 1.0);",
        "  col = pow(col, vec3(1.0/2.2));",
        "  gl_FragColor = vec4(col, 1.0);",
        "}",
      ].join("\n");

      function compile(type, src) {
        const s = gl.createShader(type);
        gl.shaderSource(s, src);
        gl.compileShader(s);
        return s;
      }
      const prog = gl.createProgram();
      gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
      gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
      gl.linkProgram(prog);
      gl.useProgram(prog);

      // full-screen triangle
      const buf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
      const posLoc = gl.getAttribLocation(prog, "position");
      gl.enableVertexAttribArray(posLoc);
      gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
      gl.viewport(0, 0, 1920, 1080);

      const L = (n) => gl.getUniformLocation(prog, n);
      const uLoc = {
        uTime: L("uTime"),
        uPhase: L("uPhase"),
        uReveal: L("uReveal"),
        uChromAb: L("uChromAb"),
        uVignette: L("uVignette"),
        uIntensity: L("uIntensity"),
        uSpeed: L("uSpeed"),
        uResolution: L("uResolution"),
        uColLow: L("uColLow"),
        uColMid: L("uColMid"),
        uColHigh: L("uColHigh"),
        uAccent: L("uAccent"),
      };

      // animated uniform state (GSAP tweens these .value fields)
      const u = {
        uTime: { value: 0 },
        uPhase: { value: 0.1 },
        uReveal: { value: 0 },
        uChromAb: { value: 0 },
        uVignette: { value: 0 },
        uIntensity: { value: 0.3 },
      };
      const speed = Number(vars.flowSpeed) || 1;
      const cLow = hexLin(vars.colorLow);
      const cMid = hexLin(vars.colorMid);
      const cHigh = hexLin(vars.colorHigh);
      const cAcc = hexLin(vars.accentColor);

      function render() {
        gl.uniform1f(uLoc.uTime, u.uTime.value);
        gl.uniform1f(uLoc.uPhase, u.uPhase.value);
        gl.uniform1f(uLoc.uReveal, u.uReveal.value);
        gl.uniform1f(uLoc.uChromAb, u.uChromAb.value);
        gl.uniform1f(uLoc.uVignette, u.uVignette.value);
        gl.uniform1f(uLoc.uIntensity, u.uIntensity.value);
        gl.uniform1f(uLoc.uSpeed, speed);
        gl.uniform2f(uLoc.uResolution, 1920, 1080);
        gl.uniform3fv(uLoc.uColLow, cLow);
        gl.uniform3fv(uLoc.uColMid, cMid);
        gl.uniform3fv(uLoc.uColHigh, cHigh);
        gl.uniform3fv(uLoc.uAccent, cAcc);
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }
      render(); // initial frame so a seek to 0 / first paint is correct

      // ── Energy-envelope drive (the "living field" breath): all repaint is via
      // onUpdate, so the field is fully determined by the timeline position — no
      // requestAnimationFrame, seek-safe. ──
      tl.eventCallback("onUpdate", render);

      tl.to(u.uTime, { value: 4, duration: 4, ease: "none" }, 0);

      // energy: settle-in → breathe → SURGE → settle dark for the hold
      tl.to(u.uIntensity, { value: 0.6, duration: 1.2, ease: "sine.inOut" }, 0);
      tl.to(u.uIntensity, { value: 1.0, duration: 0.7, ease: "power2.in" }, 1.3);
      tl.to(u.uIntensity, { value: 0.35, duration: 2.0, ease: "power2.out" }, 2.0);

      // chromatic light-leak: light at entry, spikes on the surge, then settles
      tl.fromTo(u.uChromAb, { value: 0 }, { value: 0.4, duration: 1.2, ease: "sine.in" }, 0);
      tl.to(u.uChromAb, { value: 1.4, duration: 0.5, ease: "power3.in" }, 1.4);
      tl.to(u.uChromAb, { value: 0.2, duration: 1.6, ease: "power2.out" }, 1.9);

      // a brief highlight sweep across the surge
      tl.fromTo(u.uReveal, { value: 0 }, { value: 1, duration: 0.7, ease: "none" }, 1.3);
      tl.set(u.uReveal, { value: 0 }, 2.0);

      // colour-temperature drift
      tl.to(u.uPhase, { value: 0.6, duration: 2.2, ease: "sine.inOut" }, 0.6);

      // black mask settles the field to near-black + central glow for the hold
      tl.to(u.uVignette, { value: 0.85, duration: 1.6, ease: "power2.out" }, 1.9);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/binary-decrypt/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>08 · binary_decrypt</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-cyan: #2afff0;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .crypt {
        font-family: "JetBrains Mono", monospace;
        font-weight: 700;
        font-size: 200px;
        letter-spacing: 0.02em;
        color: var(--hg-cyan);
        text-shadow: 0 0 30px rgba(42, 255, 240, 0.5);
      }
      .crypt .char {
        display: inline-block;
        min-width: 1ch;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="crypt">
            <span class="char" data-target="D">_</span><span class="char" data-target="E">_</span
            ><span class="char" data-target="C">_</span><span class="char" data-target="R">_</span
            ><span class="char" data-target="Y">_</span><span class="char" data-target="P">_</span
            ><span class="char" data-target="T">_</span>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const SCRAMBLE = "01█▓▒░▀▄■◆◇▪▫";
      function scrambleChar(charIdx, phase) {
        const tick = Math.floor(phase * 60);
        const i = (charIdx * 7 + tick * 13 + 3) % SCRAMBLE.length;
        return SCRAMBLE.charAt(i);
      }
      const cryptState = [{ p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }];
      cryptState.forEach((state, idx) => {
        tl.to(
          state,
          {
            p: 1,
            duration: 0.85,
            delay: idx * 0.05,
            ease: "power2.out",
            onUpdate: function () {
              const spans = document.querySelectorAll(".crypt .char");
              const span = spans[idx];
              if (!span) return;
              const target = span.getAttribute("data-target");
              if (state.p >= 0.72) {
                span.textContent = target;
                span.style.color = "var(--hg-fg)";
                span.style.textShadow = "0 0 30px rgba(168, 85, 247, 0.8)";
              } else {
                span.textContent = scrambleChar(idx, state.p);
              }
            },
          },
          0.15,
        );
      });

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/blur-resolve/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>blur_resolve</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
        will-change: filter, transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="hero" id="hero">FOCUS</div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Resolve: heavy blur + scaled-up + dim settles into perfect crisp focus.
      tl.fromTo(
        "#hero",
        { filter: "blur(36px)", scale: 1.08, opacity: 0.25 },
        { filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.7, ease: "power2.out" },
        0.05,
      );

      // Hold crisp through the middle (no keys needed — state persists to ~1.5).

      // Blur-out near the end: brief defocus + dim, a cut-away that loops seamlessly.
      tl.to(
        "#hero",
        { filter: "blur(24px)", opacity: 0.25, duration: 0.25, ease: "power2.in" },
        1.7,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/braam-punch/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>03 · braam_punch</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
        will-change: transform, text-shadow;
      }
      .glow-ring {
        position: absolute;
        width: 900px;
        height: 900px;
        border-radius: 50%;
        background: radial-gradient(circle, rgba(124, 58, 237, 0.55) 0%, rgba(124, 58, 237, 0) 65%);
        filter: blur(20px);
        will-change: transform, opacity;
        opacity: 0;
      }
      .flash {
        position: absolute;
        inset: 0;
        background: #fff;
        opacity: 0;
        pointer-events: none;
        will-change: opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="glow-ring"></div>
          <div class="hero" id="hero">BRAAM</div>
        </div>
        <div class="flash"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.set("#hero", { scale: 0.92, opacity: 0 }, 0);
      tl.set(".glow-ring", { scale: 0.6, opacity: 0 }, 0);
      tl.to("#hero", { opacity: 1, duration: 0.18, ease: "power2.out" }, 0.05);
      tl.to(".glow-ring", { scale: 1.2, opacity: 1, duration: 0.45, ease: "power3.out" }, 0.05);
      tl.to(
        "#hero",
        {
          scale: 1.18,
          textShadow: "0 0 60px rgba(168,85,247,0.9)",
          duration: 0.12,
          ease: "power4.out",
        },
        0.25,
      );
      tl.to(
        "#hero",
        {
          scale: 1.0,
          textShadow: "0 0 0px rgba(168,85,247,0)",
          duration: 0.7,
          ease: "elastic.out(1, 0.5)",
        },
        0.37,
      );
      tl.fromTo(
        ".flash",
        { opacity: 0 },
        { opacity: 0.85, duration: 0.06, ease: "power4.out" },
        0.25,
      );
      tl.to(".flash", { opacity: 0, duration: 0.35, ease: "power3.in" }, 0.31);
      tl.to(
        ".glow-ring",
        { scale: 1.45, opacity: 0.4, duration: 1.0, ease: "power2.out", overwrite: "auto" },
        0.55,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/chromatic-split/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>04 · chromatic_split</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .stack {
        position: relative;
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        white-space: nowrap;
      }
      .layer {
        position: absolute;
        top: 0;
        left: 0;
        mix-blend-mode: screen;
        will-change: transform;
      }
      .layer.r {
        color: #ff2a4a;
      }
      .layer.g {
        color: #2afff0;
      }
      .layer.b {
        color: #b56dff;
      }
      .spacer {
        color: transparent;
      }
      .scanlines {
        position: absolute;
        inset: 0;
        background: repeating-linear-gradient(
          0deg,
          rgba(255, 255, 255, 0) 0px,
          rgba(255, 255, 255, 0) 3px,
          rgba(255, 255, 255, 0.04) 3px,
          rgba(255, 255, 255, 0.04) 4px
        );
        opacity: 0;
        pointer-events: none;
        will-change: opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="stack">
            <span class="spacer">GLITCH</span>
            <span class="layer r">GLITCH</span>
            <span class="layer g">GLITCH</span>
            <span class="layer b">GLITCH</span>
          </div>
        </div>
        <div class="scanlines"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.fromTo(
        ".stack",
        { opacity: 0, scale: 0.95 },
        { opacity: 1, scale: 1, duration: 0.25, ease: "power2.out" },
        0.05,
      );
      tl.set(".layer.r", { x: -2 }, 0);
      tl.set(".layer.g", { x: 2 }, 0);
      tl.set(".layer.b", { x: 0 }, 0);
      tl.to(".layer.r", { x: -14, duration: 0.06, ease: "steps(1)" }, 0.35);
      tl.to(".layer.g", { x: 14, duration: 0.06, ease: "steps(1)" }, 0.35);
      tl.to(".layer.r", { x: -4, duration: 0.06, ease: "steps(1)" }, 0.5);
      tl.to(".layer.g", { x: 4, duration: 0.06, ease: "steps(1)" }, 0.5);
      tl.to(".layer.r", { x: -22, duration: 0.04, ease: "steps(1)" }, 0.85);
      tl.to(".layer.g", { x: 22, duration: 0.04, ease: "steps(1)" }, 0.85);
      tl.to(".layer.b", { x: 6, duration: 0.04, ease: "steps(1)" }, 0.85);
      tl.to(".layer.r", { x: -6, duration: 0.04, ease: "steps(1)" }, 0.97);
      tl.to(".layer.g", { x: 6, duration: 0.04, ease: "steps(1)" }, 0.97);
      tl.to(".layer.b", { x: -2, duration: 0.04, ease: "steps(1)" }, 0.97);
      tl.to(".layer.r", { x: -3, duration: 0.4, ease: "power2.out" }, 1.2);
      tl.to(".layer.g", { x: 3, duration: 0.4, ease: "power2.out" }, 1.2);
      tl.to(".layer.b", { x: 0, duration: 0.4, ease: "power2.out" }, 1.2);
      tl.fromTo(
        ".scanlines",
        { opacity: 0 },
        { opacity: 0.85, duration: 0.06, ease: "steps(1)" },
        0.35,
      );
      tl.to(".scanlines", { opacity: 0.25, duration: 0.5, ease: "power2.out" }, 1.0);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/chrome-sweep/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>13 · chrome_sweep</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .chrome {
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        background: linear-gradient(
          110deg,
          #404048 0%,
          #b4b4be 20%,
          #ffffff 45%,
          #ffffff 50%,
          #d8d8e0 60%,
          #5a5a64 85%,
          #ffffff 100%
        );
        background-size: 250% 100%;
        background-position: 200% 0;
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
        color: transparent;
        will-change: background-position;
        filter: drop-shadow(0 6px 0 rgba(0, 0, 0, 0.4));
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage"><div class="chrome">CHROME</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.fromTo(
        ".chrome",
        { opacity: 0, scale: 0.96 },
        { opacity: 1, scale: 1, duration: 0.3, ease: "power3.out" },
        0.2,
      );
      tl.fromTo(
        ".chrome",
        { backgroundPosition: "200% 0" },
        { backgroundPosition: "-100% 0", duration: 1.4, ease: "power2.inOut" },
        0.3,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/counting-punch/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>22 · counting_punch</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-cyan: #2afff0;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 30px;
      }
      .counter {
        font-weight: 900;
        font-size: 360px;
        letter-spacing: -0.04em;
        font-variant-numeric: tabular-nums;
        color: var(--hg-fg);
        will-change: transform, text-shadow;
        line-height: 1;
      }
      .caption {
        font-family: "JetBrains Mono", monospace;
        font-size: 28px;
        color: var(--hg-dim);
        letter-spacing: 0.3em;
        text-transform: uppercase;
        opacity: 0;
      }
      .underline {
        height: 4px;
        background: var(--hg-violet-2);
        width: 0;
        box-shadow: 0 0 20px var(--hg-violet);
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="counter" id="counter">0</div>
          <div class="underline" id="underline"></div>
          <div class="caption" id="caption">USERS THIS WEEK</div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const TARGET = 12847;
      const counter = { v: 0 };
      let lastShown = 0;

      function format(n) {
        return Math.floor(n).toLocaleString("en-US");
      }

      tl.to(
        counter,
        {
          v: TARGET,
          duration: 1.15,
          ease: "expo.out",
          onUpdate: function () {
            const el = document.getElementById("counter");
            if (!el) return;
            const cur = Math.floor(counter.v);
            if (cur !== lastShown) {
              el.textContent = format(cur);
              // tiny scale tick on each digit change
              el.style.transform = "scale(1.045)";
              el.style.textShadow = "0 0 40px rgba(168, 85, 247, 0.6)";
              // browser will paint, then this resets via the next onUpdate
            }
            lastShown = cur;
          },
        },
        0.15,
      );

      // Big settle punch at landing
      tl.to(
        "#counter",
        {
          scale: 1.12,
          textShadow: "0 0 80px rgba(168, 85, 247, 0.9)",
          duration: 0.1,
          ease: "power4.out",
        },
        1.3,
      );
      tl.to(
        "#counter",
        {
          scale: 1.0,
          textShadow: "0 0 20px rgba(168, 85, 247, 0.4)",
          duration: 0.6,
          ease: "elastic.out(1, 0.55)",
        },
        1.4,
      );

      // Underline draws
      tl.to("#underline", { width: 420, duration: 0.6, ease: "expo.out" }, 1.35);
      tl.to("#caption", { opacity: 1, duration: 0.5, ease: "power2.out" }, 1.55);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/crash-zoom-in/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>19 · crash_zoom_in</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .zoom-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        will-change: transform;
      }
      .radial-bg {
        position: absolute;
        inset: 0;
        background: radial-gradient(
          circle at center,
          rgba(168, 85, 247, 0.18) 0%,
          transparent 35%,
          rgba(0, 0, 0, 0.6) 80%
        );
        opacity: 0;
        will-change: opacity, transform;
      }
      .speed-lines {
        position: absolute;
        inset: 0;
        background: repeating-radial-gradient(
          circle at 50% 50%,
          transparent 0px,
          transparent 6px,
          rgba(255, 255, 255, 0.08) 7px,
          transparent 8px
        );
        opacity: 0;
        will-change: opacity;
        mix-blend-mode: screen;
      }
      .hero {
        font-weight: 900;
        font-size: 360px;
        letter-spacing: -0.05em;
        color: var(--hg-fg);
        white-space: nowrap;
        will-change: transform, filter;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="radial-bg"></div>
        <div class="speed-lines"></div>
        <div class="zoom-stage"><div class="hero">CRASH</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Hold black for tension
      tl.set(".hero", { scale: 0.03, filter: "blur(20px)", opacity: 0 }, 0);

      const CRASH = 0.45;

      // The crash itself
      tl.to(
        ".hero",
        { scale: 1.0, filter: "blur(0px)", opacity: 1, duration: 0.32, ease: "expo.in" },
        CRASH,
      );

      // Background swoosh in same window
      tl.fromTo(
        ".radial-bg",
        { opacity: 0, scale: 0.4 },
        { opacity: 1, scale: 1, duration: 0.32, ease: "expo.in" },
        CRASH,
      );
      tl.fromTo(
        ".speed-lines",
        { opacity: 0, scale: 0.2 },
        { opacity: 1, scale: 1.4, duration: 0.32, ease: "expo.in" },
        CRASH,
      );

      // Landing settle
      const LAND = CRASH + 0.32;
      tl.to(".hero", { scale: 0.96, duration: 0.08, ease: "power3.out" }, LAND);
      tl.to(".hero", { scale: 1.0, duration: 0.5, ease: "elastic.out(1, 0.55)" }, LAND + 0.08);

      // Quick landing shake
      const sh = [9, -7, 5, -4, 2, -1];
      sh.forEach((amp, i) => {
        tl.to(
          ".zoom-stage",
          { x: amp, y: amp * 0.5 * (i % 2 ? -1 : 1), duration: 0.03, ease: "steps(1)" },
          LAND + i * 0.03,
        );
      });
      tl.to(
        ".zoom-stage",
        { x: 0, y: 0, duration: 0.1, ease: "power2.out" },
        LAND + sh.length * 0.03,
      );

      // Speed lines fade
      tl.to(".speed-lines", { opacity: 0, duration: 0.4, ease: "power2.out" }, LAND + 0.05);
      // Radial settles
      tl.to(".radial-bg", { opacity: 0.5, duration: 0.5, ease: "power2.out" }, LAND);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/datamosh-smear/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>27 · datamosh_smear</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-cyan: #2afff0;
        --hg-magenta: #ff2a4a;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .smear-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .smear-hero {
        position: relative;
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.05em;
        line-height: 0.95;
        white-space: nowrap;
      }
      .smear-layer {
        position: absolute;
        top: 0;
        left: 0;
        mix-blend-mode: screen;
        will-change: transform, opacity, filter;
      }
      .smear-layer.l1 {
        color: #ff2a4a;
      }
      .smear-layer.l2 {
        color: #2afff0;
      }
      .smear-layer.l3 {
        color: #b56dff;
      }
      .smear-layer.l4 {
        color: #ffd166;
      }
      .smear-layer.l5 {
        color: #f5f5fa;
      }
      .spacer {
        color: transparent;
      }
      .scan-noise {
        position: absolute;
        inset: 0;
        background:
          repeating-linear-gradient(
            0deg,
            rgba(255, 255, 255, 0) 0px,
            rgba(255, 255, 255, 0) 2px,
            rgba(255, 255, 255, 0.07) 3px
          ),
          repeating-linear-gradient(
            90deg,
            rgba(0, 0, 0, 0) 0px,
            rgba(0, 0, 0, 0) 4px,
            rgba(0, 0, 0, 0.15) 5px
          );
        opacity: 0;
        will-change: opacity;
        pointer-events: none;
        mix-blend-mode: overlay;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="smear-stage">
          <div class="smear-hero">
            <span class="spacer">SMEAR</span>
            <span class="smear-layer l1">SMEAR</span>
            <span class="smear-layer l2">SMEAR</span>
            <span class="smear-layer l3">SMEAR</span>
            <span class="smear-layer l4">SMEAR</span>
            <span class="smear-layer l5">SMEAR</span>
          </div>
        </div>
        <div class="scan-noise"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // All layers start far right, stagger-lag in, then settle near center
      const layers = [
        ".smear-layer.l1",
        ".smear-layer.l2",
        ".smear-layer.l3",
        ".smear-layer.l4",
        ".smear-layer.l5",
      ];

      layers.forEach((sel, i) => {
        tl.fromTo(
          sel,
          { x: 360, opacity: 0, filter: "blur(20px)" },
          { x: 0, opacity: 0.85, filter: "blur(0px)", duration: 0.7, ease: "expo.out" },
          0.2 + i * 0.08,
        );
      });

      // Final settle: pull layers into proper offset
      const finalX = [-6, -2, 0, 2, 4];
      layers.forEach((sel, i) => {
        tl.to(sel, { x: finalX[i], duration: 0.4, ease: "power2.out" }, 1.1);
      });

      // Scan noise pulses
      tl.fromTo(
        ".scan-noise",
        { opacity: 0 },
        { opacity: 1, duration: 0.06, ease: "steps(1)" },
        0.2,
      );
      tl.to(".scan-noise", { opacity: 0.25, duration: 0.8, ease: "power2.out" }, 0.6);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/directional-fill/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>directional_fill</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 36px;
      }
      .bar {
        position: relative;
        width: 1040px;
        height: 188px;
        border: 3px solid var(--hg-dim);
        border-radius: 12px;
        overflow: hidden;
        display: flex;
        align-items: center;
        padding: 0 56px;
      }
      .fill {
        position: absolute;
        inset: 0;
        transform: scaleX(0);
        will-change: transform;
      }
      .fill.a {
        background: var(--hg-violet);
        transform-origin: left center;
      }
      .fill.b {
        background: var(--hg-violet-2);
        transform-origin: right center;
      }
      .fill.c {
        background: #2afff0;
        transform-origin: left center;
      }
      .label {
        position: relative;
        z-index: 1;
        font-weight: 900;
        font-size: 96px;
        letter-spacing: -0.03em;
        line-height: 1;
        color: var(--hg-fg);
        will-change: color;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="1.8"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="1.8" data-track-index="1">
        <div class="stage">
          <div class="bar">
            <div class="fill a"></div>
            <span class="label" id="l1">READY</span>
          </div>
          <div class="bar">
            <div class="fill b"></div>
            <span class="label" id="l2">SET</span>
          </div>
          <div class="bar">
            <div class="fill c"></div>
            <span class="label" id="l3">FILL</span>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Bar 1 — sweeps left -> right, landing on the beat at ~0.55s.
      tl.to(".fill.a", { scaleX: 1, duration: 0.35, ease: "power2.out" }, 0.2);
      tl.set("#l1", { color: "var(--hg-bg)" }, 0.55);

      // Bar 2 — sweeps right -> left, landing at ~0.85s.
      tl.to(".fill.b", { scaleX: 1, duration: 0.35, ease: "power2.out" }, 0.5);
      tl.set("#l2", { color: "var(--hg-bg)" }, 0.85);

      // Bar 3 — sweeps left -> right, landing at ~1.15s.
      tl.to(".fill.c", { scaleX: 1, duration: 0.35, ease: "power2.out" }, 0.8);
      tl.set("#l3", { color: "var(--hg-bg)" }, 1.15);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/dolly-zoom/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>09 · dolly_zoom</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .layers {
        position: absolute;
        inset: 0;
        overflow: hidden;
      }
      .bg-far,
      .bg-mid,
      .bg-near {
        position: absolute;
        inset: 0;
        will-change: transform, filter;
      }
      .bg-far {
        background:
          radial-gradient(2px 2px at 20% 30%, var(--hg-dim) 50%, transparent 51%),
          radial-gradient(2px 2px at 70% 80%, var(--hg-dim) 50%, transparent 51%),
          radial-gradient(1px 1px at 45% 60%, var(--hg-dim) 50%, transparent 51%),
          radial-gradient(1px 1px at 85% 25%, var(--hg-dim) 50%, transparent 51%),
          radial-gradient(2px 2px at 10% 75%, var(--hg-dim) 50%, transparent 51%),
          radial-gradient(1px 1px at 60% 15%, var(--hg-dim) 50%, transparent 51%);
        background-size: 400px 400px;
        filter: blur(2px);
      }
      .bg-mid {
        background:
          linear-gradient(90deg, transparent 49.7%, rgba(168, 85, 247, 0.25) 50%, transparent 50.3%)
            0 0 / 200px 200px,
          linear-gradient(
              180deg,
              transparent 49.7%,
              rgba(168, 85, 247, 0.15) 50%,
              transparent 50.3%
            )
            0 0 / 200px 200px;
      }
      .bg-near {
        background:
          radial-gradient(circle at 15% 50%, rgba(168, 85, 247, 0.4) 0%, transparent 25%),
          radial-gradient(circle at 85% 50%, rgba(124, 58, 237, 0.4) 0%, transparent 25%);
      }
      .subject {
        position: relative;
        z-index: 5;
        font-family: "Playfair Display", serif;
        font-style: italic;
        font-weight: 900;
        font-size: 180px;
        letter-spacing: -0.02em;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="layers">
          <div class="bg-far"></div>
          <div class="bg-mid"></div>
          <div class="bg-near"></div>
        </div>
        <div class="stage"><div class="subject">subject.</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.fromTo(
        ".bg-far",
        { scale: 1, filter: "blur(2px)" },
        { scale: 1.6, filter: "blur(4px)", duration: 1.8, ease: "power2.inOut" },
        0.1,
      );
      tl.fromTo(".bg-mid", { scale: 1 }, { scale: 1.4, duration: 1.8, ease: "power2.inOut" }, 0.1);
      tl.fromTo(".bg-near", { scale: 1 }, { scale: 1.2, duration: 1.8, ease: "power2.inOut" }, 0.1);
      tl.fromTo(
        ".subject",
        { opacity: 0, scale: 1.1 },
        { opacity: 1, scale: 1, duration: 0.6, ease: "power3.out" },
        0.15,
      );
      tl.to(".layers", { skewY: -0.5, duration: 1.8, ease: "power2.inOut" }, 0.1);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/electric-arc/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>11 · electric_arc</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-cyan: #2afff0;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .arc-svg {
        position: absolute;
        inset: 0;
      }
      .arc-path {
        fill: none;
        stroke: var(--hg-fg);
        stroke-width: 3;
        stroke-linecap: round;
        filter: drop-shadow(0 0 8px var(--hg-cyan)) drop-shadow(0 0 16px var(--hg-cyan));
        stroke-dasharray: 1200;
        stroke-dashoffset: 1200;
        will-change: stroke-dashoffset, opacity;
      }
      .arc-flash {
        position: absolute;
        inset: 0;
        background: radial-gradient(circle at 50% 50%, rgba(42, 255, 240, 0.3) 0%, transparent 40%);
        opacity: 0;
        will-change: opacity;
      }
      .arc-label {
        font-weight: 900;
        font-size: 240px;
        letter-spacing: -0.04em;
        color: var(--hg-fg);
        position: relative;
        z-index: 2;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <svg class="arc-svg" viewBox="0 0 1920 1080" preserveAspectRatio="none">
            <path
              class="arc-path"
              id="arc1"
              d="M 200 540 L 380 480 L 440 580 L 620 500 L 720 600 L 880 510 L 960 540"
            />
            <path
              class="arc-path"
              id="arc2"
              d="M 1720 540 L 1540 600 L 1480 500 L 1300 580 L 1200 480 L 1040 570 L 960 540"
            />
            <path
              class="arc-path"
              id="arc3"
              d="M 960 200 L 920 360 L 1040 420 L 940 520 L 960 540"
            />
          </svg>
          <div class="arc-flash"></div>
          <div class="arc-label">CHARGE</div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.fromTo(
        ".arc-label",
        { opacity: 0, scale: 0.9 },
        { opacity: 1, scale: 1, duration: 0.3, ease: "power3.out" },
        0.5,
      );
      const arcs = ["#arc1", "#arc2", "#arc3"];
      arcs.forEach((p, i) => {
        tl.fromTo(
          p,
          { strokeDashoffset: 1200, opacity: 0 },
          { strokeDashoffset: 0, opacity: 1, duration: 0.18, ease: "power4.out" },
          0.15 + i * 0.05,
        );
      });
      tl.to(arcs.join(","), { opacity: 0.3, duration: 0.04, ease: "steps(1)" }, 0.55);
      tl.to(arcs.join(","), { opacity: 1, duration: 0.04, ease: "steps(1)" }, 0.6);
      tl.to(arcs.join(","), { opacity: 0.5, duration: 0.04, ease: "steps(1)" }, 0.7);
      tl.to(arcs.join(","), { opacity: 1, duration: 0.04, ease: "steps(1)" }, 0.78);
      tl.fromTo(
        ".arc-flash",
        { opacity: 0 },
        { opacity: 1, duration: 0.06, ease: "steps(1)" },
        0.5,
      );
      tl.to(".arc-flash", { opacity: 0, duration: 0.5, ease: "power2.out" }, 0.6);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/flash-cut/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>flash_cut</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .word {
        position: absolute;
        font-weight: 900;
        font-size: 260px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        white-space: nowrap;
        color: var(--hg-fg);
        will-change: opacity;
      }
      .accent {
        position: absolute;
        bottom: 360px;
        width: 220px;
        height: 14px;
        border-radius: 999px;
        background: var(--hg-violet);
        will-change: background, transform;
      }
      .flash {
        position: absolute;
        inset: 0;
        background: #ffffff;
        opacity: 0;
        pointer-events: none;
        will-change: opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="1.8"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="1.8" data-track-index="1">
        <div class="stage">
          <div class="accent" id="accent"></div>
          <div class="word" id="w0">READY</div>
          <div class="word" id="w1">IMPACT</div>
          <div class="word" id="w2">DONE</div>
        </div>
        <div class="flash" id="flash"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Initial state: first word visible, others hidden, accent violet.
      tl.set("#w0", { opacity: 1 }, 0);
      tl.set("#w1", { opacity: 0 }, 0);
      tl.set("#w2", { opacity: 0 }, 0);
      tl.set("#accent", { backgroundColor: "#7c3aed", scaleX: 1 }, 0);
      tl.set("#flash", { opacity: 0 }, 0);

      // Two hits; during each white frame, swap underlying word + accent colour.
      const hits = [
        { t: 0.5, from: "#w0", to: "#w1", color: "#a855f7", scaleX: 1.45 },
        { t: 1.2, from: "#w1", to: "#w2", color: "#f5f5fa", scaleX: 0.7 },
      ];

      hits.forEach((h) => {
        // Instant full-frame white flash on the hit.
        tl.set("#flash", { opacity: 1 }, h.t);
        // While white (~2 frames at 30fps), swap the revealed state.
        tl.set(h.from, { opacity: 0 }, h.t + 0.03);
        tl.set(h.to, { opacity: 1 }, h.t + 0.03);
        tl.set("#accent", { backgroundColor: h.color, scaleX: h.scaleX }, h.t + 0.03);
        // Snap the flash back out within ~3 frames.
        tl.set("#flash", { opacity: 0 }, h.t + 0.06);
      });

      // Graceful loop: dip back to white at the very end and restore the start
      // state so the cycle re-enters cleanly on the next pass.
      tl.set("#flash", { opacity: 1 }, 1.74);
      tl.set("#w2", { opacity: 0 }, 1.77);
      tl.set("#w0", { opacity: 1 }, 1.77);
      tl.set("#accent", { backgroundColor: "#7c3aed", scaleX: 1 }, 1.77);
      tl.set("#flash", { opacity: 0 }, 1.8);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/gooey-metaball/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>17 · gooey_metaball</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-cyan: #2afff0;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .hero {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        color: var(--hg-fg);
        z-index: 5;
        pointer-events: none;
      }
      .gooey-stage {
        position: absolute;
        inset: 0;
        filter: url(#gooey-filter);
      }
      .ball {
        position: absolute;
        border-radius: 50%;
        background: var(--hg-violet-2);
        top: 50%;
        left: 50%;
        will-change: transform;
        opacity: 0;
      }
      .ball.a {
        width: 280px;
        height: 280px;
        background: #7c3aed;
      }
      .ball.b {
        width: 240px;
        height: 240px;
        background: #a855f7;
      }
      .ball.c {
        width: 200px;
        height: 200px;
        background: #d946ef;
      }
      .ball.d {
        width: 320px;
        height: 320px;
        background: #8b5cf6;
      }
      .ball.e {
        width: 180px;
        height: 180px;
        background: #c084fc;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <svg style="position: absolute; width: 0; height: 0">
          <defs>
            <filter id="gooey-filter">
              <feGaussianBlur in="SourceGraphic" stdDeviation="20" result="blur" />
              <feColorMatrix
                in="blur"
                mode="matrix"
                values="
                1 0 0 0 0
                0 1 0 0 0
                0 0 1 0 0
                0 0 0 22 -10"
                result="gooey"
              />
              <feComposite in="SourceGraphic" in2="gooey" operator="atop" />
            </filter>
          </defs>
        </svg>

        <div class="gooey-stage">
          <div class="ball a" id="ballA"></div>
          <div class="ball b" id="ballB"></div>
          <div class="ball c" id="ballC"></div>
          <div class="ball d" id="ballD"></div>
          <div class="ball e" id="ballE"></div>
        </div>

        <div class="hero">GOOEY</div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Initial: all balls collapsed at center
      ["#ballA", "#ballB", "#ballC", "#ballD", "#ballE"].forEach((id) => {
        tl.set(id, { xPercent: -50, yPercent: -50, scale: 0, opacity: 0 }, 0);
      });
      // Spawn outward
      tl.to(
        "#ballA",
        {
          xPercent: -50 - 120,
          yPercent: -50 + 20,
          scale: 1,
          opacity: 1,
          duration: 0.5,
          ease: "back.out(1.6)",
        },
        0.15,
      );
      tl.to(
        "#ballB",
        {
          xPercent: -50 + 100,
          yPercent: -50 + 50,
          scale: 1,
          opacity: 1,
          duration: 0.55,
          ease: "back.out(1.6)",
        },
        0.2,
      );
      tl.to(
        "#ballC",
        {
          xPercent: -50 + 30,
          yPercent: -50 - 80,
          scale: 1,
          opacity: 1,
          duration: 0.55,
          ease: "back.out(1.6)",
        },
        0.25,
      );
      tl.to(
        "#ballD",
        {
          xPercent: -50 - 50,
          yPercent: -50 - 30,
          scale: 1,
          opacity: 1,
          duration: 0.55,
          ease: "back.out(1.6)",
        },
        0.3,
      );
      tl.to(
        "#ballE",
        {
          xPercent: -50 + 80,
          yPercent: -50 - 40,
          scale: 1,
          opacity: 1,
          duration: 0.55,
          ease: "back.out(1.6)",
        },
        0.18,
      );

      // Idle drift
      tl.to(
        "#ballA",
        { xPercent: "+=20", yPercent: "+=12", duration: 1.0, ease: "sine.inOut" },
        0.7,
      );
      tl.to(
        "#ballB",
        { xPercent: "-=18", yPercent: "-=10", duration: 1.0, ease: "sine.inOut" },
        0.7,
      );
      tl.to(
        "#ballC",
        { xPercent: "-=15", yPercent: "+=20", duration: 1.0, ease: "sine.inOut" },
        0.7,
      );
      tl.to(
        "#ballD",
        { xPercent: "+=25", yPercent: "+=8", duration: 1.0, ease: "sine.inOut" },
        0.7,
      );
      tl.to(
        "#ballE",
        { xPercent: "-=22", yPercent: "+=18", duration: 1.0, ease: "sine.inOut" },
        0.7,
      );

      // Reveal text behind
      tl.fromTo(
        ".hero",
        { opacity: 0, scale: 0.95 },
        { opacity: 1, scale: 1, duration: 0.5, ease: "power3.out" },
        0.6,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/hard-cut/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>hard_cut</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
      }
      .panel {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        opacity: 0;
        will-change: opacity;
      }
      .word {
        font-weight: 900;
        font-size: 340px;
        letter-spacing: -0.05em;
        line-height: 0.9;
        text-transform: uppercase;
        white-space: nowrap;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage" id="stage"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Deterministic palette + word list. Variation is derived from index only.
      const BG = ["#7c3aed", "#0a0a0f", "#ff2a4a", "#2afff0", "#a855f7", "#f5f5fa", "#0a0a0f"];
      const FG = ["#f5f5fa", "#a855f7", "#0a0a0f", "#0a0a0f", "#0a0a0f", "#7c3aed", "#f5f5fa"];
      const WORDS = ["CUT", "NOW", "HARD", "SNAP", "STOP", "GO", "CUT"];
      const STEP = 0.28; // beat interval
      const N = WORDS.length;

      // Build one full-frame panel per cut, all hidden but the first.
      const stage = document.getElementById("stage");
      for (let i = 0; i < N; i++) {
        const panel = document.createElement("div");
        panel.className = "panel";
        panel.id = "p" + i;
        panel.style.background = BG[i];
        panel.style.opacity = i === 0 ? "1" : "0";
        const word = document.createElement("div");
        word.className = "word";
        word.style.color = FG[i];
        word.textContent = WORDS[i];
        panel.appendChild(word);
        stage.appendChild(panel);
      }

      // Sample-accurate 0ms cuts: at each beat, hide current panel, show next.
      // tl.set has no duration and no ease — brutal instant switching.
      for (let i = 1; i < N; i++) {
        const t = i * STEP;
        tl.set("#p" + (i - 1), { opacity: 0 }, t);
        tl.set("#p" + i, { opacity: 1 }, t);
      }

      // Loop gracefully: snap back to the first panel before the clip ends so a
      // re-entry starts clean on the same color/word it began with.
      tl.set("#p" + (N - 1), { opacity: 0 }, N * STEP);
      tl.set("#p0", { opacity: 1 }, N * STEP);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/hypercut-whip/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>01 · hypercut_whip</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
        will-change: transform, filter;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage"><div class="hero" id="hero">HYPERCUT</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.set("#hero", { x: 0, filter: "blur(0px)" }, 0);
      tl.to("#hero", { x: 1800, filter: "blur(30px)", duration: 0.18, ease: "power4.in" }, 1.65);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/iris-open/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>10 · iris_open</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .iris-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        clip-path: circle(0% at 50% 50%);
        will-change: clip-path;
        background: linear-gradient(135deg, #1a0033 0%, #0a0a0f 60%);
      }
      .iris-stage .hero {
        font-family: "Playfair Display", serif;
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
      }
      .iris-ring {
        position: absolute;
        border-radius: 50%;
        border: 2px solid var(--hg-violet);
        box-shadow: 0 0 40px var(--hg-violet);
        opacity: 0;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="iris-stage">
          <div class="iris-ring" style="width: 200px; height: 200px"></div>
          <div class="hero">IRIS</div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.set(".iris-stage", { clipPath: "circle(0% at 50% 50%)" }, 0);
      tl.fromTo(
        ".iris-ring",
        { scale: 0, opacity: 0.9 },
        { scale: 9, opacity: 0, duration: 0.85, ease: "expo.out" },
        0.2,
      );
      tl.to(
        ".iris-stage",
        { clipPath: "circle(75% at 50% 50%)", duration: 0.9, ease: "expo.out" },
        0.3,
      );
      tl.from(".iris-stage .hero", { y: 40, opacity: 0, duration: 0.6, ease: "power3.out" }, 0.6);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/kinetic-letter-in/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>02 · kinetic_letter_in</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
      }
      .char {
        display: inline-block;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="hero" id="hero">
            <span class="char">K</span><span class="char">I</span><span class="char">N</span
            ><span class="char">E</span><span class="char">T</span><span class="char">I</span
            ><span class="char">C</span>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.from(
        ".char",
        { y: 80, opacity: 0, stagger: 0.045, ease: "back.out(2)", duration: 0.55 },
        0.05,
      );
      // Letters loosen apart as they settle. Animate each glyph's x (a transform), NOT the
      // word's letter-spacing: animating letter-spacing reflows text and snaps glyph positions
      // to the pixel grid, which micro-stutters on a slow settle under the seek-by-frame
      // capture engine (the gsap_non_transform_motion lint rule flags it). The chars are
      // already per-glyph spans, so spread them directly: (0.04em − (−0.04em)) × 280px = 22.4px
      // per gap, centered about index 3 of the 7-letter word.
      tl.to(".char", { x: (i) => (i - 3) * 22.4, duration: 0.6, ease: "power2.out" }, 0.7);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/liquid-morph/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>16 · liquid_morph</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .blob-wrap {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .blob {
        position: absolute;
        width: 1100px;
        height: 800px;
        background: linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #d946ef 100%);
        clip-path: polygon(
          50% 5%,
          65% 8%,
          78% 16%,
          88% 30%,
          92% 50%,
          88% 70%,
          78% 84%,
          65% 92%,
          50% 95%,
          35% 92%,
          22% 84%,
          12% 70%,
          8% 50%,
          12% 30%,
          22% 16%,
          35% 8%
        );
        will-change: clip-path, transform;
        filter: blur(0.5px);
      }
      .hero {
        font-family: "Playfair Display", serif;
        font-style: italic;
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        color: var(--hg-fg);
        white-space: nowrap;
        position: relative;
        z-index: 2;
        mix-blend-mode: difference;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="blob-wrap"><div class="blob"></div></div>
        <div class="stage"><div class="hero">liquid</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const BLOB_A =
        "polygon(50% 5%, 65% 8%, 78% 16%, 88% 30%, 92% 50%, 88% 70%, 78% 84%, 65% 92%, 50% 95%, 35% 92%, 22% 84%, 12% 70%, 8% 50%, 12% 30%, 22% 16%, 35% 8%)";
      const BLOB_B =
        "polygon(38% 10%, 58% 4%, 75% 14%, 90% 28%, 95% 46%, 86% 60%, 80% 78%, 62% 88%, 46% 94%, 30% 86%, 16% 74%, 6% 58%, 10% 40%, 20% 22%, 30% 14%, 38% 10%)";
      const BLOB_C =
        "polygon(46% 8%, 62% 10%, 80% 20%, 92% 36%, 90% 52%, 92% 70%, 76% 86%, 58% 90%, 44% 94%, 28% 88%, 14% 76%, 10% 60%, 12% 44%, 18% 26%, 30% 12%, 38% 8%)";

      tl.set(".blob", { clipPath: BLOB_A, scale: 0.95 }, 0);
      tl.to(".blob", { clipPath: BLOB_B, scale: 1.05, duration: 0.7, ease: "power2.inOut" }, 0.2);
      tl.to(".blob", { clipPath: BLOB_C, scale: 1.0, duration: 0.7, ease: "power2.inOut" }, 0.9);
      tl.fromTo(
        ".hero",
        { opacity: 0, y: 30 },
        { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" },
        0.4,
      );
      tl.to(".blob", { rotation: 6, duration: 1.5, ease: "sine.inOut" }, 0.2);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/mask-reveal/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>05 · mask_reveal</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
      }
      .mask-wrap {
        position: relative;
        overflow: hidden;
        padding: 0 60px;
      }
      .mask-clip {
        clip-path: inset(0 100% 0 0);
        will-change: clip-path;
      }
      .sweep {
        position: absolute;
        top: 0;
        bottom: 0;
        width: 8px;
        background: var(--hg-violet);
        box-shadow:
          0 0 40px var(--hg-violet),
          0 0 80px var(--hg-violet);
        left: 0;
        opacity: 0;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="mask-wrap">
            <div class="hero mask-clip">REVEAL</div>
            <div class="sweep"></div>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.set(".mask-clip", { clipPath: "inset(0 100% 0 0)" }, 0);
      tl.set(".sweep", { x: 0, opacity: 0 }, 0);
      tl.to(".sweep", { opacity: 1, duration: 0.08, ease: "power2.out" }, 0.1);
      tl.to(".sweep", { x: 1300, duration: 0.75, ease: "expo.out" }, 0.15);
      tl.to(".mask-clip", { clipPath: "inset(0 0% 0 0)", duration: 0.75, ease: "expo.out" }, 0.18);
      tl.to(".sweep", { opacity: 0, duration: 0.3, ease: "power2.in" }, 0.85);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/mosaic-pack/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>mosaic_pack</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .grid {
        display: grid;
        grid-template-columns: repeat(6, 200px);
        grid-template-rows: repeat(4, 200px);
        gap: 18px;
      }
      .tile {
        border-radius: 14px;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2.4"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2.4" data-track-index="1">
        <div class="stage">
          <div class="grid" id="grid"></div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Deterministic palette + grid build (6x4 = 24 cells).
      const PALETTE = ["#7c3aed", "#a855f7", "#f5f5fa", "#2a2a3a", "#b56dff", "#6b6b78"];
      const COLS = 6;
      const ROWS = 4;
      const COUNT = COLS * ROWS;
      const grid = document.getElementById("grid");
      const tiles = [];
      for (let i = 0; i < COUNT; i++) {
        const tile = document.createElement("div");
        tile.className = "tile";
        tile.style.background = PALETTE[i % PALETTE.length];
        grid.appendChild(tile);
        tiles.push(tile);
      }

      // Scatter each tile from a deterministic offset/rotation, then pack into place.
      // Land order ripples diagonally (col + row) for a deterministic "assembling" feel.
      const STEP = 0.07;
      for (let i = 0; i < COUNT; i++) {
        const col = i % COLS;
        const row = Math.floor(i / COLS);
        const dx = (((i * 53) % 200) - 100) * 9; // far-flung scatter X
        const dy = (((i * 89) % 200) - 100) * 7; // far-flung scatter Y
        const rot = ((i * 37) % 80) - 40; // deterministic tilt
        const order = col + row; // diagonal land sequence
        tl.fromTo(
          tiles[i],
          { x: dx, y: dy, rotation: rot, scale: 0.6, autoAlpha: 0.15 },
          {
            x: 0,
            y: 0,
            rotation: 0,
            scale: 1,
            autoAlpha: 1,
            duration: 0.6,
            ease: "back.out(1.6)",
          },
          0.1 + order * STEP,
        );
      }

      // Settle the packed poster with a tiny breath so the loop seats cleanly.
      tl.to("#grid", { scale: 1.02, duration: 0.18, ease: "power2.out" }, 1.9);
      tl.to("#grid", { scale: 1, duration: 0.32, ease: "power2.inOut" }, 2.08);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/neon-flicker/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>12 · neon_flicker</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .neon {
        font-family: "Playfair Display", serif;
        font-style: italic;
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.02em;
        color: #ff64a0;
        text-shadow:
          0 0 8px #ff64a0,
          0 0 24px #ff64a0,
          0 0 60px #ff1a7a,
          0 0 120px #ff1a7a;
        will-change: opacity, text-shadow;
      }
      .neon-bg {
        position: absolute;
        inset: 0;
        background: radial-gradient(
          circle at 50% 50%,
          rgba(255, 26, 122, 0.12) 0%,
          transparent 60%
        );
        opacity: 0;
        will-change: opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="neon-bg"></div>
        <div class="stage"><div class="neon">neon</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.set(".neon", { opacity: 0 }, 0);
      const neonPattern = [
        { t: 0.2, o: 1 },
        { t: 0.26, o: 0 },
        { t: 0.3, o: 1 },
        { t: 0.38, o: 0.3 },
        { t: 0.42, o: 1 },
        { t: 0.5, o: 0.5 },
        { t: 0.55, o: 1 },
      ];
      neonPattern.forEach((p) => {
        tl.to(".neon", { opacity: p.o, duration: 0.02, ease: "steps(1)" }, p.t);
      });
      tl.to(".neon-bg", { opacity: 1, duration: 0.4, ease: "power2.out" }, 0.5);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/outline-to-fill/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>21 · outline_to_fill</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .outline-stack {
        position: relative;
        font-family: "Playfair Display", serif;
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        white-space: nowrap;
      }
      .stroke-layer {
        position: absolute;
        top: 0;
        left: 0;
        color: transparent;
        -webkit-text-stroke: 3px var(--hg-violet-2);
        text-shadow: 0 0 30px rgba(168, 85, 247, 0.5);
        will-change:
          opacity,
          -webkit-text-stroke;
      }
      .fill-layer {
        position: relative;
        color: var(--hg-fg);
        clip-path: inset(100% 0 0 0);
        will-change: clip-path;
      }
      .spacer {
        color: transparent;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="outline-stack">
            <span class="spacer">Premium</span>
            <span class="stroke-layer">Premium</span>
            <span class="fill-layer">Premium</span>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Stroke draws in
      tl.fromTo(
        ".stroke-layer",
        { opacity: 0, scale: 1.04 },
        { opacity: 1, scale: 1, duration: 0.45, ease: "power3.out" },
        0.15,
      );

      // Fill rises from bottom
      tl.set(".fill-layer", { clipPath: "inset(100% 0 0 0)" }, 0);
      tl.to(".fill-layer", { clipPath: "inset(0% 0 0 0)", duration: 1.0, ease: "expo.out" }, 0.55);

      // Stroke recedes once fill arrives
      tl.to(".stroke-layer", { opacity: 0.35, duration: 0.6, ease: "power2.out" }, 1.0);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/palette-flip/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>palette_flip</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
      }
      .kicker {
        font-weight: 700;
        font-size: 38px;
        letter-spacing: 0.42em;
        text-transform: uppercase;
        margin-bottom: 40px;
      }
      .word {
        font-weight: 900;
        font-size: 300px;
        letter-spacing: -0.05em;
        line-height: 0.9;
        white-space: nowrap;
      }
      .bars {
        display: flex;
        gap: 26px;
        margin-top: 56px;
      }
      .bar {
        width: 220px;
        height: 26px;
        border-radius: 4px;
      }
      .bar.b2 {
        width: 140px;
      }
      .bar.b3 {
        width: 90px;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2.6"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2.6" data-track-index="1">
        <div class="stage">
          <div class="kicker" id="kicker">REGIME</div>
          <div class="word" id="word">REGIME</div>
          <div class="bars">
            <div class="bar b1"></div>
            <div class="bar b2"></div>
            <div class="bar b3"></div>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Four distinct palette regimes — same layout, re-skinned on each flip.
      const regimes = [
        { bg: "#0a0a0f", fg: "#f5f5fa", accent: "#7c3aed", kicker: "#a855f7" }, // dark / violet
        { bg: "#f4ead8", fg: "#1a1410", accent: "#ff5a47", kicker: "#c4452f" }, // cream / coral
        { bg: "#08222b", fg: "#eafaf6", accent: "#1fd1b0", kicker: "#7fe3d2" }, // navy / teal
        { bg: "#000000", fg: "#ffce3a", accent: "#ffce3a", kicker: "#7a6010" }, // black / amber
      ];

      const beat = 0.6;
      const scene = "#scene";
      const word = "#word";
      const kicker = "#kicker";
      const bars = ".bar";

      // Instant 0ms flips on every beat — cycle through the regimes deterministically.
      // The motion IS the colour change; layout never moves.
      const flips = 5;
      for (let i = 0; i < flips; i++) {
        const p = regimes[i % regimes.length];
        const t = i * beat;
        tl.set(scene, { backgroundColor: p.bg }, t);
        tl.set(word, { color: p.fg }, t);
        tl.set(kicker, { color: p.kicker }, t);
        tl.set(bars, { backgroundColor: p.accent }, t);
      }

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/particle-burst/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>24 · particle_burst</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.05em;
        color: var(--hg-fg);
        white-space: nowrap;
        will-change: transform, opacity;
        position: relative;
        z-index: 10;
      }
      .particle-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        pointer-events: none;
      }
      .particle {
        position: absolute;
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background: var(--hg-violet-2);
        box-shadow: 0 0 12px var(--hg-violet-2);
        opacity: 0;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="particle-stage" id="particles"></div>
        <div class="stage"><div class="hero">SPARK</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Build N particles deterministically
      const N = 36;
      const container = document.getElementById("particles");
      const particles = [];
      for (let i = 0; i < N; i++) {
        const p = document.createElement("div");
        p.className = "particle";
        // Vary size per particle for visual interest
        const sz = 4 + (i % 4) * 3;
        p.style.width = sz + "px";
        p.style.height = sz + "px";
        // Slight color variation
        const hue = ((i * 17) % 60) - 30;
        p.style.filter = `hue-rotate(${hue}deg)`;
        container.appendChild(p);
        particles.push(p);
      }

      // Hero punch + flash entry
      tl.fromTo(
        ".hero",
        { scale: 0.85, opacity: 0 },
        { scale: 1, opacity: 1, duration: 0.3, ease: "power4.out" },
        0.3,
      );

      // Particles burst out
      particles.forEach((p, i) => {
        const angle = (i / N) * Math.PI * 2 + i * 0.137; // golden-angle jitter
        const radius = 500 + (i % 5) * 90 + (i % 3) * 40;
        const tx = Math.cos(angle) * radius;
        const ty = Math.sin(angle) * radius;
        const dur = 1.1 + (i % 5) * 0.08;
        tl.fromTo(
          p,
          { x: 0, y: 0, scale: 1, opacity: 1 },
          { x: tx, y: ty, scale: 0, opacity: 0, duration: dur, ease: "expo.out" },
          0.35 + (i % 6) * 0.012,
        );
      });

      // Hero settle wobble
      tl.to(".hero", { scale: 1.04, duration: 0.1, ease: "power3.out" }, 0.35);
      tl.to(".hero", { scale: 1.0, duration: 0.5, ease: "elastic.out(1, 0.5)" }, 0.45);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/pixel-dissolve/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>26 · pixel_dissolve</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.05em;
        color: var(--hg-fg);
        white-space: nowrap;
        background: linear-gradient(135deg, #7c3aed 0%, #a855f7 60%, #d946ef 100%);
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
        color: transparent;
      }
      .grid {
        position: absolute;
        inset: 0;
        display: grid;
        will-change: contents;
        pointer-events: none;
      }
      .cell {
        background: var(--hg-bg);
        will-change: opacity, transform;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage"><div class="hero">DISSOLVE</div></div>
        <div class="grid" id="grid"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const COLS = 24;
      const ROWS = 14;
      const grid = document.getElementById("grid");
      grid.style.gridTemplateColumns = `repeat(${COLS}, 1fr)`;
      grid.style.gridTemplateRows = `repeat(${ROWS}, 1fr)`;

      const cells = [];
      for (let i = 0; i < COLS * ROWS; i++) {
        const c = document.createElement("div");
        c.className = "cell";
        grid.appendChild(c);
        cells.push(c);
      }

      // Deterministic shuffle: hash function based on i+j
      function hash(i) {
        return ((i * 9301 + 49297) % 233280) / 233280;
      }
      const order = cells.map((c, i) => ({ c, i, h: hash(i) })).sort((a, b) => a.h - b.h);

      // All cells start visible (covering hero), fade out in shuffled order
      const TOTAL = 0.85;
      order.forEach((o, k) => {
        const t = 0.35 + (k / order.length) * TOTAL;
        tl.to(o.c, { opacity: 0, duration: 0.06, ease: "steps(1)" }, t);
      });

      // After dissolve, gentle hero scale settle (it was always visible, now exposed)
      tl.fromTo(".hero", { scale: 1.03 }, { scale: 1.0, duration: 0.5, ease: "power2.out" }, 1.0);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/radial-burst-lines/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>25 · radial_burst_lines</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .hero {
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.05em;
        color: var(--hg-fg);
        white-space: nowrap;
        position: relative;
        z-index: 10;
        will-change: transform;
      }
      .burst-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        pointer-events: none;
      }
      .line {
        position: absolute;
        width: 5px;
        height: 180px;
        background: linear-gradient(to top, transparent 0%, var(--hg-fg) 50%, transparent 100%);
        transform-origin: bottom center;
        opacity: 0;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="burst-stage" id="bursts"></div>
        <div class="stage"><div class="hero">BAM</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Build 20 lines
      const N = 20;
      const container = document.getElementById("bursts");
      const lines = [];
      for (let i = 0; i < N; i++) {
        const l = document.createElement("div");
        l.className = "line";
        const angle = (i / N) * 360;
        const innerRadius = 220;
        // Rotate around center, push outward via transform-origin trick.
        // Easier: use translate then rotate
        l.style.transform = `rotate(${angle}deg) translateY(-${innerRadius}px) scaleY(0)`;
        container.appendChild(l);
        lines.push({ el: l, angle });
      }

      // Hero punch
      tl.fromTo(
        ".hero",
        { scale: 0.7, opacity: 0 },
        { scale: 1.1, opacity: 1, duration: 0.15, ease: "power4.out" },
        0.3,
      );
      tl.to(".hero", { scale: 1.0, duration: 0.4, ease: "elastic.out(1, 0.5)" }, 0.45);

      // Burst lines expand
      lines.forEach((l, i) => {
        const innerRadius = 220 + (i % 3) * 30; // vary radius
        const len = 200 + (i % 4) * 40; // vary length
        const ang = l.angle;
        tl.set(
          l.el,
          {
            rotation: ang,
            x: 0,
            y: 0,
            scaleY: 0,
            opacity: 0,
            transformOrigin: "center bottom",
          },
          0,
        );
        // GSAP transforms compose, so we set the rotation + translate via separate properties
        // Use a custom transform string approach
        tl.to(
          l.el,
          {
            scaleY: 1,
            opacity: 1,
            duration: 0.18,
            ease: "power4.out",
            onUpdate: function () {
              const p = this.progress();
              const t = -innerRadius - len * p * 0.2;
              l.el.style.transform = `rotate(${ang}deg) translateY(${t}px) scaleY(${p})`;
              l.el.style.opacity = (p > 0.7 ? 1 - (p - 0.7) / 0.3 : 1).toString();
            },
          },
          0.32 + (i % 5) * 0.018,
        );
      });

      // Hold then fade
      tl.to(".line", { opacity: 0, duration: 0.4, ease: "power2.out" }, 0.9);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/screen-shake/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>07 · screen_shake</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .hero {
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.04em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
      }
      .shake-stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        will-change: transform;
      }
      .impact-bg {
        position: absolute;
        inset: 0;
        background: radial-gradient(circle at 50% 50%, rgba(255, 42, 74, 0.18) 0%, transparent 60%);
        opacity: 0;
        will-change: opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="impact-bg"></div>
        <div class="shake-stage"><div class="hero">IMPACT</div></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      tl.fromTo(
        ".hero",
        { scale: 0.6, opacity: 0 },
        { scale: 1, opacity: 1, duration: 0.15, ease: "power4.out" },
        0.3,
      );
      tl.fromTo(
        ".impact-bg",
        { opacity: 0 },
        { opacity: 1, duration: 0.06, ease: "steps(1)" },
        0.3,
      );
      tl.to(".impact-bg", { opacity: 0, duration: 0.6, ease: "power2.in" }, 0.4);

      const shakeStart = 0.32;
      const shakeAmps = [28, -24, 20, -22, 16, -18, 14, -14, 10, -8, 6, -5, 3, -2];
      shakeAmps.forEach((amp, i) => {
        tl.to(
          ".shake-stage",
          { x: amp, y: amp * 0.7 * (i % 2 ? -1 : 1), duration: 0.03, ease: "steps(1)" },
          shakeStart + i * 0.03,
        );
      });
      tl.to(
        ".shake-stage",
        { x: 0, y: 0, duration: 0.1, ease: "power2.out" },
        shakeStart + shakeAmps.length * 0.03,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/slot-machine-reveal/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>15 · slot_machine_reveal</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .slot {
        font-family: "JetBrains Mono", monospace;
        font-weight: 700;
        font-size: 280px;
        letter-spacing: 0.04em;
        color: var(--hg-fg);
        display: flex;
        gap: 30px;
      }
      .slot .digit {
        display: inline-block;
        min-width: 1ch;
        text-shadow: 0 0 40px rgba(168, 85, 247, 0.6);
      }
      .slot-label {
        position: absolute;
        bottom: 30%;
        left: 50%;
        transform: translateX(-50%);
        font-family: "JetBrains Mono", monospace;
        font-size: 22px;
        color: var(--hg-dim);
        letter-spacing: 0.4em;
        text-transform: uppercase;
        opacity: 0;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="slot">
            <span class="digit" data-target="5">0</span>
            <span class="digit" data-target=".">0</span>
            <span class="digit" data-target="5">0</span>
            <span class="digit" data-target="x">0</span>
          </div>
        </div>
        <div class="slot-label">FINAL VERSION</div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const DIGITS = "0123456789";
      const slotStates = [{ p: 0 }, { p: 0 }, { p: 0 }, { p: 0 }];
      slotStates.forEach((state, idx) => {
        tl.to(
          state,
          {
            p: 1,
            duration: 1.0,
            delay: idx * 0.08,
            ease: "power3.out",
            onUpdate: function () {
              const spans = document.querySelectorAll(".slot .digit");
              const span = spans[idx];
              if (!span) return;
              const target = span.getAttribute("data-target");
              if (state.p >= 0.85) {
                span.textContent = target;
                span.style.color = "var(--hg-violet-2)";
              } else {
                const tick = Math.floor(state.p * 80 + idx * 3);
                span.textContent = DIGITS.charAt(tick % 10);
              }
            },
          },
          0.15,
        );
      });
      tl.to(".slot-label", { opacity: 1, duration: 0.5, ease: "power2.out" }, 1.2);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/spotlight-sweep/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>20 · spotlight_sweep</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .dim-layer {
        position: absolute;
        inset: 0;
        background: #03030a;
      }
      .hero-bright,
      .hero-dim {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        font-family: "Playfair Display", serif;
        font-weight: 900;
        font-size: 320px;
        letter-spacing: -0.04em;
        white-space: nowrap;
      }
      .hero-dim {
        color: rgba(245, 245, 250, 0.06);
      }
      .hero-bright {
        color: var(--hg-fg);
        text-shadow:
          0 0 30px rgba(168, 85, 247, 0.4),
          0 0 80px rgba(168, 85, 247, 0.2);
        -webkit-mask-image: radial-gradient(
          circle 360px at -400px 50%,
          #000 0%,
          rgba(0, 0, 0, 0.6) 60%,
          transparent 80%
        );
        mask-image: radial-gradient(
          circle 360px at -400px 50%,
          #000 0%,
          rgba(0, 0, 0, 0.6) 60%,
          transparent 80%
        );
        will-change: mask-image;
      }
      .spotlight-glow {
        position: absolute;
        inset: 0;
        background: radial-gradient(
          circle 380px at -400px 50%,
          rgba(168, 85, 247, 0.18) 0%,
          transparent 60%
        );
        will-change: background;
        mix-blend-mode: screen;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="dim-layer"></div>
        <div class="hero-dim">DISCOVER</div>
        <div class="hero-bright" id="bright">DISCOVER</div>
        <div class="spotlight-glow" id="glow"></div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Animate the spotlight x position via mask-image (use a proxy + onUpdate for smoothness)
      const state = { x: -400 };
      tl.to(
        state,
        {
          x: 2300,
          duration: 1.4,
          ease: "power2.inOut",
          onUpdate: function () {
            const m = `radial-gradient(circle 360px at ${state.x}px 50%, #000 0%, rgba(0,0,0,0.6) 60%, transparent 80%)`;
            const g = `radial-gradient(circle 380px at ${state.x}px 50%, rgba(168, 85, 247, 0.18) 0%, transparent 60%)`;
            const bright = document.getElementById("bright");
            const glow = document.getElementById("glow");
            if (bright) {
              bright.style.maskImage = m;
              bright.style.webkitMaskImage = m;
            }
            if (glow) glow.style.background = g;
          },
        },
        0.3,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/staggered-exit/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>staggered_exit</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .grid {
        display: grid;
        grid-template-columns: repeat(4, 320px);
        grid-template-rows: repeat(2, 320px);
        gap: 36px;
      }
      .card {
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 28px;
        background: linear-gradient(135deg, var(--hg-violet), var(--hg-violet-2));
        color: var(--hg-fg);
        font-weight: 900;
        font-size: 72px;
        letter-spacing: -0.02em;
        box-shadow: 0 24px 60px rgba(124, 58, 237, 0.35);
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="grid">
            <div class="card">NORTH</div>
            <div class="card">EAST</div>
            <div class="card">SOUTH</div>
            <div class="card">WEST</div>
            <div class="card">PEAK</div>
            <div class="card">EDGE</div>
            <div class="card">CORE</div>
            <div class="card">FLUX</div>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const cards = gsap.utils.toArray(".card");
      const hold = 0.4; // fully visible hold before the cascade begins
      const step = 0.11; // per-element offset
      const exitDur = 0.45;

      // start fully visible
      tl.set(cards, { autoAlpha: 1, y: 0 }, 0);

      // ordered exit: each card slides down + fades, one after another
      for (let i = 0; i < cards.length; i++) {
        tl.to(
          cards[i],
          { y: 80, autoAlpha: 0, duration: exitDur, ease: "power3.in" },
          hold + i * step,
        );
      }

      // snap all back to the visible start state so the next loop begins full
      tl.set(cards, { autoAlpha: 1, y: 0 }, 1.95);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/text-spectral-rays/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"text",       "type":"string", "label":"Wordmark",           "default":"SPECTRAL"},
    {"id":"textColor",  "type":"color",  "label":"Wordmark fill",       "default":"#f6ff7a"},
    {"id":"bgColor",    "type":"color",  "label":"Stage background",    "default":"#000000"},
    {"id":"sweepSpeed", "type":"number", "label":"Light sweep speed", "default":1, "min":0.2, "max":3, "step":0.1}
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>text_spectral_rays</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #000000;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        background: #000;
      }
      .vfx-canvas {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        display: block;
      }
      .label {
        position: absolute;
        left: 48px;
        bottom: 40px;
        font-size: 22px;
        font-weight: 600;
        letter-spacing: 0.18em;
        text-transform: uppercase;
        color: rgba(255, 255, 255, 0.6);
        mix-blend-mode: screen;
        pointer-events: none;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="4"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="4" data-track-index="1">
        <canvas class="vfx-canvas" width="1920" height="1080" aria-hidden="true"></canvas>
        <div class="label">text_spectral_rays</div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      (function () {
        const W = 1920;
        const H = 1080;

        const defaults = {
          text: "SPECTRAL",
          textColor: "#f6ff7a",
          bgColor: "#000000",
          sweepSpeed: 1,
        };
        const vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );

        const canvas = document.querySelector(".vfx-canvas");
        const gl = canvas.getContext("webgl", {
          premultipliedAlpha: false,
          preserveDrawingBuffer: true,
        });

        // animated state — GSAP tweens these; mouse (the light cursor) is derived
        const state = { progress: 0, effectMix: 0 };
        const mouse = { x: 0, y: H * 0.5 };
        const speed = Number(vars.sweepSpeed) || 1;

        // ── Rasterize the wordmark into a white MASK + a coloured SRC canvas ──
        function drawWord(ctx, fill) {
          ctx.clearRect(0, 0, W, H);
          let fs = 340;
          const stack = "900 " + fs + 'px "Arial Black", Impact, "Inter", system-ui, sans-serif';
          ctx.font = stack;
          const maxW = 1600;
          const measured = ctx.measureText(vars.text).width || 1;
          if (measured > maxW) {
            fs = Math.floor((fs * maxW) / measured);
            ctx.font = "900 " + fs + 'px "Arial Black", Impact, "Inter", system-ui, sans-serif';
          }
          ctx.textAlign = "center";
          ctx.textBaseline = "middle";
          ctx.fillStyle = fill;
          ctx.fillText(vars.text, W * 0.5, H * 0.5);
        }
        const maskCanvas = document.createElement("canvas");
        maskCanvas.width = W;
        maskCanvas.height = H;
        drawWord(maskCanvas.getContext("2d"), "#ffffff");
        const colorCanvas = document.createElement("canvas");
        colorCanvas.width = W;
        colorCanvas.height = H;
        drawWord(colorCanvas.getContext("2d"), vars.textColor);

        // ── WebGL fallback: flat fill (keeps the timeline registered) ──
        if (!gl) {
          const ctx = canvas.getContext("2d");
          ctx.fillStyle = vars.bgColor;
          ctx.fillRect(0, 0, W, H);
          tl.to(state, { progress: 1, duration: 3.6, ease: "none" }, 0.2);
          window.__timelines["main"] = tl;
          return;
        }

        const VERT = [
          "attribute vec2 position;",
          "varying vec2 vUv;",
          "void main(){",
          "  vUv = position * 0.5 + 0.5;",
          "  gl_Position = vec4(position, 0.0, 1.0);",
          "}",
        ].join("\n");

        // Volumetric spectral light-rays cast by the text mask toward a moving
        // light cursor — ported from the vfx-text-cursor golden sample, made
        // generic (reads any text mask/colour). All state from uniforms.
        const FRAG = [
          "precision highp float;",
          "varying vec2 vUv;",
          "uniform vec2 resolution;",
          "uniform vec2 mouse;",
          "uniform float maskY;",
          "uniform float maskScale;",
          "uniform float effectMix;",
          "uniform sampler2D src;",
          "uniform sampler2D colorSrc;",
          "#define PI 3.141593",
          "#define SAMPLES 112.0",
          "float hash(vec2 p){ return fract(sin(dot(p, vec2(489.0,589.0))) * 492.0) * 2.0 - 1.0; }",
          "float hash(vec3 p){ return fract(sin(dot(p, vec3(489.0,589.0,58.0))) * 492.0) * 2.0 - 1.0; }",
          "vec2 hash2(vec3 p){ return vec2(hash(p), hash(p + 1.0)); }",
          "vec4 readMask(vec2 uv){ if(uv.x<0.0||uv.x>1.0||uv.y<0.0||uv.y>1.0){return vec4(0.0);} return texture2D(src, uv); }",
          "vec4 readColor(vec2 uv){ if(uv.x<0.0||uv.x>1.0||uv.y<0.0||uv.y>1.0){return vec4(0.0);} return texture2D(colorSrc, uv); }",
          "vec3 spectrum(float x){ return cos((x - vec3(0.0,0.5,1.0)) * vec3(0.6,1.0,0.5) * PI); }",
          "void main(){",
          "  vec2 uv = vUv;",
          "  vec2 center = vec2(0.5, 0.5);",
          "  vec2 maskUv = (uv - center) / maskScale + center + vec2(0.0, maskY / resolution.y);",
          "  float mask = readMask(maskUv).r;",
          "  vec4 col0 = readColor(maskUv);",
          "  vec2 p = uv * 2.0 - 1.0;",
          "  p.x *= resolution.x / resolution.y;",
          "  vec2 mp = mouse / resolution;",
          "  mp.y = 1.0 - mp.y;",
          "  mp = mp * 2.0 - 1.0;",
          "  mp.x *= resolution.x / resolution.y;",
          "  vec2 rp = p;",
          "  vec2 d = (mp - p) / SAMPLES;",
          "  float acc = 0.0;",
          "  for(float i = 0.0; i < SAMPLES; i++){",
          "    rp += d;",
          "    rp += hash2(vec3(rp, i)) * 0.5 / SAMPLES;",
          "    vec2 uv2 = rp;",
          "    uv2.x /= resolution.x / resolution.y;",
          "    uv2 = uv2 * 0.5 + 0.5;",
          "    vec2 rayUv = (uv2 - center) / maskScale + center + vec2(0.0, maskY / resolution.y);",
          "    acc += readMask(rayUv).r / SAMPLES;",
          "  }",
          "  vec4 c = vec4(0.0, 0.0, 0.0, 1.0);",
          "  c -= acc * 0.24;",
          "  c += vec4(spectrum(cos(acc * 4.9)), 1.0) * acc * 3.65;",
          "  c.rgb = max(c.rgb - 0.035, 0.0);",
          "  c.rgb *= 1.16;",
          "  float cursorX = mouse.x / resolution.x;",
          "  float sweep = exp(-pow((uv.x - cursorX) * 6.2, 2.0));",
          "  float textProximity = smoothstep(0.006, 0.34, acc);",
          "  float longRay = smoothstep(0.004, 0.18, acc);",
          "  vec3 sweepColor = vec3(0.14, 0.65, 0.85) + spectrum(cursorX * 1.8) * 0.28;",
          "  c.rgb += sweepColor * sweep * (0.34 + textProximity * 2.35);",
          "  c.rgb += spectrum(cursorX + acc * 2.0) * longRay * 0.58;",
          "  c.rgb += col0.rgb * acc * 1.18;",
          "  c.rgb += col0.rgb * sweep * mask * 0.92;",
          "  c.rgb *= effectMix;",
          "  c.rgb = mix(c.rgb, col0.rgb, smoothstep(0.02, 0.75, col0.a));",
          "  c.rgb += vec3(1.0) * smoothstep(0.04, 0.38, mask) * 0.12 * effectMix;",
          "  float grain = hash(vec3(uv.xyy + mouse.x * 0.0007));",
          "  float dust = smoothstep(0.02, 0.38, acc) + smoothstep(0.03, 0.22, mask);",
          "  c.rgb += grain * 0.07 * (0.42 + dust) * effectMix;",
          "  c.rgb += spectrum(grain + cursorX) * abs(grain) * 0.045 * smoothstep(0.02, 0.5, acc) * effectMix;",
          "  c.a = 1.0;",
          "  gl_FragColor = c;",
          "}",
        ].join("\n");

        function compile(type, source) {
          const s = gl.createShader(type);
          gl.shaderSource(s, source);
          gl.compileShader(s);
          return s;
        }
        const program = gl.createProgram();
        gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
        gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
        gl.linkProgram(program);
        gl.useProgram(program);

        function createTexture(source) {
          const t = gl.createTexture();
          gl.bindTexture(gl.TEXTURE_2D, t);
          gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
          return t;
        }
        const maskTex = createTexture(maskCanvas);
        const colorTex = createTexture(colorCanvas);

        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(
          gl.ARRAY_BUFFER,
          new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
          gl.STATIC_DRAW,
        );
        const posLoc = gl.getAttribLocation(program, "position");
        const L = (n) => gl.getUniformLocation(program, n);
        const uRes = L("resolution");
        const uMouse = L("mouse");
        const uMaskY = L("maskY");
        const uMaskScale = L("maskScale");
        const uMix = L("effectMix");
        const uSrc = L("src");
        const uColorSrc = L("colorSrc");

        function render() {
          mouse.x = state.progress * W;
          mouse.y = H * 0.5;
          gl.viewport(0, 0, W, H);
          gl.clearColor(0, 0, 0, 1);
          gl.clear(gl.COLOR_BUFFER_BIT);
          gl.useProgram(program);
          gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
          gl.enableVertexAttribArray(posLoc);
          gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
          gl.activeTexture(gl.TEXTURE0);
          gl.bindTexture(gl.TEXTURE_2D, maskTex);
          gl.uniform1i(uSrc, 0);
          gl.activeTexture(gl.TEXTURE1);
          gl.bindTexture(gl.TEXTURE_2D, colorTex);
          gl.uniform1i(uColorSrc, 1);
          gl.uniform2f(uRes, W, H);
          gl.uniform2f(uMouse, mouse.x, mouse.y);
          gl.uniform1f(uMaskY, 0);
          gl.uniform1f(uMaskScale, 1);
          gl.uniform1f(uMix, state.effectMix);
          gl.drawArrays(gl.TRIANGLES, 0, 6);
        }
        render(); // initial frame (seek-to-0 correctness)

        // All repaint via the timeline's onUpdate — fully seek-safe, no rAF.
        tl.eventCallback("onUpdate", render);

        // effect punches in, the light cursor sweeps L→R across the wordmark,
        // then fades for a clean loop.
        tl.set(state, { progress: 0, effectMix: 0 }, 0);
        tl.to(state, { effectMix: 1, duration: 0.45, ease: "power3.out" }, 0.15);
        tl.to(state, { progress: 1, duration: 3.4 / speed, ease: "none" }, 0.2);
        tl.to(state, { effectMix: 0, duration: 0.4, ease: "power2.in" }, 3.6);

        window.__timelines["main"] = tl;
      })();
    </script>
  </body>
</html>
references/motion-primitives/text-spectral-rays/USAGE.md
# Using `text-spectral-rays` — it OWNS its wordmark

`text-spectral-rays` is a **self-contained WebGL hero-text renderer**. From ONE rasterized
glyph mask it draws **both** the solid wordmark **and** the spectral rays that emanate from
it. Letters and rays share the same mask, so they are always perfectly registered.

## The one trap: never give the word a second source

The ghost / doubled-wordmark artifact comes from splitting the word across two sources:

- ❌ **Wrong** — use the shader as a "rays-only background" and draw the visible letters
  with a **separate DOM element** (or stack a second text move like `content_swap` /
  `chromatic_pressure` on the same word). The DOM font (e.g. Inter) and the shader's raster
  font (Arial Black / Impact fallback) differ in width, shape, and position, so the ray
  edges never line up with the DOM letters → a misregistered ghost. **Deleting the shader's
  letter terms does NOT fix it** — the ray mask itself is still the second, misaligned copy
  of the word.

- ✅ **Right** — let the shader render the wordmark. There is exactly ONE source, so a
  ghost is structurally impossible.

## Integrate in one pass

1. **It IS the wordmark.** Hide any DOM logo for that word (keep it only as an invisible
   layout spacer if a tagline/CTA below depends on its box). Never stack a discrete text
   move on the same word.
2. **One timeline.** Merge its `progress` / `effectMix` state tweens onto the group's master
   timeline and repaint via `tl.eventCallback("onUpdate", render)` — no second timeline, no
   `requestAnimationFrame`.
3. **Align the cursor to the word.** `mouse.y` must equal the mask's vertical center. If you
   move the rasterized word off frame-center (e.g. up, to leave room for a tagline), shift
   the cursor's `y` by the same amount — otherwise the rays cast at the wrong angle.
4. **Local raster only.** Rasterize the word with a bundled / system font (no CDN font);
   upload the mask + colour canvases as textures.
5. **Entrance.** Slam the whole canvas (autoAlpha + a scale punch, `transform-origin` on the
   word's optical center) on the hit; let `effectMix` bloom the rays just after. The solid
   letters are present the instant the canvas reveals.

## Pairs with

A background bed (`bg-flow-field`) or **separate** supporting elements (tagline, CTA, rule)
— never a second treatment of its own word.
references/motion-primitives/text-wave-distort/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>28 · text_wave_distort</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-cyan: #2afff0;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .wave-hero {
        font-family: "Playfair Display", serif;
        font-style: italic;
        font-weight: 900;
        font-size: 280px;
        letter-spacing: -0.02em;
        white-space: nowrap;
        text-shadow: 0 0 40px rgba(168, 85, 247, 0.5);
      }
      .wave-hero .char {
        display: inline-block;
        will-change: transform;
      }
      /* per-char solid colors marching across a violet→cyan ramp */
      .wave-hero .char:nth-child(1) {
        color: #7c3aed;
      }
      .wave-hero .char:nth-child(2) {
        color: #a855f7;
      }
      .wave-hero .char:nth-child(3) {
        color: #d946ef;
      }
      .wave-hero .char:nth-child(4) {
        color: #ec4899;
      }
      .wave-hero .char:nth-child(5) {
        color: #2afff0;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2" data-track-index="1">
        <div class="stage">
          <div class="wave-hero" id="hero">
            <span class="char">f</span><span class="char">l</span><span class="char">u</span
            ><span class="char">i</span><span class="char">d</span>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Explicit fromTo entry — safer than .from() in paused/seek mode
      tl.fromTo(
        "#hero .char",
        { y: 60, opacity: 0 },
        { y: 0, opacity: 1, stagger: 0.06, ease: "back.out(1.8)", duration: 0.5 },
        0.1,
      );

      // Continuous wave: phase 0→4 cycles over 1.5s, amplitude envelope rise→settle
      const state = { phase: 0 };
      tl.to(
        state,
        {
          phase: 4,
          duration: 1.5,
          ease: "none",
          onUpdate: function () {
            const chars = document.querySelectorAll("#hero .char");
            const p = state.phase / 4;
            let amp;
            if (p < 0.35) amp = (p / 0.35) * 24;
            else if (p < 0.7)
              amp = 24 - ((p - 0.35) / 0.35) * 12; // 24 → 12
            else amp = 12 - ((p - 0.7) / 0.3) * 4; // 12 → 8
            chars.forEach((c, i) => {
              const y = amp * Math.sin(state.phase * Math.PI * 2 + i * 0.45);
              // Use gsap.set so it cooperates with GSAP's transform tracking
              gsap.set(c, { y });
            });
          },
        },
        0.65,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/tile-mosaic/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>tile_mosaic</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .mosaic {
        position: relative;
        display: grid;
        grid-template-columns: repeat(6, 1fr);
        grid-template-rows: repeat(4, 1fr);
        gap: 6px;
        width: 1440px;
        height: 960px;
      }
      .tile {
        border-radius: 4px;
        will-change: transform, opacity;
      }
      .title {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        font-weight: 900;
        font-size: 220px;
        letter-spacing: -0.03em;
        color: var(--hg-fg);
        text-shadow: 0 8px 40px rgba(0, 0, 0, 0.55);
        will-change: transform, opacity;
        pointer-events: none;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2.4"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2.4" data-track-index="1">
        <div class="stage">
          <div class="mosaic" id="mosaic"></div>
          <div class="title" id="title">MOSAIC</div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const COLS = 6;
      const ROWS = 4;
      const STEP = 0.06;
      // Deterministic palette; tile colour chosen by index, no randomness.
      const PALETTE = [
        "#7c3aed",
        "#a855f7",
        "#6d28d9",
        "#c084fc",
        "#5b21b6",
        "#8b5cf6",
        "#9333ea",
        "#4c1d95",
      ];

      const mosaic = document.getElementById("mosaic");
      const tiles = [];
      for (let i = 0; i < COLS * ROWS; i++) {
        const row = Math.floor(i / COLS);
        const col = i % COLS;
        const cell = document.createElement("div");
        cell.className = "tile";
        const a = PALETTE[(row + col) % PALETTE.length];
        const b = PALETTE[(row + col + 3) % PALETTE.length];
        cell.style.background = "linear-gradient(135deg, " + a + " 0%, " + b + " 100%)";
        mosaic.appendChild(cell);
        tiles.push({ el: cell, diag: row + col });
      }

      // Diagonal sweep: reveal order governed by (row + col), explicit per-tile timing.
      for (const t of tiles) {
        tl.fromTo(
          t.el,
          { autoAlpha: 0, scale: 0.6 },
          { autoAlpha: 1, scale: 1, duration: 0.4, ease: "back.out(1.7)" },
          t.diag * STEP,
        );
      }

      // Title settles over the finished, dense mosaic.
      tl.fromTo(
        "#title",
        { autoAlpha: 0, scale: 1.12 },
        { autoAlpha: 1, scale: 1, duration: 0.45, ease: "power2.out" },
        1.7,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/typewriter-reveal/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>typewriter_reveal</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .line {
        display: flex;
        align-items: baseline;
        font-family: "JetBrains Mono", ui-monospace, monospace;
        font-weight: 800;
        font-size: 200px;
        letter-spacing: 0.02em;
        line-height: 0.95;
        white-space: nowrap;
      }
      .char {
        display: inline-block;
        opacity: 0;
        will-change: opacity;
      }
      .char.space {
        width: 0.4em;
      }
      .caret {
        display: inline-block;
        width: 0.09em;
        height: 0.86em;
        margin-left: 0.06em;
        background: var(--hg-violet-2);
        will-change: opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2.4"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2.4" data-track-index="1">
        <div class="stage">
          <div class="line" id="line">
            <span class="char">S</span><span class="char">Y</span><span class="char">S</span
            ><span class="char">T</span><span class="char">E</span><span class="char">M</span
            ><span class="char space">&nbsp;</span><span class="char">O</span
            ><span class="char">N</span><span class="char">L</span><span class="char">I</span
            ><span class="char">N</span><span class="char">E</span
            ><span class="caret" id="caret"></span>
          </div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      const chars = gsap.utils.toArray(".char");
      const step = 0.07;

      // Explicit per-character reveal: one set() per char at time i * step.
      chars.forEach((char, i) => {
        tl.set(char, { opacity: 1 }, i * step);
      });

      const lastT = (chars.length - 1) * step;

      // Caret rides just past the last revealed char and blinks a finite number
      // of times (no repeat:-1), then the phrase locks crisp.
      tl.set("#caret", { opacity: 1 }, 0);
      tl.to(
        "#caret",
        { opacity: 0, duration: 0.09, ease: "steps(1)", repeat: 8, yoyo: true },
        lastT + step,
      );
      tl.set("#caret", { opacity: 0 }, 2.32);

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/motion-primitives/word-grid-burst/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>word_grid_burst</title>
    <script src="../assets/gsap.min.js"></script>
    <style>
      :root {
        --hg-violet: #7c3aed;
        --hg-violet-2: #a855f7;
        --hg-bg: #0a0a0f;
        --hg-fg: #f5f5fa;
        --hg-dim: #6b6b78;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--hg-bg);
        font-family: "Inter", system-ui, sans-serif;
        color: var(--hg-fg);
        -webkit-font-smoothing: antialiased;
      }
      #scene {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
      }
      .stage {
        position: absolute;
        inset: 0;
        display: grid;
        grid-template-columns: repeat(5, 1fr);
        grid-template-rows: repeat(3, 1fr);
        align-items: center;
        justify-items: center;
        padding: 140px 120px;
        gap: 24px;
      }
      .cell {
        font-weight: 900;
        font-size: 92px;
        letter-spacing: -0.03em;
        line-height: 0.95;
        color: var(--hg-fg);
        white-space: nowrap;
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="2.8"
      data-width="1920"
      data-height="1080"
    >
      <div id="scene" class="clip" data-start="0" data-duration="2.8" data-track-index="1">
        <div class="stage">
          <div class="cell">PULSE</div>
          <div class="cell">FORGE</div>
          <div class="cell">SURGE</div>
          <div class="cell">VOLT</div>
          <div class="cell">CORE</div>
          <div class="cell">FLUX</div>
          <div class="cell">SPARK</div>
          <div class="cell">DRIVE</div>
          <div class="cell">BLAZE</div>
          <div class="cell">WAVE</div>
          <div class="cell">SHIFT</div>
          <div class="cell">BOLT</div>
          <div class="cell">RUSH</div>
          <div class="cell">PEAK</div>
          <div class="cell">IGNITE</div>
        </div>
      </div>
    </div>
    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });

      // Deterministic reveal order: scatter sequence across the 15-cell grid.
      const order = [7, 0, 12, 4, 9, 2, 14, 6, 11, 1, 8, 3, 13, 5, 10];
      const cells = gsap.utils.toArray(".cell");
      const step = 0.13;
      const FOCUS = 7; // chosen focal cell ("DRIVE")

      // Each cell pops in on its own "onset" — explicit per-cell timing.
      order.forEach((idx, i) => {
        tl.fromTo(
          cells[idx],
          { autoAlpha: 0, scale: 0.8 },
          { autoAlpha: 1, scale: 1, duration: 0.22, ease: "back.out(1.8)" },
          i * step,
        );
      });

      // Downbeat refocus (~2.3s): dim the whole grid, pop the focal word.
      tl.to(".cell", { autoAlpha: 0.25, duration: 0.18, ease: "power2.out" }, 2.3);
      tl.to(
        cells[FOCUS],
        {
          autoAlpha: 1,
          scale: 1.25,
          color: "var(--hg-violet-2)",
          duration: 0.22,
          ease: "back.out(2)",
        },
        2.32,
      );

      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
references/planning.md
# Planning (Step 3) — pick the brand, fill every frame

At Step 3 **you (the orchestrator)** turn the Step-2 skeleton into a complete, approved
`STORYBOARD.md`. You edit the **same file** in place: pick the brand spine, then for each
frame decide its **groups**, give each group a treatment, bind real beat anchors, and write
the copy.

Your mantra: **music is the spine; a template is a head start, not a cage; typography is the
floor and assets are an optional ingredient on the same beat grid.**

**You own WHAT, not HOW.** You name the template / primitives, the content, the brand, the
anchor seconds, and the intent. The frame-worker (Step 4) decides HOW — micro-timing,
realization, intra-frame cuts. **Never write millisecond tweens into the storyboard.**

## Inputs

- The Step-2 skeleton already in `STORYBOARD.md` — frames with `span_sec` + `pacing` + `mood` + `feel`.
- `audiomap.json` — timing truth; read the real anchor seconds inside each frame's span.
- [`template-catalog.md`](template-catalog.md) — the template selection menu.
- [`motion-primitive-catalog.md`](motion-primitive-catalog.md) — the free-compose menu (L0 recipes).
- [`montage.md`](montage.md) — asset treatments (only if the user supplied images/videos).
- User brief / supplied copy — topic, mood, exact words to keep.

## Step A — pick the brand spine (one preset, unmodified)

The whole video shares one type family + palette. Pick **one ready-made preset** from
`../hyperframes-creative/frame-presets/` using the preset table in
`../hyperframes-creative/references/design-spec.md` — choose by the track's mood + the brief,
and **only its fonts + colors matter** (templates own composition + motion; the preset only
sets the look). Copy it in **unmodified**:

```bash
cp ../hyperframes-creative/frame-presets/<preset>/FRAME.md "$PROJECT_DIR/frame.md"
```

Then fill the storyboard frontmatter `style` from it: the `font` from its `typography:` and a
≤4–6 swatch `palette` from its `colors:`. **Quote the hex / family verbatim — never invent or
round.** Every group's palette params draw from this one palette; that unity is what makes
different templates read as one piece.

## Step B — per frame, decide its groups

A frame is usually **one group** (one template or one free composition spanning the frame).
Subdivide into 2+ groups **only when a single treatment can't cover the frame** — e.g. a busy
opener plus a closing lockup. When you split, cut at a **real audiomap anchor** inside the
frame's span (a `key_moment` / `phrase` edge / onset-cluster gap), **never inside a `rolls[]`
run**, and keep every group **≥ ~1 bar**. Density does **not** force more groups — a dense
frame is usually ONE group whose template absorbs the density internally (a meta-template like
`poster-tile-mosaic`). **Group count tracks distinct treatments, not beats.**

## Step C — per group, pick a treatment (exactly one of three)

### A. Match a template

Read [`template-catalog.md`](template-catalog.md). Match the group's `feel` + `mood` + `pacing`
to a template's **Reach for it when**; take the closest fit. Then bind it:

- Fill `params` (keys from the catalog entry) — your copy into text slots, palette from the brand spine, `duration` = the group's span length.
- Fill `role_bindings` with this group's **real anchor seconds** read from `audiomap.json` over its span (not example times).
- If the template's natural stop and the group's span end disagree, snap to the nearest anchor.

### B. Free-compose (no template fits)

Write a `free_design` — one visual thesis from [`motion-primitive-catalog.md`](motion-primitive-catalog.md)
(a dominant system + the named L0 primitives + a density topology) + `anchors` (the real
beat / onset seconds the moves ride). Free-compose is a **first-class** choice, written as
carefully as a matched group — never a failure.

### C. Asset treatment (only when the user supplied assets and they fit)

Make it an `asset` group ([`montage.md`](montage.md)). **Obey `pacing`:** on a `beat_cut`
frame use `beat_cut` (one clip per anchor) or `bg_under_text`; on a `phrase_flow` frame use
`ken_burns` or a slow crossfade — **never** per-onset hard cuts. Assets are additive: if none
fits a group, fall back to template / free (typography is the floor — a complete video needs
no assets).

## Copy (you own the words)

- Keep exact user words; else invent with taste, on the brief's mood.
- **Message vs texture:** a readable word holds ≥1 beat (headline 3–8, sentence 4–10), stable + focal; a word held <1 beat is texture (strobe / grid / ticks). Never force a message onto a sub-beat — demote it to texture.
- Place copy into the template's text params, onto a free group's anchors, or as an asset group's `overlay_copy`. Declare the anchor + accumulate / stagger intent; leave micro-timing to the worker.
- A closing logo / CTA lands on the final hit / hard stop and holds through trailing silence.

## Transitions (you do not emit them)

Everything is a **0ms hard cut** for now. **frame → frame** is owned by the assembler
(back-to-back files); adjacent `span_sec` already imply the cut. **group → group inside a
frame** is owned by the worker on its frame timeline; you only set each group's `span_sec`.

## Write + validate

Complete `STORYBOARD.md` ([`storyboard-format.md`](storyboard-format.md)), then run
`node scripts/validate-plan.mjs` and fix every `✗`. Show the user a frame-by-frame summary and
iterate until approved.

## Self-check

- `frame.md` is a verbatim copy of one preset; frontmatter `style.font` / `style.palette` are drawn from it (exact values).
- Every frame became ≥1 group; groups tile the frame span in order; no group < ~1 bar; no group boundary inside a `rolls[]` run.
- Each group is exactly one of template / free_design / asset.
- Template `params` keys match the catalog entry; `role_bindings` / `anchors` use real audiomap seconds.
- Asset treatments obey `pacing` (no `beat_cut` on a `phrase_flow` frame).
- Every group's palette draws from the one brand palette.
- `duration_s == audiomap.audio.duration_sec`; `validate-plan.mjs` passes.
references/storyboard-format.md
# STORYBOARD.md format — frames → groups

`STORYBOARD.md` is the single reviewable plan the user approves at Step 3 and the **manual**
each frame-worker follows at Step 4. It is **hierarchical**: one block per **frame**, written
as a `## Frame N — <frame_id>` heading (the parser recognizes `Frame`). **One frame = one
scene = one composition file.** Inside each frame block are its **groups** (the treatment
units). The assembler reads the frame level (`duration` → `data-start`, `src`); the worker
reads its own frame block. A frame's **composition id = its `src` file stem**
(`compositions/frames/01-f1.html` → `01-f1`), which the worker uses as `data-composition-id`
and the `window.__timelines` key.

Step 2 writes the skeleton (frame fields, groups `TBD`); Step 3 fills the groups + brand. It
is a build spec, not code — the planner writes WHAT, the worker decides HOW. **Never write
millisecond tweens here.**

## File shape

YAML frontmatter (the video-wide spine) + one `## Frame N — <frame_id>` block per frame.

```markdown
---
compositionId: bgm
duration_s: 30.0 # == audiomap.audio.duration_sec, exactly
canvas: { w: 1920, h: 1080, fps: 30 }
style: # brand spine — from the chosen frame.md preset (Step 3)
  font: "EB Garamond / Inter / JetBrains Mono" # the preset's typography, verbatim
  palette: ["#FAF9F5", "#141413", "#CC785C", "#181715"] # ≤4–6 swatches from the preset's colors
assets: false # false, or a note like "assets/ has 6 user photos"
build_notes: ["one paused timeline per frame", "no remote assets"]
avoid: ["generic slideshow", "tiny unreadable hero text"]
---

## Frame 1 — f1

- src: compositions/frames/01-f1.html # worker writes here; assembler refs it; stem (01-f1) = composition id
- duration: 7.198s # = span length; the assembler reads this for cumulative data-start
- span_sec: [0.0, 7.198] # track seconds; frames tile the track
- pacing: beat_cut # beat_cut | phrase_flow (from the skeleton; obey it)
- mood: [hype]
- feel: accelerating onset stream into a held downbeat

### Groups

- **g1** — template: `intro-kinetic-cascade`
  - span_sec: [0.0, 4.017] # frame-LOCAL build is 0-based; these are TRACK seconds (worker subtracts frame start)
  - params: { theme: "light", icon: "bolt", phrases: "[…]", climax: "{…}" }
  - role_bindings: { phrase: { times: [0.14, 0.55, 0.87] }, climax: { in: 3.79, iconAt: 4.9 } }
  - copy: "GROWTH THROUGH CREATIVITY"
- **g2** — free_design
  - span_sec: [4.017, 7.198]
  - free_design: { dominant_system: "per-onset typography", primitives: ["content-swap", "braam-punch"], density_topology: "accumulate" }
  - anchors: [4.10, 4.80, 5.50, 6.20] # onset seconds the reveals ride (from audiomap)
  - copy: ["BUILD", "SHIP", "REPEAT"]

## Frame 2 — f2

…
```

## Frame block — required fields

| field                             | meaning                                                                                                                        |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| heading `## Frame N — <frame_id>` | `frame_id` matches the `src` stem; `N` = the 1-based index.                                                                    |
| `src`                             | `compositions/frames/NN-<frame_id>.html` — where the worker writes; the assembler references it. Stem = `data-composition-id`. |
| `duration`                        | the frame span length in seconds (e.g. `7.198s`) — **required**; the assembler sums these for cumulative `data-start`.         |
| `span_sec`                        | `[start, end]` track seconds. `duration = end − start`.                                                                        |
| `pacing`                          | `beat_cut` \| `phrase_flow` — from the skeleton; the worker must obey (no hard-cut on `phrase_flow`).                          |
| `mood`, `feel`                    | from the skeleton; tone + the one-line music situation the planner matched against.                                            |
| `### Groups` list                 | ≥1 group; groups tile the frame span in order.                                                                                 |

## Group entry — exactly one of three kinds

Every group is **template** OR **free_design** OR **asset** — never two, never none. All three
carry `span_sec` (track seconds, tiling the frame) and may carry `copy`.

- **template** — `template: <catalog id>` + `params` (keys from the catalog entry) + `role_bindings` (real audiomap anchor seconds) + `copy`.
- **free_design** — `free_design: { dominant_system, primitives: [catalog ids], density_topology }` + `anchors` (real beat / onset seconds) + `copy`.
- **asset** — `asset: { treatment, clips: [public/…], anchors?, overlay_copy? }`. `treatment` ∈ `beat_cut` (one clip per anchor — only on a `beat_cut` frame) | `ken_burns` (slow push — fits `phrase_flow`) | `bg_under_text` (clip dimmed behind a template / free group). See [`montage.md`](montage.md).

## Rules

- Frames tile the track (gap-free, first at 0, last at `duration_s`); a frame's groups tile its span; no group < ~1 bar; no group boundary inside a `rolls[]` run.
- `params` keys come from the template's [`template-catalog.md`](template-catalog.md) entry.
- All anchor seconds are **track seconds from `audiomap.json`** — the worker converts to frame-local by subtracting the frame start.
- A `phrase_flow` frame MUST NOT use `beat_cut` asset treatment or per-onset hard cuts.
- The brand `style` is set once; every group's palette draws from it.
- Reviewable prose-plus-data; keep it scannable. No GSAP, no millisecond timing.

## Self-check (the planner runs `validate-plan.mjs`)

- frontmatter has `compositionId`, `duration_s` (== audiomap), `canvas`, `style`.
- every frame has `span_sec` + `src` + positive `duration` + `pacing` + ≥1 group; frames tile the track.
- every group is exactly one of template / free_design / asset; template ids exist in the catalog; anchors are real audiomap seconds; `phrase_flow` frames have no `beat_cut`.
references/template-catalog.md
# Template catalog — the selection menu (Step 3)

Reverse-engineered, asset-free **group templates**. This file is the **only** thing the
planner reads to pick one — you do **not** open the template's `index.html` to choose. One
template realizes **one group** of a frame (a frame may stack several groups; the brand spine
unifies them).

**How to pick.** For each group, read its frame's `pacing` + `mood` + the one-line music
situation from the skeleton, then scan **Reach for it when** + **Best span** below and take the
closest fit. If nothing fits, **free-compose** from
[`motion-primitive-catalog.md`](motion-primitive-catalog.md) — that is a first-class choice, not a
failure.

**Brand comes from `frame.md`, not the template.** Templates ship their own demo palette
(`theme` / `palette` / color params). Fill those params from the project's `frame.md` (the
chosen preset's colors + fonts) so every group reads as one piece — the template supplies the
**motion + layout**, the preset supplies the **look**.

**Params** are listed so you can fill content slots in the storyboard. Exact semantics +
defaults live in each template's `index.html` (`data-composition-variables`) — the frame-worker
reads those at build time; you only name the values.

Pacing tag: every template below is **beat_cut** except `held-message-living-field`
(**phrase_flow**). Never put a beat_cut template on a phrase_flow frame.

## Group duration discipline

Template choice is per **group**, not per frame. A frame longer than a template's **Best span**
should usually split into multiple groups at real audiomap anchors (`SURGE`, `DROP`, roll edge,
hard_stop, phrase edge) instead of stretching one template across the whole frame.

- **Best span** below is the active treatment span: the part where the template's system is doing
  meaningful motion. A short readable hold at the end is fine; a long empty tail means pick another
  group.
- **Frames over ~6s** usually need 2+ groups. Beat-cut exceptions are rich programs with real
  sub-phases (`poster-tile-mosaic`, sometimes `card-flyby`); phrase-flow exceptions use
  `held-message-living-field`.
- **Roll templates are one-roll tools.** If a frame has two rolls or a drop between rolls, split it
  into two groups.
- Do not extend by slowing every tween. Preserve the template's motion feel, then fill extra time
  with a hold / palette change / new group.

---

### card-flyby

- **What** — a depth column of cards rolls forward through perspective; each landing beat tumbles the next card into the front slot with a solid colored wipe, the old front falls toward camera, dwells shrink card-to-card so the deck accelerates into a held final card.
- **Reach for it when** — a stream of discrete onsets that **accelerate** (gaps shrinking / a build into a downbeat) and you want to flash a **sequence of items** — titles, projects, posters, tiles — one per hit, climaxing on a held card.
- **Best span** — **4-6.5s** for 4-7 landings plus a short final hold; split at the next downbeat if it wants to run **>7s**.
- **Params** — `theme`, `bgColor`, `cards`, `landings`, `yaw`

### held-message-living-field · phrase_flow

- **What** — a readable mark (logo / word / title) held dead still over a soft, color-shifting blurred field; only the field breathes.
- **Reach for it when** — a **calm / sparse** stretch with an onset desert — energy present but few or no onsets (a held pad or riser); you have one word or mark to hold and let breathe.
- **Best span** — **6-16s**; this is the long-group exception. Under 4s feels underdeveloped; over ~20s needs a state change or another group.
- **Params** — `markText`, `titleText`, `tagText`, `palette`, `flowSpeed`, `duration`

### held-text-strobe-burst

- **What** — a dead-still word whose letters flip through texture-filled frames (texture-clipped fill + per-frame tint + bg color) every ~3 frames, in short bursts pinned to a roll.
- **Reach for it when** — a **dense, hard-hitting roll / fill** and a single word you want to strobe through textures for a few bars. (Ships texture-mask PNGs under `assets/`.)
- **Best span** — **1.2-3.5s**; strobe fatigue starts fast, so cap at ~4s and cut to a cleaner system.
- **Params** — `markText`, `fontStyle`, `markScale`, `idleColor`, `idleInk`, `frames`, `strobePlan`, `decor`, `duration`

### intro-kinetic-cascade

- **What** — a line laid out as a sequence of big editorial **phrases** (each a stacked poster with one enlarged hero word), revealed word-by-word on its anchors, hard cut between phrases, climaxing on a phrase that slides in with a swappable ringing **icon** (bell / cursor / sparkle / emoji / SVG).
- **Reach for it when** — an **intro / opening statement**: a short line to land word-by-word as big type, climaxing on one keyword + an icon. Medium-or-more energy, steady grid.
- **Best span** — **3.5-7s** for 2-4 phrase beats; if the statement needs more time, make the next clause a new group.
- **Params** — `theme`, `icon`, `phrases`, `climax`

### logo-split-lockup-pulse

- **What** — a two-part mark joined at center splits left↔right to open a gap, grows a center word-lockup one word per onset (key word lands on the downbeat surge), snap-closes on a hit, then pulses with the beat.
- **Reach for it when** — a short **logo / brand sting** (not a typed sentence): fast dense onsets + a sustained roll bed to pulse on, with a left/right bracketing mark.
- **Best span** — **2-4s**; at **>4.5s** it reads like a sting stretched too long. Follow with a separate held-lockup / next idea group.
- **Params** — `bgColor`, `markColor`, `textColor`, `leftMark`, `rightMark`, `word1`, `word2`, `word3`, `word4`

### poster-tile-mosaic

- **What** — a packed **mosaic** of different-sized colored tiles that tessellate to fill the frame (no overlap), driven by interchangeable beat-synced operations: staggered enter/exit, locked global recolor, snake-fill + overlay.
- **Reach for it when** — many discrete, individually-placeable onsets (a hit for every tile move) with distinct sub-phases you want articulated differently (accumulate → recolor-on-roll → fill-then-drop). A dense section best held as **one** rich tile program rather than split.
- **Best span** — **4-7s**, up to **8s** only when the program has clear sub-phases. If the music changes regime, split even if the tile system could continue.
- **Params** — `bgColor`, `tiles`, `bands`, `gap`, `showText`, `labels`, `program`

### roll-flipbook-word-cycle

- **What** — a hi-hat roll drives a centred word that flips every 16th-note through a word list; optionally the flicker resolves and locks into a final phrase.
- **Reach for it when** — a **fast sustained-fill roll** (hundreds of hits/min, ~16th-note) with no single readable message — fill the roll with a rapidly-cycling word flipbook.
- **Best span** — **1.2-3.8s**, one roll into one resolve. Two rolls, or a drop between rolls, means two groups.
- **Params** — `bgColor`, `textColor`, `accentColor`, `flipWords`, `resolveText`, `periodChar`

### split-anchor-word-slot

- **What** — a held left anchor column of fixed-word rows beside a torn-paper word-slot box on the right, driven by beat-synced operators: anchor lock-in, slot word-group cycle (in/out + per-line color), full-scene background flip, per-beat jitter, box-zoom exit wipe. Row count + number of flips are data.
- **Reach for it when** — a short section with a **held idea** (a brand / name to anchor on the left) **and** a stream of onsets popping separate words on the right, plus a dense run to ride a shake on and a strong downbeat to wipe out into.
- **Best span** — **3-6s**; above ~6.5s the fixed anchor goes stale unless the right slot enters a new group/program.
- **Params** — `bgColor`, `anchors`, `theme`, `showText`, `program`

### typewriter-phrase-keyword-shuffle

- **What** — words type in one-per-onset to spell a phrase, then one keyword cycles typefaces on the beat while everything else holds dead still.
- **Reach for it when** — a steady grid with a **continuous onset stream** (no desert): a phrase to type out, then a keyword to shuffle. The inverse of `held-message-living-field` (which wants an onset desert).
- **Best span** — **2.5-5s**; if the phrase cannot type and shuffle inside ~5s, reduce words or split the sentence across groups.
- **Params** — `bgColor`, `textColor`, `accentColor`, `lead1`, `lead2`, `lead3`, `keyword`, `periodChar`
references/templates/card-flyby/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"theme",    "type":"string", "label":"Palette theme — aurora | ember | mono | slate",     "default":"aurora"},
    {"id":"bgColor",  "type":"color",  "label":"Stage background override (empty = theme bg)",       "default":""},
    {"id":"cards",    "type":"string", "label":"Deck JSON — array of {tag,title,meta,color?}",        "default":""},
    {"id":"landings", "type":"string", "label":"Landing beats JSON — abs seconds, one per card",     "default":""},
    {"id":"yaw",      "type":"number", "label":"Scene yaw (deg, side tilt of the whole deck)",        "default":-12}
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Card Flyby · accelerating 3D deck cascade — reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 1920px;
        height: 1080px;
        background: #06070d;
        overflow: hidden;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        background: radial-gradient(1300px 820px at 50% 52%, #0d1018 0%, #06070d 70%, #04050a 100%);
        overflow: hidden;
        font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
      }
      /* .stage carries the perspective; .scene carries the static yaw. The cards' own
         rotateX drives the tumble-forward — the scene only holds the side tilt. */
      .stage {
        position: absolute;
        left: 0;
        top: 0;
        width: 1920px;
        height: 1080px;
        perspective: 2400px;
        perspective-origin: 50% 55%;
      }
      .scene {
        position: absolute;
        left: 0;
        top: 0;
        width: 1920px;
        height: 1080px;
        transform-style: preserve-3d;
        transform: rotateY(-12deg);
      }
      .card {
        position: absolute;
        left: calc(50% - 700px);
        top: calc(50% - 395px);
        width: 1400px;
        height: 790px;
        border-radius: 32px;
        will-change: transform, opacity;
        --wipe: 0;
        --color: #444;
        transform-origin: 50% 50%;
      }
      /* Wireframe (always present, opacity tracks the slot). */
      .wire {
        position: absolute;
        inset: 0;
        border-radius: 28px;
        border: 1.5px solid rgba(255, 255, 255, 0.42);
        box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.18);
        background: rgba(255, 255, 255, 0.012);
        overflow: hidden;
      }
      .wire .wire-label {
        position: absolute;
        left: 68px;
        top: 60px;
        font-size: 26px;
        font-weight: 500;
        letter-spacing: 0.18em;
        text-transform: uppercase;
        color: rgba(255, 255, 255, 0.5);
      }
      .wire .wire-corner {
        position: absolute;
        right: 68px;
        top: 60px;
        width: 34px;
        height: 34px;
        border: 1.5px solid rgba(255, 255, 255, 0.32);
        border-radius: 50%;
      }
      .wire .wire-bar {
        position: absolute;
        left: 68px;
        right: 68px;
        bottom: 68px;
        height: 1.5px;
        background: rgba(255, 255, 255, 0.22);
      }
      /* Solid colored face — revealed via an angled clip-path wipe driven by --wipe.
         Polygon sweeps upper-left → lower-right at ~72°. wipe=0 collapsed off-element,
         wipe=1 covers the full card. */
      .solid {
        position: absolute;
        inset: 0;
        border-radius: 28px;
        /* gentle top→bottom tonal gradient (theme-derived shades) for depth */
        background: linear-gradient(
          158deg,
          var(--c1, var(--color)) 0%,
          var(--c2, var(--color)) 100%
        );
        box-shadow:
          0 34px 100px rgba(0, 0, 0, 0.55),
          inset 0 1px 0 rgba(255, 255, 255, 0.14);
        overflow: hidden;
        /* the title is the hero — centered in the card */
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 90px 104px;
        clip-path: polygon(
          0% 0%,
          calc(var(--wipe) * 200% - 30%) 0%,
          calc(var(--wipe) * 200% - 80%) 100%,
          0% 100%
        );
      }
      .solid::before {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: 28px;
        background: linear-gradient(
          135deg,
          rgba(255, 255, 255, 0.13) 0%,
          rgba(255, 255, 255, 0.03) 40%,
          rgba(0, 0, 0, 0.14) 100%
        );
        pointer-events: none;
      }
      .solid .title {
        font-size: 184px;
        font-weight: 800;
        letter-spacing: -0.035em;
        line-height: 0.92;
        color: var(--ink, #ffffff);
        text-align: center;
        max-width: 100%;
        overflow-wrap: break-word;
        text-wrap: balance;
      }
      .solid .play {
        position: absolute;
        right: 68px;
        bottom: 70px;
        width: 80px;
        height: 80px;
        border-radius: 50%;
        background: rgba(0, 0, 0, 0.28);
        display: flex;
        align-items: center;
        justify-content: center;
      }
      .solid .play::after {
        content: "";
        width: 0;
        height: 0;
        border-left: 22px solid #ffffff;
        border-top: 14px solid transparent;
        border-bottom: 14px solid transparent;
        margin-left: 5px;
      }
    </style>
  </head>
  <body>
    <!-- duration 6.0s = a 6-card accelerating cascade (1 card lands per beat, dwells shrinking) -->
    <div
      data-hf-id="hf-cf-root"
      id="root"
      data-composition-id="card-flyby"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="6.0"
      data-root="true"
    >
      <div data-hf-id="hf-cf-stage" class="stage">
        <div data-hf-id="hf-cf-scene" class="scene" id="scene"></div>
      </div>

      <script>
        (function () {
          // ════════════════════════════════════════════════════════════════════
          //  CARD FLYBY  ·  accelerating 3D deck cascade
          //  ────────────────────────────────────────────────────────────────────
          //  A depth column of N cards rolls FORWARD. On each landing beat the next
          //  card tumbles up into the front slot (s0) and WIPES in its solid colored
          //  face (angled clip-path), the previous front card tumbles forward + falls
          //  down toward the camera on exit, and every card behind advances one slot.
          //  Dwells shrink card-to-card → the deck reads as an accelerating rush into
          //  a held final card. Asset-free: wireframe + solid CSS cards, system fonts.
          //
          //  COMPOSE BY DATA — `cards` (the deck) and `landings` (one beat per card)
          //  are the two data surfaces. Card count is data: 3/6/9 cards all "just
          //  work"; landings default to an accelerating schedule derived from count.
          // ════════════════════════════════════════════════════════════════════

          window.__timelines = window.__timelines || {};
          // force3D is set per card tween/set below (NOT a global default — a global
          // would leak onto the plain-object padding tween and warn). It keeps a 3D
          // matrix on every transform so cards never collapse to 2D: a card whose
          // rotX/Y/Z all hit 0 (the front s0 pose) would otherwise drop out of the 3D
          // stacking context and paint in DOM order, letting back cards render on top.

          // ── 1. Variables ──────────────────────────────────────────────────────
          var defaults = { theme: "aurora", bgColor: "", cards: "", landings: "", yaw: -12 };
          var vars = Object.assign(
            {},
            defaults,
            window.__hyperframes && window.__hyperframes.getVariables
              ? window.__hyperframes.getVariables()
              : {},
          );

          // ── Palette themes — each is a COHESIVE ramp (one temperature/saturation
          //    family) so any card count reads as a designed set. card.color still
          //    overrides per card. White-on-card stays AA at the card type sizes. ──
          var THEMES = {
            // cool jewel arc: teal → ocean → indigo → violet → plum
            aurora: {
              bg: "#06060e",
              ink: "#ffffff",
              colors: ["#0e5f63", "#15517e", "#283f86", "#4a3a83", "#6a3380", "#8a2e63"],
            },
            // warm sunset arc: wine → brick → rust → amber → ochre → bronze
            ember: {
              bg: "#0e0805",
              ink: "#fff6ee",
              colors: ["#5c2030", "#7e2828", "#9c3b1c", "#a9551a", "#8a5a1c", "#5f4626"],
            },
            // sleek monochrome blue staircase (dark → mid)
            mono: {
              bg: "#060912",
              ink: "#ffffff",
              colors: ["#11243f", "#16314f", "#1b3d63", "#214a78", "#27568d", "#2d63a2"],
            },
            // muted cool slate — understated / editorial
            slate: {
              bg: "#0b0e12",
              ink: "#eef2f6",
              colors: ["#232a33", "#2b3540", "#33414e", "#3b4c5c", "#43576a", "#4b6278"],
            },
          };
          var TH = THEMES[String(vars.theme || "").trim()] || THEMES.aurora;

          // tiny hex helpers (deterministic, no deps) — derive shades + soft ink.
          function parseHex(h) {
            h = String(h || "")
              .trim()
              .replace(/^#/, "");
            if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
            if (h.length !== 6) return null;
            var n = parseInt(h, 16);
            return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
          }
          function clamp8(v) {
            return Math.max(0, Math.min(255, Math.round(v)));
          }
          function toHex(c) {
            return (
              "#" +
              [c.r, c.g, c.b]
                .map(function (v) {
                  return clamp8(v).toString(16).padStart(2, "0");
                })
                .join("")
            );
          }
          function mix(hex, t, amt) {
            var c = parseHex(hex);
            if (!c) return hex;
            return toHex({
              r: c.r + (t.r - c.r) * amt,
              g: c.g + (t.g - c.g) * amt,
              b: c.b + (t.b - c.b) * amt,
            });
          }
          var BLACK = { r: 0, g: 0, b: 0 },
            WHITE = { r: 255, g: 255, b: 255 };
          function darken(hex, amt) {
            return mix(hex, BLACK, amt);
          }
          function lighten(hex, amt) {
            return mix(hex, WHITE, amt);
          }

          var DEFAULT_CARDS = [
            { title: "FADEGLOW" },
            { title: "SAUL BASS" },
            { title: "EULER" },
            { title: "HERMÈS" },
            { title: "DOSSIER" },
            { title: "BLACK HOLES" },
          ];
          // landings tuned by hand on the sandbox: accelerating dwells, final card holds ~1.6s.
          var DEFAULT_LANDINGS = [0.4, 1.7, 2.7, 3.45, 4.0, 4.4];

          var DUR = 6.0; // matches data-duration
          var YAW = Number(vars.yaw);
          if (!isFinite(YAW)) YAW = -12;

          function parseJSON(v) {
            if (!v) return null;
            if (typeof v !== "string") return v;
            try {
              return JSON.parse(v);
            } catch (e) {
              return null;
            }
          }

          var CARDS = parseJSON(vars.cards);
          if (!CARDS || !CARDS.length) CARDS = DEFAULT_CARDS;
          var N = CARDS.length;

          // ── 2. Landing schedule (accelerating cascade) ─────────────────────────
          // gaps between landings shrink geometrically; the final card lands early
          // enough to hold a beat before the cut.
          function genLandings(n, total) {
            var start = 0.4;
            var hold = Math.min(1.6, total * 0.27);
            var end = Math.max(start + 0.3, total - hold);
            if (n <= 1) return [start];
            var ratio = 0.78,
              weights = [],
              sum = 0,
              i;
            for (i = 0; i < n - 1; i++) {
              var w = Math.pow(ratio, i);
              weights.push(w);
              sum += w;
            }
            var span = end - start,
              acc = start,
              times = [start];
            for (i = 0; i < n - 1; i++) {
              acc += (span * weights[i]) / sum;
              times.push(acc);
            }
            return times;
          }
          var LANDED = parseJSON(vars.landings);
          if (!LANDED || LANDED.length !== N) {
            LANDED = N === DEFAULT_CARDS.length ? DEFAULT_LANDINGS.slice() : genLandings(N, DUR);
          }
          // per-card transition duration derived from the gap into it (faster as we accelerate).
          function transDur(i) {
            var gap = i === 0 ? (N > 1 ? LANDED[1] - LANDED[0] : 0.4) : LANDED[i] - LANDED[i - 1];
            return Math.max(0.12, Math.min(0.42, gap * 0.32));
          }

          // ── 3. Build the deck DOM from data ────────────────────────────────────
          var scene = document.getElementById("scene");
          scene.style.transform = "rotateY(" + YAW + "deg)";
          // bg: explicit bgColor override wins; else the theme's field color. The radial
          // lifts the centre slightly and sinks the edges, both derived from the bg.
          var BG = vars.bgColor && String(vars.bgColor).trim() ? vars.bgColor : TH.bg;
          document.documentElement.style.background = BG;
          document.body.style.background = BG;
          var rootEl = document.getElementById("root");
          rootEl.style.background =
            "radial-gradient(1300px 820px at 50% 52%, " +
            lighten(BG, 0.1) +
            " 0%, " +
            BG +
            " 70%, " +
            darken(BG, 0.4) +
            " 100%)";

          function el(cls, parent) {
            var d = document.createElement("div");
            d.className = cls;
            if (parent) parent.appendChild(d);
            return d;
          }
          var cardEls = [];
          for (var i = 0; i < N; i++) {
            var c = CARDS[i] || {};
            var card = el("card", scene);
            card.id = "card-" + i;
            // color: per-card override, else cycle the cohesive theme ramp. A gentle
            // top→bottom shade pair (--c1/--c2) gives each face tonal depth.
            var col = c.color || TH.colors[i % TH.colors.length];
            card.style.setProperty("--color", col);
            card.style.setProperty("--c1", lighten(col, 0.05));
            card.style.setProperty("--c2", darken(col, 0.22));
            card.style.setProperty("--ink", TH.ink);

            var wire = el("wire", card);
            var label = el("wire-label", wire);
            label.textContent = String(i + 1).padStart(3, "0");
            el("wire-corner", wire);
            el("wire-bar", wire);

            var solid = el("solid", card);
            var title = el("title", solid);
            title.textContent = c.title || "";
            el("play", solid);

            cardEls.push(card);
          }

          // ── 4. SLOT poses ───────────────────────────────────────────────────────
          // rotX is the key. Cards behind tilt BACK (top edge away, +rotX); the front
          // is upright (rotX 0); on exit a card continues forward into a face-down
          // tumble (-rotX) while dropping down + toward camera. Depth from perspective
          // (Z) + the cards' rotateX, not a camera move.
          var SLOT = {
            far: { x: 360, y: 380, z: -1080, rotX: 84, scale: 0.74, opacity: 0, zi: 60 },
            s3: { x: 280, y: 290, z: -820, rotX: 66, scale: 0.8, opacity: 0.32, zi: 70 },
            s2: { x: 195, y: 200, z: -560, rotX: 46, scale: 0.86, opacity: 0.58, zi: 80 },
            s1: { x: 105, y: 105, z: -290, rotX: 22, scale: 0.93, opacity: 0.85, zi: 90 },
            s0: { x: 0, y: 0, z: 0, rotX: 0, scale: 1.0, opacity: 1.0, zi: 100 },
            exit: { x: -90, y: 620, z: 420, rotX: -88, scale: 1.1, opacity: 0, zi: 110 },
          };
          // pose for a card whose index is `rel` slots behind the current front card
          // (rel 0 = front, +ve = behind, -ve = already exiting / gone).
          function poseForRel(rel) {
            if (rel <= -1) return SLOT.exit;
            if (rel === 0) return SLOT.s0;
            if (rel === 1) return SLOT.s1;
            if (rel === 2) return SLOT.s2;
            if (rel === 3) return SLOT.s3;
            return SLOT.far;
          }

          var tl = gsap.timeline({ paused: true });

          function poseTween(idx, slot, t, dur, ease) {
            // z-index is set instantly at the swap start so paint order matches the
            // slot — CSS can't reliably depth-sort non-intersecting planes.
            tl.set("#card-" + idx, { zIndex: slot.zi }, t);
            tl.to(
              "#card-" + idx,
              {
                x: slot.x,
                y: slot.y,
                z: slot.z,
                rotationX: slot.rotX,
                scale: slot.scale,
                opacity: slot.opacity,
                force3D: true,
                duration: dur,
                ease: ease || "power2.inOut",
              },
              t,
            );
          }

          // ── 5. Initial pose (front index = -1: nothing landed yet) ─────────────
          // card j sits at rel = j + 1, so card0 starts at s1, card1 at s2, etc. Apply
          // via gsap.set (so the paused render at t=0 shows the staged stack) AND via
          // tl.set at 0 (so scrubbing back to 0 restores it).
          for (var j = 0; j < N; j++) {
            var s = poseForRel(j + 1);
            var props = {
              x: s.x,
              y: s.y,
              z: s.z,
              rotationX: s.rotX,
              scale: s.scale,
              opacity: s.opacity,
              zIndex: s.zi,
              force3D: true,
              "--wipe": 0,
            };
            gsap.set("#card-" + j, props);
            tl.set("#card-" + j, props, 0);
          }

          // ── 6. Per-landing orchestration ───────────────────────────────────────
          // On landing i, card i becomes front. Tween the live window [i-1 .. i+4]:
          // i-1 tumbles to exit, i rolls into s0 (+ wipe), the rest advance toward
          // the front. Cards outside the window are already at rest (far / gone).
          for (var L = 0; L < N; L++) {
            var dur = transDur(L);
            var t = Math.max(0, LANDED[L] - dur);

            for (var k = L - 1; k <= L + 4; k++) {
              if (k < 0 || k >= N) continue;
              var rel = k - L;
              var ease = rel === 0 ? "power3.out" : rel < 0 ? "power2.in" : "power2.inOut";
              poseTween(k, poseForRel(rel), t, dur, ease);
            }

            // the landing card wipes in its solid face as it rolls up.
            tl.to(
              "#card-" + L,
              {
                "--wipe": 1,
                duration: dur * 0.85,
                ease: "power2.inOut",
              },
              t + dur * 0.15,
            );
          }

          // pad the timeline so duration() >= data-duration.
          tl.to({}, { duration: DUR }, 0);
          tl.seek(0);

          window.__timelines["card-flyby"] = tl;
        })();
      </script>
    </div>
  </body>
</html>
references/templates/card-flyby/program.json
{
  "_": "Default card-flyby program. `theme` picks a cohesive palette; `cards` is the deck (each card = a big centered title; `color` is OPTIONAL — omit it and the card takes the next color from the theme ramp); `landings` is one onset per card (accelerating cadence; omit to auto-derive). Pass these as the matching composition-variables; empty vars fall back to these values.",
  "theme": "aurora",
  "cards": [
    { "title": "FADEGLOW" },
    { "title": "SAUL BASS" },
    { "title": "EULER" },
    { "title": "HERMÈS" },
    { "title": "DOSSIER" },
    { "title": "BLACK HOLES" }
  ],
  "landings": [0.4, 1.7, 2.7, 3.45, 4.0, 4.4]
}
references/templates/held-message-living-field/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor",    "type":"color",  "label":"Page background",        "default":"#050507"},
    {"id":"colorLow",   "type":"color",  "label":"Flow — shadow tone",     "default":"#0a1430"},
    {"id":"colorMid",   "type":"color",  "label":"Flow — mid tone",        "default":"#6a6f9e"},
    {"id":"colorHigh",  "type":"color",  "label":"Flow — highlight tone",  "default":"#e8f0ff"},
    {"id":"accentColor","type":"color",  "label":"Core / glow accent",     "default":"#aee4ff"},
    {"id":"markText",   "type":"string", "label":"Monogram",               "default":"HF"},
    {"id":"titleText",  "type":"string", "label":"Title",                  "default":"HYPERFRAMES"},
    {"id":"tagText",    "type":"string", "label":"Tagline",                "default":"HTML · SHADERS · MP4"},
    {"id":"flowSpeed",  "type":"number", "label":"Flow speed", "default":1, "min":0.1, "max":3, "step":0.1}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>WebGL Textures Playground — Template</title>
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
      rel="stylesheet"
    />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      /* --bg / --mark-top / --mark-bottom / --line are set from variables in JS */
      html,
      body {
        background: var(--bg, #050507);
        overflow: hidden;
        font-family: "Inter", system-ui, sans-serif;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--bg, #050507);
      }
      .sf-stage {
        position: absolute;
        inset: 0;
      }
      .sf-canvas {
        position: absolute;
        inset: 0;
        width: 1920px;
        height: 1080px;
        display: block;
      }
      .sf-overlay {
        position: absolute;
        inset: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 28px;
        color: #fff;
        text-align: center;
        mix-blend-mode: screen;
        pointer-events: none;
      }
      .sf-mark {
        font-size: 280px;
        font-weight: 800;
        letter-spacing: -18px;
        line-height: 1;
        display: flex;
        gap: 0;
        opacity: 0;
        filter: drop-shadow(0 0 60px rgba(180, 220, 255, 0.4));
      }
      .sf-mark-letter {
        display: inline-block;
        background: linear-gradient(
          180deg,
          var(--mark-top, #ffffff) 0%,
          var(--mark-bottom, #8aa9ff) 100%
        );
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
        transform: translateY(0);
      }
      .sf-rule {
        width: 0;
        height: 2px;
        background: linear-gradient(
          90deg,
          transparent 0%,
          var(--line, rgba(255, 255, 255, 0.9)) 50%,
          transparent 100%
        );
        opacity: 0;
      }
      .sf-title {
        font-size: 64px;
        font-weight: 700;
        letter-spacing: 18px;
        opacity: 0;
        clip-path: inset(0 100% 0 0);
        text-transform: uppercase;
      }
      .sf-tag {
        font-size: 18px;
        font-weight: 500;
        letter-spacing: 8px;
        color: rgba(255, 255, 255, 0.65);
        opacity: 0;
        text-transform: uppercase;
      }
      .sf-grain {
        position: absolute;
        inset: 0;
        pointer-events: none;
        opacity: 0.06;
        mix-blend-mode: overlay;
        background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
      }
    </style>
  </head>
  <body>
    <div
      data-hf-id="hf-a0d4"
      id="root"
      data-composition-id="webgl-textures-playground"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="12"
      data-root="true"
    >
      <div data-hf-id="hf-clsq" class="sf-stage">
        <canvas data-hf-id="hf-yk51" class="sf-canvas" width="1920" height="1080"></canvas>
        <div data-hf-id="hf-7z0g" class="sf-overlay">
          <div data-hf-id="hf-yo0z" class="sf-mark"><!-- letters injected from markText --></div>
          <div data-hf-id="hf-nig2" class="sf-rule"></div>
          <div data-hf-id="hf-gjx6" class="sf-title"></div>
          <div data-hf-id="hf-e7y8" class="sf-tag"></div>
        </div>
        <div data-hf-id="hf-ku55" class="sf-grain"></div>
      </div>
    </div>

    <script>
      (function () {
        // ── 1. Resolve variables (read ONCE during init) ──────────────────
        var defaults = {
          bgColor: "#050507",
          colorLow: "#0a1430",
          colorMid: "#6a6f9e",
          colorHigh: "#e8f0ff",
          accentColor: "#aee4ff",
          markText: "HF",
          titleText: "HYPERFRAMES",
          tagText: "HTML · SHADERS · MP4",
          flowSpeed: 1,
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );

        // sRGB hex -> linear-light vec3 (the shader works in linear space and
        // gamma-encodes at the end, so converting here makes the picked color
        // land close to what a color picker shows).
        function hexToLinear(hex) {
          var h = String(hex).replace("#", "").trim();
          if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
          var r = parseInt(h.substr(0, 2), 16) / 255;
          var g = parseInt(h.substr(2, 2), 16) / 255;
          var b = parseInt(h.substr(4, 2), 16) / 255;
          var lin = function (c) {
            return Math.pow(c, 2.2);
          };
          return new THREE.Vector3(lin(r), lin(g), lin(b));
        }

        var root = document.getElementById("root");
        var canvas = root.querySelector(".sf-canvas");
        var W = 1920;
        var H = 1080;

        // ── 2. Apply text + CSS-driven colors ─────────────────────────────
        var docEl = document.documentElement;
        docEl.style.setProperty("--bg", vars.bgColor);
        docEl.style.setProperty("--mark-top", vars.colorHigh);
        docEl.style.setProperty("--mark-bottom", vars.colorMid);
        docEl.style.setProperty("--line", vars.colorHigh);

        var markEl = root.querySelector(".sf-mark");
        markEl.innerHTML = "";
        String(vars.markText)
          .split("")
          .forEach(function (ch) {
            var span = document.createElement("span");
            span.className = "sf-mark-letter";
            span.textContent = ch;
            markEl.appendChild(span);
          });
        root.querySelector(".sf-title").textContent = vars.titleText;
        root.querySelector(".sf-tag").textContent = vars.tagText;

        // ── 3. WebGL flow field ───────────────────────────────────────────
        var renderer = new THREE.WebGLRenderer({
          canvas: canvas,
          antialias: false,
          alpha: false,
          preserveDrawingBuffer: true,
        });
        renderer.setPixelRatio(1);
        renderer.setSize(W, H, false);

        var scene = new THREE.Scene();
        var camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

        var uniforms = {
          uTime: { value: 0.0 },
          uPhase: { value: 0.0 },
          uReveal: { value: 0.0 },
          uChromAb: { value: 0.0 },
          uVignette: { value: 0.0 },
          uIntensity: { value: 0.0 },
          uResolution: { value: new THREE.Vector2(W, H) },
          uSpeed: { value: Number(vars.flowSpeed) || 1.0 },
          // ── COLOR CONTROLS (driven by the composition variables) ──
          uColLow: { value: hexToLinear(vars.colorLow) },
          uColMid: { value: hexToLinear(vars.colorMid) },
          uColHigh: { value: hexToLinear(vars.colorHigh) },
          uAccent: { value: hexToLinear(vars.accentColor) },
        };

        var vert = [
          "varying vec2 vUv;",
          "void main(){",
          "  vUv = uv;",
          "  gl_Position = vec4(position.xy, 0.0, 1.0);",
          "}",
        ].join("\n");

        var frag = [
          "precision highp float;",
          "varying vec2 vUv;",
          "uniform float uTime;",
          "uniform float uPhase;",
          "uniform float uReveal;",
          "uniform float uChromAb;",
          "uniform float uVignette;",
          "uniform float uIntensity;",
          "uniform float uSpeed;",
          "uniform vec2  uResolution;",
          "uniform vec3  uColLow;",
          "uniform vec3  uColMid;",
          "uniform vec3  uColHigh;",
          "uniform vec3  uAccent;",

          "float hash21(vec2 p){",
          "  p = fract(p * vec2(123.34, 456.21));",
          "  p += dot(p, p + 45.32);",
          "  return fract(p.x * p.y);",
          "}",

          "float noise2(vec2 p){",
          "  vec2 i = floor(p);",
          "  vec2 f = fract(p);",
          "  vec2 u = f * f * (3.0 - 2.0 * f);",
          "  float a = hash21(i);",
          "  float b = hash21(i + vec2(1.0, 0.0));",
          "  float c = hash21(i + vec2(0.0, 1.0));",
          "  float d = hash21(i + vec2(1.0, 1.0));",
          "  return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);",
          "}",

          "float fbm(vec2 p){",
          "  float v = 0.0;",
          "  float a = 0.5;",
          "  for(int i=0;i<5;i++){",
          "    v += a * noise2(p);",
          "    p *= 2.02;",
          "    a *= 0.5;",
          "  }",
          "  return v;",
          "}",

          "vec2 curl(vec2 p, float t){",
          "  float e = 0.6;",
          "  float n1 = fbm(p + vec2(0.0,  e) + t*0.18);",
          "  float n2 = fbm(p + vec2(0.0, -e) + t*0.18);",
          "  float n3 = fbm(p + vec2( e, 0.0) + t*0.18);",
          "  float n4 = fbm(p + vec2(-e, 0.0) + t*0.18);",
          "  return vec2(n1 - n2, -(n3 - n4));",
          "}",

          // 3-stop gradient palette built from the color variables.
          // t≈0 -> shadow, t≈0.5 -> mid, t≈1 -> highlight.
          "vec3 palette(float t){",
          "  t = clamp(t, 0.0, 1.0);",
          "  vec3 lowMid = mix(uColLow, uColMid, smoothstep(0.0, 0.5, t));",
          "  return mix(lowMid, uColHigh, smoothstep(0.5, 1.0, t));",
          "}",

          "vec3 sampleField(vec2 uv, float t, float energy){",
          "  vec2 p = (uv - 0.5) * vec2(uResolution.x/uResolution.y, 1.0) * 2.4;",
          "  vec2 c = curl(p * 0.9, t);",
          "  vec2 q = p + c * (0.45 + 0.55 * energy);",

          "  float flow = fbm(q * 1.6 + vec2(t * 0.11, -t * 0.07));",
          "  float bands = sin(flow * 14.0 + t * 1.1);",
          "  bands = smoothstep(0.0, 1.0, 0.5 + 0.5 * bands);",

          "  float r = length(p);",
          "  float core = smoothstep(1.5, 0.05, r) * (0.6 + 0.4 * energy);",
          "  float glow = exp(-r * 1.6) * (0.55 + 0.45 * energy);",

          "  float k = clamp(flow * 0.85 + 0.15 * bands + 0.25 * core + 0.6 * glow, 0.0, 1.4);",
          "  vec3 col = palette(k * 0.9 + 0.1 * sin(t * 0.4));",
          "  col *= 0.7 + 0.6 * energy;",
          "  col += uAccent * core * 0.55;",
          "  return col;",
          "}",

          "void main(){",
          "  vec2 uv = vUv;",
          "  float t = uTime * uSpeed;",
          "  float e = uIntensity;",

          "  vec2 dir = normalize(uv - 0.5 + 1e-6);",
          "  float ca = uChromAb * (0.004 + 0.012 * length(uv - 0.5));",
          "  vec3 cR = sampleField(uv + dir * ca,        t, e);",
          "  vec3 cG = sampleField(uv,                   t, e);",
          "  vec3 cB = sampleField(uv - dir * ca,        t, e);",
          "  vec3 col = vec3(cR.r, cG.g, cB.b);",

          "  float lum = dot(col, vec3(0.299, 0.587, 0.114));",
          "  col = mix(vec3(lum), col, 1.05);",

          "  float rev = uReveal;",
          "  float sweep = smoothstep(0.0, 0.6, rev) * (1.0 - smoothstep(0.6, 1.0, rev));",
          "  col += uColHigh * sweep * 0.35;",

          "  vec3 phaseTint = mix(vec3(0.85, 0.92, 1.05), vec3(1.05, 0.95, 0.85), uPhase);",
          "  col *= phaseTint;",

          "  float v = length(uv - 0.5);",
          "  col *= 1.0 - smoothstep(0.45, 0.95, v) * uVignette;",

          "  col = col / (col + 1.0);",
          "  col = pow(col, vec3(1.0/2.2));",

          "  gl_FragColor = vec4(col, 1.0);",
          "}",
        ].join("\n");

        var mat = new THREE.ShaderMaterial({
          uniforms: uniforms,
          vertexShader: vert,
          fragmentShader: frag,
        });
        var geo = new THREE.PlaneGeometry(2, 2);
        var mesh = new THREE.Mesh(geo, mat);
        scene.add(mesh);

        function renderFrame() {
          renderer.render(scene, camera);
        }
        renderFrame();

        // Continuous loop: ensures the WebGL backbuffer always has fresh
        // pixels when puppeteer takes a screenshot. All visible state comes
        // from uniforms driven by the GSAP timeline, so output is fully
        // determined by the timeline position (deterministic across renders).
        function loop() {
          renderFrame();
          window.requestAnimationFrame(loop);
        }
        window.requestAnimationFrame(loop);

        // ── 4. Deterministic timeline (unchanged motion design) ───────────
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({
          paused: true,
          onUpdate: renderFrame,
        });

        tl.to(uniforms.uTime, { value: 12.0, duration: 12, ease: "none" }, 0);

        tl.to(uniforms.uIntensity, { value: 0.55, duration: 3.0, ease: "sine.inOut" }, 0);
        tl.to(uniforms.uIntensity, { value: 1.0, duration: 1.2, ease: "power2.in" }, 3.0);
        tl.to(uniforms.uIntensity, { value: 0.55, duration: 3.0, ease: "power2.out" }, 4.2);
        tl.to(uniforms.uIntensity, { value: 0.3, duration: 4.8, ease: "sine.inOut" }, 7.2);

        tl.fromTo(
          uniforms.uChromAb,
          { value: 0.0 },
          { value: 0.4, duration: 3.0, ease: "sine.in" },
          0,
        );
        tl.to(uniforms.uChromAb, { value: 1.6, duration: 1.0, ease: "power3.in" }, 3.0);
        tl.to(uniforms.uChromAb, { value: 0.25, duration: 1.5, ease: "power2.out" }, 4.0);
        tl.to(uniforms.uChromAb, { value: 0.18, duration: 6.5, ease: "sine.inOut" }, 5.5);

        tl.fromTo(
          uniforms.uReveal,
          { value: 0.0 },
          { value: 1.0, duration: 1.2, ease: "none" },
          3.4,
        );
        tl.set(uniforms.uReveal, { value: 0.0 }, 4.6);

        tl.to(uniforms.uPhase, { value: 0.6, duration: 5.0, ease: "sine.inOut" }, 1.5);
        tl.to(uniforms.uPhase, { value: 0.15, duration: 5.5, ease: "sine.inOut" }, 6.5);

        tl.to(uniforms.uVignette, { value: 0.85, duration: 3.0, ease: "power2.out" }, 4.2);

        var mark = root.querySelector(".sf-mark");
        var letters = root.querySelectorAll(".sf-mark-letter");
        var rule = root.querySelector(".sf-rule");
        var title = root.querySelector(".sf-title");
        var tag = root.querySelector(".sf-tag");

        tl.fromTo(
          mark,
          {
            opacity: 0,
            scale: 1.18,
            filter: "blur(24px) drop-shadow(0 0 60px rgba(180,220,255,0.4))",
          },
          {
            opacity: 1,
            scale: 1.0,
            filter: "blur(0px) drop-shadow(0 0 80px rgba(180,220,255,0.55))",
            duration: 0.9,
            ease: "power3.out",
          },
          3.6,
        );
        tl.fromTo(
          letters,
          { y: 80 },
          { y: 0, duration: 1.0, ease: "expo.out", stagger: 0.08 },
          3.6,
        );

        tl.fromTo(
          rule,
          { width: 0, opacity: 0 },
          { width: 360, opacity: 0.85, duration: 0.8, ease: "power2.out" },
          5.1,
        );

        tl.fromTo(
          title,
          { opacity: 0, clipPath: "inset(0 100% 0 0)" },
          { opacity: 1, clipPath: "inset(0 0% 0 0)", duration: 0.95, ease: "power3.out" },
          5.3,
        );

        tl.fromTo(
          tag,
          { opacity: 0, y: 12 },
          { opacity: 0.7, y: 0, duration: 0.8, ease: "sine.out" },
          6.0,
        );

        tl.to(mark, { scale: 1.04, duration: 5.0, ease: "sine.inOut" }, 7.0);

        tl.to([mark, rule, title, tag], { opacity: 0, duration: 0.6, ease: "power1.in" }, 11.4);

        window.__timelines["webgl-textures-playground"] = tl;
      })();
    </script>
  </body>
</html>
references/templates/held-text-strobe-burst/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"markText",   "type":"string", "label":"Held word",                       "default":"NAMSHI"},
    {"id":"fontStyle",  "type":"select", "label":"Font style", "options":["display","serif"], "default":"display"},
    {"id":"markScale",  "type":"number", "label":"Word size scale", "default":1, "min":0.3, "max":2, "step":0.05},
    {"id":"idleColor",  "type":"color",  "label":"Idle background (between bursts)", "default":"#000000"},
    {"id":"idleInk",    "type":"color",  "label":"Idle word color",                 "default":"#ffffff"},
    {"id":"frames",     "type":"string", "label":"Texture frames JSON (empty=built-in)", "default":""},
    {"id":"strobePlan", "type":"string", "label":"Strobe plan JSON bursts (empty=built-in)", "default":""},
    {"id":"decor",      "type":"select", "label":"Blueprint grid + crop border", "options":["no","yes"], "default":"no"}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Held Text / Strobe Burst (Texture Flip) — Template</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 100%;
        height: 100%;
        background: #000;
        overflow: hidden;
      }

      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #000;
        font-family: Arial, sans-serif;
      }

      /* Persistent base: the held word on the idle plate. Always visible; the
         texture looks flip ON TOP of it during bursts, so the word reads as
         continuously held (anchor) and falls back to a solid color on black
         between bursts. */
      .base {
        position: absolute;
        inset: 0;
        background: var(--idle-bg, #000);
      }

      /* One full-frame layer per texture frame. Only opacity is toggled — a
         seek-safe flip-book (deterministic under frame-by-frame render). */
      .look {
        position: absolute;
        inset: 0;
        overflow: hidden;
        opacity: 0;
        background: var(--bg);
      }
      /* optional blueprint grid */
      .look.decor::before,
      .base.decor::before {
        content: "";
        position: absolute;
        inset: 0;
        background-image:
          linear-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px),
          linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
        background-size: 72px 72px;
        opacity: 0.18;
        mix-blend-mode: soft-light;
      }
      /* optional inner crop border */
      .look.decor::after,
      .base.decor::after {
        content: "";
        position: absolute;
        inset: 44px;
        border: 5px solid rgba(255, 255, 255, 0.22);
        border-radius: 4px;
      }

      .word-wrap {
        position: absolute;
        inset: 0;
        z-index: 8;
        display: flex;
        align-items: center;
        justify-content: center;
        filter: drop-shadow(0 18px 26px rgba(0, 0, 0, 0.28));
      }

      /* THE technique: the word's letterforms are a window — a solid tint
         (multiply-blended) over a texture image, clipped to the text. */
      .word {
        color: transparent;
        background-color: transparent;
        background-image: var(--solid-fill), var(--texture-url);
        background-blend-mode: multiply;
        background-size:
          100% 100%,
          var(--texture-size, 150% 150%);
        background-position: center, var(--texture-position, center);
        background-repeat: repeat;
        -webkit-background-clip: text;
        background-clip: text;
        text-align: center;
        white-space: nowrap;
      }
      /* solid-fill variant for the idle base word (no texture) */
      .word.solid {
        background-image: none;
        color: var(--ink, #fff);
        -webkit-text-fill-color: var(--ink, #fff);
      }

      .word.display {
        font-family: Impact, "Arial Black", Arial, sans-serif;
        font-size: 470px;
        font-weight: 900;
        /* line-height ≥ 1 so the background-paint box encloses the full glyph;
           with line-height < 1 the cap tops/bottoms overflow the box and the
           texture (clipped to text) leaves untextured slivers there. */
        line-height: 1.05;
        text-transform: uppercase;
        transform: scaleX(0.78);
        -webkit-text-stroke: 1px rgba(255, 255, 255, 0.22);
      }
      .word.serif {
        font-family: Georgia, "Times New Roman", serif;
        font-size: 360px;
        font-weight: 500;
        font-style: italic;
        line-height: 1.05;
      }
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="held-text-strobe-burst"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="3"
      data-root="true"
    >
      <!-- .base (held word on idle plate) and .look layers are injected by JS -->
    </div>

    <script>
      (function () {
        // Built-in texture frames (texture-flip palette). texture = file name
        // (no .png) under assets/texture-mask-text/masks/.
        var BUILTIN_FRAMES =
          '[{"texture":"tiles-138","bg":"#ff70b0","fill":"#f0a020","ink":"#2a0f1d"},' +
          '{"texture":"paving-stones-150","bg":"#f0d020","fill":"#d07838","ink":"#2a2410"},' +
          '{"texture":"metal-041-a","bg":"#1050e0","fill":"#9fd0ff","ink":"#eaf2ff"},' +
          '{"texture":"lava","bg":"#101127","fill":"#ff5a2c","ink":"#ffd9c2"},' +
          '{"texture":"ground-080","bg":"#0f7a5a","fill":"#ffd27a","ink":"#04241a"},' +
          '{"texture":"onyx","bg":"#e8e2d6","fill":"#3a3a44","ink":"#1a1a22"},' +
          '{"texture":"bricks-075-a","bg":"#ff9000","fill":"#7fb0c0","ink":"#241400"}]';

        // Built-in plan: as a BASE template the default is one continuous burst
        // across the whole composition — flips every 0.125s (≈3f@24fps), never
        // stops, never cuts to idle. (For the gruop_4-faithful two-burst look,
        // pass strobePlan = [{start:1.125,end:1.542,step:.125},{start:2.042,end:2.708,step:.125}].)
        var BUILTIN_PLAN = '[{"start":0,"end":3,"step":0.125}]';

        var defaults = {
          markText: "NAMSHI",
          fontStyle: "display",
          markScale: 1,
          idleColor: "#000000",
          idleInk: "#ffffff",
          frames: "",
          strobePlan: "",
          decor: "no",
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );

        var root = document.getElementById("root");
        var masksDir = "assets/texture-mask-text/masks";
        var style = vars.fontStyle === "serif" ? "serif" : "display";
        var decor = vars.decor === "yes";
        var scale = Number(vars.markScale) || 1;

        function parse(str, fallback) {
          var v = str && String(str).trim() ? str : fallback;
          try {
            var p = JSON.parse(v);
            return Array.isArray(p) ? p : [];
          } catch (e) {
            return [];
          }
        }
        var frames = parse(vars.frames, BUILTIN_FRAMES);
        var plan = parse(vars.strobePlan, BUILTIN_PLAN);

        // Apply word size scale (relative to the per-style base size).
        var baseSize = style === "serif" ? 360 : 470;

        function makeWord(extraClass) {
          var word = document.createElement("div");
          word.className = "word " + style + (extraClass ? " " + extraClass : "");
          word.textContent = String(vars.markText);
          word.style.fontSize = baseSize * scale + "px";
          return word;
        }
        function wrap(word) {
          var w = document.createElement("div");
          w.className = "word-wrap";
          w.appendChild(word);
          return w;
        }

        // ── Persistent base: held word on the idle plate ──────────────────
        var base = document.createElement("div");
        base.className = "base" + (decor ? " decor" : "");
        base.style.setProperty("--idle-bg", vars.idleColor);
        var baseWord = makeWord("solid");
        baseWord.style.setProperty("--ink", vars.idleInk);
        base.appendChild(wrap(baseWord));
        root.appendChild(base);

        // ── One stacked .look per texture frame ───────────────────────────
        var looks = frames.map(function (f) {
          var look = document.createElement("div");
          look.className = "look" + (decor ? " decor" : "");
          look.style.setProperty("--bg", f.bg || "#000");
          var word = makeWord("");
          word.style.setProperty("--texture-url", 'url("' + masksDir + "/" + f.texture + '.png")');
          word.style.setProperty(
            "--solid-fill",
            "linear-gradient(" + (f.fill || "#fff") + "," + (f.fill || "#fff") + ")",
          );
          if (f.pos) word.style.setProperty("--texture-position", f.pos);
          look.appendChild(wrap(word));
          root.appendChild(look);
          return look;
        });

        // ── Deterministic, beat-quantized timeline ────────────────────────
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });
        gsap.set(looks, { opacity: 0 });
        var compDur = Number(root.getAttribute("data-duration")) || 3;

        function gridJumps(start, end, stp) {
          var s = Number(start) || 0,
            e = Number(end) || s,
            d = Number(stp) || 0.125;
          var out = [],
            n = Math.max(0, Math.floor((e - s) / d + 1e-6) + 1);
          for (var i = 0; i < n; i++) out.push(+(s + i * d).toFixed(4));
          return out;
        }

        // For each burst: flip a fresh texture look on every jump (0ms), cycling
        // frames by a global step index; clear back to the idle base at end.
        var step = 0;
        var prev = null;
        plan.forEach(function (burst) {
          var jumps =
            Array.isArray(burst.jumps) && burst.jumps.length
              ? burst.jumps.slice()
              : gridJumps(burst.start, burst.end, burst.step);
          jumps.forEach(function (t) {
            if (!looks.length) return;
            var cur = looks[step % looks.length];
            if (prev && prev !== cur) tl.set(prev, { opacity: 0 }, t);
            tl.set(cur, { opacity: 1 }, t);
            prev = cur;
            step += 1;
          });
          // clean cut back to idle base (held word on black) at burst end —
          // but NOT when the burst runs to the composition end, so the strobe
          // never blacks out / stops on the last frame.
          if (prev && burst.end < compDur - 1e-6) {
            tl.set(prev, { opacity: 0 }, burst.end);
            prev = null;
          }
        });

        // pin total duration
        tl.set({}, {}, compDur);

        window.__timelines["held-text-strobe-burst"] = tl;
      })();
    </script>
  </body>
</html>
references/templates/intro-kinetic-cascade/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"theme",   "type":"string", "label":"Palette — light | dark | bold",                         "default":"light"},
    {"id":"icon",    "type":"string", "label":"Climax icon — bell|cursor|sparkle|bolt|play|heart|check|star, an emoji, <svg>, or none", "default":"bell"},
    {"id":"phrases", "type":"string", "label":"Phrases JSON (empty = built-in intro)",                  "default":""},
    {"id":"climax",  "type":"string", "label":"Climax JSON {text,in,iconAt,hold,size} (empty = default)","default":""}
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Intro Kinetic Cascade · beat-synced kinetic typography — reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #f5f5f7;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #f5f5f7;
        color: #1d1d1f;
        font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
      }
      #stage {
        position: absolute;
        inset: 0;
      }

      /* A PHRASE = a stacked, left-aligned editorial poster, vertically centered.
         Word-by-word reveal, then a hard cut-out. */
      .phrase {
        position: absolute;
        left: 0;
        right: 0;
        top: 50%;
        transform: translateY(-50%);
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        opacity: 0;
        will-change: opacity;
      }
      .line {
        font-weight: 900;
        letter-spacing: -0.045em;
        line-height: 0.88;
        white-space: nowrap;
      }
      .line + .line {
        margin-top: -0.04em;
      }
      /* the HERO line's elegant serif-italic contrast (offline system fallback) */
      .line.serif {
        font-family: "Playfair Display", Georgia, "Times New Roman", serif;
        font-style: italic;
        letter-spacing: -0.02em;
      }
      .w {
        display: inline-block;
        opacity: 0;
        will-change: opacity;
      }
      .w.grad {
        background: var(--grad);
        background-size: 200% 100%;
        background-position: 0% 50%;
        -webkit-background-clip: text;
        background-clip: text;
        -webkit-text-fill-color: transparent;
        color: transparent;
      }

      /* The CLIMAX phrase SLIDES in, holds to the end, and may carry an inline
         animated icon (the {icon} token). */
      .climax {
        position: absolute;
        left: 50%;
        top: 50%;
        display: flex;
        align-items: center;
        gap: 0.32em;
        white-space: nowrap;
        opacity: 0;
        font-weight: 900;
        letter-spacing: -0.035em;
        line-height: 1;
        will-change: transform, opacity;
      }
      .cword {
        flex-shrink: 0;
      }
      .icon {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        position: relative;
        flex-shrink: 0;
        opacity: 0;
        overflow: visible;
        line-height: 1;
      }
      .icon-glyph {
        position: relative;
        display: flex;
        align-items: center;
        justify-content: center;
        transform-origin: 50% 14%;
      }
      .icon-glyph svg {
        display: block;
        width: 100%;
        height: 100%;
      }
      .ring {
        position: absolute;
        left: 50%;
        top: 50%;
        border-radius: 50%;
        border: 3px solid var(--ring1);
        opacity: 0;
        transform: translate(-50%, -50%) scale(0.4);
      }
      .ring.ring2 {
        border-color: var(--ring2);
      }
    </style>
  </head>
  <body>
    <div
      data-hf-id="hf-ikc-root"
      id="root"
      data-composition-id="intro-kinetic-cascade"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="7"
      data-root="true"
    >
      <div data-hf-id="hf-ikc-stage" id="stage"></div>

      <script>
        (function () {
          // ════════════════════════════════════════════════════════════════════
          //  INTRO KINETIC CASCADE  ·  beat-synced editorial kinetic typography
          //  ────────────────────────────────────────────────────────────────────
          //  A spoken/anchored line laid out as a sequence of big editorial PHRASES,
          //  each revealed word-by-word on its anchors (VO word-times OR audio onsets),
          //  hard cut-out between them, climaxing on a phrase that SLIDES in with a
          //  swappable, ringing ICON. Asset-free: system type, inline-SVG icon library,
          //  bundled GSAP. Reversed from the VO intro `act0-intro-bell`.
          //
          //  COMPOSE BY DATA — `phrases` (the cascade) + `climax` (the finale) + `icon`
          //  (which glyph rings) + `theme` (palette). The icon is its own knob so it can
          //  match the scene: bell for timing, cursor for a click, sparkle for AI, …
          // ════════════════════════════════════════════════════════════════════

          window.__timelines = window.__timelines || {};

          // ── 1. Variables ──
          var defaults = { theme: "light", icon: "bell", phrases: "", climax: "" };
          var vars = Object.assign(
            {},
            defaults,
            window.__hyperframes && window.__hyperframes.getVariables
              ? window.__hyperframes.getVariables()
              : {},
          );

          // ── 2. Palette themes (accents are AA-tuned on each bg) ──
          var THEMES = {
            light: {
              bg: "#f5f5f7",
              ink: "#1d1d1f",
              accents: ["#0b8a3e", "#0a78b4", "gradient"],
              gradFrom: "#0a78b4",
              gradTo: "#0b8a3e",
            },
            dark: {
              bg: "#0d0e12",
              ink: "#f2f3f5",
              accents: ["#3be06b", "#37c6ff", "gradient"],
              gradFrom: "#37c6ff",
              gradTo: "#3be06b",
            },
            bold: {
              bg: "#0a1228",
              ink: "#ffffff",
              accents: ["#ffd23f", "#ff5da2", "gradient"],
              gradFrom: "#ff5da2",
              gradTo: "#ffd23f",
            },
          };
          var TH = THEMES[String(vars.theme || "").trim()] || THEMES.light;

          function hexRgba(hex, a) {
            var h = String(hex).replace(/^#/, "");
            if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
            var n = parseInt(h, 16);
            return (
              "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")"
            );
          }

          // ── 3. Inline-SVG icon library (asset-free; fill follows the text color) ──
          var ICONS = {
            bell: '<path d="M12 2.2a1.9 1.9 0 0 1 1.9 1.9v.7a6.2 6.2 0 0 1 4.3 5.9v3.5l1.9 2.1v1.1H3.9v-1.1l1.9-2.1V10.7a6.2 6.2 0 0 1 4.3-5.9v-.7A1.9 1.9 0 0 1 12 2.2zM9.6 19.6h4.8a2.4 2.4 0 0 1-4.8 0z"/>',
            cursor: '<path d="M5.5 2.6l13.4 7.9-5.9 1.2-1.1 5.9-6.4-15z"/>',
            sparkle: '<path d="M12 1.8l2.1 6.9 6.9 2.1-6.9 2.1L12 19.8l-2.1-6.9L3 10.8l6.9-2.1z"/>',
            bolt: '<path d="M13.2 1.8L4.4 13.6h5.6l-1.2 8.6 9.2-12.4h-5.8z"/>',
            play: '<path d="M12 2.4a9.6 9.6 0 1 0 0 19.2 9.6 9.6 0 0 0 0-19.2zm-2.1 5.1l6 4.5-6 4.5z"/>',
            heart:
              '<path d="M12 21.2S2.8 15.4 2.8 8.9C2.8 5.8 5.1 4 7.6 4c1.9 0 3.4 1.1 4.4 2.6C13 5.1 14.5 4 16.4 4c2.5 0 4.8 1.8 4.8 4.9 0 6.5-9.2 12.3-9.2 12.3z"/>',
            check: '<path d="M9.6 16.7L4.8 11.9l1.7-1.7 3.1 3.1 7.9-7.9 1.7 1.7z"/>',
            star: '<path d="M12 2.2l2.9 6.4 7 .6-5.3 4.6 1.6 6.8L12 17.4 5.8 20.6l1.6-6.8L2.1 9.2l7-.6z"/>',
          };
          function iconSVG(name) {
            return (
              '<svg viewBox="0 0 24 24" fill="currentColor" width="100%" height="100%">' +
              ICONS[name] +
              "</svg>"
            );
          }
          // Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
          // a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
          // everything that isn't inert drawing: only allow-listed shape elements +
          // presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
          // javascript: hrefs and every on* handler are removed before the nodes ever
          // enter the live document. A custom icon SVG therefore can't smuggle active
          // content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
          var SVG_OK_TAGS = {
            svg: 1,
            g: 1,
            path: 1,
            line: 1,
            polyline: 1,
            polygon: 1,
            rect: 1,
            circle: 1,
            ellipse: 1,
            defs: 1,
            lineargradient: 1,
            radialgradient: 1,
            stop: 1,
            clippath: 1,
            title: 1,
            desc: 1,
            text: 1,
            tspan: 1,
          };
          var SVG_OK_ATTRS = {
            viewbox: 1,
            xmlns: 1,
            width: 1,
            height: 1,
            fill: 1,
            "fill-rule": 1,
            "fill-opacity": 1,
            stroke: 1,
            "stroke-width": 1,
            "stroke-linecap": 1,
            "stroke-linejoin": 1,
            "stroke-dasharray": 1,
            "stroke-dashoffset": 1,
            "stroke-opacity": 1,
            opacity: 1,
            d: 1,
            x: 1,
            y: 1,
            x1: 1,
            y1: 1,
            x2: 1,
            y2: 1,
            cx: 1,
            cy: 1,
            r: 1,
            rx: 1,
            ry: 1,
            points: 1,
            transform: 1,
            offset: 1,
            "stop-color": 1,
            "stop-opacity": 1,
            gradientunits: 1,
            gradienttransform: 1,
            "clip-path": 1,
            "clip-rule": 1,
            class: 1,
            id: 1,
          };
          function cleanSvg(node) {
            var attrs = Array.prototype.slice.call(node.attributes || []);
            for (var a = 0; a < attrs.length; a++) {
              if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
            }
            var kids = Array.prototype.slice.call(node.childNodes);
            for (var k = 0; k < kids.length; k++) {
              var c = kids[k];
              if (c.nodeType === 1) {
                if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
                else node.removeChild(c);
              } else if (c.nodeType !== 3) {
                node.removeChild(c);
              }
            }
          }
          function setSvg(el, markup) {
            el.textContent = "";
            var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
            var root = doc.documentElement;
            if (
              !root ||
              root.nodeName.toLowerCase() !== "svg" ||
              doc.getElementsByTagName("parsererror").length
            ) {
              el.textContent = String(markup);
              return;
            }
            cleanSvg(root);
            el.appendChild(document.importNode(root, true));
          }

          // ── 4. Default program (reversed from act0-intro-bell) ──
          var DEFAULT_PHRASES = [
            {
              out: 1.36,
              lines: [
                { text: "If you've", size: 220, x: 140 },
                { text: "ever", size: 520, x: 240, hero: true, font: "serif" },
                { text: "used AI", size: 280, x: 780 },
              ],
              times: [0.14, 0.3, 0.55, 0.87, 1.06],
            },
            {
              out: 3.06,
              lines: [
                { text: "for", size: 220, x: 160 },
                { text: "video", size: 500, x: 220, hero: true },
                { text: "editing before,", size: 200, x: 220 },
              ],
              times: [1.36, 1.58, 1.92, 2.48],
            },
            {
              out: 3.79,
              lines: [
                { text: "you", size: 240, x: 200 },
                { text: "know", size: 500, x: 260, hero: true, accent: "gradient" },
                { text: "that", size: 340, x: 820 },
              ],
              times: [3.06, 3.3, 3.54],
            },
          ];
          var DEFAULT_CLIMAX = {
            text: "timing is {icon} tricky.",
            in: 3.79,
            iconAt: 4.9,
            hold: 1.3,
            size: 150,
          };

          var WORD_FADE = 0.18,
            CUT = 0.1,
            SLIDE = 0.42,
            OFFSCREEN_R = 1400;

          function parseJSON(v, fallback) {
            if (!v) return fallback;
            if (typeof v !== "string") return v;
            try {
              var p = JSON.parse(v);
              return p || fallback;
            } catch (e) {
              return fallback;
            }
          }
          var PHRASES = parseJSON(vars.phrases, DEFAULT_PHRASES);
          if (!PHRASES.length) PHRASES = DEFAULT_PHRASES;
          var C = Object.assign({}, DEFAULT_CLIMAX, parseJSON(vars.climax, {}));

          var words = function (s) {
            return String(s).trim().split(/\s+/).filter(Boolean);
          };

          // ── 5. Theme the field ──
          var root = document.getElementById("root");
          var stage = document.getElementById("stage");
          root.style.background = TH.bg;
          root.style.color = TH.ink;
          document.documentElement.style.background = TH.bg;
          document.body.style.background = TH.bg;
          var GRAD =
            "linear-gradient(90deg," +
            TH.gradFrom +
            " 0%," +
            TH.gradTo +
            " 50%," +
            TH.gradFrom +
            " 100%)";

          // ── 6. Build phrase DOM, collect word spans in reading order ──
          PHRASES.forEach(function (p, pi) {
            var pEl = document.createElement("div");
            pEl.className = "phrase";
            var accent = p.accent || TH.accents[pi % TH.accents.length];
            p._spans = [];
            p._grad = [];
            p.lines.forEach(function (ln) {
              var lEl = document.createElement("div");
              lEl.className = "line" + (ln.font === "serif" ? " serif" : "");
              lEl.style.fontSize = ln.size + "px";
              lEl.style.marginLeft = (ln.x != null ? ln.x : 0) + "px";
              var heroColor = ln.hero ? ln.accent || accent : null;
              words(ln.text).forEach(function (wtext) {
                var w = document.createElement("span");
                w.className = "w";
                w.textContent = wtext;
                if (heroColor === "gradient") {
                  w.classList.add("grad");
                  w.style.setProperty("--grad", GRAD);
                  p._grad.push(w);
                } else if (heroColor) w.style.color = heroColor;
                lEl.appendChild(w);
                lEl.appendChild(document.createTextNode(" "));
                p._spans.push(w);
              });
              pEl.appendChild(lEl);
            });
            stage.appendChild(pEl);
            p._el = pEl;
          });

          // ── 7. Build the climax (slide-in + swappable icon) ──
          var cEl = document.createElement("div");
          cEl.className = "climax";
          cEl.style.fontSize = C.size + "px";
          cEl.style.setProperty("--ring1", hexRgba(TH.ink, 0.25));
          cEl.style.setProperty("--ring2", hexRgba(TH.ink, 0.1));
          var iconPx = Math.round(C.size * 1.7);
          var iconEl = null,
            rings = [];
          var iconSpec = String(vars.icon == null ? "bell" : vars.icon).trim();

          words(C.text).forEach(function (tok) {
            if (tok === "{icon}") {
              if (!iconSpec || iconSpec === "none") return; // drop the slot → pure-text climax
              iconEl = document.createElement("span");
              iconEl.className = "icon";
              iconEl.style.width = iconPx + "px";
              iconEl.style.height = iconPx + "px";
              var r1 = document.createElement("span");
              r1.className = "ring";
              var r2 = document.createElement("span");
              r2.className = "ring ring2";
              [r1, r2].forEach(function (r) {
                var rs = Math.round(iconPx * 1.3);
                r.style.width = rs + "px";
                r.style.height = rs + "px";
                iconEl.appendChild(r);
              });
              rings = [r1, r2];
              var glyph = document.createElement("span");
              glyph.className = "icon-glyph";
              glyph.style.width = iconPx + "px";
              glyph.style.height = iconPx + "px";
              glyph.style.color = TH.ink;
              if (ICONS[iconSpec]) {
                setSvg(glyph, iconSVG(iconSpec)); // library icon
              } else if (iconSpec.slice(0, 4).toLowerCase() === "<svg") {
                setSvg(glyph, iconSpec); // custom inline SVG
              } else {
                glyph.style.fontSize = Math.round(iconPx * 0.86) + "px"; // emoji / text glyph
                glyph.textContent = iconSpec;
              }
              iconEl.appendChild(glyph);
              cEl.appendChild(iconEl);
            } else {
              var w = document.createElement("span");
              w.className = "cword";
              w.textContent = tok;
              cEl.appendChild(w);
            }
          });
          stage.appendChild(cEl);

          // ── 8. Timeline ──
          var tl = gsap.timeline({ paused: true });

          PHRASES.forEach(function (p) {
            gsap.set(p._el, { opacity: 0 });
            gsap.set(p._spans, { opacity: 0 });
          });
          gsap.set(cEl, { xPercent: -50, yPercent: -50, x: OFFSCREEN_R, opacity: 0 });
          if (iconEl) {
            gsap.set(iconEl, { opacity: 0 });
            gsap.set(iconEl.querySelector(".icon-glyph"), { scale: 0.4, rotation: -8, opacity: 0 });
            gsap.set(rings, { opacity: 0, scale: 0.4 });
          }

          PHRASES.forEach(function (p, pi) {
            var n = p._spans.length;
            var prevOut = pi === 0 ? 0.15 : PHRASES[pi - 1].out;
            var times = p.times && p.times.length === n ? p.times : null;
            if (!times) {
              var start = p.start != null ? p.start : prevOut;
              var end = p.out - Math.max(0.25, (p.out - start) * 0.18);
              times = p._spans.map(function (_, i) {
                return n <= 1 ? start : start + ((end - start) * i) / (n - 1);
              });
            }
            tl.to(p._el, { opacity: 1, duration: 0.01, overwrite: "auto" }, times[0]);
            p._spans.forEach(function (w, i) {
              tl.to(
                w,
                { opacity: 1, duration: WORD_FADE, ease: "power2.out", overwrite: "auto" },
                times[i],
              );
            });
            if (p._grad.length) {
              tl.fromTo(
                p._grad,
                { backgroundPosition: "0% 50%" },
                {
                  backgroundPosition: "-200% 50%",
                  duration: Math.max(0.6, p.out - times[0]),
                  ease: "none",
                },
                times[0],
              );
            }
            tl.to(
              p._el,
              { opacity: 0, duration: CUT, ease: "power2.in", overwrite: "auto" },
              p.out - CUT,
            );
          });

          // climax slide-in
          tl.to(
            cEl,
            { x: 0, opacity: 1, duration: SLIDE, ease: "power3.out", overwrite: "auto" },
            C.in,
          );

          // icon pop + ring
          if (iconEl) {
            var glyph = iconEl.querySelector(".icon-glyph");
            tl.to(
              iconEl,
              { opacity: 1, duration: 0.3, ease: "power3.out", overwrite: "auto" },
              C.iconAt,
            );
            tl.fromTo(
              glyph,
              { scale: 0.4, rotation: -8, opacity: 0 },
              {
                scale: 1,
                rotation: 0,
                opacity: 1,
                duration: 0.35,
                ease: "back.out(2.4)",
                overwrite: "auto",
              },
              C.iconAt + 0.05,
            );
            tl.to(
              glyph,
              { rotation: 13, duration: 0.1, ease: "power2.out", overwrite: "auto" },
              C.iconAt + 0.4,
            );
            tl.to(
              glyph,
              { rotation: 0, duration: 0.85, ease: "elastic.out(1, 0.25)", overwrite: "auto" },
              C.iconAt + 0.5,
            );
            [0, 0.28].forEach(function (off) {
              tl.fromTo(
                rings,
                { scale: 0.4, opacity: 0.55 },
                { scale: 1.5, opacity: 0, duration: 0.8, ease: "power2.out", stagger: 0.08 },
                C.iconAt + 0.42 + off,
              );
            });
          }

          var TOTAL = Math.round((C.iconAt + C.hold) * 100) / 100;
          tl.to({}, { duration: TOTAL }, 0);
          root.setAttribute("data-duration", String(TOTAL));
          tl.seek(0);

          window.__timelines["intro-kinetic-cascade"] = tl;
        })();
      </script>
    </div>
  </body>
</html>
references/templates/intro-kinetic-cascade/program.json
{
  "_": "Default intro-kinetic-cascade program (reversed from act0-intro-bell). `theme` = palette; `icon` = the ringing climax glyph (MATCH TO SCENE — bell|cursor|sparkle|bolt|play|heart|check|star, an emoji, a raw <svg>, or 'none'); `phrases` = the word-by-word cascade (each `times` = VO word onsets / audio onsets, omit to auto-spread); `climax` = the slide-in finale ({icon} marks the glyph). Pass these as the matching composition-variables; empty vars fall back to these values.",
  "theme": "light",
  "icon": "bell",
  "phrases": [
    {
      "out": 1.36,
      "lines": [
        { "text": "If you've", "size": 220, "x": 140 },
        { "text": "ever", "size": 520, "x": 240, "hero": true, "font": "serif" },
        { "text": "used AI", "size": 280, "x": 780 }
      ],
      "times": [0.14, 0.3, 0.55, 0.87, 1.06]
    },
    {
      "out": 3.06,
      "lines": [
        { "text": "for", "size": 220, "x": 160 },
        { "text": "video", "size": 500, "x": 220, "hero": true },
        { "text": "editing before,", "size": 200, "x": 220 }
      ],
      "times": [1.36, 1.58, 1.92, 2.48]
    },
    {
      "out": 3.79,
      "lines": [
        { "text": "you", "size": 240, "x": 200 },
        { "text": "know", "size": 500, "x": 260, "hero": true, "accent": "gradient" },
        { "text": "that", "size": 340, "x": 820 }
      ],
      "times": [3.06, 3.3, 3.54]
    }
  ],
  "climax": {
    "text": "timing is {icon} tricky.",
    "in": 3.79,
    "iconAt": 4.9,
    "hold": 1.3,
    "size": 150
  }
}
references/templates/logo-split-lockup-pulse/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor",   "type":"color",  "label":"Background",                       "default":"#249b5f"},
    {"id":"markColor", "type":"color",  "label":"Logo mark color",                  "default":"#e7e3dd"},
    {"id":"textColor", "type":"color",  "label":"Word color",                       "default":"#e7e3dd"},
    {"id":"leftMark",  "type":"string", "label":"Left mark SVG (empty = spark)",     "default":""},
    {"id":"rightMark", "type":"string", "label":"Right mark SVG (empty = cloud)",    "default":""},
    {"id":"word1",     "type":"string", "label":"Word 1 (opens the gap)",           "default":"lille"},
    {"id":"word2",     "type":"string", "label":"Word 2 (lands on the surge)",       "default":"2020"},
    {"id":"word3",     "type":"string", "label":"Word 3 (optional)",                 "default":""},
    {"id":"word4",     "type":"string", "label":"Word 4 (optional)",                 "default":""}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Logo Split / Lockup Pulse — group_7 reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <!-- Velocity-driven directional motion blur (registry: motion-blur). Samples each
         target's GSAP x/y per seek and applies a one-sided ghost trail + top blur
         proportional to speed; clears at rest. This is what makes the fast mark moves
         (split / snap) read SILKY instead of stiff. Self-contained (SVG filters). -->
    <script>
      (function () {
        if (!window._hfMbUid) window._hfMbUid = 0;
        window.attachMotionBlur = function (selector, tl, opts) {
          opts = opts || {};
          var blurScale = opts.blurScale !== undefined ? opts.blurScale : 0.008;
          var blurMax = opts.blurMax !== undefined ? opts.blurMax : 20;
          var stretchScale = opts.stretchScale !== undefined ? opts.stretchScale : 0.0002;
          var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;
          var axis = opts.axis || "both";
          var items = Array.isArray(selector) ? selector : [selector];
          var targets = items.reduce(function (acc, s) {
            if (typeof s === "string") {
              document.querySelectorAll(s).forEach(function (el) {
                acc.push(el);
              });
            } else if (s instanceof Element) {
              acc.push(s);
            }
            return acc;
          }, []);
          var ns = "http://www.w3.org/2000/svg";
          var GHOST_CFG = [
            { frac: 0.25, slope: 0.55 },
            { frac: 0.55, slope: 0.28 },
            { frac: 1.0, slope: 0.1 },
          ];
          var state = targets.map(function (el) {
            var uid = "hf-mb-" + window._hfMbUid++;
            var svg = document.createElementNS(ns, "svg");
            svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden;");
            var filter = document.createElementNS(ns, "filter");
            filter.id = uid;
            filter.setAttribute("x", "-110%");
            filter.setAttribute("y", "-25%");
            filter.setAttribute("width", "260%");
            filter.setAttribute("height", "150%");
            var ghosts = GHOST_CFG.map(function (cfg, gi) {
              var feOff = document.createElementNS(ns, "feOffset");
              feOff.setAttribute("in", "SourceGraphic");
              feOff.setAttribute("dx", "0");
              feOff.setAttribute("dy", "0");
              feOff.setAttribute("result", "go" + gi);
              var feGB = document.createElementNS(ns, "feGaussianBlur");
              feGB.setAttribute("in", "go" + gi);
              feGB.setAttribute("stdDeviation", "0 0");
              feGB.setAttribute("result", "gb" + gi);
              var feCT = document.createElementNS(ns, "feComponentTransfer");
              feCT.setAttribute("in", "gb" + gi);
              feCT.setAttribute("result", "gf" + gi);
              var feFuncA = document.createElementNS(ns, "feFuncA");
              feFuncA.setAttribute("type", "linear");
              feFuncA.setAttribute("slope", String(cfg.slope));
              feCT.appendChild(feFuncA);
              filter.appendChild(feOff);
              filter.appendChild(feGB);
              filter.appendChild(feCT);
              return { feOff: feOff, feGB: feGB, frac: cfg.frac };
            });
            var feTopBlur = document.createElementNS(ns, "feGaussianBlur");
            feTopBlur.setAttribute("in", "SourceGraphic");
            feTopBlur.setAttribute("stdDeviation", "0 0");
            feTopBlur.setAttribute("result", "top");
            filter.appendChild(feTopBlur);
            var feMerge = document.createElementNS(ns, "feMerge");
            for (var mi = GHOST_CFG.length - 1; mi >= 0; mi--) {
              var mn = document.createElementNS(ns, "feMergeNode");
              mn.setAttribute("in", "gf" + mi);
              feMerge.appendChild(mn);
            }
            var mnTop = document.createElementNS(ns, "feMergeNode");
            mnTop.setAttribute("in", "top");
            feMerge.appendChild(mnTop);
            filter.appendChild(feMerge);
            svg.appendChild(filter);
            document.body.appendChild(svg);
            return {
              el: el,
              ghosts: ghosts,
              feTopBlur: feTopBlur,
              filterId: uid,
              prevX: parseFloat(gsap.getProperty(el, "x")) || 0,
              prevY: parseFloat(gsap.getProperty(el, "y")) || 0,
              prevTime: tl.time(),
            };
          });
          var _proxy = { t: 0 };
          tl.to(
            _proxy,
            {
              t: 1,
              duration: Math.max(tl.duration(), 0.1),
              ease: "none",
              onUpdate: function () {
                var time = tl.time();
                state.forEach(function (s) {
                  var x = parseFloat(gsap.getProperty(s.el, "x")) || 0;
                  var y = parseFloat(gsap.getProperty(s.el, "y")) || 0;
                  var dt = time - s.prevTime;
                  if (dt > 0.0005) {
                    var vx = axis !== "y" ? (x - s.prevX) / dt : 0;
                    var vy = axis !== "x" ? (y - s.prevY) / dt : 0;
                    var bx = Math.min(Math.abs(vx) * blurScale, blurMax);
                    var by = Math.min(Math.abs(vy) * blurScale, blurMax);
                    var bxFinal = axis !== "y" ? bx : Math.max(by * 0.08, 0.4);
                    var byFinal = axis !== "x" ? by : Math.max(bx * 0.08, 0.4);
                    var active = bx > 0.3 || by > 0.3;
                    if (active) {
                      s.el.style.filter = "url(#" + s.filterId + ")";
                      s.ghosts.forEach(function (g) {
                        var dx =
                          axis !== "y" ? (vx >= 0 ? -bxFinal * g.frac : bxFinal * g.frac) : 0;
                        var dy =
                          axis !== "x" ? (vy >= 0 ? -byFinal * g.frac : byFinal * g.frac) : 0;
                        g.feOff.setAttribute("dx", dx.toFixed(2));
                        g.feOff.setAttribute("dy", dy.toFixed(2));
                        var gbx =
                          axis !== "y"
                            ? (bxFinal * g.frac * 0.5).toFixed(2)
                            : Math.max(byFinal * g.frac * 0.04, 0.4).toFixed(2);
                        var gby =
                          axis !== "x"
                            ? (byFinal * g.frac * 0.5).toFixed(2)
                            : Math.max(bxFinal * g.frac * 0.04, 0.4).toFixed(2);
                        g.feGB.setAttribute("stdDeviation", gbx + " " + gby);
                      });
                      s.feTopBlur.setAttribute(
                        "stdDeviation",
                        (bxFinal * 0.15).toFixed(2) + " " + (byFinal * 0.15).toFixed(2),
                      );
                    } else {
                      s.el.style.filter = "";
                      s.ghosts.forEach(function (g) {
                        g.feOff.setAttribute("dx", "0");
                        g.feOff.setAttribute("dy", "0");
                        g.feGB.setAttribute("stdDeviation", "0 0");
                      });
                      s.feTopBlur.setAttribute("stdDeviation", "0 0");
                    }
                    s.prevX = x;
                    s.prevY = y;
                    s.prevTime = time;
                  } else if (dt < -0.0005) {
                    s.el.style.filter = "";
                    s.ghosts.forEach(function (g) {
                      g.feOff.setAttribute("dx", "0");
                      g.feOff.setAttribute("dy", "0");
                      g.feGB.setAttribute("stdDeviation", "0 0");
                    });
                    s.feTopBlur.setAttribute("stdDeviation", "0 0");
                    s.prevX = x;
                    s.prevY = y;
                    s.prevTime = time;
                  }
                });
              },
            },
            0,
          );
        };
      })();
    </script>
    <style>
      @font-face {
        font-family: "Lockup";
        src: url("./assets/fredoka-700.woff2") format("woff2");
        font-weight: 700;
        font-style: normal;
        font-display: block;
      }
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        background: var(--bg, #249b5f);
        overflow: hidden;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--bg, #249b5f);
        display: flex;
        align-items: center;
        justify-content: center;
      }
      /* a 0-size anchor at screen center; marks + words are positioned absolutely
         around it, and the whole group scales/translates for the pulse. */
      #lockup {
        position: relative;
        width: 0;
        height: 0;
      }
      .words {
        position: absolute;
        left: 0;
        top: 0;
        transform: translate(-50%, -50%);
        display: flex;
        align-items: center;
        gap: 34px;
        white-space: nowrap;
        color: var(--text, #e7e3dd);
        font-family: "Lockup", system-ui, sans-serif;
        font-size: 200px;
        font-weight: 700;
        letter-spacing: -0.045em;
        line-height: 0.8;
      }
      .word {
        display: none; /* revealed one per onset on the timeline */
      }
      .mark {
        position: absolute;
        top: 0;
        left: 0;
        width: 300px;
        height: 340px;
        color: var(--mark, #e7e3dd);
      }
      .mark svg {
        width: 100%;
        height: 100%;
        display: block;
        overflow: visible;
      }
      /* edge/vertical centering is set in JS via GSAP xPercent/yPercent so it
         composes with the animated x (a CSS % transform would be clobbered). */
    </style>
  </head>
  <body>
    <!-- duration = 2.875s = the green logo lifecycle (seed -> split -> lockup ->
         snap -> pulse); the source then HARD-CUTS to the next group at f70 (2.917s). -->
    <div
      data-hf-id="hf-g7rt"
      id="root"
      data-composition-id="group-seven-logo"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="2.875"
      data-root="true"
    >
      <div data-hf-id="hf-g7lk" id="lockup">
        <div data-hf-id="hf-g7ml" class="mark left" id="markLeft"></div>
        <div data-hf-id="hf-g7wd" class="words" id="words"></div>
        <div data-hf-id="hf-g7mr" class="mark right" id="markRight"></div>
      </div>
    </div>

    <script>
      (function () {
        // ════════════════════════════════════════════════════════════════
        //  LOGO SPLIT / LOCKUP PULSE  ·  structural_type: per_onset_sequential
        //  A two-part mark sits joined at center, SPLITS left<->right to open a
        //  gap, GROWS a center word-lockup one word per onset (key word on the
        //  surge), SNAP-CLOSES on a hit, then PULSES with the roll/hihat bed.
        //  Reversed from group_7 (Lille — World Design Capital 2020 sting).
        // ════════════════════════════════════════════════════════════════

        // ── 1. Content variables (what a user changes) ─────────────────────
        var defaults = {
          bgColor: "#249b5f",
          markColor: "#e7e3dd",
          textColor: "#e7e3dd",
          leftMark: "",
          rightMark: "",
          word1: "lille",
          word2: "2020",
          word3: "",
          word4: "",
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );

        // ── 2. DEFAULT LOGO HALVES (line-art; swap via leftMark / rightMark) ─
        // Either two related SVGs (a left + a right graphic) OR the two halves of
        // one logo. currentColor inherits --mark. Thick rounded line-art so they
        // read as a bold pair bracketing the words (matched to the Lille mark:
        // a left radial SPARK with a hollow hub, a right CLOUD/SWIRL).
        var DEFAULT_LEFT = [
          // radial SPARK: 5 rounded strokes, a tight fan up-and-left from a hollow
          // hub near the text (NOT converging to a needle — reads as a spark/shine).
          // hub at the INNER (right) edge so the spark hugs the text; 5 long strokes
          // fanning ~140 up-and-left — a big radial spark that helps wrap the lockup.
          '<svg viewBox="0 0 150 150" fill="none" stroke="currentColor"',
          ' stroke-width="16" stroke-linecap="round">',
          '<line x1="134" y1="66" x2="112" y2="6"/>',
          '<line x1="130" y1="72" x2="66"  y2="16"/>',
          '<line x1="126" y1="80" x2="24"  y2="42"/>',
          '<line x1="124" y1="88" x2="10"  y2="84"/>',
          '<line x1="126" y1="96" x2="36"  y2="136"/>',
          "</svg>",
        ].join("");
        var DEFAULT_RIGHT = [
          // CLOUD / backwards-3 SWIRL: two bold stacked lobes bulging right with a
          // left-pointing middle notch, opening to the left (the Lille cloud).
          // bold backwards-3 swirl: opening tips at the INNER (left) edge so it hugs the
          // text, bulging right (mass OUTER). Fills the box height to help wrap the lockup.
          '<svg viewBox="0 0 150 150" fill="none" stroke="currentColor"',
          ' stroke-width="16" stroke-linecap="round" stroke-linejoin="round">',
          '<path d="M14 40',
          " C50 6 128 14 122 58",
          " C120 86 80 92 54 78",
          " C86 86 128 96 122 128",
          ' C116 154 40 158 14 120"/>',
          "</svg>",
        ].join("");

        // ── 3. BEAT MAP (filled from <group>.audiomap.json — see README) ────
        // group_7.audiomap.json: BPM 92.3. ABSOLUTE audio seconds.
        // The marks move on only these DISCRETE anchors — NOT on the sustained hi-hat
        // fill (f17-f86), which is rhythmic texture, not a motion driver. Left + right
        // always move together (mirrored). After the snap the weld HOLDS dead still.
        var ENTRY = 0.083; // f2  — joined bracket pair appears at center (perc onset)
        var SPLIT = 0.25; // f6  — brackets fly apart to full width (perc onset)
        var WORD_ONSETS = [0.25, 1.0]; // one per word; 1.0 = SURGE@1s (f24) carries the key word
        var CLOSE = 2.083; // f50 — snap shut: words vanish + brackets weld back together
        var DUR = 2.875; // f69 — hold, then the source hard-cuts to the next group (f70)

        var MARK_PAD = 14; // gap between a mark's inner edge and the nearest word
        var WORD_GAP = 34; // must match .words { gap }

        // ── 4. Build content ───────────────────────────────────────────────
        document.documentElement.style.setProperty("--bg", vars.bgColor);
        document.documentElement.style.setProperty("--mark", vars.markColor);
        document.documentElement.style.setProperty("--text", vars.textColor);

        // Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
        // a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
        // everything that isn't inert drawing: only allow-listed shape elements +
        // presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
        // javascript: hrefs and every on* handler are removed before the nodes ever
        // enter the live document. A custom mark SVG therefore can't smuggle active
        // content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
        var SVG_OK_TAGS = {
          svg: 1,
          g: 1,
          path: 1,
          line: 1,
          polyline: 1,
          polygon: 1,
          rect: 1,
          circle: 1,
          ellipse: 1,
          defs: 1,
          lineargradient: 1,
          radialgradient: 1,
          stop: 1,
          clippath: 1,
          title: 1,
          desc: 1,
          text: 1,
          tspan: 1,
        };
        var SVG_OK_ATTRS = {
          viewbox: 1,
          xmlns: 1,
          width: 1,
          height: 1,
          fill: 1,
          "fill-rule": 1,
          "fill-opacity": 1,
          stroke: 1,
          "stroke-width": 1,
          "stroke-linecap": 1,
          "stroke-linejoin": 1,
          "stroke-dasharray": 1,
          "stroke-dashoffset": 1,
          "stroke-opacity": 1,
          opacity: 1,
          d: 1,
          x: 1,
          y: 1,
          x1: 1,
          y1: 1,
          x2: 1,
          y2: 1,
          cx: 1,
          cy: 1,
          r: 1,
          rx: 1,
          ry: 1,
          points: 1,
          transform: 1,
          offset: 1,
          "stop-color": 1,
          "stop-opacity": 1,
          gradientunits: 1,
          gradienttransform: 1,
          "clip-path": 1,
          "clip-rule": 1,
          class: 1,
          id: 1,
        };
        function cleanSvg(node) {
          var attrs = Array.prototype.slice.call(node.attributes || []);
          for (var a = 0; a < attrs.length; a++) {
            if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
          }
          var kids = Array.prototype.slice.call(node.childNodes);
          for (var k = 0; k < kids.length; k++) {
            var c = kids[k];
            if (c.nodeType === 1) {
              if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
              else node.removeChild(c);
            } else if (c.nodeType !== 3) {
              node.removeChild(c);
            }
          }
        }
        function setSvg(el, markup) {
          el.textContent = "";
          var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
          var root = doc.documentElement;
          if (
            !root ||
            root.nodeName.toLowerCase() !== "svg" ||
            doc.getElementsByTagName("parsererror").length
          ) {
            el.textContent = String(markup);
            return;
          }
          cleanSvg(root);
          el.appendChild(document.importNode(root, true));
        }

        var markLeft = document.getElementById("markLeft");
        var markRight = document.getElementById("markRight");
        setSvg(markLeft, (vars.leftMark && vars.leftMark.trim()) || DEFAULT_LEFT);
        setSvg(markRight, (vars.rightMark && vars.rightMark.trim()) || DEFAULT_RIGHT);

        var wordsEl = document.getElementById("words");
        var wordList = [vars.word1, vars.word2, vars.word3, vars.word4].filter(function (w) {
          return w && String(w).trim() !== "";
        });
        var wordEls = wordList.map(function (w) {
          var s = document.createElement("span");
          s.className = "word";
          s.textContent = w;
          wordsEl.appendChild(s);
          return s;
        });

        // ── 5. Build the timeline once the lockup font is ready ─────────────
        // The word widths drive how far the marks open, so they MUST be measured
        // in the real (rounded) face, not a fallback. Gate the whole build on
        // document.fonts so the measurement is correct; the render engine also
        // waits for fonts before capturing.
        function build() {
          // measure each word in the loaded face, then hide them all so a hidden
          // word occupies NO layout width (the lockup truly grows as words enter).
          var widths = wordEls.map(function (el) {
            el.style.display = "inline-block";
            var w = el.offsetWidth;
            el.style.display = "none";
            return w;
          });
          function markOffsetFor(k) {
            if (k <= 0) return 0;
            var blockW = 0;
            for (var i = 0; i < k; i++) blockW += widths[i];
            blockW += WORD_GAP * (k - 1);
            return blockW / 2 + MARK_PAD;
          }

          var OFF_FULL = markOffsetFor(wordEls.length); // open width (brackets the full lockup)
          var OVL = 14; // joined position: welded brackets sit close (small center gap)

          window.__timelines = window.__timelines || {};
          var tl = gsap.timeline({ paused: true });

          // start state: the two brackets JOINED at center (full-outside / empty-middle),
          // invisible; words hidden. xPercent/yPercent center each mark on the anchor and
          // COMPOSE with the animated x (a CSS % transform would be clobbered by GSAP x).
          gsap.set(markLeft, { autoAlpha: 0, xPercent: -100, yPercent: -50, x: OVL, scale: 0.72 });
          gsap.set(markRight, { autoAlpha: 0, xPercent: 0, yPercent: -50, x: -OVL, scale: 0.72 });
          gsap.set("#lockup", { scale: 1, transformOrigin: "50% 50%" });

          // ── MOTION = the few DISCRETE anchors only (the hi-hat bed is texture, not
          //    a motion driver). Left + right always move TOGETHER (mirrored). ──

          // f2  ENTRY — the joined bracket pair BLOOMS in at center (smooth scale-in,
          //     not a 1-frame pop — the soft grow is what makes the start feel silky).
          tl.to(
            [markLeft, markRight],
            { autoAlpha: 1, scale: 1, duration: 0.16, ease: "power2.out" },
            ENTRY,
          );

          // f6  SPLIT — both brackets GLIDE apart together to the FULL lockup width in ONE
          //     move: a fast launch + long settle (expo.out), and the velocity motion blur
          //     (attached below) smooths the travel so it reads silky, not stiff. Then HOLD
          //     — words fill the reserved center space; the marks don't track each word.
          tl.to(markLeft, { x: -OFF_FULL, duration: 0.3, ease: "expo.out" }, SPLIT);
          tl.to(markRight, { x: OFF_FULL, duration: 0.3, ease: "expo.out" }, SPLIT);

          // word reveals — each word eases in with a soft stretch on its onset (the key
          // word on the SURGE). Smooth deceleration (power3.out), NO poppy overshoot.
          wordEls.forEach(function (el, i) {
            var t =
              i < WORD_ONSETS.length
                ? WORD_ONSETS[i]
                : WORD_ONSETS[WORD_ONSETS.length - 1] + 0.3 * (i - WORD_ONSETS.length + 1);
            tl.set(el, { display: "inline-block" }, t);
            tl.fromTo(
              el,
              { autoAlpha: 0, scaleX: 0.58, scaleY: 1.09, transformOrigin: "50% 50%" },
              { autoAlpha: 1, scaleX: 1, scaleY: 1, duration: 0.24, ease: "power3.out" },
              t,
            );
          });

          // f50 SNAP-CLOSE — 1 frame: words vanish + both brackets teleport back together
          //     (the teleport reads as an impact streak through the motion blur).
          tl.set(wordEls, { autoAlpha: 0, display: "none" }, CLOSE);
          tl.set(markLeft, { x: OVL }, CLOSE);
          tl.set(markRight, { x: -OVL }, CLOSE);
          tl.fromTo(
            "#lockup",
            { scale: 0.9 },
            { scale: 1, duration: 0.22, ease: "power2.out" },
            CLOSE,
          );

          // SEE-SAW BOUNCE — the welded unit does a damped vertical spring + a building
          // rigid tilt (rotation: +deg = left-spark UP / right-cloud DOWN), beat-synced to
          // the hi-hat hits after the snap: f51 slam-down (lowest), f55 up-rebound (highest),
          // f58 smaller dip, f63 final flick — amplitude decays, ending tilted to the f70 cut.
          var B1 = 2.125,
            B2 = 2.292,
            B3 = 2.417,
            B4 = 2.625; // f51 f55 f58 f63
          tl.set("#lockup", { y: 0, rotation: 0 }, CLOSE);
          tl.to("#lockup", { y: 34, rotation: -3, duration: B1 - CLOSE, ease: "power2.in" }, CLOSE);
          tl.to("#lockup", { y: -64, rotation: 10, duration: B2 - B1, ease: "power3.out" }, B1);
          tl.to("#lockup", { y: -38, rotation: 6, duration: B3 - B2, ease: "power2.inOut" }, B2);
          tl.to("#lockup", { y: -60, rotation: 10.5, duration: B4 - B3, ease: "power2.inOut" }, B3);
          tl.to("#lockup", { y: -56, rotation: 10.5, duration: DUR - B4, ease: "sine.out" }, B4);

          // pin duration, then attach velocity motion blur: the brackets (x: split + snap)
          // and the welded group (y: the ending bounce). MUST be after all tweens.
          tl.set(document.body, {}, DUR);
          attachMotionBlur([markLeft, markRight], tl, { axis: "x", blurMax: 26, blurScale: 0.011 });
          attachMotionBlur("#lockup", tl, { axis: "y", blurMax: 20, blurScale: 0.012 });

          tl.seek(0);
          window.__timelines["group-seven-logo"] = tl;
        }

        if (document.fonts && document.fonts.ready) {
          document.fonts.load('700 200px "Lockup"').then(function () {
            document.fonts.ready.then(build);
          });
        } else {
          build();
        }
      })();
    </script>
  </body>
</html>
references/templates/poster-tile-mosaic/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor", "type":"color",   "label":"Stage background",          "default":"#e9e9e9"},
    {"id":"tiles",   "type":"number",  "label":"Tile count (mosaic)",      "default":12},
    {"id":"bands",   "type":"number",  "label":"Snake-fill row bands",     "default":3},
    {"id":"gap",     "type":"number",  "label":"Seam / gap (px)",          "default":10},
    {"id":"showText","type":"boolean", "label":"Show tile labels",         "default":true},
    {"id":"labels",  "type":"string",  "label":"Tile labels (comma list)", "default":"DARE TO BE BOLD,NEW DROP,WALK THE TALK,STAND OUT,PUCKER UP,SUPER SONIC,IN TECHNICOLOUR,BOLD PRINTS,NEW SCENTS,TIME TO MOVE,CRAZY FUNKY COOL,BEAUTY INSIDE"},
    {"id":"program", "type":"string",  "label":"Program JSON (empty = built-in group_5 score)", "default":""}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Poster-Tile Wall · 3 beat-synced operations — group_5 reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        background: var(--bg, #e9e9e9);
        overflow: hidden;
        font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--bg, #e9e9e9);
      }
      /* the wall = the BASE TEMPLATE. all three operations act on this one grid. */
      #wall {
        position: absolute;
        inset: 0;
      }
      .gf-tile {
        position: absolute;
        display: flex;
        align-items: flex-end;
        overflow: hidden;
        will-change: transform, opacity;
        backface-visibility: hidden;
      }
      .gf-label {
        font-weight: 800;
        line-height: 0.95;
        letter-spacing: -0.01em;
        padding: 0.5em 0.55em;
        text-transform: uppercase;
        word-break: break-word;
      }
      /* overlay "cutouts" (phase C) — drawn, asset-free silhouette + label */
      .gf-ov {
        position: absolute;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: flex-end;
        border-radius: 6px;
        will-change: transform, opacity;
        box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
      }
      .gf-ov .sil {
        position: relative;
        width: 60%;
        flex: 1 1 auto;
        margin-top: 14%;
        display: flex;
        align-items: flex-end;
        justify-content: center;
      }
      .gf-ov .sil::before {
        /* head */
        content: "";
        position: absolute;
        top: 0;
        width: 34%;
        padding-top: 34%;
        border-radius: 50%;
        background: currentColor;
      }
      .gf-ov .sil::after {
        /* shoulders / body */
        content: "";
        width: 100%;
        height: 56%;
        border-radius: 48% 48% 14% 14% / 60% 60% 14% 14%;
        background: currentColor;
      }
      .gf-ov .cap {
        align-self: stretch;
        text-align: center;
        font-weight: 800;
        font-size: 26px;
        letter-spacing: 0.02em;
        padding: 10px 6px;
        text-transform: uppercase;
      }
    </style>
  </head>
  <body>
    <!-- duration 5.75s = the full group_5 span (3 operations back to back, ends frozen) -->
    <div
      data-hf-id="hf-g5rt"
      id="root"
      data-composition-id="group-five-recreate"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="5.75"
      data-root="true"
    >
      <div data-hf-id="hf-g5wall" id="wall"></div>
    </div>

    <script>
      (function () {
        // ════════════════════════════════════════════════════════════════════
        //  POSTER-TILE MOSAIC  ·  ONE base template, THREE beat-synced operations
        //  ────────────────────────────────────────────────────────────────────
        //  BASE   : a packed MOSAIC of DIFFERENT-SIZED colored tiles (asset-free:
        //           color + label). The tiles are NOT a uniform grid — they vary in
        //           size (tall / wide / small) and tessellate to fill the frame with
        //           no overlap (thin seams only), exactly like the source's 错落 wall.
        //           "adding a box" = a new image; "changing a box color" = a new
        //           asset/recolor. Everything below just schedules these tiles on
        //           the beat. Reverse-engineered from group_5 of the golden sample.
        //
        //  OP A  staggerInOut : tiles enter staggered (top-right→bottom-left waterfall)
        //                       → hold on a downbeat → exit staggered. each in/out
        //                       lands on one onset. mixed sizes packing to full frame.
        //  OP B  holdRecolor  : all tiles snap on at once, positions LOCKED, the
        //                       whole palette recolors as a unit on each beat.
        //  OP C  snakeFill+overlay : tiles reveal in a snake path (band by band,
        //                       L→R then R→L) → a full-grid recolor on the hard-stop
        //                       → extra "cutout" tiles pop on top, one per beat.
        //
        //  ARCHITECTURE: the operators (§6) are pure, parameterized, order-independent
        //  functions. A data PROGRAM (§3) lists which operators run, in what order,
        //  on which beats — and §7 just dispatches it. So you compose by editing DATA
        //  (the PROGRAM array, or the `program` render variable / program.json), never
        //  the operator code. Combine freely, repeat, or run one solo.
        // ════════════════════════════════════════════════════════════════════

        // ── 1. Content variables (what a user changes; everything else is timing)
        var defaults = {
          bgColor: "#e9e9e9",
          tiles: 12,
          bands: 3,
          gap: 10,
          showText: true,
          labels:
            "DARE TO BE BOLD,NEW DROP,WALK THE TALK,STAND OUT,PUCKER UP," +
            "SUPER SONIC,IN TECHNICOLOUR,BOLD PRINTS,NEW SCENTS,TIME TO MOVE," +
            "CRAZY FUNKY COOL,BEAUTY INSIDE",
          program: "", // empty = built-in group_5 score (DEFAULT_PROGRAM, §3)
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );
        var N = Math.max(2, parseInt(vars.tiles, 10) || 12);
        var BANDS = Math.max(1, parseInt(vars.bands, 10) || 3);
        var GAP = Math.max(0, parseFloat(vars.gap) || 0);
        var SHOW_TEXT = vars.showText !== false && vars.showText !== "false";
        var LABELS = String(vars.labels)
          .split(",")
          .map(function (s) {
            return s.trim();
          });

        // ── 2. PALETTE LIBRARY — each THEME is a "look" the whole wall can wear.
        //  A recolor (OP B) / a swap (OP C) just points the wall at another theme.
        //  Tile i takes theme[i % theme.length], so one theme dresses the grid in a
        //  coordinated multi-color scheme. Add themes freely — more beats, more looks.
        var THEMES = [
          ["#c8f53a", "#101010", "#ffffff", "#1d1d1d"], // 0 lime / ink (default + OP A)
          ["#b6f23c", "#0c2a14", "#ffffff", "#0c2a14"], // 1 green
          ["#2f5cff", "#dce8ff", "#0a1748", "#9fc0ff"], // 2 blue
          ["#ff5ab3", "#23d6a3", "#1a0a14", "#ffd1ec"], // 3 pink / mint
          ["#ff3b30", "#ffd60a", "#0a84ff", "#30d158"], // 4 saturated DROP look
        ];
        var FONTS = [
          // typographic variety per tile (the "字体" inside each box)
          '"Helvetica Neue", Arial, sans-serif',
          'Georgia, "Times New Roman", serif',
          '"Arial Narrow", "Helvetica Neue", sans-serif',
          '"Courier New", Courier, monospace',
        ];
        function contrast(hex) {
          // readable text color for a given fill
          var h = hex.replace("#", "");
          var r = parseInt(h.substr(0, 2), 16),
            g = parseInt(h.substr(2, 2), 16),
            b = parseInt(h.substr(4, 2), 16);
          return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? "#0b0b0b" : "#ffffff";
        }
        function themeColor(t, slot) {
          var pal = THEMES[((t % THEMES.length) + THEMES.length) % THEMES.length];
          return pal[slot % pal.length];
        }

        // ── 3. THE PROGRAM (data) — the composable surface ──────────────────────
        //  An ordered list of operator steps {op, ...beats/opts}. THIS is what you
        //  recompose: reorder, drop, repeat, or swap a step's beats — no operator code
        //  changes. An agent can override the whole list at render time via the
        //  `program` variable (a JSON string), so it composes by emitting DATA, not by
        //  editing JS. Beats are absolute audio seconds from <group>.audiomap.json.
        //  Mirrors program.json. group_5: 129.2 BPM · downbeats 1.324/3.181/5.039 ·
        //  hard_stop 4.0s. (See program.json for each op's fields.)
        var DEFAULT_PROGRAM = [
          {
            op: "staggerInOut", // build → hold (downbeat) → exit
            theme: 0,
            enter: [0.23, 0.37, 0.46, 0.6, 0.7, 0.84, 0.91, 1.07, 1.14, 1.28],
            hold: 1.324,
            exit: [1.37, 1.51, 1.62, 1.74, 1.83, 1.97, 2.07],
          },
          {
            op: "holdRecolor", // snap on, lock, recolor per snare
            on: 2.35,
            onTheme: 1,
            recolor: [2.42, 2.53, 2.81, 2.93],
            themes: [2, 3, 1, 2],
            off: 2.98,
          },
          {
            op: "snakeFillOverlay", // snake fill → swap → overlay pops
            baseTheme: 0,
            fill: [3.04, 3.181, 3.44, 3.65, 3.79, 3.9], // 3.181 = downbeat f76 (accel)
            swap: 4.0,
            swapTheme: 4, // hard_stop "一键换装"
            overlay: [4.11, 4.34, 4.48, 4.81, 4.97, 5.25, 5.48],
          }, // 7 onsets, 1 cutout each
        ];
        var DUR = 5.75; // = root data-duration; re-timing a new track edits both + the beats

        // Resolve the active program: a non-empty `program` variable (JSON) overrides.
        var PROGRAM = DEFAULT_PROGRAM;
        if (vars.program && String(vars.program).trim()) {
          try {
            var parsed = typeof vars.program === "string" ? JSON.parse(vars.program) : vars.program;
            if (parsed && parsed.length) PROGRAM = parsed;
          } catch (e) {
            PROGRAM = DEFAULT_PROGRAM;
          } // malformed → fall back, render stays valid
        }
        // Pre-build enough overlay "cutout" elements for the hungriest step.
        var maxOverlays = 0;
        PROGRAM.forEach(function (s) {
          if (s.overlay && s.overlay.length > maxOverlays) maxOverlays = s.overlay.length;
        });

        // ── 4. Build the MOSAIC (the base template — DIFFERENT-sized packed tiles) ─
        var root = document.getElementById("root");
        document.documentElement.style.setProperty("--bg", vars.bgColor);
        var wall = document.getElementById("wall");

        var MARGIN = 56;
        var innerW = 1920 - MARGIN * 2,
          innerH = 1080 - MARGIN * 2;

        // Pack the frame into N DIFFERENT-sized rects that tessellate with NO overlap:
        // recursively split the largest region along its longer side. Deterministic —
        // the split ratio is a fixed function of the step index, so it renders the same
        // every time (no Math.random). This is the "错落 but gap-filling" look of OP A.
        function packMosaic(n, W, H) {
          var rects = [{ x: 0, y: 0, w: W, h: H }];
          while (rects.length < n) {
            var bi = 0;
            for (var i = 1; i < rects.length; i++) {
              if (rects[i].w * rects[i].h > rects[bi].w * rects[bi].h) bi = i;
            }
            var r = rects.splice(bi, 1)[0];
            var ratio = 0.4 + ((rects.length * 37) % 21) / 100; // 0.40..0.60, deterministic
            if (r.w >= r.h) {
              var w1 = Math.round(r.w * ratio);
              rects.push({ x: r.x, y: r.y, w: w1, h: r.h });
              rects.push({ x: r.x + w1, y: r.y, w: r.w - w1, h: r.h });
            } else {
              var h1 = Math.round(r.h * ratio);
              rects.push({ x: r.x, y: r.y, w: r.w, h: h1 });
              rects.push({ x: r.x, y: r.y + h1, w: r.w, h: r.h - h1 });
            }
          }
          return rects;
        }
        var rects = packMosaic(N, innerW, innerH);

        var tiles = [];
        for (var idx = 0; idx < N; idx++) {
          var R = rects[idx];
          var el = document.createElement("div");
          el.className = "gf-tile";
          el.style.left = MARGIN + R.x + GAP / 2 + "px";
          el.style.top = MARGIN + R.y + GAP / 2 + "px";
          el.style.width = Math.max(1, R.w - GAP) + "px";
          el.style.height = Math.max(1, R.h - GAP) + "px";
          el.style.backgroundColor = themeColor(0, idx);

          var lab = document.createElement("span");
          lab.className = "gf-label";
          lab.textContent = SHOW_TEXT ? LABELS[idx % LABELS.length] || "" : "";
          lab.style.color = contrast(themeColor(0, idx));
          lab.style.fontFamily = FONTS[idx % FONTS.length];
          lab.style.fontSize = Math.round(Math.min(R.w, R.h) * 0.16) + "px";
          el.appendChild(lab);

          wall.appendChild(el);
          tiles.push({
            el: el,
            label: lab,
            idx: idx,
            cx: R.x + R.w / 2,
            cy: R.y + R.h / 2,
            rect: R,
          });
        }

        // enter/exit order (OP A): top-right → bottom-left waterfall (matches source)
        var enterOrder = tiles
          .map(function (t) {
            return t.idx;
          })
          .sort(function (a, b) {
            return tiles[b].cx - tiles[b].cy - (tiles[a].cx - tiles[a].cy);
          });
        // snake order (OP C): band by band top→bottom, alternating L→R / R→L
        var bandH = innerH / BANDS;
        var snakeOrder = tiles
          .map(function (t) {
            return t.idx;
          })
          .sort(function (a, b) {
            var ba = Math.min(BANDS - 1, Math.floor(tiles[a].cy / bandH));
            var bb = Math.min(BANDS - 1, Math.floor(tiles[b].cy / bandH));
            if (ba !== bb) return ba - bb;
            return (ba % 2 === 0 ? 1 : -1) * (tiles[a].cx - tiles[b].cx);
          });

        // overlay "cutouts" (OP C) — drawn silhouettes at scattered spots
        var OV_POS = [
          // % of the inner stage
          { l: 60, t: 50 },
          { l: 14, t: 56 },
          { l: 78, t: 12 },
          { l: 38, t: 26 },
          { l: 50, t: 58 },
          { l: 70, t: 66 },
          { l: 26, t: 40 },
        ];
        var overlays = [];
        for (var k = 0; k < maxOverlays; k++) {
          var pos = OV_POS[k % OV_POS.length];
          var ov = document.createElement("div");
          ov.className = "gf-ov";
          var ow = innerW * 0.2,
            oh = innerH * 0.62;
          ov.style.width = ow + "px";
          ov.style.height = oh + "px";
          ov.style.left = MARGIN + innerW * (pos.l / 100) - ow / 2 + "px";
          ov.style.top = MARGIN + innerH * (pos.t / 100) - oh / 2 + "px";
          var fill = themeColor(4, k);
          ov.style.backgroundColor = fill;
          var sil = document.createElement("div");
          sil.className = "sil";
          sil.style.color = contrast(fill);
          var cap = document.createElement("div");
          cap.className = "cap";
          cap.textContent = SHOW_TEXT ? "MODEL " + (k + 1) : "";
          cap.style.color = contrast(fill);
          ov.appendChild(sil);
          ov.appendChild(cap);
          wall.appendChild(ov);
          overlays.push(ov);
        }

        // ── 5. Timeline + small helpers ─────────────────────────────────────────
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });

        // overwrite:"auto" lets a later move on a tile cleanly supersede an earlier
        // one (and keeps the static linter from cross-flagging dynamically-targeted tweens).
        // immediateRender:false — a from/fromTo MUST NOT bake its from-state at creation,
        // or an operator scheduled LATER (e.g. staggerInOut's jitter) would park the tiles
        // off-grid during an EARLIER operator (e.g. holdRecolor). Keeps operators order-independent.
        var POP = {
          duration: 0.12,
          ease: "back.out(1.7)",
          overwrite: "auto",
          immediateRender: false,
        };
        function setTheme(t, idx, themeIdx) {
          // 0ms recolor on the beat (a hit, not a fade)
          var col = themeColor(themeIdx, idx);
          tl.set(tiles[idx].el, { backgroundColor: col }, t);
          tl.set(tiles[idx].label, { color: contrast(col) }, t);
        }
        function jitter(i) {
          // deterministic scatter offset (no Math.random — must render identically)
          return { x: (((i * 37) % 7) - 3) * 16, y: (((i * 53) % 7) - 3) * 16 };
        }
        function beatAt(i, n, beats) {
          // distribute n items across a beat list, monotonic
          if (!beats || !beats.length) return 0;
          return beats[Math.min(beats.length - 1, Math.floor((i * beats.length) / n))];
        }
        function orderOf(name) {
          // resolve a reveal order (string preset or explicit array)
          if (Array.isArray(name)) return name;
          if (name === "snake") return snakeOrder;
          if (name === "index")
            return tiles.map(function (t) {
              return t.idx;
            });
          return enterOrder; // default = top-right→bottom-left waterfall
        }

        gsap.set(
          tiles.map(function (t) {
            return t.el;
          }),
          { autoAlpha: 0 },
        );
        if (overlays.length) gsap.set(overlays, { autoAlpha: 0 });

        // ── 6. THE THREE OPERATORS — pure, parameterized, order-independent ──────
        //  Each takes ONE program step P and schedules onto `tl`. Each "shows" tiles
        //  via fromTo(from hidden) and sets its own theme, so operators compose in ANY
        //  order, repeat safely, and can run solo. Add a new operator = add a function
        //  here + a key in OPS; the PROGRAM (§3 / the `program` var) can then call it.

        //  A — staggered enter (waterfall) → hold → staggered exit. one move per beat.
        //  P: { enter:[s], exit?:[s], theme?:i, order?:"waterfall"|"snake"|"index"|[idx] }
        function staggerInOut(P) {
          var order = orderOf(P.order),
            theme = P.theme || 0;
          order.forEach(function (i, k) {
            setTheme(beatAt(k, N, P.enter), i, theme);
            var d = jitter(i);
            tl.fromTo(
              tiles[i].el,
              { autoAlpha: 0, scale: 0.78, x: d.x, y: d.y },
              Object.assign({ autoAlpha: 1, scale: 1, x: 0, y: 0 }, POP),
              beatAt(k, N, P.enter),
            );
          });
          // hold = the wall simply stays put until the exit begins (no extra tween)
          if (!P.exit || !P.exit.length) return; // no exit → leave the wall standing
          order.forEach(function (i, k) {
            tl.to(
              tiles[i].el,
              {
                autoAlpha: 0,
                scale: 0.9,
                y: 46,
                duration: 0.12,
                ease: "power2.in",
                overwrite: "auto",
              },
              beatAt(k, N, P.exit),
            );
          });
        }

        //  B — snap whole wall on, LOCK positions, recolor as a unit on each beat.
        //  P: { on:s, recolor?:[s], themes?:[i], onTheme?:i, off?:s }
        function holdRecolor(P) {
          tiles.forEach(function (_, i) {
            setTheme(P.on, i, P.onTheme || 0);
          });
          // x:0,y:0 resets any transform a prior operator left behind → clean LOCKED grid
          tl.fromTo(
            tiles.map(function (t) {
              return t.el;
            }),
            { autoAlpha: 0, scale: 0.96, x: 0, y: 0 },
            {
              autoAlpha: 1,
              scale: 1,
              x: 0,
              y: 0,
              duration: 0.1,
              ease: "power2.out",
              overwrite: "auto",
              immediateRender: false,
            },
            P.on,
          );
          (P.recolor || []).forEach(function (t, ri) {
            var th = (P.themes || [0])[ri % (P.themes || [0]).length];
            tiles.forEach(function (_, i) {
              setTheme(t, i, th);
            });
          });
          if (P.off != null) {
            tl.to(
              tiles.map(function (t) {
                return t.el;
              }),
              { autoAlpha: 0, scale: 0.97, duration: 0.1, ease: "power2.in", overwrite: "auto" },
              P.off,
            );
          }
        }

        //  C — snake reveal → optional full-grid swap → overlay cutouts pop on top.
        //  P: { fill:[s], baseTheme?:i, swap?:s, swapTheme?:i, overlay?:[s], order?:… }
        function snakeFillOverlay(P) {
          var order = orderOf(P.order || "snake"),
            base = P.baseTheme || 0;
          var f0 = P.fill && P.fill.length ? P.fill[0] : 0;
          order.forEach(function (i) {
            setTheme(Math.max(0, f0 - 0.01), i, base);
          });
          order.forEach(function (i, k) {
            tl.fromTo(
              tiles[i].el,
              { autoAlpha: 0, scale: 0.85, x: 0, y: 0 }, // x/y reset → fills on the clean grid
              {
                autoAlpha: 1,
                scale: 1,
                x: 0,
                y: 0,
                duration: 0.1,
                ease: "back.out(1.6)",
                overwrite: "auto",
                immediateRender: false,
              },
              beatAt(k, N, P.fill),
            );
          });
          if (P.swap != null) {
            // hard-stop "一键换装" — recolor the whole grid at once
            tiles.forEach(function (_, i) {
              setTheme(P.swap, i, P.swapTheme != null ? P.swapTheme : 4);
            });
          }
          (P.overlay || []).forEach(function (t, i) {
            // one cutout per beat; final holds
            if (!overlays[i]) return;
            tl.fromTo(
              overlays[i],
              { autoAlpha: 0, scale: 0.6, y: 30 },
              {
                autoAlpha: 1,
                scale: 1,
                y: 0,
                duration: 0.16,
                ease: "back.out(2)",
                overwrite: "auto",
                immediateRender: false,
              },
              t,
            );
          });
        }

        var OPS = {
          staggerInOut: staggerInOut,
          holdRecolor: holdRecolor,
          snakeFillOverlay: snakeFillOverlay,
        };

        // ── 7. RUN THE PROGRAM — dispatch each step to its operator, in order ────
        PROGRAM.forEach(function (step) {
          var fn = step && OPS[step.op];
          if (fn) fn(step);
        });

        tl.to({}, { duration: 0.001 }, DUR); // pin the timeline length to the full span
        tl.seek(0);
        window.__timelines["group-five-recreate"] = tl;
      })();
    </script>
  </body>
</html>
references/templates/poster-tile-mosaic/program.json
[
  {
    "op": "staggerInOut",
    "theme": 0,
    "enter": [0.23, 0.37, 0.46, 0.6, 0.7, 0.84, 0.91, 1.07, 1.14, 1.28],
    "hold": 1.324,
    "exit": [1.37, 1.51, 1.62, 1.74, 1.83, 1.97, 2.07]
  },
  {
    "op": "holdRecolor",
    "on": 2.35,
    "onTheme": 1,
    "recolor": [2.42, 2.53, 2.81, 2.93],
    "themes": [2, 3, 1, 2],
    "off": 2.98
  },
  {
    "op": "snakeFillOverlay",
    "baseTheme": 0,
    "fill": [3.04, 3.181, 3.44, 3.65, 3.79, 3.9],
    "swap": 4.0,
    "swapTheme": 4,
    "overlay": [4.11, 4.34, 4.48, 4.81, 4.97, 5.25, 5.48]
  }
]
references/templates/roll-flipbook-word-cycle/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor",    "type":"color",  "label":"Background",      "default":"#000000"},
    {"id":"textColor",  "type":"color",  "label":"Text color",      "default":"#ffffff"},
    {"id":"accentColor","type":"color",  "label":"Period accent",   "default":"#ff6a32"},
    {"id":"flipWords",  "type":"string", "label":"Flipbook words (comma-separated)", "default":"Design,Strategy,Branding,Marketing,Production,Campaigns,Experiential,Communication,Event Management"},
    {"id":"resolveText","type":"string", "label":"Lock phrase (empty = pure flicker, no lock)", "default":"everything you need"},
    {"id":"periodChar", "type":"string", "label":"Trailing accent (empty = none)", "default":"."}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Roll-Driven Flipbook (Word Cycle → Optional Lock) — gruop_3 reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        background: var(--bg, #000000);
        overflow: hidden;
        font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--bg, #000000);
      }
      .g3-overlay {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        pointer-events: none;
      }
      /* grid stack — every layer occupies the same centred cell, so scaling a
         layer transforms around its own centre (no translate(-50%) to clobber). */
      .g3-stack {
        display: grid;
        place-items: center;
      }
      .g3-stack > * {
        grid-area: 1 / 1;
      }
      .g3-layer {
        white-space: nowrap;
        color: var(--text, #ffffff);
        text-shadow:
          0 0 18px rgba(255, 255, 255, 0.45),
          0 0 46px rgba(255, 255, 255, 0.22);
      }
      .g3-flip {
        font-size: 120px;
        font-weight: 800;
        letter-spacing: -0.01em;
      }
      .g3-resolve {
        font-size: 116px;
        font-weight: 800;
        font-style: italic;
        letter-spacing: -0.01em;
      }
      .g3-period {
        color: var(--accent, #ff6a32);
        font-weight: 800;
        font-style: italic;
      }
    </style>
  </head>
  <body>
    <!-- data-duration = the scene span. Set this to YOUR scene length; the script
         reads it back so duration is single-sourced (no hard-coded value in JS).
         Rebased to scene-relative time — content starts at ~0, no dead pre-roll. -->
    <div
      data-hf-id="hf-g3rt"
      id="root"
      data-composition-id="group-three-recreate"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="3.8"
      data-root="true"
    >
      <div data-hf-id="hf-g3ov" class="g3-overlay">
        <div data-hf-id="hf-g3sk" class="g3-stack" id="stack"></div>
      </div>
    </div>

    <script>
      (function () {
        // ════════════════════════════════════════════════════════════════
        //  ROLL-DRIVEN FLIPBOOK (Word Cycle → Optional Lock) · dense_multiply
        //  A hi-hat ROLL drives a centred word that flips every 16th-note (flipbook),
        //  cycling a word list to FILL the roll. Optionally, when a SURGE/phrase
        //  follows, the flicker resolves (scramble-decode) into a locked phrase.
        //  Background is pure black. Every move is a 0ms autoAlpha set on a pre-built
        //  node (no GSAP textContent-sets, no onUpdate — both fail under seek render).
        // ════════════════════════════════════════════════════════════════

        var root = document.getElementById("root");

        // ── 1. BEAT MAP — the ONLY block you edit per track ─────────────────
        // SCENE-RELATIVE time (starts at ~0 — no dead pre-roll). When reusing, map
        // your audiomap roll [rollStart,rollEnd] onto [flipStart, flipStart+rollLen],
        // i.e. subtract the roll's start offset. bpm stays your track's tempo.
        var BEAT = {
          bpm: 129.2, // tempo.bpm  →  16th-note flip step = 60/bpm/4
          flipStart: 0.1, // tiny entrance lead-in — NO dead air before the flicker
          flipEnd: 2.19, // flicker length ≈ the source roll (~2.1s); auto-fills with words
          resolveStart: 2.33, // lock phrase enters after a short clear (only if a phrase is set)
          resolveLock: 3.33, // locks ~1s later (place just before a downbeat in your track)
        };
        // Scene length is single-sourced from data-duration (edit it in the HTML above).
        var DUR = parseFloat(root.getAttribute("data-duration")) || 7.34;

        // ── 2. Content variables ────────────────────────────────────────────
        var defaults = {
          bgColor: "#000000",
          textColor: "#ffffff",
          accentColor: "#ff6a32",
          flipWords:
            "Design,Strategy,Branding,Marketing,Production,Campaigns,Experiential,Communication,Event Management",
          resolveText: "everything you need",
          periodChar: ".",
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );

        document.documentElement.style.setProperty("--bg", vars.bgColor);
        document.documentElement.style.setProperty("--text", vars.textColor);
        document.documentElement.style.setProperty("--accent", vars.accentColor);
        var stack = root.querySelector("#stack");

        var WORDS = String(vars.flipWords)
          .split(",")
          .map(function (w) {
            return w.trim();
          })
          .filter(function (w) {
            return w !== "";
          });
        var RESOLVE = String(vars.resolveText || "").trim(); // empty → pure flicker, no lock
        var HAS_RESOLVE = RESOLVE !== "";
        var PERIOD =
          vars.periodChar && String(vars.periodChar).trim() !== "" ? vars.periodChar : "";

        // ── 3. Derived timing (auto-fills the roll — adapts to any length / bpm) ─
        var FLIP_STEP = 60 / BEAT.bpm / 4; // one word per 16th-note
        var N_SWAPS = Math.max(1, Math.round((BEAT.flipEnd - BEAT.flipStart) / FLIP_STEP));

        // ── 4. Build nodes ──────────────────────────────────────────────────
        var flipNodes = {};
        WORDS.forEach(function (w) {
          if (!flipNodes[w]) {
            var s = document.createElement("span");
            s.className = "g3-layer g3-flip";
            s.textContent = w;
            stack.appendChild(s);
            flipNodes[w] = s;
            gsap.set(s, { autoAlpha: 0 });
          }
        });

        var resolveNodes = [];
        if (HAS_RESOLVE) {
          var GLYPHS = "!<>-_/[]{}=+*^?#$%&01ABXYZ";
          var decodeAt = function (str, p) {
            var n = str.length,
              out = "",
              step = Math.floor(p * 48);
            for (var k = 0; k < n; k++) {
              var c = str.charAt(k);
              if (c === " ") {
                out += " ";
                continue;
              }
              var lock = 0.4 + 0.6 * ((k + 1) / n); // lock left→right, in the back 60%
              out += p >= lock ? c : GLYPHS.charAt((k * 7 + step * 3) % GLYPHS.length);
            }
            return out;
          };
          var DECODE_STEP = FLIP_STEP / 2; // scramble twice as fast as the flip
          var N_STEPS = Math.max(
            1,
            Math.round((BEAT.resolveLock - BEAT.resolveStart) / DECODE_STEP),
          );
          for (var s2 = 0; s2 <= N_STEPS; s2++) {
            var node = document.createElement("span");
            node.className = "g3-layer g3-resolve";
            if (s2 === N_STEPS) {
              node.appendChild(document.createTextNode(RESOLVE));
              if (PERIOD) {
                var pd = document.createElement("span");
                pd.className = "g3-period";
                pd.textContent = PERIOD;
                node.appendChild(pd);
              }
            } else {
              node.textContent = decodeAt(RESOLVE, s2 / N_STEPS);
            }
            stack.appendChild(node);
            gsap.set(node, { autoAlpha: 0 });
            resolveNodes.push({ node: node, t: BEAT.resolveStart + s2 * DECODE_STEP });
          }
        }

        // ── 5. Timeline — one paused GSAP timeline, absolute audio seconds ──
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });

        // PHASE A — flipbook word-cycle on the 16th grid, FILLING the roll window
        var prev = null;
        for (var i = 0; i < N_SWAPS; i++) {
          var t = BEAT.flipStart + i * FLIP_STEP;
          var node = flipNodes[WORDS[i % WORDS.length]];
          if (prev && prev !== node) tl.set(prev, { autoAlpha: 0 }, t);
          tl.set(node, { autoAlpha: 1 }, t);
          prev = node;
        }
        // glitchy reveal of the first word as the roll kicks in (scale is safe here)
        tl.fromTo(
          flipNodes[WORDS[0]],
          { scale: 0.8, filter: "blur(10px)" },
          { scale: 1, filter: "blur(0px)", duration: 0.12, ease: "power2.out" },
          BEAT.flipStart,
        );

        // PHASE B — resolve (OPTIONAL). With a lock phrase: clear, then scramble-decode
        // and lock, holding to the end. Without one: the last word just holds (locks).
        if (HAS_RESOLVE) {
          if (prev) tl.set(prev, { autoAlpha: 0 }, BEAT.flipEnd); // clear the flicker
          var prevR = null;
          for (var r = 0; r < resolveNodes.length; r++) {
            if (prevR) tl.set(prevR, { autoAlpha: 0 }, resolveNodes[r].t);
            tl.set(resolveNodes[r].node, { autoAlpha: 1 }, resolveNodes[r].t);
            prevR = resolveNodes[r].node;
          }
        }
        // (if no resolve, `prev` — the last flip word — stays visible to the end)

        // Extend the timeline to the full duration with a no-op so the held final
        // state renders on every frame to DUR (a set AT the timeline's last instant
        // gets dropped by the seek renderer).
        tl.set(stack, { opacity: 1 }, DUR);

        tl.seek(0);
        window.__timelines["group-three-recreate"] = tl;
      })();
    </script>
  </body>
</html>
references/templates/split-anchor-word-slot/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor", "type":"color",   "label":"Stage background (theme 0)",           "default":"#0a84ff"},
    {"id":"anchors", "type":"string",  "label":"Left anchor column (comma list = rows)","default":"studio,contre,courant"},
    {"id":"theme",   "type":"number",  "label":"Base theme index",                      "default":0},
    {"id":"showText","type":"boolean", "label":"Show text (false = blocks only)",        "default":true},
    {"id":"program", "type":"string",  "label":"Program JSON (empty = built-in group_6 score)", "default":""}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Split-Anchor Word-Slot · beat-synced kinetic typography — group_6 reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        background: #0a84ff;
        overflow: hidden;
        font-family: "Helvetica Neue", "Arial Black", Arial, sans-serif;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: #0a84ff;
      }
      /* #frame = the CENTERING wrapper. CSS positions it slightly ABOVE center (the source
         sits high-center); GSAP never touches #frame, so the CSS transform here is safe. */
      #frame {
        position: absolute;
        left: 50%;
        top: 43%;
        transform: translate(-50%, -50%);
      }
      /* #lockup = the thing GSAP jitters/tilts (shake) — NO CSS transform, so GSAP owns
         x/y/rotation cleanly. Holds the base tilt the whole brutalist lockup leans at. */
      #lockup {
        position: relative;
        display: flex;
        align-items: center;
        gap: 0;
        will-change: transform;
      }
      /* anchors LEFT-aligned (ragged right) so the slot box clears the shorter words and
         never hides the held text. */
      #anchors {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
      }
      .anchor {
        font-weight: 900;
        line-height: 0.96;
        letter-spacing: -0.045em;
        text-transform: lowercase;
        white-space: nowrap;
        will-change: transform, opacity;
      }
      /* the inner .t span carries the faux-EXTENDED width (scaleX) for the ANCHORS — they
         have no background, so nothing overflows. GSAP animates the outer element; the
         span's scaleX is never touched by GSAP, so the two never fight (asset-free
         Druk-Wide / Extended-Black look from a system font). */
      .t {
        display: inline-block;
        transform: scaleX(1.12);
        transform-origin: left center;
      }
      /* slot TAPES carry their extend via the LINE's GSAP scaleX instead, so the tape
         BACKGROUND stretches WITH the text and the word never overflows the strip. */
      .sline .t {
        transform: scaleX(1);
      }
      /* #slotbox = a TRANSPARENT container sitting just RIGHT of the anchors (small positive
         margin → it never covers the held words). Holds the 3 stacked cards + the word tapes. */
      /* small NEGATIVE margin = the source's tight collage TUCK: the tape kisses / lightly
         presses over the tail of the longest anchor without hiding the glyph (the tape's own
         text starts after its left padding, so words never collide). Tune this for press amount. */
      #slotbox {
        position: relative;
        margin-left: -0.12em;
        will-change: transform, opacity;
        backface-visibility: hidden;
        transform-origin: 50% 42%;
      }
      /* THREE stacked, offset, torn CARDS = the brutalist collage. Transparent during word
         phases; `bg` turns them black for the empty box + the expand zoom (they scale as one
         with #slotbox, staying staggered). Siblings — NOT parent-clipped — so the offsets show. */
      .card {
        position: absolute;
        inset: 0;
        background: transparent;
        will-change: transform;
      }
      #card1 {
        z-index: -1;
        clip-path: polygon(0% 4%, 97% 0%, 100% 92%, 3% 100%);
      }
      #card2 {
        z-index: -2;
        transform: translate(22px, 17px) rotate(-2.8deg);
        clip-path: polygon(0% 2%, 100% 0%, 98% 100%, 2% 96%);
      }
      #card3 {
        z-index: -3;
        transform: translate(-15px, 10px) rotate(2.1deg);
        clip-path: polygon(0% 6%, 96% 3%, 100% 95%, 4% 100%);
      }
      .lines {
        position: relative;
        z-index: 1;
        display: grid;
      }
      .sgroup {
        grid-area: 1 / 1;
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        gap: 3px;
      }
      /* each .sline is its OWN torn TAPE — sized to its word, its GSAP scaleX stretches
         bg+text TOGETHER (no overflow), its own micro-rotation + stair offset + shadow. */
      .sline {
        font-weight: 900;
        line-height: 0.96;
        letter-spacing: -0.045em;
        white-space: nowrap;
        text-align: left;
        background: #f4f4f4;
        padding: 0.03em 0.22em 0.08em 0.16em;
        clip-path: polygon(0% 6%, 98% 0%, 100% 90%, 3% 100%);
        box-shadow: 5px 6px 0 rgba(0, 0, 0, 0.16);
        transform-origin: 0% 50%; /* GSAP scaleX stretches RIGHTward only — never back over the anchors */
        will-change: transform, opacity;
      }
    </style>
  </head>
  <body>
    <!-- duration 5.41s = the full group_6 span (lockup → 2 slot cycles → bg flip + shake → expand wipe) -->
    <div
      data-hf-id="hf-g6rt"
      id="root"
      data-composition-id="group-six-recreate"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="5.41"
      data-root="true"
    >
      <div data-hf-id="hf-g6frame" id="frame">
        <div data-hf-id="hf-g6lock" id="lockup">
          <div data-hf-id="hf-g6anch" id="anchors"></div>
          <div data-hf-id="hf-g6box" id="slotbox">
            <div data-hf-id="hf-g6c1" id="card1" class="card"></div>
            <div data-hf-id="hf-g6c2" id="card2" class="card"></div>
            <div data-hf-id="hf-g6c3" id="card3" class="card"></div>
            <div class="lines" id="lines"></div>
          </div>
        </div>
      </div>
    </div>

    <script>
      (function () {
        // ════════════════════════════════════════════════════════════════════
        //  SPLIT-ANCHOR WORD-SLOT  ·  ONE base lockup, FIVE beat-synced operators
        //  ────────────────────────────────────────────────────────────────────
        //  BASE  : a kinetic-typography LOCKUP — a HELD left "anchor" column of R rows
        //          (fixed words) beside a torn-paper word-SLOT box on the right. The
        //          whole lockup LEANS at a base tilt (brutalist/acid attitude). R is
        //          data (the `anchors` comma-list). Asset-free: text + colored boxes.
        //          Reversed from group_6 + tuned against a Gemini side-by-side pass.
        //
        //  OP lockup : pop the R anchor words in (punchy scale, faux motion-blur squash)
        //              + reveal the empty slot box. The held stage. (source f3–f12)
        //  OP slot   : one word-GROUP cycle — lines WHIP in from the right (expo.out +
        //              scaleX stretch = fake motion blur), hold, then arc out down-left.
        //              own color per line. repeatable. (source f24–f44, f49–f66)
        //  OP bg     : HARD-CUT flip the whole scene to another theme on a beat (bg +
        //              anchor ink + box fill + slot ink invert, 0ms). 0/1/2× = changes
        //              none / once / twice. (source f83 blue→green)
        //  OP shake  : global per-beat JOLT of the lockup (x/y + rotation around the base
        //              tilt) with a back-ease recoil — one punch per beat. (source f52–f100)
        //  OP expand : EXIT wipe — fade text, then the slot box ZOOMS (expo.in, blur) from
        //              its own centre until it swallows the frame. (source f106–f117)
        //
        //  ARCHITECTURE: every operator writes GSAP tweens at ABSOLUTE audio seconds onto
        //  ONE paused timeline → order-independent, may overlap (shake runs under slot/bg).
        //  A data PROGRAM lists which ops run on which beats; §7 dispatches it. Compose by
        //  editing DATA (the PROGRAM / `program` var / program.json), never operator code.
        // ════════════════════════════════════════════════════════════════════

        // ── 1. Content variables ──
        var defaults = {
          bgColor: "#0a84ff",
          anchors: "studio,contre,courant",
          theme: 0,
          showText: true,
          program: "",
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );
        var ANCHORS = String(vars.anchors)
          .split(",")
          .map(function (s) {
            return s.trim();
          })
          .filter(function (s) {
            return s.length;
          });
        if (!ANCHORS.length) ANCHORS = ["studio", "contre", "courant"];
        var BASE_THEME = Math.max(0, parseInt(vars.theme, 10) || 0);
        var SHOW_TEXT = vars.showText !== false && vars.showText !== "false";

        // base attitude: the whole lockup leans; the box leans extra the other way.
        var BASE_TILT = -3.5; // deg, the lockup's resting lean
        var BOX_TILT = 2.6; // deg, the slot box's extra lean vs the anchors

        // ── 2. THEME LIBRARY — brighter electric/fluoro to match the source ──
        var THEMES = [
          { bg: "#0a84ff", ink: "#ffffff", box: "#f4f4f4", boxInk: "#0a84ff" }, // 0 electric blue
          { bg: "#5cf03a", ink: "#0a0a0a", box: "#0a0a0a", boxInk: "#5cf03a" }, // 1 lime/acid green (invert)
          { bg: "#0a0a0a", ink: "#ffffff", box: "#ffffff", boxInk: "#0a0a0a" }, // 2 ink
          { bg: "#ffe400", ink: "#0a0a0a", box: "#0a0a0a", boxInk: "#ffe400" }, // 3 amber
        ];
        var ACCENTS = ["#0a84ff", "#39e024", "#ff3bd0", "#ffe400", "#ffffff", "#0a0a0a"];
        function theme(i) {
          return THEMES[((i % THEMES.length) + THEMES.length) % THEMES.length];
        }
        function accent(i) {
          return ACCENTS[((i % ACCENTS.length) + ACCENTS.length) % ACCENTS.length];
        }

        // ── 3. THE PROGRAM (data) — see program.json ──
        //  group_6: ~92 BPM · downbeats 2.322(f56)/4.923(f118) · hihat fill 2.16–4.18s.
        var DEFAULT_PROGRAM = [
          { op: "lockup", enter: [0.125, 0.333, 0.5], box: 0.292, theme: 0 },
          {
            op: "slot",
            words: ["I bring", "ideas", "to life"],
            enter: [1.0, 1.167, 1.292],
            exit: [1.667, 1.75, 1.833],
          },
          {
            op: "slot",
            words: ["you", "wanna", "see?"],
            enter: [2.042, 2.208, 2.375],
            exit: [2.75, 2.75, 2.75],
            colors: [0, 1, 1],
          },
          {
            op: "shake",
            amp: 26,
            rot: 2.4,
            settle: 0.16,
            beats: [
              2.16, 2.3, 2.44, 2.62, 2.76, 2.88, 3.06, 3.27, 3.41, 3.53, 3.71, 3.85, 4.09, 4.18,
            ],
          },
          { op: "bg", at: 3.458, theme: 1 },
          { op: "expand", fade: 4.18, at: 4.4, to: 4.82 },
        ];
        var DUR = 5.41;

        var PROGRAM = DEFAULT_PROGRAM;
        if (vars.program && String(vars.program).trim()) {
          try {
            var parsed = typeof vars.program === "string" ? JSON.parse(vars.program) : vars.program;
            if (parsed && parsed.length) PROGRAM = parsed;
          } catch (e) {
            PROGRAM = DEFAULT_PROGRAM;
          }
        }

        // ── 4. Build the LOCKUP ──────────────────────────────────────────────────
        var R = ANCHORS.length;
        var slotSteps = PROGRAM.filter(function (s) {
          return s.op === "slot";
        });
        var maxSlot = slotSteps.reduce(function (m, s) {
          return Math.max(m, (s.words || []).length);
        }, 0);
        var maxRows = Math.max(R, maxSlot, 1);
        var fontPx = maxRows <= 3 ? 116 : maxRows === 4 ? 92 : 74;
        var rowH = Math.round(fontPx * 0.99);

        var root = document.getElementById("root");
        var lockupEl = document.getElementById("lockup");
        var anchorsBox = document.getElementById("anchors");
        var slotbox = document.getElementById("slotbox");
        var card1 = document.getElementById("card1");
        var card2 = document.getElementById("card2");
        var card3 = document.getElementById("card3");
        var cards = [card1, card2, card3];
        var linesBox = document.getElementById("lines");

        var TH0 = theme(BASE_THEME);
        document.documentElement.style.background = vars.bgColor || TH0.bg;
        document.body.style.background = vars.bgColor || TH0.bg;
        root.style.background = vars.bgColor || TH0.bg;

        function mkWord(text, px, h) {
          // word node with an inner faux-extended span
          var d = document.createElement("div");
          d.style.fontSize = px + "px";
          d.style.height = h + "px";
          var sp = document.createElement("span");
          sp.className = "t";
          sp.textContent = SHOW_TEXT ? text : "";
          d.appendChild(sp);
          return { row: d, span: sp };
        }

        // left anchor column (held words). The faux-extend scaleX renders the glyphs WIDER
        // than their layout box (origin-left → overflow to the right); pin each row's width
        // to the SCALED width so the column — and the slot box to its right — accounts for
        // it and never overlaps the held words.
        var anchorEls = [];
        for (var a = 0; a < R; a++) {
          var w = mkWord(ANCHORS[a], fontPx, rowH);
          w.row.className = "anchor";
          w.span.style.color = TH0.ink;
          anchorsBox.appendChild(w.row);
          w.row.style.width = Math.ceil(w.span.getBoundingClientRect().width + 2) + "px";
          anchorEls.push(w.row);
        }

        // the slot box is a TRANSPARENT backing during word phases (so only the per-line
        // tapes show on the bg — no dead space). `bg` turns it into a solid torn card for
        // the empty state + the expand zoom. ONE .sgroup per slot step, stacked in one grid
        // cell. Each .sline is its own TAPE (own width/rotation/offset). No runtime text edit.
        slotbox.style.background = "transparent";
        cards.forEach(function (c) {
          c.style.background = "transparent";
        });
        var groups = []; // [{ lines:[rowEls], spans:[spanEls] }]
        slotSteps.forEach(function (step) {
          var g = document.createElement("div");
          g.className = "sgroup";
          var lines = [],
            spans = [];
          (step.words || []).forEach(function (word, li) {
            var lw = mkWord(word, fontPx, rowH);
            lw.row.className = "sline";
            lw.row.style.background = TH0.box; // each line = its own tape
            lw.row.style.marginLeft = [0, 16, 8, 24][li % 4] + "px"; // rightward stair offset (collage)
            lw.span.style.color = TH0.boxInk;
            g.appendChild(lw.row);
            lines.push(lw.row);
            spans.push(lw.span);
          });
          linesBox.appendChild(g);
          groups.push({ lines: lines, spans: spans });
          step.__g = groups.length - 1; // remember which group this step drives
        });
        var allSlotLines = groups.reduce(function (acc, g) {
          return acc.concat(g.lines);
        }, []);
        var allSlotSpans = groups.reduce(function (acc, g) {
          return acc.concat(g.spans);
        }, []);

        // ── 5. Timeline + helpers ────────────────────────────────────────────────
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });

        function beatAt(i, n, beats) {
          if (!beats || !beats.length) return 0;
          return beats[Math.min(beats.length - 1, Math.floor((i * beats.length) / n))];
        }
        function jolt(k) {
          // deterministic per-beat shake offset (no Math.random)
          return { x: ((k * 37) % 11) - 5, y: ((k * 53) % 9) - 4, r: ((k * 29) % 7) - 3 };
        }
        function lineRot(i) {
          return [-2.0, 1.6, -1.0, 2.2][i % 4];
        } // per-line lean

        // rest pose: lockup leans at BASE_TILT, box leans extra, all content hidden.
        gsap.set(lockupEl, { rotation: BASE_TILT });
        gsap.set(slotbox, { rotation: BOX_TILT });
        gsap.set(anchorEls, { autoAlpha: 0, y: 18, scale: 0.9, scaleX: 0.9 });
        gsap.set(slotbox, { autoAlpha: 0, scale: 0.86 });
        allSlotLines.forEach(function (el, i) {
          gsap.set(el, { autoAlpha: 0, rotation: lineRot(i) });
        });

        // ── 6. THE FIVE OPERATORS ────────────────────────────────────────────────

        //  lockup — punchy anchor pop-in (scale + faux-blur squash) + box reveal.
        function lockup(P) {
          var th = theme(P.theme != null ? P.theme : BASE_THEME);
          tl.set(lockupEl, { rotation: BASE_TILT }, 0);
          anchorEls.forEach(function (el, i) {
            tl.set(el.firstChild, { color: th.ink }, 0);
            tl.fromTo(
              el,
              { autoAlpha: 0, y: 26, scale: 0.84, scaleX: 0.7 },
              {
                autoAlpha: 1,
                y: 0,
                scale: 1,
                scaleX: 1,
                duration: 0.2,
                ease: "expo.out",
                overwrite: "auto",
                immediateRender: false,
              },
              beatAt(i, R, P.enter),
            );
          });
          var boxAt = P.box != null ? P.box : beatAt(R - 1, R, P.enter);
          tl.fromTo(
            slotbox,
            { autoAlpha: 0, scale: 0.6, rotation: BOX_TILT - 6 },
            {
              autoAlpha: 1,
              scale: 1,
              rotation: BOX_TILT,
              duration: 0.22,
              ease: "back.out(2.2)",
              overwrite: "auto",
              immediateRender: false,
            },
            boxAt,
          );
        }

        //  slot — word-GROUP cycle: WHIP in from the right (expo.out + scaleX stretch),
        //  hold, arc out down-left. each line its own colour + lean.
        function slot(P) {
          var g = groups[P.__g != null ? P.__g : 0];
          if (!g) return;
          var th = theme(P.theme != null ? P.theme : BASE_THEME);
          var n = g.lines.length;
          g.lines.forEach(function (el, i) {
            var col = P.colors && P.colors[i] != null ? accent(P.colors[i]) : th.boxInk;
            var tIn = beatAt(i, n, P.enter);
            tl.set(g.spans[i], { color: col }, tIn);
            tl.fromTo(
              el,
              { autoAlpha: 0, x: 150, scaleX: 1.7, rotation: lineRot(i) },
              {
                autoAlpha: 1,
                x: 0,
                scaleX: 1.12,
                rotation: lineRot(i),
                duration: 0.24,
                ease: "expo.out",
                overwrite: "auto",
                immediateRender: false,
              }, // rest scaleX 1.12 = faux-extend (bg stretches with text)
              tIn,
            );
          });
          if (!P.exit || !P.exit.length) return;
          g.lines.forEach(function (el, i) {
            tl.to(
              el,
              {
                autoAlpha: 0,
                x: -120,
                y: 46,
                scaleX: 1.4,
                duration: 0.16,
                ease: "power3.in",
                overwrite: "auto",
              },
              beatAt(i, n, P.exit),
            );
          });
        }

        //  bg — hard-cut scene flip (0ms invert). 0/1/2 of these = bg changes 0/1/2×.
        function bg(P) {
          var th = theme(P.theme != null ? P.theme : 0);
          tl.set(root, { backgroundColor: th.bg }, P.at);
          tl.set(
            anchorEls.map(function (e) {
              return e.firstChild;
            }),
            { color: th.ink },
            P.at,
          );
          tl.set(cards, { backgroundColor: th.box }, P.at); // 3 stacked torn cards = the EMPTY box + zoom target
          tl.set(allSlotLines, { backgroundColor: th.box }, P.at); // recolor any visible tapes
          tl.set(allSlotSpans, { color: th.boxInk }, P.at);
        }

        //  shake — per-beat JOLT around the base tilt, with a back-ease recoil.
        function shake(P) {
          var amp = P.amp != null ? P.amp : 22;
          var rot = P.rot != null ? P.rot : 2.2;
          var settle = P.settle != null ? P.settle : 0.15;
          (P.beats || []).forEach(function (t, k) {
            var j = jolt(k);
            tl.fromTo(
              lockupEl,
              { x: (j.x * amp) / 5, y: (j.y * amp) / 4, rotation: BASE_TILT + (j.r * rot) / 3 },
              {
                x: 0,
                y: 0,
                rotation: BASE_TILT,
                duration: settle,
                ease: "back.out(3)",
                overwrite: "auto",
                immediateRender: false,
              },
              t,
            );
          });
        }

        //  expand — EXIT wipe: fade text, then the box ZOOMS (expo.in + blur) to swallow
        //  the frame. keeps the lockup lean; clip-path off so the scaled box fully covers.
        function expand(P) {
          var fadeAt = P.fade != null ? P.fade : P.at;
          tl.to(
            anchorEls,
            { autoAlpha: 0, duration: 0.16, ease: "power2.in", overwrite: "auto" },
            fadeAt,
          );
          tl.to(
            allSlotLines,
            { autoAlpha: 0, duration: 0.14, ease: "power2.in", overwrite: "auto" },
            fadeAt,
          );
          var startAt = P.at,
            endAt = P.to != null ? P.to : P.at + 0.46;
          tl.set(lockupEl, { x: 0, y: 0 }, startAt); // clear shake residual (keep the lean)
          if (P.theme != null) tl.set(cards, { backgroundColor: theme(P.theme).box }, startAt);
          var mid = Math.max(startAt + 0.04, endAt - 0.12);
          // stage A — the TORN mass grows but stays SUB-frame (≈half-screen, decelerating),
          // so the eye reads "the box is swelling" across most of the window (matches the
          // source, whose mass is only ~half-frame as late as f113). A big rest box covers
          // the frame at a low scale, so cap stage A low and let stage B do the swallow.
          tl.fromTo(
            slotbox,
            { scale: 1, filter: "blur(0px)" },
            {
              scale: 2.2,
              filter: "blur(4px)",
              duration: mid - startAt,
              ease: "power2.out",
              overwrite: "auto",
              immediateRender: false,
            },
            startAt,
          );
          // stage B — SWALLOW the frame in the last beats (accelerating), then flatten to solid.
          tl.to(
            slotbox,
            {
              scale: 34,
              filter: "blur(12px)",
              duration: endAt - mid,
              ease: "power2.in",
              overwrite: "auto",
            },
            mid,
          );
          tl.set(card1, { clipPath: "none" }, endAt - 0.03); // card1 (inset 0) flattens to a solid full-frame black-out
        }

        var OPS = { lockup: lockup, slot: slot, bg: bg, shake: shake, expand: expand };

        // ── 7. RUN THE PROGRAM ───────────────────────────────────────────────────
        PROGRAM.forEach(function (step) {
          var fn = step && OPS[step.op];
          if (fn) fn(step);
        });

        tl.to({}, { duration: 0.001 }, DUR);
        tl.seek(0);
        window.__timelines["group-six-recreate"] = tl;
      })();
    </script>
  </body>
</html>
references/templates/split-anchor-word-slot/program.json
[
  {
    "op": "lockup",
    "enter": [0.125, 0.333, 0.5],
    "box": 0.292,
    "theme": 0
  },
  {
    "op": "slot",
    "words": ["I bring", "ideas", "to life"],
    "enter": [1.0, 1.167, 1.292],
    "exit": [1.667, 1.75, 1.833]
  },
  {
    "op": "slot",
    "words": ["you", "wanna", "see?"],
    "enter": [2.042, 2.208, 2.375],
    "exit": [2.75, 2.75, 2.75],
    "colors": [0, 1, 1]
  },
  {
    "op": "shake",
    "amp": 26,
    "rot": 2.4,
    "settle": 0.16,
    "beats": [2.16, 2.3, 2.44, 2.62, 2.76, 2.88, 3.06, 3.27, 3.41, 3.53, 3.71, 3.85, 4.09, 4.18]
  },
  {
    "op": "bg",
    "at": 3.458,
    "theme": 1
  },
  {
    "op": "expand",
    "fade": 4.18,
    "at": 4.4,
    "to": 4.82
  }
]
references/templates/typewriter-phrase-keyword-shuffle/index.html
<!doctype html>
<html
  lang="en"
  data-composition-variables='[
    {"id":"bgColor",    "type":"color",  "label":"Background",            "default":"#000000"},
    {"id":"textColor",  "type":"color",  "label":"Text color",            "default":"#ffffff"},
    {"id":"accentColor","type":"color",  "label":"Period / keyword accent","default":"#ff6a32"},
    {"id":"lead1",      "type":"string", "label":"Lead word 1 (optional)", "default":"growth"},
    {"id":"lead2",      "type":"string", "label":"Lead word 2 (optional)", "default":"through"},
    {"id":"lead3",      "type":"string", "label":"Lead word 3 (optional)", "default":""},
    {"id":"keyword",    "type":"string", "label":"Keyword (shuffles font)","default":"creativity"},
    {"id":"periodChar", "type":"string", "label":"Trailing accent char (empty = none)","default":"."}
  ]'
>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <title>Typewriter Phrase / Keyword Font-Shuffle — group_2 reference impl</title>
    <script src="../../motion-primitives/assets/gsap.min.js"></script>
    <style>
      *,
      *::before,
      *::after {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        background: var(--bg, #000000);
        overflow: hidden;
        font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
      }
      #root {
        position: relative;
        width: 1920px;
        height: 1080px;
        overflow: hidden;
        background: var(--bg, #000000);
      }
      .g2-overlay {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        pointer-events: none;
      }
      /* one centered line; when the keyword changes typeface the line re-centers,
         exactly as the source does (the lead words shift slightly, that's faithful). */
      .g2-line {
        display: inline-flex;
        align-items: baseline;
        gap: 0.28em;
        white-space: nowrap;
        color: var(--text, #ffffff);
        font-size: 112px;
        font-weight: 800;
        letter-spacing: -0.01em;
        text-shadow:
          0 0 18px rgba(255, 255, 255, 0.45),
          0 0 44px rgba(255, 255, 255, 0.22);
      }
      .g2-word {
        display: inline-block;
      }
      /* keyword + its trailing accent travel together so the dot hugs the word */
      .g2-cword {
        display: inline-flex;
        align-items: baseline;
      }
      .g2-period {
        display: inline-block;
        color: var(--accent, #ff6a32);
        font-weight: 800;
      }
    </style>
  </head>
  <body>
    <!-- duration = 3.85s = the spell-out + shuffle scene (0 → just before the 3.855s drop) -->
    <div
      data-hf-id="hf-g2rt"
      id="root"
      data-composition-id="group-two-recreate"
      data-width="1920"
      data-height="1080"
      data-start="0"
      data-duration="3.85"
      data-root="true"
    >
      <div data-hf-id="hf-g2ov" class="g2-overlay">
        <div data-hf-id="hf-g2ln" class="g2-line" id="line"></div>
      </div>
    </div>

    <script>
      (function () {
        // ════════════════════════════════════════════════════════════════
        //  TYPEWRITER PHRASE / KEYWORD FONT-SHUFFLE  ·  structural_type: per_onset_sequential
        //  Words type in one-per-onset to spell a phrase; then ONE keyword cycles
        //  typefaces on the beat while everything else holds dead still.
        // ════════════════════════════════════════════════════════════════

        // ── 1. Content variables (what a user changes) ─────────────────────
        var defaults = {
          bgColor: "#000000",
          textColor: "#ffffff",
          accentColor: "#ff6a32",
          lead1: "growth",
          lead2: "through",
          lead3: "",
          keyword: "creativity",
          periodChar: ".",
        };
        var vars = Object.assign(
          {},
          defaults,
          window.__hyperframes && window.__hyperframes.getVariables
            ? window.__hyperframes.getVariables()
            : {},
        );

        // ── 2. FONT PRESET LIBRARY (preset many — the keyword cycles these) ─
        // FONTS[0] is the BASE face, shared with the lead words. The keyword flips
        // to FONTS[i] on each SHUFFLE step. Offline-safe generic stacks only (no
        // remote fonts). Extend this list freely — more beats → more presets to
        // cycle through. Each entry: { family, weight, style }.
        var FONTS = [
          {
            family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
            weight: 800,
            style: "normal",
          }, // 0 base bold sans
          { family: 'Georgia, "Times New Roman", Times, serif', weight: 500, style: "normal" }, // 1 upright serif
          { family: "Georgia, serif", weight: 800, style: "normal" }, // 2 heavy serif
          { family: 'Georgia, "Times New Roman", serif', weight: 500, style: "italic" }, // 3 italic serif
          { family: '"Courier New", Courier, monospace', weight: 700, style: "normal" }, // 4 monospace
          { family: '"Arial Narrow", "Helvetica Neue", sans-serif', weight: 700, style: "normal" }, // 5 condensed sans
          { family: '"Helvetica Neue", Arial, sans-serif', weight: 300, style: "italic" }, // 6 light sans italic
          { family: "Georgia, serif", weight: 700, style: "italic" }, // 7 bold serif italic
        ];
        function fontProps(i) {
          var f = FONTS[((i % FONTS.length) + FONTS.length) % FONTS.length];
          return { fontFamily: f.family, fontWeight: f.weight, fontStyle: f.style };
        }

        // ── 3. BEAT MAP (filled from <group>.audiomap.json — see README) ────
        // group_2.audiomap.json: BPM 129.2, hi-hat onsets drive every move.
        //   one ENTRY onset per lead word (in order), then KEYWORD, then PERIOD.
        var LEAD_ONSETS = [0.557, 0.906]; // grid.events[].t — one per non-empty lead word
        var KEY_ONSET = 1.231; // keyword types in here
        var PERIOD_ONSET = 1.44; // accent char punches in here
        // keyword font-shuffle: each {t,font} flips the keyword on a beat/onset.
        // t  = a grid.beats_sec[] / events[].t after the phrase is set.
        // font = index into FONTS[]. Add as many rows as the beat has flips — and
        // re-use indices to repeat faces, or walk new ones for a longer shuffle.
        var SHUFFLE = [
          { t: 1.904, font: 1 }, // → upright serif
          { t: 2.392, font: 2 }, // → heavy serif
          { t: 2.624, font: 3 }, // → italic serif
          { t: 3.088, font: 0 }, // → back to base, then HOLD to the end
        ];
        var DUR = 3.85; // scene end (just before the 3.855 drop/kick)

        // ── 4. Build the line (1–3 lead words + keyword + optional accent) ──
        var root = document.getElementById("root");
        document.documentElement.style.setProperty("--bg", vars.bgColor);
        document.documentElement.style.setProperty("--text", vars.textColor);
        document.documentElement.style.setProperty("--accent", vars.accentColor);

        var line = root.querySelector("#line");
        var leadWords = [vars.lead1, vars.lead2, vars.lead3].filter(function (w) {
          return w && String(w).trim() !== "";
        });

        var leadEls = leadWords.map(function (w) {
          var s = document.createElement("span");
          s.className = "g2-word";
          s.textContent = w;
          line.appendChild(s);
          return s;
        });

        var cword = document.createElement("span");
        cword.className = "g2-cword";
        var keyEl = document.createElement("span");
        keyEl.className = "g2-word";
        keyEl.textContent = vars.keyword;
        cword.appendChild(keyEl);
        var periodEl = null;
        if (vars.periodChar && String(vars.periodChar).trim() !== "") {
          periodEl = document.createElement("span");
          periodEl.className = "g2-period";
          periodEl.textContent = vars.periodChar;
          cword.appendChild(periodEl);
        }
        line.appendChild(cword);

        // ── 5. Timeline — one paused GSAP timeline, absolute audio seconds ──
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });

        var allSpans = leadEls.concat([keyEl]);
        if (periodEl) allSpans.push(periodEl);
        gsap.set(allSpans, { autoAlpha: 0 });
        gsap.set(keyEl, fontProps(0)); // keyword starts in the base face

        var POP_IN = { autoAlpha: 0, scale: 0.92, y: 5 };
        var POP_OUT = { autoAlpha: 1, scale: 1, y: 0, duration: 0.1, ease: "back.out(2)" };

        // typewriter accumulation — each lead word pops on its onset (words stay)
        leadEls.forEach(function (el, i) {
          var t = i < LEAD_ONSETS.length ? LEAD_ONSETS[i] : KEY_ONSET;
          tl.fromTo(el, POP_IN, POP_OUT, t);
        });
        // keyword types in (in the base face)
        tl.fromTo(keyEl, POP_IN, POP_OUT, KEY_ONSET);
        // trailing accent char punches in on its own onset
        if (periodEl) {
          tl.fromTo(
            periodEl,
            { autoAlpha: 0, scale: 0.3 },
            { autoAlpha: 1, scale: 1, duration: 0.09, ease: "back.out(3)" },
            PERIOD_ONSET,
          );
        }

        // keyword font-shuffle — 0ms hard sets on the beat (the percussion IS the swap)
        SHUFFLE.forEach(function (s) {
          tl.set(keyEl, fontProps(s.font), s.t);
        });

        tl.seek(0);
        window.__timelines["group-two-recreate"] = tl;
      })();
    </script>
  </body>
</html>
scripts/analyze-beatgrid.py
#!/usr/bin/env python3
"""Beat-grid + drum/event analysis engine for music-to-video.

Turns a BGM track directly into a deterministic `audiomap.json` — the music skeleton
that the Director and Builder hang visuals on. It merges:

  - a reliable tempo + beat grid + downbeat (librosa beat tracker),
  - metrical position per event (strong / weak / syncopated / off-grid) over a 16th-note bar grid,
  - drum-element classification (kick / snare / hihat / perc) via band-split,
  - special audio events (riser / glitch / crash-impact / hard-stop silence),
  - an energy narrative (audio-driven energy phases + builds + key moments; the Music
    Reader names the sections — no fixed Intro/Build/Drop/Outro template),
  - a phrase layer + per-section density budgets for visual planning.

Output is the canonical `audiomap.json` documented in the skill.

Usage:
    python3 analyze-beatgrid.py track.mp3 -o audiomap.json
    python3 analyze-beatgrid.py track.mp3 --print          # also print a readable brief

Deps: ffmpeg/ffprobe on PATH + librosa, numpy, soundfile (band-split heuristics,
no learned models / no madmom).
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path

import librosa
import numpy as np
import soundfile as sf

# Windows sizes stdio to the ANSI code page (cp1252), which cannot encode the glyphs
# the brief prints (Δ, →) — every `--print` run died with UnicodeEncodeError. These
# scripts emit UTF-8 on every platform; say so instead of trading the glyphs away.
# Carry `errors` across: reconfigure() resets it to "strict", and CPython deliberately
# gives stderr "backslashreplace" so the diagnostic path can never itself raise.
for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors=_stream.errors)

SR = 22050
HOP = 512  # ~23 ms frames
AUDIOMAP_VERSION = 2


# ── decode ────────────────────────────────────────────────────────────────
def load_audio(path: str) -> tuple[np.ndarray, int, float]:
    """Decode any ffmpeg-readable file to mono float32 @ SR via a temp wav."""
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
        wav = tmp.name
    subprocess.run(
        ["ffmpeg", "-y", "-i", path, "-ac", "1", "-ar", str(SR), wav],
        capture_output=True, check=True,
    )
    y, sr = sf.read(wav, dtype="float32")
    Path(wav).unlink(missing_ok=True)
    if y.ndim > 1:
        y = y.mean(axis=1)
    return y, sr, len(y) / sr


# ── tempo + beat grid + downbeat phase ──────────────────────────────────────
def beat_grid(y: np.ndarray, sr: int) -> dict:
    tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr, hop_length=HOP, units="frames")
    beats = librosa.frames_to_time(beat_frames, sr=sr, hop_length=HOP)
    return {"bpm": float(np.atleast_1d(tempo)[0]), "beats": beats, "beat_frames": beat_frames}


def _norm_flux(band: np.ndarray) -> np.ndarray:
    """Positive first-difference (onset flux) of a band-energy curve, normalized."""
    flux = np.maximum(0.0, np.diff(np.sqrt(band), prepend=band[:1]))
    return flux / (flux.max() + 1e-9)


def band_energy_curves(y: np.ndarray, sr: int) -> dict:
    """Per-frame band energy + per-band normalized onset flux (for drum typing)."""
    S = np.abs(librosa.stft(y, hop_length=HOP)) ** 2
    freqs = librosa.fft_frequencies(sr=sr)
    # Tight drum bands: kick fundamental, snare body, hihat sizzle. Narrow bands
    # keep one drum's transient from leaking into another's flux on a full mix.
    low = (freqs < 150)                       # kick
    mid = (freqs >= 150) & (freqs < 900)      # snare body
    high = (freqs >= 5000)                    # hihat / cymbal
    e_low, e_mid, e_high = S[low].sum(axis=0), S[mid].sum(axis=0), S[high].sum(axis=0)
    return {
        "S": S,
        "low": e_low, "mid": e_mid, "high": e_high,
        "total": S.sum(axis=0) + 1e-9,
        "flux_low": _norm_flux(e_low), "flux_mid": _norm_flux(e_mid), "flux_high": _norm_flux(e_high),
        "flatness": librosa.feature.spectral_flatness(S=np.sqrt(S))[0],
        "centroid": librosa.feature.spectral_centroid(S=np.sqrt(S), sr=sr)[0],
        "n": S.shape[1],
    }


def downbeat_phase(beat_frames: np.ndarray, bc: dict, beats_per_bar: int = 4) -> int:
    """Pick the bar phase whose beats carry the most KICK (low-band) energy."""
    kick = bc["low"] / bc["total"]
    best_p, best_score = 0, -1.0
    for p in range(beats_per_bar):
        idx = [bf for i, bf in enumerate(beat_frames) if (i - p) % beats_per_bar == 0]
        idx = [min(f, bc["n"] - 1) for f in idx]
        score = float(np.sum([kick[f] for f in idx])) if idx else 0.0
        if score > best_score:
            best_p, best_score = p, score
    return best_p


# ── metrical position: strong / weak / syncopated / off-grid ─────────────────
# 16-step bar grid (4 beats x 4 sixteenths). Strength by metrical weight.
GRID_CLASS = {0: "strong", 8: "strong", 4: "weak", 12: "weak",
              2: "weak", 6: "weak", 10: "weak", 14: "weak"}  # else (odd 16ths) -> syncopated


def classify_metric(t: float, beats: np.ndarray, phase: int, bpb: int = 4) -> tuple:
    """Return (grid_class, bar, beat_in_bar, step16) for a time t."""
    if len(beats) < 2:
        return "off-grid", -1, -1, -1
    i = int(np.searchsorted(beats, t) - 1)
    i = max(0, min(i, len(beats) - 2))
    beat_dur = beats[i + 1] - beats[i]
    frac = (t - beats[i]) / max(beat_dur, 1e-6)         # 0..1 within the beat
    sixteenth = int(round(frac * 4)) % 4                # nearest 16th in beat
    carry = 1 if round(frac * 4) >= 4 else 0
    beat_idx = i + carry
    beat_in_bar = (beat_idx - phase) % bpb              # 0..3
    bar = (beat_idx - phase) // bpb
    step16 = beat_in_bar * 4 + sixteenth                # 0..15
    # distance to the nearest 16th line (in seconds) → off-grid test
    nearest = beats[i] + (round(frac * 4) / 4) * beat_dur
    if abs(t - nearest) > 0.5 * (beat_dur / 4):
        return "off-grid", bar, beat_in_bar + 1, step16
    return GRID_CLASS.get(step16, "syncopated"), bar, beat_in_bar + 1, step16


# ── drum classification (band-split heuristic) ──────────────────────────────
def frame_at(t: float, sr: int, n: int) -> int:
    return min(int(round(t * sr / HOP)), n - 1)


def classify_drum(t: float, bc: dict, sr: int) -> tuple:
    """(drum_type, energy_norm, feel): which band's onset TRANSIENT dominates.

    Uses per-band normalized flux (relative transient strength), the standard
    way to separate kick (low) / snare (mid+noise) / hihat (high). Falls back to
    glitch for noisy non-harmonic bursts and perc when no band clearly leads.
    """
    f = frame_at(t, sr, bc["n"])
    win = slice(max(0, f - 1), min(bc["n"], f + 2))
    fl = float(bc["flux_low"][win].max())
    fm = float(bc["flux_mid"][win].max())
    fh = float(bc["flux_high"][win].max())
    lo = float(bc["low"][win].mean()); md = float(bc["mid"][win].mean())
    hi = float(bc["high"][win].mean()); tot = float(bc["total"][win].mean())
    flat = float(bc["flatness"][win].mean())
    lr, mr, hr = lo / tot, md / tot, hi / tot

    fluxes = {"kick": fl, "snare": fm, "hihat": fh}
    lead = max(fluxes, key=fluxes.get)
    lead_val = fluxes[lead]
    if lead_val < 0.06:                      # no real transient → texture/perc
        drum = "glitch" if flat > 0.30 else "perc"
    elif lead == "snare" and flat > 0.30 and mr < 0.30:
        drum = "glitch"                      # mid-band but noisy & thin → scratch/glitch
    else:
        drum = lead
    # feel (frequency character)
    has_bot, has_top, has_mid = lr > 0.30, hr > 0.20, mr > 0.30
    feel = ("full" if has_bot and has_top and has_mid else
            "heavy" if has_bot and not has_top else
            "bright" if has_top and not has_bot else
            "intimate" if has_mid else "sparse")
    return drum, tot, feel


# ── energy structure (RMS @1s) + sections + key moments + builds ────────────
def energy_structure(y: np.ndarray, sr: int, dur: float, first_onset: float = 0.0) -> dict:
    rms = librosa.feature.rms(y=y, hop_length=sr)[0]  # ~1s frames
    rms = rms / (rms.max() + 1e-9)
    norms = rms.tolist()

    def lvl(n):
        return "VOID" if n < 0.2 else "LOW" if n < 0.4 else "MEDIUM" if n < 0.65 else "HIGH"

    phases, cur, cs = [], None, 0
    for i, n in enumerate(norms):
        l = lvl(n)
        if l != cur:
            if cur:
                phases.append({"s": cs, "e": i, "lvl": cur})
            cur, cs = l, i
    if cur:
        phases.append({"s": cs, "e": len(norms), "lvl": cur})

    moments = []
    for i in range(1, len(norms)):
        d = norms[i] - norms[i - 1]
        if abs(d) > 0.12:
            moments.append({"t": i, "kind": "DROP" if d < 0 else "SURGE", "delta": round(d, 2)})
    moments.sort(key=lambda m: abs(m["delta"]), reverse=True)

    # hard stop: a HIGH→low cliff (a sudden stop) in the back third
    hard_stops = [m for m in moments if m["kind"] == "DROP" and m["t"] > dur * 0.6 and m["delta"] < -0.25]

    # NO forced Intro/Build/Drop/Outro template. The energy phases (audio-driven runs of
    # one energy level, variable count) are the raw structural blocks. The Music Reader
    # (LLM) decides the actual sections — count, boundaries, and free-form names — from
    # these phases + key_moments + rolls + hard_stops + phrases. Sections are
    # interpretation; only the timing they snap to is fact.
    phases_sec = []
    for p in phases:
        seg = norms[p["s"]:max(p["s"] + 1, p["e"])]
        phases_sec.append({
            "start": float(p["s"]),
            "end": float(min(p["e"], round(dur, 1))),
            "level": p["lvl"],
            "energy": round(float(np.mean(seg)) if seg else 0.0, 2),
        })

    return {"norms": [round(n, 2) for n in norms], "phases": phases_sec,
            "moments": moments[:8], "hard_stops": hard_stops}


# ── rolls / fills (localized rapid-onset runs) ───────────────────────────────
# A roll is where choreography should switch from discrete hits to a continuous /
# cascading visual (per-letter cascade, stagger). Derived straight from the onset
# stream — runs never overlap, so no dedup is needed (unlike a band-energy detector).
ROLL_MIN_HITS = 4
ROLL_CONT = 0.55      # × beat_dur: gap up to ~half a beat still keeps a run alive
ROLL_ACCEPT = 0.42    # × beat_dur: mean spacing denser than an 8th note counts
ROLL_DEDUP = 0.08     # seconds: merge onsets closer than a 32nd (double-trigger)


def detect_rolls(events: list, beat_dur: float) -> list:
    """Runs of >=4 onsets whose MEAN spacing is denser than an 8th note. Tuned so a
    full hihat/snare roll is captured as ONE span (not fragmented down to its tail),
    while a sparse groove stays out — validated against the golden 7.5-9.5s roll.
    The linear scan means runs never overlap (no LEGACY-style double-counting)."""
    cont = beat_dur * ROLL_CONT     # max gap that keeps a run alive
    accept = beat_dur * ROLL_ACCEPT  # max MEAN gap for a run to count
    # collapse onset double-triggers (two onsets < a 32nd apart = one hit) so a
    # held/sparse passage can't masquerade as a roll on a duplicated transient.
    ev = []
    for e in events:
        if ev and e["t"] - ev[-1]["t"] <= ROLL_DEDUP:
            if e.get("energy", 0) > ev[-1].get("energy", 0):
                ev[-1] = e
            continue
        ev.append(e)
    times = [e["t"] for e in ev]
    n = len(times)
    rolls, i = [], 0
    while i < n - 1:
        j = i
        while j + 1 < n and (times[j + 1] - times[j]) <= cont:
            j += 1
        if j - i + 1 >= ROLL_MIN_HITS:
            gaps = [times[k + 1] - times[k] for k in range(i, j)]
            if sum(gaps) / len(gaps) <= accept:
                t0, t1 = times[i], times[j]
                half = len(gaps) // 2
                accel = (half >= 1 and
                         sum(gaps[half:]) / (len(gaps) - half) <
                         sum(gaps[:half]) / half * 0.85)
                dcount: dict[str, int] = {}
                for e in ev[i:j + 1]:
                    dcount[e["drum"]] = dcount.get(e["drum"], 0) + 1
                rolls.append({
                    "start": round(t0, 3), "end": round(t1, 3),
                    "dur_sec": round(t1 - t0, 3),
                    "hits": j - i + 1,
                    "rate_per_min": round((j - i) / max(t1 - t0, 1e-6) * 60),
                    "kind": "accel-roll" if accel else ("sustained-fill" if t1 - t0 > 1.2 else "fill"),
                    "drum": max(dcount, key=dcount.get),
                })
        i = j + 1
    return rolls


# ── per-section spectral character (sustained "feel") ────────────────────────
# Coarse, reliable bands for how a SECTION sounds (not the noisy per-second dump).
# Distinct from a per-event `feel`, which is the transient color of a single hit.
FEEL_BANDS = [("sub", 0, 60), ("bass", 60, 250), ("low_mid", 250, 800),
              ("mid", 800, 2500), ("presence", 2500, 6000), ("air", 6000, 1e9)]


def annotate_section_feel(bc: dict, sr: int, sections: list) -> None:
    """Attach {character, bands} to each energy phase from its sustained band balance."""
    S, n = bc["S"], bc["n"]
    freqs = librosa.fft_frequencies(sr=sr)
    fps = sr / HOP
    masks = [(name, (freqs >= lo) & (freqs < hi)) for name, lo, hi in FEEL_BANDS]
    for s in sections:
        f0 = int(s["start"] * fps)
        f1 = min(max(f0 + 1, int(s["end"] * fps)), n)
        seg = S[:, f0:f1]
        if seg.shape[1] == 0 or s.get("energy", 0) < 0.15:
            s["feel"] = {"character": "sparse", "bands": []}
            continue
        en = {name: float(seg[m].sum()) for name, m in masks}
        tot = sum(en.values()) + 1e-9
        ratios = {name: en[name] / tot for name in en}
        present = sorted([bn for bn, r in ratios.items() if r > 0.12],
                         key=lambda bn: -ratios[bn])
        lo = ratios["sub"] + ratios["bass"]
        hi = ratios["presence"] + ratios["air"]
        mid = ratios["low_mid"] + ratios["mid"]
        char = ("heavy" if lo > 0.5 else "bright" if hi > 0.45 else
                "full" if lo > 0.25 and hi > 0.25 else
                "warm" if mid > 0.5 else "sparse")
        s["feel"] = {"character": char, "bands": present}


# ── audiomap enrichment: phrase layer + section density budgets ─────────────
def round3(n: float) -> float:
    return round(float(n), 3)


def derive_phrases(downbeats: list[float], phrase_bars: int, duration_sec: float) -> list:
    """Group downbeats into phrase spans of `phrase_bars` bars."""
    phrases = []
    if not downbeats:
        return phrases
    index = 0
    for i in range(0, len(downbeats), phrase_bars):
        start = downbeats[i]
        next_idx = i + phrase_bars
        end = downbeats[next_idx] if next_idx < len(downbeats) else duration_sec
        phrases.append({
            "index": index,
            "start": round3(start),
            "end": round3(end),
            "bars": min(phrase_bars, len(downbeats) - i),
        })
        index += 1
    return phrases


def count_in(times: list[float], start: float, end: float) -> int:
    return sum(1 for t in times if t >= start - 1e-6 and t < end - 1e-6)


def derive_phase_budgets(timeline: dict) -> list:
    """Attach an objective density read to each energy phase.

    Density is a fact (onsets-per-second + rolls). It is a hint for how much visual
    content a span can hold; it does not set timing or sections. The Music Reader uses
    these phases to decide the actual sections.
    """
    phases = timeline.get("energy_phases", [])
    onset_times = [e["t"] for e in timeline.get("events", [])]
    rolls = timeline.get("rolls", [])
    hard_stops = timeline.get("hard_stops", [])

    out = []
    for s in phases:
        span = max(1e-6, float(s.get("end", 0)) - float(s.get("start", 0)))
        onsets = count_in(onset_times, s["start"], s["end"])
        ph_rolls = [
            {"start": r["start"], "end": r["end"], "kind": r["kind"], "drum": r["drum"]}
            for r in rolls
            if r["start"] < s["end"] - 1e-6 and r["end"] > s["start"] + 1e-6
        ]
        ph_stops = [
            h["t"]
            for h in hard_stops
            if h["t"] >= s["start"] - 1e-6 and h["t"] < s["end"] + 1e-6
        ]

        if s.get("energy", 0) < 0.2 or onsets < 6:
            density = "sparse"
        elif onsets >= 18 or ph_rolls:
            density = "dense"
        else:
            density = "medium"

        enriched = dict(s)
        enriched["onsets"] = onsets
        enriched["onsetRate"] = round(onsets / span, 1)
        enriched["rolls"] = ph_rolls
        enriched["hardStops"] = ph_stops
        enriched["density"] = density
        out.append(enriched)
    return out


def finalize_audiomap(timeline: dict, phrase_bars: int = 4) -> dict:
    downbeats = timeline.get("grid", {}).get("downbeats_sec", [])
    duration_sec = timeline.get("audio", {}).get("duration_sec", 0)
    energy_phases = derive_phase_budgets(timeline)
    phrases = derive_phrases(downbeats, phrase_bars, duration_sec)
    return {
        "version": AUDIOMAP_VERSION,
        "phraseBars": phrase_bars,
        **timeline,
        "energy_phases": energy_phases,
        "phrases": phrases,
    }


# ── main ────────────────────────────────────────────────────────────────────
def analyze(path: str, phrase_bars: int = 4) -> dict:
    y, sr, dur = load_audio(path)
    bg = beat_grid(y, sr)
    bc = band_energy_curves(y, sr)
    phase = downbeat_phase(bg["beat_frames"], bc)
    beats = bg["beats"]
    downbeats = [float(beats[i]) for i in range(len(beats)) if (i - phase) % 4 == 0]

    # onsets → events
    onset_t = librosa.onset.onset_detect(
        y=y, sr=sr, hop_length=HOP, units="time", backtrack=True
    )
    en_at = bc["total"]
    en_max = float(en_at.max()) + 1e-9
    events = []
    for t in onset_t:
        gclass, bar, bib, step16 = classify_metric(float(t), beats, phase)
        drum, energy, feel = classify_drum(float(t), bc, sr)
        f = frame_at(float(t), sr, bc["n"])
        events.append({
            "t": round(float(t), 3),
            "bar": int(bar), "beat_in_bar": int(bib), "step16": int(step16),
            "grid": gclass, "drum": drum,
            "energy": round(float(en_at[f]) / en_max, 2), "feel": feel,
            "special": None,
        })

    first_onset = next((float(t) for t in onset_t if t >= 2.0), 0.0)
    es = energy_structure(y, sr, dur, first_onset)

    # tag specials onto nearby events
    for hs in es["hard_stops"]:
        for e in events:
            if abs(e["t"] - hs["t"]) < 0.6:
                e["special"] = "hard_stop"
    # riser: events inside a 1.5s+ rising-energy run that precedes a SURGE
    surges = [m["t"] for m in es["moments"] if m["kind"] == "SURGE"]
    for st in surges:
        for e in events:
            if st - 2.0 <= e["t"] < st and e["special"] is None and e["drum"] in ("perc", "glitch"):
                e["special"] = "riser"

    # rolls / fills + whether each leads straight into a surge/drop (cascade cue)
    beat_dur = float(np.median(np.diff(beats))) if len(beats) > 1 else 60.0 / max(bg["bpm"], 1e-6)
    rolls = detect_rolls(events, beat_dur)
    for r in rolls:
        r["leads_to"] = next((m["kind"] for m in es["moments"]
                              if 0 <= m["t"] - r["end"] <= 1.2), None)
    # near-silent windows (= VOID energy phases) — convenience for "hold / breathe"
    silences = [{"start": p["start"], "end": p["end"]}
                for p in es["phases"] if p["level"] == "VOID"]
    # per-phase sustained spectral character (how each energy block FEELS)
    annotate_section_feel(bc, sr, es["phases"])

    n_drum = {}
    for e in events:
        n_drum[e["drum"]] = n_drum.get(e["drum"], 0) + 1
    n_grid = {}
    for e in events:
        n_grid[e["grid"]] = n_grid.get(e["grid"], 0) + 1

    summary = (f"{bg['bpm']:.0f} BPM · {len(beats)} beats / {len(downbeats)} bars · "
               f"{len(events)} events ({n_drum}) · {len(rolls)} rolls · "
               f"{len(es['phases'])} energy phases · {dur:.1f}s")

    timeline = {
        "summary": summary,
        "audio": {"path": path, "duration_sec": round(dur, 3), "sr": sr},
        "tempo": {"bpm": round(bg["bpm"], 1), "beats_per_bar": 4,
                  "downbeat_phase": phase, "n_beats": len(beats), "n_bars": len(downbeats)},
        "grid": {"beats_sec": [round(float(b), 3) for b in beats],
                 "downbeats_sec": [round(b, 3) for b in downbeats]},
        "energy_phases": es["phases"],
        "key_moments": es["moments"],
        "hard_stops": es["hard_stops"],
        "rolls": rolls,
        "silences": silences,
        "stats": {"drum_counts": n_drum, "grid_counts": n_grid},
        "events": events,
    }
    return finalize_audiomap(timeline, phrase_bars)


def print_brief(d: dict) -> None:
    print(f"\n{d['summary']}\n{'='*70}")
    print("ENERGY PHASES (audio-driven blocks; the Music Reader names the sections)")
    for s in d.get("energy_phases", []):
        feel = s.get("feel", {})
        bands = ",".join(feel.get("bands", []))
        print(f"  {s['start']:5.1f}-{s['end']:5.1f}s  {s.get('level', ''):6s}  energy={s.get('energy')}  "
              f"{feel.get('character', ''):6s}  [{bands}]  {s.get('density', '?')}")
    print("PHRASES")
    for p in d.get("phrases", []):
        print(f"  #{p['index']}  {p['start']:5.2f}-{p['end']:5.2f}s  bars={p['bars']}")
    print("KEY MOMENTS")
    for m in d["key_moments"]:
        print(f"  {m['t']:3d}s  {m['kind']:5s}  Δ{m['delta']:+.2f}")
    print(f"HARD STOPS: {[h['t'] for h in d['hard_stops']]}")
    print("ROLLS / FILLS")
    for r in d.get("rolls", []):
        lead = f"  → {r['leads_to']}" if r.get("leads_to") else ""
        print(f"  {r['start']:6.2f}-{r['end']:5.2f}s  {r['hits']:2d} hits @ {r['rate_per_min']:4d}/min  "
              f"{r['kind']:10s} ({r['drum']}){lead}")
    print(f"SILENCES: {[(s['start'], s['end']) for s in d.get('silences', [])]}")
    print(f"DRUM COUNTS: {d['stats']['drum_counts']}    GRID: {d['stats']['grid_counts']}")
    print(f"\nEVENTS ({len(d['events'])})  [t · bar:beat · grid · drum · energy · special]")
    for e in d["events"]:
        sp = f"  <{e['special']}>" if e["special"] else ""
        print(f"  {e['t']:6.2f}s  b{e['bar']}:{e['beat_in_bar']}  {e['grid']:3s}  "
              f"{e['drum']:6s}  e={e['energy']:.2f}  {e['feel']:8s}{sp}")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("audio")
    ap.add_argument("-o", "--out", default=None)
    ap.add_argument("--phrase-bars", type=int, default=4)
    ap.add_argument("--print", action="store_true", dest="do_print")
    a = ap.parse_args()
    d = analyze(a.audio, phrase_bars=a.phrase_bars)
    if a.out:
        # ensure_ascii=False means the payload can carry non-ASCII, so the file
        # encoding cannot be left to the platform default (cp1252 on Windows).
        Path(a.out).write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
        dens = " ".join(f"{s.get('level', '?')}:{s.get('density', '?')}" for s in d.get("energy_phases", []))
        print(
            f"[analyze-beatgrid] wrote audiomap {a.out} · {len(d.get('energy_phases', []))} phases · density [{dens}]",
            file=sys.stderr,
        )
    if a.do_print or not a.out:
        print_brief(d)


if __name__ == "__main__":
    main()
scripts/assemble-index.mjs
#!/usr/bin/env node
// assemble-index.mjs — deterministic top-level index.html assembly for a
// music-to-video project. No subagent, no judgment: turns STORYBOARD.md + the
// built per-frame composition files (+ assets/bgm.mp3) into the standalone
// index.html the renderer consumes.
//
// index.html is a *standalone* composition (root <div id="root"> directly in
// <body>, no <template> wrapper — template is for the frame sub-comps). Each
// frame is referenced (not inlined) as a <div class="frame"> with
// data-composition-src pointing at its file; the renderer seeks each at its
// absolute data-start. Frames tile the track gap-free, so frame→frame is a
// plain back-to-back HARD CUT — there is NO transition injector (unlike
// product-launch, whose transitions.mjs exists only for soft transitions).
//
// Track lanes:
//   1   frame clips (sequential, gap-free, hard cut between)
//   10  optional per-frame VO <audio>  (deferred; mounted only if audio_meta has it)
//   11  BGM <audio> (full duration)
//
// Reads:  --storyboard STORYBOARD.md, --hyperframes <root>, [--audiomap audiomap.json],
//         [--bgm assets/bgm.mp3], [--audio-meta audio_meta.json]. On disk: each frame's src html.
// Writes: <project>/index.html
//
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
// frames, a frame missing/empty/with-no-duration, an inner id mismatch).

import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";

const argv = process.argv.slice(2);
const flag = (name, def) => {
  const i = argv.indexOf(`--${name}`);
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
function die(msg) {
  console.error(`✗ assemble-index.mjs: ${msg}`);
  process.exit(1);
}
const r3 = (x) => Math.round(x * 1000) / 1000;
const anomalies = [];

const hyperframesDir = resolve(flag("hyperframes", "."));
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audiomapPath = resolve(flag("audiomap", join(hyperframesDir, "audiomap.json")));
const audioMetaPath = resolve(flag("audio-meta", join(hyperframesDir, "audio_meta.json")));
const bgmRel = flag("bgm", "assets/bgm.mp3");
const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));

// ---------- parse storyboard ----------
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const G = manifest.globals.extra ?? {};

// canvas from frontmatter `canvas: {"w":1920,"h":1080,"fps":30}` (key lowercased by parser)
let WIDTH = 1920,
  HEIGHT = 1080;
if (G.canvas) {
  try {
    const c = JSON.parse(G.canvas);
    if (Number.isFinite(c.w)) WIDTH = c.w;
    if (Number.isFinite(c.h)) HEIGHT = c.h;
  } catch {
    anomalies.push(
      `could not JSON.parse canvas frontmatter: ${G.canvas} — using ${WIDTH}×${HEIGHT}`,
    );
  }
}

// audio duration is the spine truth
let audioDur = null;
if (existsSync(audiomapPath)) {
  try {
    audioDur = JSON.parse(readFileSync(audiomapPath, "utf8"))?.audio?.duration_sec ?? null;
  } catch (e) {
    anomalies.push(`audiomap.json parse failed (${e.message}) — using frame sum for duration`);
  }
}

// ---------- resolve mountable frames in document order ----------
const mounted = [];
for (const f of manifest.frames) {
  const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`;
  if (!f.src) die(`${label} has no \`src\` — the planner must write it in STORYBOARD.md`);
  const compAbs = join(hyperframesDir, f.src);
  if (!existsSync(compAbs))
    die(`${label}: src ${f.src} is not on disk — re-dispatch its frame-worker before assembling`);
  if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0)
    die(`${label}: no positive \`duration\` (got ${JSON.stringify(f.duration)})`);
  const compId = basename(f.src).replace(/\.html?$/i, "");
  const inner = readFileSync(compAbs, "utf8");
  if (!inner.trim() || !/<\w/.test(inner))
    die(
      `${label}: ${f.src} is empty/blank — the frame-worker wrote a partial file. Re-dispatch it.`,
    );
  if (
    !inner.includes(`data-composition-id="${compId}"`) &&
    !inner.includes(`data-composition-id='${compId}'`)
  )
    die(`${label}: ${f.src} has no data-composition-id="${compId}" (host/inner id must match)`);
  mounted.push({ frame: f, compId, durationSeconds: r3(f.durationSeconds) });
}
if (mounted.length === 0) die("no mountable frames (none with an on-disk src)");

// cumulative starts — start[i] + duration[i] == start[i+1] exactly (gap-free hard cuts)
let acc = 0;
for (const m of mounted) {
  m.start = r3(acc);
  acc += m.durationSeconds;
}
const FRAME_SUM = r3(acc);
const TOTAL = r3(audioDur ?? FRAME_SUM);
if (audioDur != null && Math.abs(FRAME_SUM - audioDur) > 0.1)
  anomalies.push(
    `frames sum to ${FRAME_SUM}s but audio is ${audioDur}s (Δ${r3(FRAME_SUM - audioDur)}s) — frames should tile the track; check the plan`,
  );

// ---------- optional VO (deferred hook) ----------
let audio = { voices: [] };
if (existsSync(audioMetaPath)) {
  try {
    audio = JSON.parse(readFileSync(audioMetaPath, "utf8"));
  } catch (e) {
    anomalies.push(`audio_meta.json parse: ${e.message}`);
  }
}
const voiceByNum = new Map();
for (const v of audio.voices ?? []) if (v.frame != null) voiceByNum.set(v.frame, v);

// ---------- build <body> ----------
const body = [];
let voiceCount = 0;
for (const m of mounted) {
  body.push(
    `      <div`,
    `        id="el-${m.compId}"`,
    `        class="frame"`,
    `        data-composition-id="${m.compId}"`,
    `        data-composition-src="${m.frame.src}"`,
    `        data-start="${m.start}"`,
    `        data-duration="${m.durationSeconds}"`,
    `        data-track-index="1"`,
    `      ></div>`,
  );
  const v = m.frame.number != null ? voiceByNum.get(m.frame.number) : undefined;
  if (v?.path && existsSync(join(hyperframesDir, v.path))) {
    body.push(
      `      <audio id="el-${m.compId}-voice" src="${v.path}" data-start="${m.start}"`,
      `        data-duration="${m.durationSeconds}" data-track-index="10" data-volume="1"></audio>`,
    );
    voiceCount++;
  }
  body.push("");
}

// BGM (track 11) — full duration. Here the music IS the content (music-first
// skill), so it never drops to the explainer pipelines' narration-bed default
// (bgmDefaultVolume() 0.12 ≈ -18 dB): an incidental VO ducks it only slightly.
let bgmEmitted = false;
if (existsSync(join(hyperframesDir, bgmRel))) {
  const vol = voiceCount > 0 ? 0.8 : 0.9;
  body.push(
    `      <!-- BGM -->`,
    `      <audio id="el-bgm" src="${bgmRel}" data-start="0" data-duration="${TOTAL}"`,
    `        data-track-index="11" data-volume="${vol}"></audio>`,
  );
  bgmEmitted = true;
} else {
  anomalies.push(`BGM not found at ${bgmRel} — index has no music track`);
}

// ---------- head + emit ----------
const headStyle = [
  "      * { margin: 0; padding: 0; box-sizing: border-box; }",
  `      html, body { width: ${WIDTH}px; height: ${HEIGHT}px; overflow: hidden; background: #000; }`,
  `      #root { position: relative; width: ${WIDTH}px; height: ${HEIGHT}px; overflow: hidden; }`,
  "      .frame { position: absolute; inset: 0; width: 100%; height: 100%; }",
].join("\n");

const html = `<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
    <style>
${headStyle}
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="${TOTAL}"
      data-width="${WIDTH}"
      data-height="${HEIGHT}"
    >
${body.join("\n")}
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = gsap.timeline({ paused: true });
    </script>
  </body>
</html>
`;
writeFileSync(outPath, html);

console.log(`✓ wrote ${outPath}`);
console.log(`  canvas:           ${WIDTH}×${HEIGHT}`);
console.log(`  frames (track 1): ${mounted.length}`);
console.log(`  bgm (track 11):   ${bgmEmitted ? bgmRel : "MISSING"}`);
console.log(`  vo (track 10):    ${voiceCount}`);
console.log(`  total duration:   ${TOTAL}s` + (audioDur != null ? ` (audio ${audioDur}s)` : ""));
if (anomalies.length) {
  console.log(`\nanomalies (non-fatal):`);
  for (const a of anomalies) console.log(`  - ${a}`);
}
scripts/lib/storyboard.mjs
// storyboard.mjs — vendored lenient parser for STORYBOARD.md.
//
// Faithful plain-JS port of @hyperframes/core/storyboard
// (packages/core/src/storyboard/parseStoryboard.ts). Vendored because skills
// ship standalone: installed via `npx skills add`, a skill's scripts can't reach
// the monorepo's core package, and the core export points at .ts source that
// `node` (which runs these scripts) can't load. CANONICAL contract = the core
// parser + skills/hyperframes-core/references/storyboard-format.md; keep this in
// lockstep. Behavior: never throws, accepts freeform narrative, recognizes
// Frame/Beat/Scene headings at H2/H3, preserves unknown keys verbatim under
// `extra` (keys lowercased). Pure node — no deps.

export const FRAME_STATUSES = ["outline", "built", "animated"];
export const DEFAULT_FRAME_STATUS = "outline";

// Detection-only frame heading (ends at the keyword); ReDoS-hardened — keep as-is.
const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
const HEADING_LEVEL_RE = /^(#{1,6})\s+/;
const META_RE = /^\s*[-*]\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/;
const LEADING_INT_RE = /^(\d+)/;
const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"];
const VOICEOVER_KEYS = new Set(VOICEOVER_ALIASES);

export function parseStoryboard(source) {
  const warnings = [];
  const { globals, bodyStartLine, body } = parseFrontmatter(source, warnings);
  const frames = parseFrames(body, bodyStartLine, warnings);
  return { globals, frames, warnings };
}

function emptyGlobals() {
  return { extra: {} };
}

function isFrameStatus(value) {
  return FRAME_STATUSES.includes(value);
}

// ── Frontmatter ─────────────────────────────────────────────────────────────
function findFrontmatterRange(lines, warnings) {
  let start = 0;
  while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
  if ((lines[start] ?? "").trim() !== "---") return null;
  for (let i = start + 1; i < lines.length; i++) {
    if ((lines[i] ?? "").trim() === "---") return { start, end: i };
  }
  warnings.push({
    message: "Frontmatter opening '---' has no closing '---'; treating whole file as body.",
    line: start + 1,
  });
  return null;
}

function parseFrontmatterEntries(lines, start, end, warnings) {
  const globals = emptyGlobals();
  for (let i = start + 1; i < end; i++) {
    const raw = lines[i] ?? "";
    if (raw.trim() === "") continue;
    const colon = raw.indexOf(":");
    if (colon === -1) {
      warnings.push({
        message: `Ignored non key:value frontmatter line: "${raw.trim()}"`,
        line: i + 1,
      });
      continue;
    }
    const key = raw.slice(0, colon).trim().toLowerCase();
    assignGlobal(globals, key, stripQuotes(raw.slice(colon + 1).trim()));
  }
  return globals;
}

function parseFrontmatter(source, warnings) {
  const lines = source.split(/\r?\n/);
  const range = findFrontmatterRange(lines, warnings);
  if (!range) return { globals: emptyGlobals(), bodyStartLine: 1, body: source };
  const globals = parseFrontmatterEntries(lines, range.start, range.end, warnings);
  const body = lines.slice(range.end + 1).join("\n");
  return { globals, bodyStartLine: range.end + 2, body };
}

function assignGlobal(globals, key, value) {
  switch (key) {
    case "format":
      globals.format = value;
      break;
    case "message":
      globals.message = value;
      break;
    case "arc":
      globals.arc = value;
      break;
    case "audience":
      globals.audience = value;
      break;
    default:
      globals.extra[key] = value;
  }
}

// ── Frames ──────────────────────────────────────────────────────────────────
function openFrameSection(line, headingLine) {
  const match = FRAME_HEADING_RE.exec(line);
  if (!match) return null;
  const headingText = line.slice(match[0].length).replace(FRAME_TITLE_SEP_RE, "").trim();
  return { headingText, headingLine, level: (match[1] ?? "##").length, lines: [] };
}

function endsFrameSection(line, current) {
  if (!current) return false;
  const heading = HEADING_LEVEL_RE.exec(line);
  return heading !== null && (heading[1] ?? "").length <= current.level;
}

function parseFrames(body, bodyStartLine, warnings) {
  const lines = body.split(/\r?\n/);
  const sections = [];
  let current = null;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i] ?? "";
    const opened = openFrameSection(line, bodyStartLine + i);
    if (opened) {
      sections.push(opened);
      current = opened;
    } else if (endsFrameSection(line, current)) {
      current = null;
    } else if (current) {
      current.lines.push(line);
    }
  }
  return sections.map((section, idx) => buildFrame(section, idx + 1, warnings));
}

function buildFrame(section, index, warnings) {
  const frame = { index, status: DEFAULT_FRAME_STATUS, narrative: "", extra: {} };
  const { number, title } = parseHeading(section.headingText);
  if (number !== undefined) frame.number = number;
  if (title) frame.title = title;

  const narrativeLines = [];
  for (const line of section.lines) {
    const meta = META_RE.exec(line);
    if (meta) {
      applyMeta(
        frame,
        (meta[1] ?? "").toLowerCase(),
        (meta[2] ?? "").trim(),
        section.headingLine,
        warnings,
      );
    } else {
      narrativeLines.push(line);
    }
  }
  frame.narrative = narrativeLines.join("\n").trim();
  return frame;
}

function parseHeading(text) {
  if (!text) return {};
  const intMatch = LEADING_INT_RE.exec(text);
  if (!intMatch) return { title: text };
  const number = Number.parseInt(intMatch[1] ?? "", 10);
  const rest = text
    .slice((intMatch[0] ?? "").length)
    .replace(/^[\s.:—-]+/, "")
    .trim();
  return { number, title: rest || undefined };
}

// Dispatch a recognized metadata key to its field, else stash under `extra`.
// Mirrors core's META_SETTERS map exactly (direct keys + alias sets).
function applyMeta(frame, key, value, headingLine, warnings) {
  switch (key) {
    case "duration":
      applyDuration(frame, value, headingLine, warnings);
      return;
    case "status":
      applyStatus(frame, value, headingLine, warnings);
      return;
    case "poster":
      applyPoster(frame, value);
      return;
    case "src":
      frame.src = value;
      return;
  }
  if (TRANSITION_KEYS.has(key)) {
    frame.transitionIn = value;
    return;
  }
  if (SCENE_KEYS.has(key)) {
    frame.scene = value;
    return;
  }
  if (VOICEOVER_KEYS.has(key)) {
    frame.voiceover = stripQuotes(value);
    return;
  }
  frame.extra[key] = value;
}

function applyPoster(frame, value) {
  const num = DURATION_NUM_RE.exec(value);
  if (num) frame.poster = Number.parseFloat(num[1] ?? "");
}

function applyDuration(frame, value, headingLine, warnings) {
  frame.duration = value;
  const num = DURATION_NUM_RE.exec(value);
  if (num) {
    frame.durationSeconds = Number.parseFloat(num[1] ?? "");
    return;
  }
  warnings.push({
    message: `Frame ${frame.index}: could not parse duration "${value}".`,
    line: headingLine,
    frameIndex: frame.index,
  });
}

function applyStatus(frame, value, headingLine, warnings) {
  const normalized = value.toLowerCase();
  if (isFrameStatus(normalized)) {
    frame.status = normalized;
    return;
  }
  frame.extra.status = value;
  warnings.push({
    message: `Frame ${frame.index}: unknown status "${value}"; defaulting to "${DEFAULT_FRAME_STATUS}".`,
    line: headingLine,
    frameIndex: frame.index,
  });
}

function stripQuotes(value) {
  if (value.length >= 2) {
    const first = value[0];
    const last = value[value.length - 1];
    if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
      return value.slice(1, -1);
    }
  }
  return value;
}
scripts/stage-assets.mjs
#!/usr/bin/env node
// stage-assets.mjs — copy user-supplied media into the project's assets/ so scene
// files (and lint/validate/render) can reference them locally. Only needed when
// the user provides images/videos for asset treatments (montage.md). First-wins,
// idempotent, safe to run twice. Never fetches remote URLs.
//
// Usage: node stage-assets.mjs --from <srcDir> --hyperframes <projectRoot>
//        [--into public]   (subdir under assets/; default copies flat into assets/)
//
// Copies common media extensions only; reports what landed.

import { existsSync, mkdirSync, readdirSync, copyFileSync, statSync } from "node:fs";
import { extname, join, resolve, basename } from "node:path";

const argv = process.argv.slice(2);
const flag = (n, d) => {
  const i = argv.indexOf(`--${n}`);
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d;
};
function die(m) {
  console.error(`✗ stage-assets.mjs: ${m}`);
  process.exit(1);
}

const fromDir = flag("from", null);
if (!fromDir) die("missing --from <srcDir>");
const fromAbs = resolve(fromDir);
if (!existsSync(fromAbs) || !statSync(fromAbs).isDirectory())
  die(`--from is not a directory: ${fromAbs}`);
const hyperframesDir = resolve(flag("hyperframes", "."));
const into = flag("into", "");
const destDir = join(hyperframesDir, "assets", into);

const MEDIA = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".mp4", ".mov", ".webm", ".m4v"]);

mkdirSync(destDir, { recursive: true });
let staged = 0,
  skipped = 0;
const landed = [];
for (const name of readdirSync(fromAbs)) {
  const src = join(fromAbs, name);
  if (!statSync(src).isFile()) continue;
  if (!MEDIA.has(extname(name).toLowerCase())) continue;
  const dest = join(destDir, basename(name));
  if (existsSync(dest)) {
    skipped++;
    continue;
  } // first-wins
  copyFileSync(src, dest);
  staged++;
  landed.push(join("assets", into, basename(name)));
}

console.log(
  `✓ stage-assets: ${staged} copied, ${skipped} already present → ${join("assets", into)}/`,
);
for (const l of landed) console.log(`  ${l}`);
if (staged === 0 && skipped === 0) console.log(`  (no media files found in ${fromAbs})`);
scripts/validate-plan.mjs
#!/usr/bin/env node
// validate-plan.mjs — machine-check STORYBOARD.md against the audiomap + template
// catalog at Step 3, before any frame is built. Runs on the PLAN (frame files do
// not exist yet), so it checks fields, not on-disk html.
//
// HARD (exit 1): frontmatter duration_s == audiomap duration; >=1 frame; each frame
//   has src + positive duration; frames tile the track gap-free (sum == duration_s);
//   every frame has >=1 filled group (a frame whose `### Groups` is still
//   `TBD (Step 3)`/empty is a Step-2 skeleton, not an approved plan — fail it so a
//   skipped Step 3 can never reach the build step).
// WARN (exit 0): best-effort group checks — each group exactly one of
//   template/free_design/asset; a template id exists under the --templates dir;
//   phrase_flow frame has no beat_cut asset treatment.
//
// Frame-level checks use the vendored storyboard parser. Group-level checks re-scan
// the RAW source (the parser's META_RE consumes indented `- params:`/`- asset:` lines).
//
// Reads: --storyboard, --audiomap, --hyperframes <root> (for templates/).

import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";

const argv = process.argv.slice(2);
const flag = (n, d) => {
  const i = argv.indexOf(`--${n}`);
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d;
};
const hyperframesDir = resolve(flag("hyperframes", "."));
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audiomapPath = resolve(flag("audiomap", join(hyperframesDir, "audiomap.json")));
const templatesDir = resolve(flag("templates", join(hyperframesDir, "templates")));

const errors = [];
const warns = [];
const r3 = (x) => Math.round(x * 1000) / 1000;

if (!existsSync(storyboardPath)) {
  console.error(`✗ STORYBOARD.md not found at ${storyboardPath}`);
  process.exit(1);
}
const raw = readFileSync(storyboardPath, "utf8");
const manifest = parseStoryboard(raw);
const G = manifest.globals.extra ?? {};

// ---------- audio duration ----------
let audioDur = null;
if (existsSync(audiomapPath)) {
  try {
    audioDur = JSON.parse(readFileSync(audiomapPath, "utf8"))?.audio?.duration_sec ?? null;
  } catch (e) {
    warns.push(`audiomap parse failed: ${e.message}`);
  }
} else {
  warns.push(`audiomap not found at ${audiomapPath} — skipping duration cross-check`);
}

// ---------- frontmatter duration_s ----------
const declaredDur = G.duration_s != null ? Number.parseFloat(G.duration_s) : NaN;
if (!Number.isFinite(declaredDur)) errors.push(`frontmatter \`duration_s\` missing or unparseable`);
else if (audioDur != null && Math.abs(declaredDur - audioDur) > 0.05)
  errors.push(`frontmatter duration_s (${declaredDur}) != audiomap duration (${audioDur})`);

// ---------- frames (hard) ----------
const frames = manifest.frames;
if (frames.length === 0) errors.push(`no frames (no \`## Frame N — <id>\` headings found)`);

let sum = 0;
for (const f of frames) {
  const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`;
  if (!f.src) errors.push(`${label}: missing \`- src:\``);
  if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0)
    errors.push(`${label}: missing/!positive \`- duration:\` (got ${JSON.stringify(f.duration)})`);
  else sum += f.durationSeconds;
}
sum = r3(sum);
const tileTarget = Number.isFinite(declaredDur) ? declaredDur : audioDur;
if (tileTarget != null && Math.abs(sum - tileTarget) > 0.1)
  errors.push(
    `frame durations sum to ${sum}s but the track is ${tileTarget}s — frames must tile it gap-free`,
  );

// ---------- group checks (warns) — parse RAW text ----------
const FRAME_HEAD = /^##\s+(?:frame|scene|section)\b/i;
const GROUP_HEAD = /^\s*[-*]\s*\*\*\s*(\w+)\s*\*\*\s*[—:-]\s*(template|free_design|asset)\b(.*)$/i;
const templateExistsCache = new Map();
function templateExists(id) {
  if (templateExistsCache.has(id)) return templateExistsCache.get(id);
  const ok = existsSync(join(templatesDir, id, "index.html"));
  templateExistsCache.set(id, ok);
  return ok;
}

// walk raw lines → group blocks tagged with their frame label + pacing
const blocks = [];
let frameLabel = "?";
let pacing = "";
let cur = null;
for (const ln of raw.split(/\r?\n/)) {
  if (FRAME_HEAD.test(ln)) {
    frameLabel = ln.replace(/^#+\s+/, "").trim();
    pacing = "";
    cur = null;
    continue;
  }
  const pm = ln.match(/^\s*[-*]\s*pacing\s*:\s*([A-Za-z_]+)/i);
  if (pm && !cur) {
    pacing = pm[1].toLowerCase();
    continue;
  }
  const h = GROUP_HEAD.exec(ln);
  if (h) {
    cur = { frameLabel, pacing, name: h[1], kind: h[2].toLowerCase(), rest: h[3] ?? "", lines: [] };
    blocks.push(cur);
    continue;
  }
  if (cur) cur.lines.push(ln);
}

// HARD: every frame needs >=1 filled group. A frame with no parseable group head
// is still a Step-2 skeleton (`### Groups` = `TBD (Step 3)`/empty) — erroring here
// is what stops a skipped Step 3 from reaching the build step.
const framesWithGroups = new Set(blocks.map((b) => b.frameLabel));
for (const f of frames) {
  const title = String(f.title ?? "").trim();
  const lbl = `Frame ${f.number ?? ""} — ${f.title ?? f.index}`.replace(/\s+—\s+$/, "");
  const hasGroup = title !== "" && [...framesWithGroups].some((s) => s.includes(title));
  if (!hasGroup) {
    errors.push(
      `${lbl}: no filled groups — still a Step-2 skeleton (\`### Groups\` is TBD/empty). ` +
        `Run Step 3: fill its groups (\`- **gN** — template|free_design|asset …\`) before building.`,
    );
  }
}

for (const b of blocks) {
  const gid = `${b.frameLabel} / ${b.name}`;
  const blockText = b.rest + " " + b.lines.join(" ");
  if (b.kind === "template") {
    const m = b.rest.match(/`([^`]+)`/) || b.rest.match(/:\s*([\w-]+)/);
    const id = m ? m[1].trim() : null;
    if (!id) warns.push(`${gid}: template kind but no template id on the head line`);
    else if (!templateExists(id))
      warns.push(`${gid}: template \`${id}\` not found at templates/${id}/index.html`);
  }
  if (b.kind === "asset" && b.pacing === "phrase_flow" && /beat_cut/.test(blockText))
    warns.push(
      `${gid}: beat_cut asset treatment on a phrase_flow frame — use ken_burns/crossfade instead`,
    );
}

// ---------- report ----------
for (const w of warns) console.log(`⚠ ${w}`);
if (errors.length) {
  for (const e of errors) console.error(`✗ ${e}`);
  console.error(`\nvalidate-plan: ${errors.length} error(s), ${warns.length} warning(s)`);
  process.exit(1);
}
console.log(
  `✓ validate-plan: ${frames.length} frames tile ${sum}s; ${blocks.length} groups; ${warns.length} warning(s)`,
);
SKILL.md
---
name: music-to-video
description: "Turn a music track (an audio file, a video to pull audio from, or a track generated from a mood brief) into a beat-synced video — lyric video, slideshow, or kinetic promo. The music drives all pacing; any user-supplied images/videos are cut onto the same beat grid, and a complete video needs zero assets. Narrated pieces → the input-matched workflow (see /hyperframes). Unclear → /hyperframes."
---

> **First, keep this skill fresh — confirm with the user before running:** `npx hyperframes skills update music-to-video`. A fast no-op when everything is current; otherwise it refreshes this skill plus the core domain skills it depends on before you rely on them.

# music-to-video — one music-grounded, beat-synced video workflow

Use this skill to turn a **music track** into a beat-synced HyperFrames video. You analyze the track once, lay out the frames, fill in a per-frame plan, and build each frame as a composition. The input is a music track plus optional user images or videos — there is **no narration and no website capture**. Typography and templates are the floor (a complete video needs zero assets); any media the user supplies is cut in on the same beat grid.

You are the **orchestrator**. Work in `videos/<project>/`. Run the steps in order and pass each **Gate** before moving on. Two steps need the user: **Step 3** (plan approval) and **Step 6** (render approval) — both are checkpoint gates per `../hyperframes-core/references/brief-contract.md` (read it before Step 0): in autonomous mode, post the summary as a heads-up and proceed instead of waiting. Do every step yourself except **Step 4**, where you dispatch **one sub-agent per frame**. Keep design and motion rules out of this file — they live in `references/` and the `frame-worker` sub-agent.

`SKILL_DIR` = this skill directory. `PROJECT_DIR` = `videos/<project-name>/`.

Workflow: Step 0 setup → `hyperframes.json` + `assets/bgm.mp3`; Step 1 analyze → `audiomap.json`; Step 2 skeleton → `STORYBOARD.md` (frames, groups `TBD`); Step 3 plan → complete `STORYBOARD.md` + `frame.md`; Step 4 build → `compositions/frames/NN-*.html`; Step 5 assemble → `index.html`; Step 6 render → `renders/video.mp4`.

## Two ideas that shape everything

- **One analyzer, and you trust it.** `analyze-beatgrid.py` is the only beat analyzer — never re-measure beats with another tool or by ear. Its energy / density / rolls / onsets / silences are always reliable. Its `bpm` and `beats_sec` are reliable **only when the music is genuinely rhythmic**; on calm music the grid is a metronome the tracker imposed, so pace by phrases and energy instead and never hard-cut to it. Deciding which case you're in is each frame's `pacing` (Step 2).
- **One frame = one file; groups live inside.** Step 2 cuts the track into **frames**, and each frame becomes one composition file `compositions/frames/NN-<frame_id>.html`, built by one frame-worker. A frame can subdivide into **groups** (each a template or a motion-primitives combo). Extra density goes _inside_ a group, so **frame count tracks distinct treatments, not beats** — a fast track does not blow up the number of sub-agents.

---

## Step 0: Setup, BGM, and inputs

Goal: Establish the music source, create the HyperFrames project, and note any user-supplied media.

**The brief starts at the intent layer.** Opening rule, in order: **(1)** `BRIEF.md` exists → read it and ask nothing it answers — its `flow`/`storyboard` derive the mode (brief contract § 1). **(2)** No `BRIEF.md` but the project exists → resume from what's on disk; never re-interrogate. **(3)** A fresh creation request that arrived here directly → read `/hyperframes` and run its intent layer (`references/intent-interview.md`): it confirms this route's must-haves (the music source, destination → aspect — `../hyperframes/references/routes/music-to-video.md`) and announces what stays deferred — brand and genre are chosen at Step 3 by design. Write `BRIEF.md` immediately after init (never before — `init` refuses a non-empty directory) and record the preference-backed answers (`brief-format.md`). Edit requests skip all of this.

The **music is the spine** — establish one track before anything else. This skill is tuned for **fast, high-energy BGM**: a strong beat grid drives the cuts (calm tracks work, but pace by phrase rather than beat). If the user supplied audio — a music file, or a video to pull audio from — use it. Otherwise choose the mood from the request and generate a track through `/media-use` (`references/bgm.md`). Before the first authenticated provider action, run `npx hyperframes auth status` and relay its output verbatim. If signed out, apply one branch:

- **Collaborative:** wait for sign-in or an explicit choice to continue offline with the local provider.
- **Autonomous:** state the status and continue through the available local provider.

If no offline provider can satisfy the required music capability, surface the blocker. Never write keys into a per-repo `.env`. Auth ownership and offline fallbacks live in `/media-use` `references/setup-providers.md` § Providers. The resulting track lands at `assets/bgm.mp3`. Stage supplied images or videos so frames can use them on the beat grid; otherwise typography carries the video.

**Lyric videos:** for lyrics synced to the vocals, get word/line timing by transcribing the track via `/media-use`, or ask the user for the lyrics text and place lines on the beat grid.

Initialize only if `hyperframes.json` is missing. Name `<project>` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.

```bash
npx hyperframes init "videos/<project>" --non-interactive --example=blank --skill=music-to-video
mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders"
cp "<user-music>" "$PROJECT_DIR/assets/bgm.mp3"   # extract from a video first if needed
# only if the user gave you images/videos:
node <SKILL_DIR>/scripts/stage-assets.mjs --from <dir> --hyperframes "$PROJECT_DIR" --into public
```

The **brand** (font + palette) is chosen at Step 3, not here. Don't pick a genre or a track type up front — assets are just an optional ingredient, and the genre emerges from the per-frame choices.

**Gate:** `hyperframes.json` + `assets/bgm.mp3` exist; aspect / length / fps and (if any) the asset inventory are noted.

---

## Step 1: Analyze the music

Goal: Produce the one canonical timing analysis the whole video is built on.

`analyze-beatgrid.py` is the **only** beat analyzer — never re-measure beats with another tool or by ear. It reads the track once and writes `audiomap.json`: energy phases (level / density / feel), onsets + `onset_rate`, rolls, silences, `hard_stops`, `key_moments`, phrases, tempo / grid, and `audio.duration_sec`. It's deterministic — the same file always gives the same map. Most fields are reliable on any music; `bpm` and `beats_sec` are reliable only when the music is genuinely rhythmic, and judging that is the call you make at Step 2.

Prerequisites: Python 3 with `librosa`, `numpy`, and `soundfile` available. If import fails, install them into the active Python environment before running the analyzer:

```bash
python3 -m pip install librosa numpy soundfile
```

```bash
python3 <SKILL_DIR>/scripts/analyze-beatgrid.py "$PROJECT_DIR/assets/bgm.mp3" \
  -o "$PROJECT_DIR/audiomap.json" --print
```

**Gate:** `audiomap.json` exists; `audio.duration_sec` is known.

---

## Step 2: Frame skeleton (structure only)

Goal: Read the music and lay out the frames — the skeleton of `STORYBOARD.md`.

Read [`references/frame-skeleton.md`](references/frame-skeleton.md). Turn `audiomap.json` into the **skeleton** of `STORYBOARD.md` yourself — there is no intermediate JSON. Cut the track into **frames** at real musical changes (`hard_stops`, SURGE / DROP `key_moments`, the edges of a roll, a stretch with no onsets, a big energy jump), snapping every boundary to an audiomap anchor. For each frame set `span_sec`, `pacing` (the verdict from Step 1's trust call — `beat_cut` when the grid is real, `phrase_flow` when it's a metronome imposed on calm music), `mood`, and a one-line `feel` (the plain music situation Step 3 matches a template against). Only classify and lay out here: leave every frame's `### Groups` as `TBD (Step 3)` and the frontmatter `style` blank — no templates, copy, color, or fonts. Expect ~1–6 frames.

**Gate:** frames tile the track (first at 0, last at `duration_s`); each carries `span_sec` + `pacing` + `mood` + `feel`; every `### Groups` is `TBD`; no content anywhere.

---

## Step 3: Fill the plan (user-gated)

Goal: Turn the skeleton into an approved, complete `STORYBOARD.md`.

Read [`references/planning.md`](references/planning.md), [`storyboard-format.md`](references/storyboard-format.md), [`template-catalog.md`](references/template-catalog.md), [`motion-primitive-catalog.md`](references/motion-primitive-catalog.md), and [`montage.md`](references/montage.md) (only if the user supplied assets). Editing the same file in place, do two things:

1. **Pick the brand.** Choose one preset from `../hyperframes-creative/frame-presets/` using the table in `../hyperframes-creative/references/design-spec.md` (match the track's mood; **only its fonts and colors matter** — templates own composition). Copy it into `frame.md` **unmodified** and fill the frontmatter `style` (font + a ≤4–6 swatch palette) from it.
2. **Fill every frame.** Decide its groups and give each a treatment: a matched template from the catalog (with bound params and real audiomap anchors), a free-compose from the primitive catalog, or an asset treatment that **obeys `pacing`**. Write the copy. You own WHAT (template / primitives + content + anchors); the frame-worker owns HOW — **never write millisecond tweens into the storyboard**.

```bash
node <SKILL_DIR>/scripts/validate-plan.mjs --storyboard "$PROJECT_DIR/STORYBOARD.md" \
  --audiomap "$PROJECT_DIR/audiomap.json" --templates <SKILL_DIR>/references/templates
```

Fix every `✗` (hard errors: duration mismatch, frames not tiling the track, a missing `src`); warnings are best-effort. Then show the user a frame-by-frame summary and iterate until they approve. In autonomous mode this is a checkpoint gate: post the summary as a heads-up and proceed (the `validate-plan.mjs` pass is a quality gate and still blocks).

**Gate:** `frame.md` is a verbatim preset copy; `validate-plan.mjs` exits 0; the user approved the plan (autonomous: the summary was posted as a heads-up).

---

## Step 4: Build frames from the plan

Goal: Build every frame as a self-contained composition file.

Create `compositions/frames/`. Read [`sub-agents/frame-worker.md`](sub-agents/frame-worker.md) and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch **one frame-worker per frame**, in parallel where possible (otherwise in waves). Each worker gets exactly one frame and this context:

```text
PROJECT_DIR: <abs path>
frame_id: <NN-frame_id>              # = the frame file stem, e.g. 02-f2; the composition id
Your block: the `## Frame N — <frame_id>` block in PROJECT_DIR/STORYBOARD.md
audiomap: PROJECT_DIR/audiomap.json
frame.md: PROJECT_DIR/frame.md
Materials: for each group, <SKILL_DIR>/references/templates/<id>/index.html (templates) and
           <SKILL_DIR>/references/motion-primitives/<id>/ (free); staged assets/ (asset groups)
Contracts: ../hyperframes-core/references/sub-compositions.md + determinism-rules.md
Canvas: <w>×<h>   Pacing: <beat_cut|phrase_flow>
Write to: PROJECT_DIR/compositions/frames/<frame_id>.html
```

The worker forks the cited materials, converts every anchor to frame-local seconds (`local_t = track_t − span_sec[0]`), gates its groups with 0ms cuts, and writes one seek-safe frame file. **The worker never runs the `hyperframes` CLI** — those commands operate on the assembled project, which doesn't exist yet, so they'd report on the wrong files. The worker just writes to the contract and stops; you verify after assembly (Step 6). As each worker returns, you can confirm its file landed on disk.

**Gate:** every frame has its `compositions/frames/NN-*.html` on disk.

---

## Step 5: Assemble

Goal: Wire the built frames + BGM into the playable `index.html`.

`assemble-index.mjs` is deterministic — no subagent, no judgment. It references each frame file at its cumulative `data-start`, mounts `assets/bgm.mp3` on track 11, and hard-cuts frame → frame (frames tile the track with no gaps, so there is **no transition injector**).

```bash
node <SKILL_DIR>/scripts/assemble-index.mjs --storyboard "$PROJECT_DIR/STORYBOARD.md" \
  --hyperframes "$PROJECT_DIR" --audiomap "$PROJECT_DIR/audiomap.json"
```

Fix any `✗` it reports — a missing or blank frame file means that worker wrote a partial file; re-dispatch it (Step 4) and re-assemble.

**Gate:** `index.html` exists; total duration == `audiomap.audio.duration_sec`.

---

## Step 6: Verify and render

Goal: Verify the assembled video, get user approval, and render the final MP4.

Run the CLI on the **assembled project** — that's the correct unit (the per-frame workers couldn't run it). `check` runs structural lint and the headless-browser runtime, layout, motion, and contrast gate in one pass; `--snapshots` also emits the review frames.

```bash
( cd "$PROJECT_DIR" && npx hyperframes check . --snapshots )
```

Inspect at `t=0`, each frame start, the strongest DROP / SURGE, every `hard_stops[].t`, and the final frame. On failure, make the **cheapest safe fix** yourself: edit the offending `compositions/frames/NN-*.html`. Never change duration or audio timing to hide a sync issue. Once the gates pass, pause for user review, then render only on approval (autonomous mode: ask the one kept question — "preview first, or render?" — then deliver the MP4 with the contact sheet):

```bash
( cd "$PROJECT_DIR" && npx hyperframes render . --skill=music-to-video -q draft -o renders/video.mp4 --fps 30 )
```

**Gate:** `check` passed and the snapshots were inspected; the user approved (autonomous: checks passed and the delivery includes the contact sheet); `renders/video.mp4` exists with audio, duration == `audiomap.audio.duration_sec`. The final reply states the MP4 path and duration.

---

## Resume table

| You have                   | Continue from |
| -------------------------- | ------------- |
| `assets/bgm.mp3` only      | Step 1        |
| `audiomap.json`            | Step 2        |
| `STORYBOARD.md` (skeleton) | Step 3        |
| `STORYBOARD.md` (complete) | Step 4        |
| all frame files            | Step 5        |
| `index.html`               | Step 6        |

## Quick Reference

**Formats:** landscape `1920x1080` by default; portrait `1080x1920`; square `1080x1080`. Set the canvas once in the storyboard frontmatter (`canvas: { w, h, fps }`).

**Scripts** under `scripts/`: `analyze-beatgrid.py` (the one analyzer), `validate-plan.mjs` (plan check), `assemble-index.mjs` (index assembly), `stage-assets.mjs` (stage user media), `lib/storyboard.mjs` (vendored parser). Everything else is the `hyperframes` CLI.

| Read                                                                                                           | When                                                    |
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| [`references/frame-skeleton.md`](references/frame-skeleton.md)                                                 | Step 2: read the music, lay out the frames, set pacing  |
| [`references/planning.md`](references/planning.md) · [`storyboard-format.md`](references/storyboard-format.md) | Step 3: pick the brand, fill each frame, write the plan |
| [`references/template-catalog.md`](references/template-catalog.md)                                             | Step 3: pick a template per group                       |
| [`references/motion-primitive-catalog.md`](references/motion-primitive-catalog.md)                             | Step 3/4: L0 recipes for free-compose                   |
| [`references/montage.md`](references/montage.md)                                                               | Step 3/4: asset treatments (beat-cut / ken-burns)       |
| [`sub-agents/frame-worker.md`](sub-agents/frame-worker.md)                                                     | Step 4: dispatch + build one frame                      |
| `../hyperframes-core/references/subagent-dispatch.md`                                                          | Step 4: dispatch sub-agents safely                      |
| `../hyperframes-creative/references/design-spec.md`                                                            | Step 3: pick the preset (the brand)                     |

## Directory layout

```
music-to-video/
  SKILL.md
  references/   frame-skeleton.md · planning.md · storyboard-format.md
                template-catalog.md · motion-primitive-catalog.md · montage.md
                templates/<id>/          { index.html (+ assets/ · program.json) }  ← L1 catalog impls
                motion-primitives/<id>/  { index.html } (+ ../assets/gsap.min.js shared by recipes) ← L0 catalog impls
  scripts/      analyze-beatgrid.py · assemble-index.mjs · validate-plan.mjs · stage-assets.mjs · lib/storyboard.mjs
  sub-agents/   frame-worker.md   ← the one subagent (one per frame)
```
sub-agents/frame-worker.md
# Frame worker — per-frame composition author (music-to-video)

You build one frame's composition file: `compositions/frames/<frame_id>.html`. Siblings build the other frames in parallel. The generic HyperFrames law — sub-composition shape, timeline registration, determinism, layout — lives in `hyperframes-core` (`references/sub-compositions.md`

- `determinism-rules.md` + `data-attributes.md`); read it first. This file covers the music-specific part.

Your job: **follow the manual, fetch the materials, assemble.** The storyboard tells you WHAT (the frame's groups, each group's template / primitives, content, brand, real beat-anchor seconds). You decide HOW (fetch the materials, bind them to this frame's audio seconds, micro-timing, layout, namespacing).

## Inputs (your dispatch context)

- `PROJECT_DIR` — project root; all paths relative to it.
- `frame_id` — the frame file's stem, e.g. `02-f2`. Use it verbatim as the composition id, the `window.__timelines` key, and the filename `compositions/frames/<frame_id>.html` (the assembler matches on it).
- Your **`## Frame N` block** in `STORYBOARD.md` — its `span_sec`, `pacing`, `mood`, `feel`, and its **`### Groups`** list. Each group is one of:
  - **template** — `template:<id>` + `params` + `role_bindings` (real audiomap anchor seconds) + `copy`.
  - **free_design** — `free_design:{dominant_system, primitives, density_topology}` + `anchors[]` + `copy`.
  - **asset** — `asset:{treatment, clips, anchors?, overlay_copy?}` (see `montage.md`).
- `audiomap.json` — timing truth; use the seconds you're given.
- `frame.md` — the brand (palette + type). Pull every visual token from here.
- **Materials** — `references/templates/<id>/index.html` (its `data-composition-variables` give the param semantics) for template groups; `references/motion-primitives/<id>/index.html` for free groups; staged `assets/…` for asset groups.
- Canvas `<width>×<height>` and the frame's `pacing`.

If your dispatch carries `lint` / `check` feedback from a prior pass, address each finding.

## What comes fixed — realize it as given

- **No plan = stop.** If your `### Groups` is `TBD`/empty (Step 3 was skipped), report back and write nothing — never invent groups, templates, or copy.
- **The plan** is set in your `## Frame` block: the groups, templates / primitives, copy, brand, and anchors. Build it as written. If a plan is genuinely wrong (wrong template or copy), stop and report — the orchestrator re-plans at Step 3.
- **Transitions** are the assembler's: it hard-cuts between frames. You author the frame's **internal** group→group cuts only.
- **Audio** lives on the root `index.html`; your frame is silent.
- **GSAP** is loaded by the host; use the global `gsap` (your frame carries no gsap `<script>`).
- **Duration** is the frame span length; build your timeline to run `0 … span_len`.

## Build

1. **Read** your `## Frame` block, `frame.md`, and the body of every template / primitive it cites. Reproduce those recipes.
2. **Work in frame-local time.** Subtract the frame start from every anchor: `local_t = track_t − span_sec[0]`.
3. **Author** `compositions/frames/<frame_id>.html`: a `<template>` wrapping `#stage` (`data-composition-id="<frame_id>"`), with all `<style>` / `<script>` inside it, and one paused `gsap.timeline({paused:true})` registered at `window.__timelines["<frame_id>"]`, built synchronously, ending in `tl.seek(0)`. Give each group its own container; show it across its frame-local span with `tl.set("#g1",{autoAlpha:1}, start)` / `tl.set("#g1",{autoAlpha:0}, end)` (this 0ms swap is the group→group cut); namespace each group's ids and shader uniforms `g1_` / `g2_`. When forking a template, inline its DOM / CSS / tweens and use the host's global `gsap`. On a `phrase_flow` frame, pace by phrase / energy.
4. **Self-check and finish.** Run the checklist and fix in place. Writing the file is your terminal action; the orchestrator runs `lint` / `check` and snapshots after assembly (Step 6) and re-dispatches you with any finding.

## Self-check

- `<template>`-wrapped `#stage`; `data-composition-id` == timeline key == filename stem == `<frame_id>`; all `<style>` / `<script>` inside `<template>`; uses the host's global `gsap`.
- One paused timeline, registered, ending in `tl.seek(0)`; `data-duration` == frame span length; silent.
- Each group shows across its frame-local span and hides outside it; `t=0` renders; group→group cuts are 0ms; ids / uniforms namespaced.
- Each group's text / palette match its block's `params` / `copy`, drawn from `frame.md`.
- `phrase_flow` frames pace by phrase / energy.
- Seek-safe per `hyperframes-core/determinism-rules.md` (derive variation from indices; swap text / numbers with `tl.set`).
- Asset clips: muted `<video>`, direct children of `#stage`, with `data-start` / `data-duration` / `data-track-index` and the crossfade hard-kill `tl.set`.
- Final frame is intentional; hero text is readable and clear of the edges.