Zurück zu Skills
heygen-com/hyperframesVor der Ausführung prüfen

SKILL DETAIL

motion-graphics

heygen-com/hyperframes/motion-graphics

This skill produces short, design-led motion graphics where motion itself carries the message. It covers kinetic typography, stat count-ups, chart/data-viz animations, logo stings/brand lockups, lower-thirds/callouts/social overlays, animated maps (highlighting regions, connecting places, zooming to a location), animated tweets/news articles/headlines, webpage/UI animations (scroll, cursor, callouts), or fusing a real image's geometry into a chart. Pieces are usually under 10 seconds (up to ~30 seconds), with no narration or live-action subject, and render to MP4 or transparent overlay. For longer, narrated, or multi-scene content, use /general-video. If uncertain, use /hyperframes.

Installationen · 1.451Quelle ansehen

Installation

npx skills add https://github.com/heygen-com/hyperframes --skill motion-graphics

Skill-Dateien

SKILL.md

Zuletzt synchronisiert · 29.08.2026

agents/builder.md
# Motion-Graphics Builder

Turn `shot-plan.json` into one renderable HyperFrames composition (`compositions/index.html`). Everything stays in the HF ecosystem — HTML is the source of truth; a single **paused** GSAP timeline carries all motion; the engine seeks it. Category-specific build rules live in `categories/<id>/module.md`; this file is the shared contract.

## Reuse-first (the default)

Default = **compose existing catalog capabilities, not hand-author**:

- `npx hyperframes add <block>` (registry) → customize in place. Most blocks bake content/data into their own script (only a few expose CSS-var params), so reuse = **add + edit**.
- `hyperframes-animation` rules / blueprints / transitions for motion; runtime adapters (GSAP default).

Hand-author only (a) gaps no block/rule covers, (b) the `asset-fusion` affordance binding. The Director named the block(s) + customizations in `shot-plan.json` (`content.block` + `content.customize`); see `catalog-map.md`.

## The HF contract (non-negotiable)

- Root `#stage` carries `data-composition-id`, `data-start="0"`, `data-duration=<s>`, `data-fps`, `data-width`, `data-height`.
- Exactly ONE `gsap.timeline({ paused:true })`; register `window.__timelines["<id>"] = tl;`; end with `tl.seek(0)`. **Never `tl.play()`** for render-critical motion. No timers / async / event-driven timeline build. Finite repeats only.
- **Timed clips** need `class="clip"` + a stable `id`. Timeline-driven groups inside one full-duration clip don't each need timing attrs.
- **Fonts**: prefer local `@font-face` (.woff2) for deterministic / offline render; CDN Google Fonts do render (compiler caches + injects `@font-face`) but warn + need network.
- **Deterministic only** — no `Date.now()` / `Math.random()` / network.

## Layout before animation

Build the **hero-frame end-state** in CSS first (flex + padding; never absolute offsets on content containers; the root must be sized). Use `fromTo()` for elements inside `.clip` and for any delayed entrance; `from()` is safe only for non-clip elements active from `t=0`. Exits belong to transitions or the final scene. Full rules: `references/builder-contract.md`.

## IR → composition

- `content.block` → `hyperframes add` it (or inline) + apply `content.customize`.
- per-category `content` (text scenes / chart data / fusion positions / news-tweet content) → realize per `categories/<id>/module.md`.
- resolved `asset_needs` → reference **frozen project-local paths** (never a remote URL or a prompt).
- `palette[-1]` / bg + `font` from the envelope.
- `export: alpha-overlay` → transparent bg; render `--format webm` (or `mov`).

## Critical correctness (GSAP / seek)

Opacity-gate delayed elements (set hidden until their entrance). Clamp at tween bounds (no overshoot past a held value). Allowed eases: `power1–4`, `back`, `bounce`, `circ`, `elastic`, `expo`, `sine` (`.in/.out/.inOut`). One motif per scene. Run `hyperframes check` for overflow / collisions.

## Hand off for verification

Self-check the authored file, then return it to the orchestrator. Step 5 runs `hyperframes lint`, `hyperframes check`, and proof snapshots on the assembled project. Do not render. When redispatched with a finding, fix the offending element and never change a fixed `data-duration` during repair. Remotion-source migrations use `/remotion-to-hyperframes` and its SSIM harness instead.
agents/director.md
# Motion-Graphics Director

Turn a request into a `shot-plan.json` for a short (~3–30s) **design-led motion graphic**. You run in **two parts** around the asset-sourcing step: **Part 1 (plan)** before sourcing, **Part 2 (design)** after. You do NOT write composition code — that's the Builder. Schema: `references/shot-plan-ir.md`.

## Part 1 — Plan (before sourcing)

Emit a DRAFT `shot-plan.json`.

0. **Decide first: does this need a search?** No → a **form category** (user supplies content). Yes → emit a search plan; the specific **search-driven category** (`webpage` / `news` / `tweet` / `asset-fusion`) is confirmed by what the search returns (Step 2 → finalized in Part 2).

1. **Classify** — form categories by intent below; search-driven categories are picked post-search:

   | Category       | Pick when…                                                                                                                                                                         |
   | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
   | `kinetic-type` | a punchy line / quote / title; text is the hero                                                                                                                                    |
   | `stat`         | a single hero number / count-up                                                                                                                                                    |
   | `charts`       | bar / line / pie / race / % from data                                                                                                                                              |
   | `logo-reveal`  | a logo sting / brand lockup (user supplies the logo)                                                                                                                               |
   | `lower-thirds` | name/title bars, callouts, social overlays                                                                                                                                         |
   | `maps`         | a geographic shot — highlight regions, connect places, zoom to a location. Sub-fork: **vector** (D3, stylized) vs **basemap** (baked MapLibre — real satellite/dark/zoom-to-place) |
   | `webpage`      | highlight / animate a real captured web page or UI _(search-driven)_                                                                                                               |
   | `news`         | a news article → article-highlight: blur → zoom into keyword _(search-driven)_                                                                                                     |
   | `tweet`        | a tweet → animated card _(search-driven)_                                                                                                                                          |
   | `asset-fusion` | a real photo/asset's geometry _becomes_ the chart _(search-driven)_                                                                                                                |

   If genuinely ambiguous between two, ask exactly one question. Then load `categories/<id>/module.md` for that category's specifics.

2. **Asset strategy → `asset_needs[]`.** Each item: `{ role, kind: image|icon|logo|svg|news|web|tweet, query|source, treatment }`.
   - asset-free (`kinetic-type`, most `stat`/`charts`) → `asset_needs: []`.
   - `maps` → **vector** lane: `asset_needs: []` (D3/TopoJSON, runs live in HF). **basemap** lane (satellite/dark/zoom-to-place): `asset_needs: [{ type: "map-bake", … }]` (baked in Source — see `categories/maps/module.md`).
   - `webpage` / `news` / `tweet` → search the real source (page / article / tweet) + a supporting image. **Two-pole queries only**: atomic (1–3 words, composable: portraits, logos, objects) OR specific (5–15 words: a news event, a tweet). Never the middle. A failed specific query is dropped, not broadened.
   - `asset-fusion` → search or generate one hero asset.
   - `logo-reveal` → user-supplied logo (`source`).

3. **Envelope**: `duration_s` (3–30), `fps` (30), `canvas` (default 1080×1920; 16:9 / 1:1 per platform), `style`, `palette` (hex list, or `"derive-from-asset"`), `font` (from the HF embed list), `beats`, `export` (`mp4` | `alpha-overlay`).

4. **Shot brief**: one paragraph — what the viewer experiences + the single dominant motion idea.

## Part 2 — Design (after sourcing)

Given the draft + resolved `assets/index.md` (if Step 2 ran) + `catalog-map.md`, design the shot **around the assets**:

- Pick the **catalog block(s)** + the `hyperframes-animation` rules / blueprints (catalog-aware — see `catalog-map.md`).
- Layout (hero-frame), motion (per `references/motion-vocabulary.md`), beats, pacing, exits.
- `asset-fusion`: read the asset's **geometric affordance** → `element_positions` (center / extent / safe-zones / avoid-zones) + **eyedropper palette** from the asset.
- Finalize `shot-plan.json`: `content.block` + `content.customize` + the per-category `content`.

## Heuristics (design-led short motion)

- **Motion IS the message**; no narration arc. Hook lands fast (~first 0.5s). **One dominant motif.** Pattern-interrupt if the piece runs >~2.5s (change exactly one thing). Effect intensity matches the energy. Legibility: a key element stays readable ≥~0.3s. Beats may be anticipated ~0.1s for perceived sync.
- **Reuse-first**: name a catalog block; ask for hand-authored motion only for gaps + the `asset-fusion` affordance.

Then hand `shot-plan.json` to the Builder.
agents/finalize.md
# Finalize / repair subagent

Perform snapshot QA and one in-place repair pass. Dispatch only when Step 5 `lint`, `check`, or snapshot review reports a defect. The orchestrator owns final approval and render; this agent never renders.

## Dispatch context

`SKILL_DIR` / `PROJECT_DIR` / proof snapshot times / `lint` and `check` output tails, when present.

## Flow

1. **Snapshots** — run `npx hyperframes snapshot --at <proof-times>` and inspect overflow, off-canvas content, text collisions, empty frames, wrong content, and unreadable motion.
2. **One in-place repair pass** — edit `compositions/index.html` for the visible issues. Never change a fixed `data-duration`; timing is set upstream.
3. **Recheck** — rerun `npx hyperframes lint`, `npx hyperframes check`, and the affected snapshots. Return the result to the orchestrator without rendering.

## STOP / escalate

Only when the shot is **fundamentally wrong** (whole content off, needs recomposition) — return to Step 3/4 (re-design + re-build), don't force it with edits. Small fixes never escalate.
catalog-map.md
# Director → catalog block map (reuse-first)

The Builder's **default is to compose existing HF catalog blocks, not hand-author.** `npx hyperframes add <block>` drops a block's source into `compositions/`; the Builder then **customizes in place** — most blocks bake their content/data into their own script (only a few expose CSS-var `params`), so reuse = **add + edit**, not pure variable injection. Hand-author only (a) gaps no block covers and (b) the asset-fusion affordance binding.

After classifying intent → category, the **Director names the block(s) + what to customize** in the shot-plan IR:

```jsonc
"content": { "block": "data-chart", "customize": { "data": [...], "headline": "…", "palette": ["…"] } }
```

## Category → block(s)

| Category               | Borrow from catalog                                                                                                                                                                                                                                                                                                                                                                                                                   | Customize                                                                                        | Gaps → hand-author                                                                                             |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| **kinetic-typo**       | one of **18 `caption-*`** (kinetic-slam, editorial-emphasis, clip-wipe, gradient-fill, neon-glow, glitch-rgb, matrix-decode, particle-burst, weight-shift, pill-karaoke, highlight, blend-difference…)                                                                                                                                                                                                                                | words; `emphasis_words` → `word--emphasis`; palette; font; timing                                | a motif none of the 18 cover                                                                                   |
| **charts**             | `data-chart` (bar+line, staggered, value labels)                                                                                                                                                                                                                                                                                                                                                                                      | edit the data array + headline/subtitle; `--bg`/`--text`                                         | pie/donut, bar-chart-race, ring/%                                                                              |
| **stat**               | `apple-money-count` (finance: $ counter + burst + SFX) **or** our generic `stat-motion` (any %/number + ring)                                                                                                                                                                                                                                                                                                                         | target, prefix/suffix, label, palette                                                            | —                                                                                                              |
| maps/geo               | **vector lane**: `us-map` (+bubble/hex/flow), `world-map`, `spain-map` + **hand-author** `geo-highlight`/`geo-flow`/`flag-borders`/`pin-rollout` (NOT in registry — build per `categories/maps/module.md`). **basemap lane** (real satellite/dark / zoom-to-place): bake via `categories/maps/bake-basemap.mjs` → `<video>` + geo-aligned SVG overlay (border draw-on + colour-block fills)                                           | regions/data, palette, callouts/connectors, basemap style                                        | (basemap lane now covers real imagery + zoom-to-address — was the gap)                                         |
| diagram                | `flowchart` (SVG connectors + nodes)                                                                                                                                                                                                                                                                                                                                                                                                  | nodes, edges, labels                                                                             | —                                                                                                              |
| **code / code-reveal** | one of the **9 Code Animations** blocks: `code-typing` (live typing), `code-diff` (a change), `code-morph` (a refactor), `code-highlight` (spotlight a line; `line` is 0-based), `code-scroll` (walk a file), `code-snippet-flight` (assemble); GPU hero reveals `code-3d-extrude` / `code-shader-dissolve` / `code-particle-assemble` for a title-card moment; for a static themed code/terminal window use a `code-snippet-*` block | the baked code string / diff / theme                                                             | a code motion none cover                                                                                       |
| **brand reveal**       | `logo-outro` (piece assembly + glow + tagline + URL pill)                                                                                                                                                                                                                                                                                                                                                                             | logo asset, tagline, URL, palette                                                                | —                                                                                                              |
| transitions            | 15+ shader transitions (domain-warp, whip-pan, sdf-iris, glitch…)                                                                                                                                                                                                                                                                                                                                                                     | direction, timing                                                                                | —                                                                                                              |
| social overlay         | ig / tiktok / yt / x / reddit / spotify / macos cards                                                                                                                                                                                                                                                                                                                                                                                 | handle, metrics, avatar                                                                          | —                                                                                                              |
| polish                 | grain-overlay, vignette, shimmer-sweep, texture-mask-text                                                                                                                                                                                                                                                                                                                                                                             | intensity                                                                                        | —                                                                                                              |
| **asset-fusion (RWA)** | **annotation kit from `north-korea-locked-down`** — scribble-circle draw-on, pop-up pill + pointer, red-wash, scanline, corners, camera push; + `us-map-bubble` callouts/connectors                                                                                                                                                                                                                                                   | asset, annotation position (`element_positions`), label, palette **eyedropper'd from the asset** | **NET-NEW (not in catalog): the affordance binding — asset geometry _becomes_ the chart axis (straw → gauge)** |

## Reuse mechanics

- `npx hyperframes add <block>` → source lands in `compositions/<block>.html`; inline it or reference via `data-composition-src`; customize content/data/palette/positions in place.
- Blocks ship at fixed canvases (1920×1080 / 1080×1920 / 1080×1080) — match or adapt.
- Blocks already follow the HF contract (paused timeline, seek) — keep it.

## Asset-fusion: borrow + net-new (the only real net-new IP)

Borrow `north-korea-locked-down`'s annotation _language_; the net-new is the RWA fusion _logic_ (see the RWA study):

1. Classify the data type; read the asset's **geometric affordance** — linearity → timeline/gauge, volume/texture → pie, height → bar, container → exploded view.
2. **`element_positions`** — measure the asset's feature (center / extent / safe-zones / avoid-zones). This is the reverse-engineer step (vision); in the prototype it's hand-estimated.
3. **Eyedropper palette** from the asset (never generic #FFF/#000).
4. **Two layers** — asset (z0, full-bleed) + data graphics fused to its geometry (z1+), anchored by `element_positions`; connectors/scribble physically tie the data to the asset; the asset stays visible.
categories/asset-fusion/module.md
# asset-fusion — category module (search-driven · the net-new IP)

**A real asset's geometry _becomes_ the chart** — RWA diegetic fusion (the straw becomes a gauge; a glass's liquid becomes a pie). Not in any catalog; the genuinely net-new capability. ~5–8s.

## Source (Step 2)

Search or generate one **hero asset** with strong geometric affordance. `asset_needs`: `{ kind: image, query|generate, treatment: cutout|none }`. Freeze it project-local.

## Plan (Director Part 2) — the fusion logic

1. Classify the **data type** (temporal / quantitative / proportion / spatial).
2. Read the asset's **geometric affordance** — linearity → timeline/gauge · volume/texture → pie · height → bar · container → exploded view.
3. `element_positions`: **GROUND with the locate protocol — never eyeball pixel coords.** Read **`grounding/PROTOCOL.md`** and run the loop with `node grounding/locate.mjs` (`overlay` → read strips → `region` → read crop → `final` → **`mark` + verify**). Zero keys/deps assumed (node + ffmpeg only); the optional `auto` fast path exists only when `GEMINI_API_KEY` happens to be set. Why: eyeballing put rings off-subject (~16–24% center error on weak vision models); the grid loop pulls it to ~2–4% (measured E2E: eyeball 6.5% → protocol 2.3% avg center error, no case worse).
4. **Eyedropper palette** from the asset (never generic #FFF/#000).

## Highlight + circle recipe (the common case)

"Ring / spotlight object X in a real image" → use the drop-in template **`samples/asset-fusion/_ref-circle-highlight.html`**: set `CFG.box` (from the locate protocol), `CFG.label`, `CFG.asset`, EVEN `CFG.W/H`, `CFG.mode` (`full` = ring+connector+label+brackets+scanlines, `circle` = ring only). It computes the radial wash, the amber double over-stroke ring, connector, callout, and corner-bracket reticle from the box. The whole pipeline is: locate (PROTOCOL.md) → fill template → render.

## Render gotchas (codified — skipping these breaks the render)

- **EVEN width & height** — odd width _or height_ (e.g. 1400×933) → `ffmpeg` encode fails / distorts. Resize the asset/stage to even dims (1400×932).
- **`data-width`/`data-height` must be STATIC HTML attrs on the stage** — the renderer's StaticGuard reads them at compile time, before JS runs. Setting them via `setAttribute` is too late → render falls back to portrait 1080×1920 and distorts. (The circle-highlight template now hard-codes them; keep them equal to `CFG.W/H`.)
- **Draw-on (`stroke-dashoffset`) must be `autoAlpha:0`-gated** — `getTotalLength()` can read 0 before layout → dash disabled → a solid line shows at t=0. Gate every draw-on element with `autoAlpha:0` until it draws, and fall back `getTotalLength() || <const>`.
- **CSS var tween scope** — `gsap.to(":root", {"--x":..})` won't reach an element that has its own inline `--x`; tween the var on the element itself.
- **No camera push under a fixed overlay** — scaling the image while the ring/wash stay fixed drifts the target out of the ring. Either skip the push or scale the whole scene together.
- Lossless delivery: `--format mov` (ProRes); `mp4` is lossy.

## Vocabulary / leans on

- Borrow the annotation kit from registry **`north-korea-locked-down`** (hand-drawn scribble circle draw-on, pop-up label + pointer, editorial wash, camera push).
- Primitives: gauge fill / marker-rise along the affordance · connector (data → asset point) · diegetic chart fused to the asset's geometry.
- Adapt the diegetic chart to each asset's affordance (read the geometry, fuse the data into it).

## Build (reuse-first + hand-author the affordance)

**Two layers**: asset (z0, full-bleed) + data graphics (z1+) fused to its geometry, anchored by `element_positions`; connectors/scribble physically tie the data to the asset; asset stays visible. Reference impl: the prototype `fusion-demo/index-annotated.html` (straw → gauge + borrowed annotation kit).
categories/charts/module.md
# charts — category module

Animated **data-viz** from data. Asset-free (the "input" is the data). "One chart, one message" for short durations.

## Plan (Director)

`content`: `{ type: bar|line|pie|race|pct, data[], labels[], headline, axes: bool }`. For `race`, data must be cumulative/time-staged.

## Vocabulary / leans on

- Block: **`data-chart`** (animated **bar + line**, staggered reveal, value labels — proven: borrowed + customized + rendered to MP4 in the prototype `charts-demo`).
- Gaps (hand-author): **pie / donut, bar-chart-race, ring/%** — `data-chart` doesn't cover these. Use D3/visx for data→geometry + GSAP for motion.
- Signature animations: bar stagger-grow · line `stroke-dashoffset` draw-on · pie radial sweep · ring fill · KPI count-up · race reorder.

## Build (reuse-first)

Reuse `data-chart`: `npx hyperframes add data-chart` → edit the data arrays + scales + headline/labels + palette in place (its data is baked in the script, not a `--variables` flag). Axes hidden by default; show muted only when magnitude is the message. Determinism: drive any animation from the seek clock, never wall-clock.

## Dashboard-skeleton variant

For a **product-dashboard** case: lay out a skeleton dashboard — a top bar with a **real test logo** (e.g. the hyperframes logo in `samples/_assets/`) + a title, then a grid of 3–4 **KPI cards** (each a `stat` count-up) + one `data-chart` panel. Reveal order: header/logo in → cards stagger in → the chart animates. Composes the `stat` + `charts` primitives inside a dashboard frame; the logo is a frozen project-local asset.
categories/kinetic-type/module.md
# kinetic-type — category module

Text is the hero; typography + motion carry the message. Usually asset-free (`asset_needs: []`).

## Plan (Director)

- **Style first.** If the project has a **`design.md` / `frame.md`** (precedence: `frame.md` → `design.md` → `DESIGN.md`), READ it and use its **exact palette / fonts / constraints** — do not invent. No spec → pick a named style, or ask: mood + light/dark + any brand color/font. (Style is an input the case must exercise; the same shot in two `design.md`s should look different.)
- Segment the copy into scenes by meaning / breath (EN ~3–7 words; ZH ~4–12 chars). Tag each scene **Hook → Build → Punch → Resolve**.
- 1–2 `emphasis_words` per scene. Per scene: `emotion` + `motion` (free-form) + `beats`.

## Vocabulary

Motion primitives + registry blocks: **`references/motion-vocabulary.md`** (slide / scale / fade / blur / typewriter / word_reveal / wave / bounce / slam / scale_pulse / shake / glow / color_shift, plus the 18 `caption-*` blocks).

## Build (reuse-first)

- Prefer a **`caption-*` block** when one fits (`caption-kinetic-slam` / `caption-editorial-emphasis` / …): `npx hyperframes add` + set words / `emphasis_words` / palette / font.
- Else hand-author: one full-duration `.clip`; a `.group` per scene (flex-centered); words as spans; explicit `gsap.fromTo()` entrances per scene `motion`; emphasis words → `glow` / `scale_pulse` on the beat; seek-safe `autoAlpha` only on inner non-clip elements. Honor `references/builder-contract.md`.
- Reference impl: prototype `v0-text-motion-demo` + `pipeline-demo`.
categories/logo-reveal/module.md
# logo-reveal — category module

A **logo sting / brand lockup**. The logo is user-supplied, or resolved for a known brand (`resolve --type logo --entity <brand>`); `asset_needs` = one logo `source`. ~3–5s. Often `export: alpha-overlay` (sting to drop on other footage).

## Plan (Director)

`content`: `{ logo: <asset path>, tagline, url }`. Envelope: brand palette (or eyedropper from the logo), elegant pacing.

## Vocabulary / leans on

- Block: **`logo-outro`** (piece-by-piece assembly + glow bloom + tagline fade + URL pill).
- Rules: `rules/svg-path-draw` (draw-on for vector logos) · `rules/scale-swap-transition` · `rules/3d-text-depth-layers`.
- Primitives: draw-on / mask-reveal / particle-assemble · `glow` bloom · `underline_sweep` · hold.

## Build (reuse-first)

Reuse `logo-outro` + swap logo / tagline / url / palette; **or** hand-author: place the logo at its hero frame (CSS), reveal via draw-on (SVG `stroke-dashoffset`) or mask/scale, add a glow bloom + accent underline sweep, hold. For an SVG logo, prefer draw-on; for a raster logo, mask/scale + glow. Transparent bg when exporting as an overlay.
categories/lower-thirds/module.md
# lower-thirds — category module

**Name/title bars, callouts, social overlays** — graphics meant to sit over other footage. Asset-free (+ optional logo). Usually `export: alpha-overlay` (transparent). ~3–6s (or loop/hold).

## Plan (Director)

`content`: `{ name, role, position (lower-left / lower-third / corner), brand_colors[] }`. Default `export: alpha-overlay`.

## Vocabulary / leans on

- Blocks: `caption-*` (pill-karaoke, neon-accent, editorial-emphasis) + registry **overlay** blocks (`instagram-follow`, `tiktok-follow`, `yt-lower-third`, `x-post`, `spotify-card`, `macos-notification`).
- Primitives: slide/wipe-in · bar reveal · `glow` · fade/slide-out.

## Build (reuse-first)

Reuse the closest overlay/caption block + set name / role / handle / brand colors / position; **or** hand-author a bar that wipes in (`scaleX` from 0, `transform-origin:left`) with text sliding up behind it, hold, slide out. **Transparent background** (`export: alpha-overlay` → `render --format webm/mov`) so it composites over footage. Keep it in the title-safe lower band.
categories/maps/bake-basemap.mjs
// bake-basemap.mjs — canonical basemap-lane helper for the `maps` skill.
//
// WHAT IT DOES (and why baking at all): drives MapLibre in headless Chrome to record ONLY the
// real-imagery basemap as an MP4 (camera zoom→hold), and projects each requested country's border
// to SCREEN coordinates at the held view. The HF composition then plays the MP4 on track 0 and
// animates a *live* SVG overlay (border draw-on, colour-block / flag fills, labels, pins) from the
// exported `coords.json`. Borders/fills are NOT baked into the video — they stay editable in HF.
//
// Why bake the imagery at all (this is the real reason, not "smoothness"): HF forbids render-time
// network and requires deterministic output. Live raster tiles re-fetch every render and can change
// → non-deterministic. Baking FREEZES the imagery into pixels = deterministic + offline-reproducible.
// (Exposing the engine's per-frame `onBeforeCapture` hook would let MapLibre run live and smooth, but
// it would NOT remove the need to freeze tiles for determinism — so this bake step stays relevant.)
//
// PARAMETRIC — drive everything by env. Example (Brazil + Argentina on satellite):
//   NAME=br-ar STYLE=satellite COUNTRIES="Brazil:#22d3ee,Argentina:#f59e0b" \
//   CENTER="-60,-25" ZSTART=2.4 ZEND=3.4 FPS=30 DUR=5 node bake-basemap.mjs
// Then encode frames-<NAME>/f%04d.png → <NAME>.mp4 (all-intra: -g 1) and feed <NAME>-coords.json
// to the HF composition.
import puppeteer from "puppeteer-core";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";

const __dirname = dirname(fileURLToPath(import.meta.url));

// --- resolve Chrome dynamically (no hardcoded machine path) ---
function resolveChrome() {
  if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
  const exe = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
  const base = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
  if (existsSync(base)) {
    for (const v of readdirSync(base).sort().reverse()) {
      // lexical sort; any working binary is fine
      try {
        for (const inner of readdirSync(join(base, v))) {
          const bin = join(base, v, inner, exe);
          if (existsSync(bin)) return bin;
        }
      } catch {
        /* skip */
      }
    }
  }
  for (const c of [
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    "/usr/bin/google-chrome",
    "/usr/bin/chromium",
    "/usr/bin/chromium-browser",
  ])
    if (existsSync(c)) return c;
  throw new Error(
    "Chrome not found. Set CHROME=/path/to/chrome-headless-shell, or install one:\n" +
      "  npx puppeteer browsers install chrome-headless-shell",
  );
}

// --- params (all overridable by env) ---
const NAME = process.env.NAME || "basemap";
const STYLE = process.env.STYLE || "satellite"; // satellite | dark | light | raw {z}/{x}/{y} template
const CENTER = (process.env.CENTER || "2.6,46.6").split(",").map(Number);
const ZSTART = +(process.env.ZSTART || 4.2);
const ZEND = +(process.env.ZEND || 5.4);
const PITCH = +(process.env.PITCH || 0);
const BEARING = +(process.env.BEARING || 0);
const FPS = +(process.env.FPS || 30),
  DUR = +(process.env.DUR || 5),
  N = Math.max(1, Math.round(FPS * DUR));
const HOLD = +(process.env.HOLD || 0.5); // p∈[0,1] at which the zoom finishes; camera holds after
const MARGIN = (process.env.KEEPMARGIN || "16,13").split(",").map(Number); // [lon°,lat°] keep-box around each country's mainland
// COUNTRIES="Name:#hex,Name:#hex" — borders to project (optional; omit for a pure zoom-to / pin shot)
const COUNTRIES = (process.env.COUNTRIES || "")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean)
  .map((s) => {
    const [name, color] = s.split(":");
    return { name, color: color || "#38bdf8" };
  });

// fail fast on bad numeric env — otherwise NaN silently bakes zero/garbage frames and still prints "done"
for (const [k, v] of Object.entries({
  "CENTER.lng": CENTER[0],
  "CENTER.lat": CENTER[1],
  ZSTART,
  ZEND,
  PITCH,
  BEARING,
  FPS,
  DUR,
  HOLD,
}))
  if (!Number.isFinite(v))
    throw new Error(
      `bad numeric env: ${k}=${v} — check CENTER="lng,lat" / ZSTART / ZEND / FPS / DUR`,
    );

// IMPORTANT: tileSize:256 matches Esri/CARTO raster endpoints. MapLibre's INTERNAL world width is
// 512·2^zoom regardless — a 512px (@2x/retina/vector) tile source needs tileSize:512 or every zoom
// level is off by one. Keep 256 for these raster sources.
const TILES =
  {
    satellite:
      "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
    dark: "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
    light: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
  }[STYLE] || STYLE; // STYLE may also be a raw {z}/{x}/{y} template

const OUT = process.env.OUT || process.cwd(); // artifacts → workspace (cwd), NOT the installed skill dir
const framesDir = join(OUT, "frames-" + NAME);
mkdirSync(framesDir, { recursive: true });

// Deps PINNED exact (mutable @5/@2 majors would drift the bake over time).
const PAGE = `<!doctype html><html><head>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/maplibre-gl.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/topojson-client.min.js"></script>
<style>*{margin:0}html,body{width:1920px;height:1080px;overflow:hidden;background:#05070d}#map{width:1920px;height:1080px}.maplibregl-control-container{display:none!important}</style>
</head><body><div id="map"></div><script>
var CENTER=${JSON.stringify(CENTER)}, ZSTART=${ZSTART}, ZEND=${ZEND}, PITCH=${PITCH}, BEARING=${BEARING}, HOLD=${HOLD};
var MARGIN=${JSON.stringify(MARGIN)}, WANT=${JSON.stringify(COUNTRIES)};
var map=new maplibregl.Map({container:"map",style:{version:8,projection:{type:"mercator"},
  sources:{s:{type:"raster",tiles:[${JSON.stringify(TILES)}],tileSize:256,maxzoom:19}},
  layers:[{id:"bg",type:"background",paint:{"background-color":"#05070d"}},{id:"s",type:"raster",source:"s"}]},
  center:CENTER,zoom:ZSTART,pitch:0,bearing:0,interactive:false,attributionControl:false,fadeDuration:0,preserveDrawingBuffer:true,maxTileCacheSize:6000});
function ease(x){return x<0.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;} // easeInOutCubic (= Remotion interpolate+Easing)
function camAt(p){ var t=ease(Math.min(1,p/HOLD)); return {center:CENTER, zoom:ZSTART+(ZEND-ZSTART)*t, pitch:PITCH*t, bearing:BEARING*t}; }
function ringCentroid(r){ var sx=0,sy=0; for(var k=0;k<r.length;k++){sx+=r[k][0];sy+=r[k][1];} return [sx/r.length, sy/r.length]; }
// Keep the polygons in a lon/lat box around the country's MAINLAND (the vertex-richest polygon),
// dropping far-flung overseas territories that would blow up the bbox. Generalizes per subject —
// no continent-specific constant. Keeps near islands (Corsica, Sicily); drops Guiana, Alaska, Hawaii.
function mainland(f){
  if(!f) return f;
  if(f.geometry.type!=="MultiPolygon"){ f.__anchorRing=f.geometry.coordinates[0]; return f; } // Polygon: outer ring
  var polys=f.geometry.coordinates, anchor=polys[0], amax=-1;
  polys.forEach(function(poly){ if(poly[0].length>amax){amax=poly[0].length;anchor=poly;} });
  var ac=ringCentroid(anchor[0]);
  var kept=polys.filter(function(poly){ var c=ringCentroid(poly[0]); return Math.abs(c[0]-ac[0])<=MARGIN[0] && Math.abs(c[1]-ac[1])<=MARGIN[1]; });
  var nf={type:"Feature",properties:f.properties,geometry:{type:"MultiPolygon",coordinates:kept}}; nf.__anchorRing=anchor[0]; return nf;
}
function lonSpan(f){ var mn=1e9,mx=-1e9; (f.geometry.type==="Polygon"?[f.geometry.coordinates]:f.geometry.coordinates).forEach(function(poly){poly[0].forEach(function(c){if(c[0]<mn)mn=c[0];if(c[0]>mx)mx=c[0];});}); return mx-mn; }
// ANTIMERIDIAN unwrap (Russia/Fiji/NZ): make all lon contiguous around the camera-center ref so a
// feature touching ±180° doesn't smear when map.project() runs per-vertex (mercatorX is linear and
// accepts out-of-range lon, so 181 sits just east of center, not far-west at -179). Mutates in place;
// __anchorRing shares the same arrays so it's covered.
function unwrapLon(f, ref){ if(!f) return; function fix(r){ for(var i=0;i<r.length;i++){ var lon=r[i][0]; while(lon-ref>180)lon-=360; while(lon-ref<-180)lon+=360; r[i][0]=lon; } }
  var g=f.geometry; if(g.type==="Polygon") g.coordinates.forEach(fix); else g.coordinates.forEach(function(poly){ poly.forEach(fix); }); }
var FEATS=[]; window.__warn=[];
window.__ready=new Promise(function(res){ map.on("load", function(){
  if(!WANT.length){ res(); return; }
  fetch("https://cdn.jsdelivr.net/npm/[email protected]/countries-110m.json").then(function(r){return r.json();}).then(function(w){
    var fc=topojson.feature(w,w.objects.countries);
    WANT.forEach(function(want){
      var f=fc.features.filter(function(x){return x.properties.name===want.name;})[0];
      if(!f){ window.__warn.push("country not found in world-atlas: "+want.name); return; }
      f=mainland(f);
      unwrapLon(f, CENTER[0]); // antimeridian: unwrap lons around the camera ref (CENTER must be near the subject) before projecting
      if(lonSpan(f)>180) window.__warn.push(want.name+" spans >180° lon even after unwrap — projection may still smear.");
      FEATS.push({name:want.name, color:want.color, f:f});
    });
    res();
  });
});});
window.__setCam=function(p){ map.jumpTo(camAt(p)); };
// returns true if the idle event did NOT fire within ms (i.e. tiles may be incomplete)
// returns true only if tiles are GENUINELY not loaded at timeout — CARTO/Esri idle is flaky and
// often never fires even when every tile is painted, so check areTilesLoaded() before crying timeout.
window.__waitIdle=function(ms){ return new Promise(function(res){ var done=false; function fin(t){if(done)return;done=true;res(t);} map.once("idle",function(){fin(false);}); setTimeout(function(){ fin(!map.areTilesLoaded()); }, ms||9000); }); };
function featurePath(f){ function ring(r){ return r.map(function(c,i){ var p=map.project(c); return (i?"L":"M")+p.x.toFixed(1)+" "+p.y.toFixed(1); }).join(" ")+"Z"; }
  var g=f.geometry,d=""; if(g.type==="Polygon") g.coordinates.forEach(function(r){d+=ring(r);}); else g.coordinates.forEach(function(poly){poly.forEach(function(r){d+=ring(r);});}); return d; }
function bboxOf(f){ var mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
  function eat(r){ r.forEach(function(c){ var p=map.project(c); if(p.x<mnx)mnx=p.x; if(p.y<mny)mny=p.y; if(p.x>mxx)mxx=p.x; if(p.y>mxy)mxy=p.y; }); }
  var g=f.geometry; if(g.type==="Polygon")g.coordinates.forEach(eat); else g.coordinates.forEach(function(poly){poly.forEach(eat);});
  return {x:+mnx.toFixed(1),y:+mny.toFixed(1),w:+(mxx-mnx).toFixed(1),h:+(mxy-mny).toFixed(1)}; }
window.__project=function(){ return {
  view:{center:CENTER, zoom:ZEND, pitch:PITCH, bearing:BEARING},
  countries: FEATS.map(function(e){ var lc=ringCentroid(e.f.__anchorRing); var lp=map.project(lc);
    return { name:e.name, color:e.color, d:featurePath(e.f), bbox:bboxOf(e.f), label:{x:+lp.x.toFixed(1),y:+lp.y.toFixed(1)} }; }),
}; };
</script></body></html>`;

// --no-sandbox is intentional: trusted Source-time bake, headless, often root/CI; deps are version-pinned above.
const browser = await puppeteer.launch({
  executablePath: resolveChrome(),
  headless: true,
  args: [
    "--no-sandbox",
    "--hide-scrollbars",
    "--use-gl=angle",
    "--use-angle=swiftshader",
    "--enable-unsafe-swiftshader",
    "--enable-webgl",
    "--window-size=1920,1080",
  ],
});
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
  await page.setContent(PAGE, { waitUntil: "load" });
  await page.evaluate(() => window.__ready);
  for (const w of await page.evaluate(() => window.__warn)) console.warn(`[${NAME}] WARN: ${w}`);
  console.log(
    `[${NAME}] ready (${STYLE}); baking ${N} frames, zoom ${ZSTART}→${ZEND} hold@p=${HOLD}, ${COUNTRIES.length} border(s)`,
  );
  let coords = null;
  const timeouts = [];
  for (let i = 0; i < N; i++) {
    const p = N === 1 ? 1 : i / (N - 1);
    await page.evaluate((pp) => window.__setCam(pp), p);
    const timedOut = await page.evaluate((ms) => window.__waitIdle(ms), 9000);
    if (timedOut) {
      timeouts.push(i);
      console.warn(`[${NAME}] idle TIMEOUT at frame ${i} — tiles may be incomplete`);
    }
    await page.screenshot({
      path: join(framesDir, `f${String(i).padStart(4, "0")}.png`),
      clip: { x: 0, y: 0, width: 1920, height: 1080 },
      optimizeForSpeed: true,
    });
    if (p >= HOLD && !coords && COUNTRIES.length)
      coords = await page.evaluate(() => window.__project()); // capture at first hold frame
    if (i % 20 === 0 || i === N - 1) console.log(`  [${NAME}] ${i + 1}/${N}`);
  }
  // encode frames → all-intra MP4 (every frame seekable for HF); fall back to printing the command if ffmpeg is absent
  const mp4 = join(OUT, NAME + ".mp4"),
    pat = join(framesDir, "f%04d.png");
  const ff = spawnSync(
    "ffmpeg",
    [
      "-y",
      "-framerate",
      String(FPS),
      "-i",
      pat,
      "-c:v",
      "libx264",
      "-pix_fmt",
      "yuv420p",
      "-g",
      "1",
      "-crf",
      "16",
      "-movflags",
      "+faststart",
      mp4,
    ],
    { stdio: "ignore" },
  );
  if (ff.status === 0) console.log(`[${NAME}] encoded → ${mp4}`);
  else
    console.warn(
      `[${NAME}] ffmpeg unavailable (status ${ff.status}) — encode manually:\n  ffmpeg -y -framerate ${FPS} -i ${pat} -c:v libx264 -pix_fmt yuv420p -g 1 -crf 16 -movflags +faststart ${mp4}`,
    );
  if (coords) {
    writeFileSync(join(OUT, NAME + "-coords.json"), JSON.stringify(coords));
    console.log(
      `[${NAME}] coords written: ${coords.countries.map((c) => c.name + "(" + c.d.length + "ch)").join(", ")}`,
    );
  }
  if (timeouts.length) {
    // FAIL LOUD: the asset exists but is suspect — don't let a half-loaded bake pass silently
    console.error(
      `[${NAME}] ${timeouts.length}/${N} frame(s) hit the idle timeout (frames ${timeouts.slice(0, 8).join(",")}${timeouts.length > 8 ? "…" : ""}). The basemap MP4 may have INCOMPLETE tiles. Re-run with a slower zoom / larger timeout / check the tile server.`,
    );
    process.exitCode = 1;
  } else {
    console.log(`[${NAME}] done — all ${N} frames reached map idle (complete tiles).`);
  }
} finally {
  await browser.close();
}
categories/maps/module.md
# maps — category module

Geographic motion: highlight regions, connect places, zoom to a location. **First decision: does the shot need a real basemap** (satellite/street/terrain imagery, globe, or zoom to a real address)? That picks the lane.

## Plan (Director)

Set `content.lane` first:

- **vector** (default) — stylized region shapes, no real imagery. Native + live in HF, cheap. `asset_needs: []`.
- **basemap** — needs real satellite/dark tiles, globe, or zoom-to-real-place. `asset_needs: [{ type: "map-bake", … }]`. **Bake the imagery in Source**: HF forbids render-time network and requires determinism, so live tiles (which re-fetch and can change per render) can't be the imagery layer — baking freezes it (and is smooth as a bonus). See Basemap lane + Determinism.

`content`: `{ lane, shot: highlight|flow|choropleth|labels|flag|pin-rollout|zoom-to, regions[], points[], basemap: satellite|dark, palette, headline, overlays: [label|pin|callout-card] }`.

`overlays` are independent of `shot` and work in both lanes. **`callout-card`** = a pinned card (flag chip + stat + progress bar) — this is the "documentary popup" (top #13); compose it on any zoom-to/highlight shot rather than treating it as its own shot.

## Vocabulary / leans on

**Vector lane** (D3 + TopoJSON — reuse the existing map family, don't duplicate):

- Reuse: `us-map` (+bubble/hex/flow), `world-map`, `spain-map`.
- **Hand-author** these (NOT in the catalog — build per the signatures below, don't expect an `add`): `geo-highlight` (N countries colored + labels + border pulse), `geo-flow` (world arcs / hub network), `flag-borders` (flag clipped to a country), `pin-rollout` (cities pulse in sequence + counter).
- Signature: country fill-in stagger · pin drop + pulse ring · arc `stroke-dashoffset` draw-on + flyer · choropleth color reveal · **viewBox** zoom.

**Basemap lane** (MapLibre, baked in Source — validated pipeline, `bake-basemap.mjs`, fully env-parametric — runs for any country, not just the prototypes):

- **Bake** `bake-basemap.mjs` (puppeteer + MapLibre; env-driven: `NAME STYLE COUNTRIES CENTER ZSTART ZEND PITCH BEARING FPS DUR`): drive the camera **zoom→hold** (`easeInOutCubic`) and **`await map.once('idle')` before every frame** so each frame has complete tiles (Remotion `delayRender` technique → no tile pop). The helper resolves Chrome itself, pins deps exactly, derives the overseas-territory filter per subject, and **fails loud on idle-timeout** (suspect frames → non-zero exit). _External deps: the pinned CDN libs (maplibre/topojson/world-atlas) + the Esri/CARTO tile endpoints are third-party — re-verify availability + ToS on any version bump._ `preserveDrawingBuffer:true`, `fadeDuration:0`. The helper then **encodes the frames to an all-intra MP4 itself** (`ffmpeg -framerate FPS -i f%04d.png -c:v libx264 -g 1 -pix_fmt yuv420p`) and writes everything to **`$OUT` (default `cwd`)**, not the skill dir → `<NAME>.mp4` + `<NAME>-coords.json`.
- **Export geo→screen** at the hold view: `map.project()` each requested country → `<NAME>-coords.json` = `{ view, countries: [{ name, color, d (SVG path), bbox, label }] }`. The camera holds static after the zoom, so these paths stay pixel-aligned. `label` is an **approximate** anchor (vertex-average of the mainland ring — for concave countries nudge per Legibility). Consume `coords.countries[i]`; the `smooth-frde2`/`smooth-flag` example dirs predate this helper and use a flat `{fr,de,…}` shape, so adapt their wiring.
- **Builder**: `<video>` basemap on track 0 + an **SVG overlay** that, during the hold, animates country **borders (`stroke-dashoffset` draw-on)** + **fills (colour-block reveal)** + labels/pins/cards — all geo-aligned via `coords.json`. This reproduces the Hera "satellite + animated coloured borders/callouts" look.
  - The same projected path also doubles as an SVG **`clipPath`**: clip a **flag (or any texture) into the real border** for _flag-in-borders_ — a richer fill than flat colour, over real map context (validated: France tricolor over a dark basemap, `smooth-flag`). Export the feature's screen **bbox** alongside its path so the flag stripes/texture can be sized to the country.
- **Stretch / data gaps** (extend `bake-basemap.mjs` as needed — the eval agents did): a **globe intro** ("start from the globe") = MapLibre `projection:{type:'globe'}` for the opening phase, easing to mercator at the target (the helper defaults to mercator). **Sub-national** regions (states/provinces) aren't in world-atlas (country-only) → use a Natural Earth **admin-1** TopoJSON, or project centroids as pins (`pin-rollout`).

## Build (reuse-first)

Vector: `npx hyperframes add <block>` → edit regions/data/palette in place. Basemap: baked `map.mp4` as track-0 `<video>`, bind overlays to anchors.

**Restraint (no cheese — this is the #1 way auto-built maps go wrong):** every animated element must serve the message — region, connector, label, pin, camera. **NO decorative ambient glows, background light blobs, floating particles, lens flares, or gratuitous bloom.** Motion = a continuous camera move (viewBox push/zoom) + purposeful, overlapping element reveals — not a light show. Palette: color must **carry meaning** — a data scale (choropleth), categorical fills that distinguish regions (political map), or 1–2 accents for the subjects (highlighted countries / route) over neutral everything-else. Don't add color as **decoration** (a country amber just for contrast, a glow for "energy"). The frame should read like a clean broadcast map, not a screensaver.

**Legibility (hard rule):** offset labels from the highlighted shape and from each other; clamp to the safe area; a callout pill must not sit on another label or a border (the eval surfaced a DE/PL "Oder–Neisse" pill overlapping the POLAND label). A key element stays readable ≥~0.3s.

**Attribution (hard rule):** real basemap imagery carries usage terms — bake a credit element into the composition whenever a basemap is on screen (Esri satellite → "Esri, Maxar, Earthstar Geographics"; CARTO → "© CARTO, © OpenStreetMap"). A small low-corner label (see `smooth-jp`). Non-negotiable for anything published.

**Determinism (hard rules — each one bit us in the prototypes):**

- Drive everything from the seek clock; **never `tl.call`** for stateful updates (counters, text) → proxy tween + `onUpdate` (tl.call freezes the timeline under HF seek).
- SVG zoom = animate **viewBox** (don't hand-compute group transform origins).
- Centered overlays (cards/labels using `transform: translate(-50%,…)` to center): animate **opacity only**, or wrap in an outer centered div — GSAP animating `y`/`scale` overwrites the whole transform and kills the centering.
- Country geometry: filter to the polygon(s) **in a lon/lat box around the subject** — world-atlas bundles overseas territories that blow up the bbox (France + Guiana). Keep near islands (Corsica, Sicily), drop far ones. (`bake-basemap.mjs` anchors on the vertex-richest polygon ± `KEEPMARGIN` — no continent-specific constant.)
- **Tile world-scale**: MapLibre's internal world width is `512·2^zoom` regardless of the raster `tileSize`. Esri/CARTO raster → `tileSize:256` (correct); a 512px / @2x / retina / vector source needs `tileSize:512` or every zoom level is off by one (this silently over-zoomed a bake once — France overflowed the frame top-to-bottom).
- **Antimeridian**: a feature crossing ±180° (Russia, Fiji, NZ) smears under per-vertex `map.project()`. `bake-basemap.mjs` **unwraps longitudes around the camera-center ref** before projecting, which handles it; it still warns if a feature spans >180° even after unwrap.
- **Smoothness = per-frame complete tiles + eased camera.** In the bake, `await map.once('idle')` before each screenshot (= Remotion `delayRender`) and ease the camera with `easeInOutCubic` (= interpolate+Easing). `preserveDrawingBuffer:true`, `fadeDuration:0`, large `maxTileCacheSize`.
- **Overlay alignment**: project feature borders at a **held** camera and only animate the overlay during the hold — a moving camera + a fixed projected path drift apart.
- **Pre-hold hidden state**: an overlay revealed _at_ the hold must be `gsap.set` to its hidden state at build time (`scaleX:0`, full `stroke-dashoffset`, `opacity:0`) — a bare `fromTo` does **not** apply its "from" until the tween starts, so the element otherwise shows at its natural (visible, mispositioned) state during the zoom-in.
- **Why bake at all** (the real reason — not just smoothness): baking freezes the imagery into **deterministic** pixels. Live raster tiles re-fetch every render and can change, and render-time network is forbidden. MapLibre _does_ render live in HF (just janky: tiles pop, deep zooms outrun loading); exposing the engine's per-frame `onBeforeCapture` hook (it exists — `frameCapture.ts:~1250`) would make live **smooth** — but it would **not** remove the need to freeze tiles for **determinism + offline reproducibility**. So `onBeforeCapture` would replace the _smoothness_ role of baking, not the _freeze_ role.

## Out of scope

3D photorealistic landmarks (Cesium territory) · per-country / per-template blocks (parametrize instead) · charts (→ `charts`). (In-engine _live_ MapLibre is possible but janky today — bake instead; revisit if the engine gains a per-frame ready hook.)

## Register

`director.md` classifier line (the lane fork) + `catalog-map.md` `maps/geo` row (add the basemap lane). Phase pipeline untouched.
categories/news/module.md
# news — category module (search-driven)

Search a real news article → animate it as an **article-highlight** — a faithful HF port of the centered-emphasis technique. Signature motion: the article text is laid out at a **readable size (NOT zoomed)**, slides + fades up, then the **keyword is highlighted in place** — a marker band grows left→right behind it. No blow-up zoom. ~5–7s.

Two layouts share that one highlight core — pick by aspect + whether there's a logo / person to feature:

| Layout                    | Aspect     | Use when                                                        | Has                                                                        |
| ------------------------- | ---------- | --------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **A · centered-emphasis** | 9:16 / 1:1 | text-only, social vertical                                      | kicker + one centered sentence + keyword highlight                         |
| **B · full article**      | 16:9       | there's a real **outlet logo** and/or a **person** in the story | logo + date + multi-line headline highlight + dek + outlet + person cutout |

## Source (Step 2)

RWA / web search (or `hyperframes capture`) → a real article. The Director extracts the **keyword** (1–2 words / a number / a name — the hook) and, for Layout B, also a **brand logo**, a **date**, and a **person photo**. `asset_needs: { kind: news|web|image, query }` — request the logo (Wikimedia / simple-icons) and the person photo (Wikimedia) as separate asset queries. Run the person photo through `hyperframes remove-background <in> -o <out>.png` to get a transparent cutout.

## The highlight core (shared by both layouts) — the marker-band technique

The keyword highlight is a marker band grown L→R via `background-size` (NOT a `transform:scaleX` bar):

```css
.hl {
  --hlw: 0%;
  background-image: linear-gradient(var(--hl), var(--hl)); /* --hl: rgba(250,222,99,.6) */
  background-repeat: no-repeat;
  background-position: 0 72%;
  background-size: var(--hlw) 64%;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}
```

`box-decoration-break:clone` makes the band **wrap across line breaks** seamlessly — a multi-word keyword spanning 2–3 lines still highlights continuously (the old `scaleX` bar could not). GSAP tweens the CSS var `--hlw` 0%→100%, **swept on AFTER the text settles — never pre-applied**. Optionally scale the duration with keyword length ~0.5–1.5s.

## Layout A — centered-emphasis (9:16, text-only) — Builder

1. **Lay out the key SENTENCE at a readable size — do NOT zoom.** Paper-light stage; an outlet `#kicker` (e.g. "BBC NEWS · TECHNOLOGY", brand accent) at top; the key sentence centered, serif, **~70–76px / 700 / line-height ~1.4**, `max-width ≈ 900`. The whole sentence stays legible; the keyword is emphasized _within_ it.
2. Wrap the keyword inline: `… raised <span class="hl" id="kw">$750M</span> at …`.
3. **Timeline** (seek-safe, paused): kicker fades+slides in → **sentence slides+fades up** (`set{autoAlpha:0,y:52}`→`to{autoAlpha:1,y:0,duration:0.8,ease:"power3.out"}`, no zoom) → **keyword highlight grows** (`--hlw 0→100%`, after the sentence settles) → gentle hold (`scale 1.012`).

**Reference:** `samples/news/_ref-centered-emphasis.html` (+ `news/v2-*`).

## Layout B — full article (16:9, logo + person) — Builder

The editorial article-card layout (logo top-left + date + big serif headline with a multi-line keyword highlight + dek + outlet bottom-left + a background-removed **person cutout** bottom-right). 1920×1080, paper-light gradient stage, Georgia serif.

1. **Logo** top-left (`#logo`, ~64px) — inline the outlet/brand SVG, `fill:currentColor`, set `color` to the mark's own hue (or near-black for a monochrome mark). **Date** below it (`#date`, ~30px, muted).
2. **Headline** (`#headline`, Georgia **96px / 700**, `width≈1080`, `line-height 1.16`) with the keyword wrapped in `<span class="hl" id="kw">` — the highlight band sweeps across **all wrapped lines** of the phrase.
3. **Dek** (`#dek`, Helvetica ~38px gray) under the headline; **outlet** (`#outlet`, Georgia bold ~50px) bottom-left.
4. **Person cutout** (`#subject`, the bg-removed PNG, `height≈860`, `right:40; bottom:0`, `drop-shadow`) — **slides up from bottom-right** as the hero beat.
5. **Timeline** (seek-safe, paused): logo `back.out` @0.15 → date @0.4 → **subject slides up** (`set{autoAlpha:0,x:90,y:60}`→`to{autoAlpha:1,x:0,y:0,duration:0.85,ease:"power3.out"}`) @0.45 → headline @0.7 → dek @1.5 → outlet @1.9 → **keyword highlight** (`--hlw 0→100%`, `duration:0.8`) @2.2. duration ~6.5s.

**Reference:** `samples/news/_ref-article-layout.html` (a real article rendered with a real outlet logo + a background-removed person + a real story).

## Critical

Highlight is **grown in place, swept on AFTER the text — never pre-applied**. The text **stays readable — do NOT zoom the keyword to fill the frame** (team feedback 2026-06-09: zoom loses context and reads wrong; the technique never zooms — it presents the text and highlights the word). For Layout B, the person MUST be a `remove-background` cutout (no rectangular photo box). Deterministic; honor `references/builder-contract.md`.
categories/stat/module.md
# stat — category module

A single **hero number** reveal. Asset-free (the "input" is the number). ~4–6s.

## Plan (Director)

`content`: `{ value, prefix ($), suffix (% / x / K-M-B), label, ring: bool }`. Envelope: bold display font, restrained palette + one accent.

## Vocabulary / leans on

- Block: **`apple-money-count`** (finance-flavoured: $ counter + green flash + money burst + SFX) when it fits; otherwise hand-author.
- Rules: `hyperframes-animation/rules/{counting-dynamic-scale, stat-bars-and-fills}`.
- Primitives: `count_up` (odometer) · `scale_pop` · `ring_fill` (arc) · `label_stagger` (after value) · `hold`.

## Build (reuse-first)

Reuse `apple-money-count` + set target / prefix / suffix / label / palette; **or** hand-author per the proven prototype `v0-stat-motion-demo`:

- **count-up is timeline-driven** — tween a proxy `{v:0}→target` with `onUpdate` writing the formatted number (seek-safe; never setInterval/wall-clock).
- `font-variant-numeric: tabular-nums`; decelerating ease; ~1.2–1.6s then **hold** the final value.
- ring/arc via `stroke-dashoffset`, finishing in sync with the count-up; label fades in **after** the number lands (value → meaning).
categories/tweet/module.md
# tweet — category module (search-driven)

**Search a tweet → animate the tweet card.** Grounded in a real post (RWA). ~4–8s.

## Source (Step 2)

RWA `search_tweets` (specific query, or a given tweet URL/id) → tweet: author, handle, avatar, text, timestamp, metrics (likes/reposts). `asset_needs`: `{ kind: tweet, query|source, treatment: none }`. Freeze the avatar + any embedded media.

## Vocabulary / leans on

- Block: registry **`x-post`** (animated X/Twitter post card overlay with engagement metrics) — reuse it directly.
- Primitives: card slide/scale-in · text type-on / line reveal · avatar pop · metrics **count-up** · optional emphasis on a keyword.

## Build (reuse-first)

`npx hyperframes add x-post` → fill author / handle / avatar / text / metrics from the resolved tweet; animate the card in, type-on the text (or line-by-line reveal), count-up the metrics. Frozen project-local avatar/media (never a remote URL). `export: alpha-overlay` if it's meant to sit over other footage.
categories/webpage/module.md
# webpage — category module (search-driven)

**Animate a webpage / link** (the "website animation" use case). Search-driven: the user gives or names a URL → fetch/capture it → animate it (scroll-through, UI reveal, cursor demo, region callouts). Distinct from captioning an existing _video file_ (that's `/embedded-captions`) and from a narrated video _of_ a site (that's a `/product-launch-video` site-tour ask); here the source is a _web page_ and the output is a short, unnarrated highlight shot.

## Source (Step 2)

Fetch the page via `hyperframes capture --json` (DOM + screenshots) or a provided screenshot → frozen project-local image(s)/DOM. `asset_needs`: `{ kind: web, source: <url>, treatment: none }`.

Inspect the capture result before building. If `BLOCKED.md` exists, JSON reports `ok: false`, or the
command exits non-zero, stop the URL-capture path and report the reason. Continue with a provided
screenshot only when it was an explicit source from the user (or the user explicitly chooses it
after the failure). Do not animate a blocked partial capture, invent missing DOM, or eyeball element
coordinates from protection/challenge pages.

This web need cannot use the generic asset-free fallback: it requires a usable captured screenshot
or DOM, or a provided screenshot explicitly selected by the user. If none is available, stop.

## Vocabulary / leans on

- Rules: `hyperframes-animation/rules/{3d-page-scroll, demo-page-scroll-spotlight, cursor-click-ripple, coordinate-target-zoom, viewport-change}`.
- Primitives: scroll pan · spotlight/dim · cursor move + click-ripple · zoom-to-region · callout pin + label.

## Build (reuse-first)

Place the captured page as the base layer; animate a **scroll-through** (translateY over the page height), **spotlight** key regions (dim + highlight), a **cursor** that moves and clicks (ripple), **zoom** to a coordinate, and **callout** pins/labels anchored to page regions. Honor `builder-contract.md`.

**Two rules that match the news article-highlight (see `../news/module.md`):**

- **Highlights are swept/animated ON, never pre-applied** — a spotlight box wipes in (`scaleX 0→1`), a marker sweeps a region, a callout pops _after_ the zoom lands; the page never starts pre-annotated.
- **Anchor to REAL element positions** — `hyperframes capture` gives the page HTML/DOM, so measure the target element (`getBoundingClientRect` → stage-local) and zoom/highlight it exactly, step-by-step (the "highlight a real element" technique). Don't eyeball coords.
grounding/locate.mjs
#!/usr/bin/env node
/* locate.mjs — find "X" in an image WITHOUT any detector API or key.
 *
 * You (the author model) are weak at regressing pixel coordinates but strong at
 * "which numbered strip is the target in?" — so localization = RSVP grid-index
 * (ACL 2025 long.715): pick strips on a numbered grid, reconstruct the box
 * geometrically. Zero npm deps — node + ffmpeg (both already required by
 * hyperframes). Full protocol: grounding/PROTOCOL.md.
 *
 * THE LOOP (you read images between steps):
 *   1) node locate.mjs overlay <img> --out DIR
 *        → DIR/gv.png (vertical strips 1..9) + DIR/gh.png (horizontal 1..9).
 *        READ both, pick the strip numbers the target spans.
 *   2) node locate.mjs region <img> --vids 4,5 --hids 6,7 --out DIR
 *        → prints coarse region + writes DIR/gc.png (region cropped+upscaled,
 *        finer 6×6 grid). READ it, pick the finer strips.
 *   3) node locate.mjs final <img> --region x0,y0,x1,y1 --vids 3,4 --hids 3,4
 *        → {box, center} normalized to the FULL image.
 *   4) node locate.mjs mark <img> --box x0,y0,x1,y1 --out DIR/check.png
 *        → VERIFY: read check.png — is the red box on the target? If off,
 *        redo step 2/3 with corrected strips. Never skip this.
 */
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync } from "node:fs";

const N1 = 9,
  PAD1 = 0.4; // stage 1: strips per axis, padding in STRIP units
const N2 = 6,
  PAD2 = 0.25; // stage 2 (crop)
const GREEN = "0x3CDC5A",
  BLUE = "0x3C82FF",
  RED = "0xFF3232";

const ff = (args) =>
  execFileSync("ffmpeg", ["-v", "error", "-y", ...args], {
    stdio: ["ignore", "ignore", "inherit"],
  });
function probe(img) {
  const out = execFileSync("ffprobe", [
    "-v",
    "error",
    "-select_streams",
    "v:0",
    "-show_entries",
    "stream=width,height",
    "-of",
    "csv=p=0",
    "--",
    img,
  ])
    .toString()
    .trim();
  const [w, h] = out.split(",").map(Number);
  return { w, h };
}
function font() {
  const dst = "/tmp/locate-font.ttf";
  if (!existsSync(dst)) {
    for (const c of [
      "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
      "/System/Library/Fonts/Supplemental/Arial.ttf",
      "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
    ]) {
      if (existsSync(c)) {
        copyFileSync(c, dst);
        break;
      }
    }
  }
  return dst;
}
function gridFilters(w, h, n, axis, fz) {
  const F = font(),
    fs = [];
  if (axis === "v" || axis === "both") {
    for (let i = 1; i < n; i++)
      fs.push(`drawbox=x=${Math.round((i * w) / n)}:y=0:w=2:h=${h}:color=${GREEN}@0.8:t=fill`);
    for (let i = 0; i < n; i++)
      fs.push(
        `drawtext=fontfile=${F}:text='${i + 1}':x=${Math.round(((i + 0.5) * w) / n - fz * 0.3)}:y=6:fontsize=${fz}:fontcolor=black:box=1:[email protected]:boxborderw=5`,
      );
  }
  if (axis === "h" || axis === "both") {
    for (let j = 1; j < n; j++)
      fs.push(`drawbox=x=0:y=${Math.round((j * h) / n)}:w=${w}:h=2:color=${BLUE}@0.8:t=fill`);
    for (let j = 0; j < n; j++)
      fs.push(
        `drawtext=fontfile=${F}:text='${j + 1}':x=6:y=${Math.round(((j + 0.5) * h) / n - fz * 0.5)}:fontsize=${fz}:fontcolor=black:box=1:[email protected]:boxborderw=5`,
      );
  }
  return fs;
}
const ids = (s) => String(s).split(",").filter(Boolean).map(Number);
const regionOf = (v, h, n, pad) => [
  Math.max(Math.min(...v) - 1 - pad, 0) / n,
  Math.max(Math.min(...h) - 1 - pad, 0) / n,
  Math.min(Math.max(...v) + pad, n) / n,
  Math.min(Math.max(...h) + pad, n) / n,
];
const r4 = (x) => Math.round(x * 1e4) / 1e4;
const out = (o) => console.log(JSON.stringify(o));

// --- tiny arg parsing: locate.mjs <cmd> <img?> --k v ---
const [cmd, ...rest] = process.argv.slice(2);
const pos = rest.filter((a) => !a.startsWith("--"));
const opt = {};
for (let i = 0; i < rest.length; i++)
  if (rest[i].startsWith("--")) opt[rest[i].slice(2)] = rest[i + 1];
const img = pos[0];

if (cmd === "overlay") {
  const dir = opt.out || ".";
  mkdirSync(dir, { recursive: true });
  const n = Number(opt.n || N1);
  const { w, h } = probe(img);
  const fz = Math.max(16, Math.round(Math.min(w, h) / 22));
  ff(["-i", img, "-vf", gridFilters(w, h, n, "v", fz).join(","), `${dir}/gv.png`]);
  ff(["-i", img, "-vf", gridFilters(w, h, n, "h", fz).join(","), `${dir}/gh.png`]);
  out({
    w,
    h,
    n,
    vertical: `${dir}/gv.png`,
    horizontal: `${dir}/gh.png`,
    next: "READ gv.png + gh.png, then: locate.mjs region <img> --vids .. --hids .. --out " + dir,
  });
} else if (cmd === "region") {
  const dir = opt.out || ".";
  mkdirSync(dir, { recursive: true });
  const n = Number(opt.n || N1),
    nf = Number(opt.nf || N2);
  const reg = regionOf(ids(opt.vids), ids(opt.hids), n, PAD1);
  const { w, h } = probe(img);
  const cx = Math.floor(reg[0] * w),
    cy = Math.floor(reg[1] * h);
  const cw = Math.max(2, Math.floor((reg[2] - reg[0]) * w)),
    ch = Math.max(2, Math.floor((reg[3] - reg[1]) * h));
  const uw = Math.max(cw, 640),
    uh = Math.round((ch * uw) / cw);
  const fz = Math.max(15, Math.round(Math.min(uw, uh) / 16));
  ff([
    "-i",
    img,
    "-vf",
    [
      `crop=${cw}:${ch}:${cx}:${cy}`,
      `scale=${uw}:${uh}`,
      ...gridFilters(uw, uh, nf, "both", fz),
    ].join(","),
    `${dir}/gc.png`,
  ]);
  out({
    region: reg.map(r4),
    crop_grid: `${dir}/gc.png`,
    nf,
    next: `READ gc.png, then: locate.mjs final <img> --region ${reg.map(r4).join(",")} --vids .. --hids ..`,
  });
} else if (cmd === "final") {
  const nf = Number(opt.nf || N2);
  const reg = opt.region.split(",").map(Number);
  const rl = regionOf(ids(opt.vids), ids(opt.hids), nf, PAD2);
  const [x0, y0, x1, y1] = reg;
  const box = [
    x0 + rl[0] * (x1 - x0),
    y0 + rl[1] * (y1 - y0),
    x0 + rl[2] * (x1 - x0),
    y0 + rl[3] * (y1 - y0),
  ].map(r4);
  out({
    box,
    center: [r4((box[0] + box[2]) / 2), r4((box[1] + box[3]) / 2)],
    next:
      "VERIFY: locate.mjs mark <img> --box " +
      box.join(",") +
      " --out <dir>/check.png — then READ it",
  });
} else if (cmd === "mark") {
  const box = opt.box.split(",").map(Number);
  const { w, h } = probe(img);
  const bx = Math.round(box[0] * w),
    by = Math.round(box[1] * h);
  const bw = Math.max(2, Math.round((box[2] - box[0]) * w)),
    bh = Math.max(2, Math.round((box[3] - box[1]) * h));
  const dst = opt.out || "./check.png";
  ff(["-i", img, "-vf", `drawbox=x=${bx}:y=${by}:w=${bw}:h=${bh}:color=${RED}@1:t=6`, dst]);
  out({
    marked: dst,
    next: "READ it — red box on the target? If off, redo region/final with corrected strips.",
  });
} else if (cmd === "auto") {
  // OPTIONAL fast path — only if a strong detector key happens to exist. Never assume it.
  const key = process.env.GEMINI_API_KEY;
  if (!key) {
    console.error(
      "auto needs GEMINI_API_KEY; use the grid loop instead (overlay→region→final→mark)",
    );
    process.exit(1);
  }
  const target = pos[1];
  const b64 = execFileSync("base64", ["-i", img]).toString().replace(/\n/g, "");
  const mime = img.match(/\.png$/i) ? "image/png" : "image/jpeg";
  const body = {
    contents: [
      {
        parts: [
          { inline_data: { mime_type: mime, data: b64 } },
          {
            text: `Detect "${target}". Return ONLY {"box_2d":[ymin,xmin,ymax,xmax]} integers 0-1000.`,
          },
        ],
      },
    ],
    generationConfig: { temperature: 0, response_mime_type: "application/json" },
  };
  const res = await fetch(
    `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${key}`,
    { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) },
  );
  const t = (await res.json()).candidates[0].content.parts[0].text;
  let bb = JSON.parse(t.match(/\{[\s\S]*\}/)[0]).box_2d;
  if (Array.isArray(bb[0])) bb = bb[0];
  const s = Math.max(...bb) > 1.5 ? 1000 : 1;
  const box = [bb[1] / s, bb[0] / s, bb[3] / s, bb[2] / s].map(r4);
  out({ box, center: [r4((box[0] + box[2]) / 2), r4((box[1] + box[3]) / 2)] });
} else {
  console.error("usage: locate.mjs overlay|region|final|mark|auto … (see header comment)");
  process.exit(1);
}
grounding/PROTOCOL.md
# locate — find "X" in an image (no detector API assumed)

The single contract every consumer (asset-fusion ring, webpage highlight, future
explainer/recut overlays) uses:

```
locate(image, target-description) → { box: [x0,y0,x1,y1], center: [cx,cy] }   # normalized 0..1
```

## Why this exists

Author models are unreliable at **regressing pixel coordinates** on a cluttered
full image (measured: weak vision models ~16–24% center error → rings land off-target)
but reliable at **picking a numbered strip** (~3–4% with the loop below). So:
never eyeball coordinates; localize by discrete choice. (RSVP, ACL 2025.)

## Routing — pick the cheapest path that's actually available

1. **A strong detector is available** (e.g. `GEMINI_API_KEY` in env) →
   `node grounding/locate.mjs auto <img> "<target>"` — one call, done.
   **Never assume the key exists.** No key → path 2.
2. **No detector (the normal case)** → YOU are the localizer; run the grid loop.

## The grid loop (you read images between steps)

```
node grounding/locate.mjs overlay <img> --out /tmp/g
  → READ /tmp/g/gv.png (vertical strips 1-9) and gh.png (horizontal 1-9);
    decide which strip numbers the target spans (list EVERY strip it touches).
node grounding/locate.mjs region <img> --vids 4,5 --hids 6,7 --out /tmp/g
  → READ /tmp/g/gc.png (the region cropped + upscaled, finer 6×6 grid);
    pick the finer strips. (Pick strips again — do NOT switch to estimating
    coordinates; discrete choice is the whole point, at BOTH stages.)
node grounding/locate.mjs final <img> --region <from step 2> --vids 3,4 --hids 3,4
  → the final {box, center}.
node grounding/locate.mjs mark <img> --box <final box> --out /tmp/g/check.png
  → VERIFY: READ check.png. Red box ON the target → done. Off → redo
    region/final with corrected strips (you now know which direction). Never
    skip this step; it converts silent misses into one cheap retry.
```

## Ambiguity — resolve BEFORE localizing

If the target description can match several instances ("a drum" when the scene
has five), no geometry will save you. First make the reference unique
("the right-most drum", "the drum on the front boat"), asking the user if
needed — then run the loop.

## Degradation path (if locate.mjs itself is unavailable)

The idea is 5 lines — reproduce it with any image tool:
draw a numbered 9×9 grid → pick the strips the target spans → crop that region
(+pad ~0.4 strip) and upscale → draw a finer 6×6 grid → pick again → map back
(`global = region_origin + local × region_size`) → draw the box and LOOK at it.

## Notes

- Geometry is frozen in `locate.mjs` (node + ffmpeg, zero npm deps — both
  already required by hyperframes). Don't re-implement it ad hoc; the measured
  accuracy holds for THIS implementation.
- Consumers: `samples/asset-fusion/_ref-circle-highlight.html` takes `CFG.box`
  directly from `final`/`auto` output.
- Measured end-to-end (same agent, same template, only the localization step
  differs): eyeballing 6.5% avg center error → this protocol 2.3%, no case worse.
phases/source/guide.md
# source phase — asset sourcing (asset-first)

Runs **only when `shot-plan.json.asset_needs` is non-empty** (the form categories never reach here). Sources each needed asset → a **frozen project-local path** + a ledger (`assets/index.md`). Uses `/media-use` (capture / asset prep) plus, **when an external asset-search skill such as media-use is installed**, its `resolve` step. If no such search capability is available, it **degrades to asset-free** (see below).

## Per asset_need

- `image / icon / logo / svg` → media-use `resolve`: **search** (asset_scout: Google Images / SerpAPI + Noun Project), **generate** (image model), or **user-supplied** (logo). Optional `treatment`: cutout (remove-bg) / recolor / vectorize.
- `news / web / tweet` → **RWA-style search** (media-use's documented lineage — `media-use/references/search-strategy.md` traces `resolve` to the RWA subagent). Two-pole queries: **atomic** (1–3 words, composable) or **specific** (5–15 words: a news event / tweet); never the middle. A failed specific query is dropped, not broadened.

## Steps

1. Read `asset_needs` from `shot-plan.json`.
2. For each: **analyze → search → review (use/maybe/reject — selection is the hard part; do NOT take the first/generated result) → freeze** the kept asset into `assets/` (rehost remote URLs).
3. Write `assets/index.md` — agent-readable ledger: `role → frozen path + provenance`.
4. For `asset-fusion`: also capture the asset's measurable geometry (so Director Part 2 can set `element_positions`) + an eyedropper palette.

## Degrade gracefully

If a provider / search is unavailable, mark the need unmet in `context.log`; the category falls back to asset-free where possible (e.g. `news` → typographic headline without the sourced image).

> Illustrative: `(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/source/resolve.mjs --plan ./shot-plan.json --out ./assets)` — or drive media-use's `resolve` procedure directly.
references/builder-contract.md
# Builder contract — composition rules (detail behind agents/builder.md)

## Root must be sized

Root `#stage` (`data-composition-id`) needs `position: relative; width: <W>px; height: <H>px`. Without a resolved height, flex children collapse to ~0 and content piles into the top-left. Automated gates may miss it, so inspect proof snapshots.

## Layout before animation

1. Identify the **hero frame** (the moment most elements are visible) → build THAT in static CSS first, no GSAP.
2. `.scene-content` fills the scene with padding, not offsets:
   ```css
   display: flex;
   flex-direction: column;
   justify-content: center;
   width: 100%;
   height: 100%;
   padding: 120px 160px;
   gap: 24px;
   box-sizing: border-box;
   ```
   Never `position:absolute; top:Npx` on a content container (it overflows). Reserve absolute for decoratives. Keep ≥80px padding (title-safe margin).
3. **Entrances**: use `gsap.from()` only for a non-clip element active from `t=0`. Inside `.clip`, in sub-compositions, and for later entrances, use explicit `fromTo()`. The CSS position is ground truth; the tween is the journey to it.
4. **Exits**: only the final scene animates elements out; between scenes the transition IS the exit.

## Timeline / clip contract

- ONE `gsap.timeline({paused:true})` on `window.__timelines["<id>"]`; `tl.seek(0)`; never `tl.play()`.
- Timed elements: `class="clip"` + `data-start`/`data-duration`/`data-track-index` + a stable `id`. Timeline-driven groups inside one full-duration clip don't each need timing attrs.
- Deterministic only — no `Date.now()` / `Math.random()` / network. Count-ups tween a proxy object via `onUpdate` (seek-safe), never a wall-clock counter.

## Correctness

- **Seek-safe reveal of delayed elements**: on a non-clip element or wrapper inside a clip, use one registered timeline `fromTo()` with an explicit `{ autoAlpha: 0 }` start and `{ autoAlpha: 1, ... }` end. Do not page-load `gsap.set()` a later `.clip`, and never target `.clip` visibility; the framework owns its lifecycle. _(Eval finding.)_
- **Count-ups** tween a proxy via `onUpdate`; they only render when the host advances the timeline **with events enabled** (`tl.time()` / non-suppressed seek). A bare `seek(t, true)` freezes them at 0 — the HF render host must seek with events on. _(Eval finding.)_
- Clamp at tween bounds; don't let a spring overshoot past a held value.
- Allowed eases: `power1–4`, `back`, `bounce`, `circ`, `elastic`, `expo`, `sine` (`.in/.out/.inOut`).
- One motif per scene. Run `hyperframes check`; mark intentional overflow `data-layout-allow-overflow="true"`.
- **Palette discipline**: define all colors in one `palette` object / CSS custom properties — no inline hex scattered through the markup (for `asset-fusion`, eyedropper the palette from the asset).
references/motion-vocabulary.md
# text module · motion vocabulary (primitive → GSAP)

Named primitives the Director references in `motion` strings and the Builder implements. `code_hint`s are framework-neutral physics; the GSAP recipe is the HF implementation. Prefer an HF **registry component** (bottom) when one fits — don't reinvent.

## Entry

| primitive                     | GSAP recipe (into CSS end-state)                               | suits                     |
| ----------------------------- | -------------------------------------------------------------- | ------------------------- |
| `slide_bottom/top/left/right` | `from({ y:±150 / x:±200, opacity:0, ease:"power4.out" })`      | calm, build, professional |
| `scale_grow`                  | `from({ scale:0, opacity:0, duration:.6, ease:"power2.out" })` | calm, gentle              |
| `scale_punch`                 | `from({ scale:.6, opacity:0, ease:"back.out(2.2)" })`          | impact, energetic         |
| `fade_in`                     | `from({ opacity:0, duration:.4 })`                             | subtle                    |
| `fade_blur`                   | `from({ opacity:0, filter:"blur(14px)" })`                     | cinematic, dreamy         |
| `typewriter`                  | reveal via clip/`SplitText` width step                         | technical, narrative      |
| `word_reveal`                 | per-word `from({opacity:0,y:..}, stagger:.1)`                  | storytelling              |
| `wave`                        | per-letter `from({y:..}, stagger:{each:.04})`                  | flowing, musical          |
| `bounce_in`                   | `from({y:-120}, ease:"bounce.out")`                            | playful                   |
| `slam`                        | `from({ y:-300, ease:"power4.out" })` + shake on land          | impact, heavy             |

## Emphasis (in place, often on a beat)

| primitive     | GSAP recipe                                                          | suits              |
| ------------- | -------------------------------------------------------------------- | ------------------ |
| `scale_pulse` | `to({ scale:1.12, yoyo:true, repeat:1, ease:"sine.inOut" })` at beat | rhythmic, peak     |
| `shake`       | `to({ keyframes:[{x:-9},{x:9},{x:0}], ease:"none" })`                | urgent, intense    |
| `glow`        | `to({ textShadow:"0 0 46px <ink/accent>", yoyo:true, repeat:1 })`    | important, magical |
| `color_shift` | `to({ color:"<accent>" })` (or accent on the word in CSS)            | dynamic            |

## Exit

| primitive   | GSAP recipe                                        | suits      |
| ----------- | -------------------------------------------------- | ---------- |
| `fade_out`  | `to({ opacity:0, duration:.4, ease:"power2.in" })` | ending     |
| `slide_out` | `to({ y/x: off, opacity:0, ease:"power2.in" })`    | transition |
| `scale_out` | `to({ scale:1.06, opacity:0, ease:"power2.in" })`  | transition |

## Accent graphics (not text)

`underline_sweep` `fromTo({scaleX:0},{scaleX:1}, transformOrigin:"left center")` · `bar_wipe` · `hold_breath` `to({scale:1.015, ease:"sine.inOut"})`.

## Prefer HF registry components when they fit

`caption-kinetic-slam` · `caption-editorial-emphasis` · `caption-neon-glow` · `caption-glitch-rgb` · `caption-particle-burst` · `caption-weight-shift` · `caption-matrix-decode` · `caption-pill-karaoke` · `shimmer-sweep`. These are pre-built, in-ecosystem, and already render-tested — the Builder should reach for them before hand-rolling an equivalent.
references/shot-plan-ir.md
# shot-plan IR

The single contract between Director and Builder. One file: `PROJECT_DIR/shot-plan.json`.

```jsonc
{
  // ── envelope (every category) ──
  "category": "kinetic-type | stat | charts | logo-reveal | lower-thirds | webpage | news | tweet | asset-fusion",
  "duration_s": 6,
  "fps": 30,
  "canvas": { "w": 1080, "h": 1920, "aspect": "9:16" },
  "style": "free-form visual direction (mood / energy / reference)",
  "palette": ["#…"], // or "derive-from-asset"
  "font": "<HF embed-list font>",
  "beats": [12, 37], // optional accent frames/seconds
  "export": "mp4", // or "alpha-overlay" (transparent webm/mov)

  // ── sourcing seam (Director Part 1) — [] means skip the source phase ──
  "asset_needs": [
    {
      "role": "hero",
      "kind": "image|icon|logo|svg|news|web|tweet",
      "query": "…",
      "source": "…",
      "treatment": "cutout|recolor|vectorize|none",
    },
  ],

  // ── build directive (Director Part 2, reuse-first) ──
  "block": "<catalog block id, e.g. data-chart | caption-kinetic-slam>", // optional
  "customize": {
    /* what to change on the block: data, text, palette, positions */
  },

  // ── category-specific content ──
  "content": {
    /* shape varies by category, below */
  },
}
```

**Per-category `content` shapes:**

- `kinetic-type` → `scenes[]` `{ id, start, end, text, emphasis_words[], emotion, motion, beats[] }`
- `stat` → `{ value, prefix, suffix, label, ring: bool }`
- `charts` → `{ type: bar|line|pie|race|pct, data[], labels[], headline, axes: bool }`
- `logo-reveal` → `{ logo: <asset path>, tagline, url }`
- `lower-thirds` → `{ name, role, position, brand_colors[] }`
- `webpage` → `{ url, capture, highlights: [ { selector|region, label } ] }` (step-highlight a real captured page)
- `news` → `{ outlet, headline, body, keyword, layout: A|B, logo?, date?, subject? }` (article-highlight: lay text out readable — **no zoom** — then sweep a marker band over the keyword in place. Layout **A** = centered-emphasis 9:16 text-only; **B** = full article 16:9 with `logo` + `date` + `subject` (a person photo → `remove-background` cutout))
- `tweet` → `{ author, handle, avatar, text, metrics }`
- `asset-fusion` → `{ data_type, asset: <path>, affordance, element_positions: {center, extent, safe[], avoid[]}, derived_palette[], connectors[] }`

**Invariants:** `scenes` (if present) partition `[0, duration_s]` with no gaps/overlaps · empty `asset_needs` ⇒ Step 2 (source) is skipped · a named `block` ⇒ the Builder reuses + customizes it rather than hand-authoring.
samples/asset-fusion/_ref-circle-highlight.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>asset-fusion · circle-highlight TEMPLATE (drop-in)</title>
    <!-- ============================================================
         DROP-IN circle-highlight (editorial amber annotation style). To reuse:
           1) set CFG below (asset path, EVEN stage dims, BOX from the locate protocol, LABEL)
           2) render with even width/height (ODD width => ffmpeg encode fails!)
         BOX comes from grounding/PROTOCOL.md (locate.mjs loop) — NEVER eyeball coords.
         Everything (wash pool, double ring, connector, callout, brackets) is
         computed from BOX. Set MODE='full' (ring+label+brackets) or 'circle'
         (ring only). Gotchas are flagged inline.
         ============================================================ -->
    <style>
      :root {
        --ink: #0a0c10;
        --amber: #f2c078;
        --paper: #f5efe6;
      }
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }
      html,
      body {
        overflow: hidden;
        background: var(--ink);
      }
      #stage {
        position: relative;
        overflow: hidden;
        background: #fff;
        font-family:
          "Inter",
          -apple-system,
          system-ui,
          sans-serif;
      }
      .scene {
        position: absolute;
        inset: 0;
      }
      #shot {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        object-fit: cover;
        display: block;
        z-index: 0;
      }
      #wash {
        position: absolute;
        inset: 0;
        z-index: 1;
      }
      #ann {
        position: absolute;
        inset: 0;
        z-index: 3;
        pointer-events: none;
        overflow: visible;
      }
      #ring-a,
      #ring-b,
      #conn {
        stroke: var(--amber);
        fill: none;
        stroke-linecap: round;
      }
      #ring-a {
        stroke-width: 8;
        filter: drop-shadow(0 0 12px rgba(242, 192, 120, 0.5));
      }
      #ring-b {
        stroke-width: 5;
        opacity: 0.85;
      }
      #conn {
        stroke-width: 5;
      }
      #callout {
        position: absolute;
        z-index: 4;
        display: inline-flex;
        align-items: center;
        gap: 14px;
        padding: 15px 28px;
        background: var(--ink);
        border: 2px solid var(--amber);
        border-radius: 999px;
        box-shadow: 0 14px 40px rgba(8, 9, 12, 0.6);
        transform-origin: left center;
      }
      #callout .dot {
        width: 12px;
        height: 12px;
        border-radius: 50%;
        background: var(--amber);
      }
      #callout .label {
        font-size: 33px;
        font-weight: 700;
        letter-spacing: 0.14em;
        color: var(--paper);
        text-transform: uppercase;
        white-space: nowrap;
      }
      .tick {
        position: absolute;
        z-index: 5;
        width: 56px;
        height: 56px;
        border: 3px solid var(--paper);
        opacity: 0.55;
      }
      .tick.tl {
        top: 44px;
        left: 44px;
        border-right: none;
        border-bottom: none;
      }
      .tick.tr {
        top: 44px;
        right: 44px;
        border-left: none;
        border-bottom: none;
      }
      .tick.bl {
        bottom: 44px;
        left: 44px;
        border-right: none;
        border-top: none;
      }
      .tick.br {
        bottom: 44px;
        right: 44px;
        border-left: none;
        border-top: none;
      }
      #scan {
        position: absolute;
        inset: 0;
        z-index: 5;
        pointer-events: none;
        mix-blend-mode: overlay;
        background: repeating-linear-gradient(
          to bottom,
          rgba(245, 239, 230, 0.05) 0 1px,
          transparent 1px 4px
        );
      }
    </style>
  </head>
  <body>
    <!-- ⚠ data-width/height + the #stage width/height MUST be set STATICALLY here
         (the renderer's StaticGuard reads them at compile time, BEFORE JS runs — JS
         setAttribute is too late and the render falls back to portrait 1080×1920).
         Keep these EQUAL to CFG.W/CFG.H below, and EVEN. -->
    <div
      id="stage"
      style="width: 1998px; height: 1368px"
      data-composition-id="circle-highlight"
      data-start="0"
      data-duration="6"
      data-fps="30"
      data-width="1998"
      data-height="1368"
    >
      <div class="scene clip" data-start="0" data-duration="6" data-track-index="0">
        <img id="shot" alt="asset" />
        <div id="wash"></div>
        <svg id="ann">
          <ellipse id="ring-a" />
          <ellipse id="ring-b" />
          <path id="conn" />
        </svg>
        <div id="callout"><span class="dot"></span><span class="label"></span></div>
        <div id="scan"></div>
        <span class="tick tl"></span><span class="tick tr"></span><span class="tick bl"></span
        ><span class="tick br"></span>
      </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
    <script>
      // ====== CONFIG — set these (BOX from the locate protocol) ======
      const CFG = {
        asset: "assets/shot.png",
        W: 1998,
        H: 1368, // EVEN dims (odd width => encode fails)
        box: [0.244, 0.277, 0.607, 0.425], // [x0,y0,x1,y1] normalized, from locate.mjs
        label: "HyperFrames",
        mode: "full", // 'full' = ring+connector+label+brackets+scan ; 'circle' = ring only
      };
      // =====================================================
      const S = document.getElementById("stage"); // dims are STATIC in the HTML above (must equal CFG.W/H)
      document.getElementById("shot").src = CFG.asset;
      const ann = document.getElementById("ann");
      ann.setAttribute("viewBox", `0 0 ${CFG.W} ${CFG.H}`);
      ann.setAttribute("width", CFG.W);
      ann.setAttribute("height", CFG.H);

      // geometry from BOX
      const [x0, y0, x1, y1] = CFG.box;
      const cx = ((x0 + x1) / 2) * CFG.W,
        cy = ((y0 + y1) / 2) * CFG.H;
      const bw = (x1 - x0) * CFG.W,
        bh = (y1 - y0) * CFG.H;
      const rxA = bw / 2 + 40,
        ryA = bh / 2 + 42;
      // wash: a lit radial pool centered on the box, dark elsewhere
      const px = (((x0 + x1) / 2) * 100).toFixed(1),
        py = (((y0 + y1) / 2) * 100).toFixed(1);
      const ex = (((x1 - x0) / 2) * 1.15 * 100).toFixed(1),
        ey = (((y1 - y0) / 2) * 1.4 * 100).toFixed(1);
      document.getElementById("wash").style.background =
        `radial-gradient(ellipse ${ex}% ${ey}% at ${px}% ${py}%, rgba(8,9,12,0) 0%, rgba(8,9,12,.1) 42%, rgba(8,9,12,.66) 70%, rgba(8,9,12,.9) 100%)`;
      const ra = document.getElementById("ring-a"),
        rb = document.getElementById("ring-b");
      ra.setAttribute("cx", cx);
      ra.setAttribute("cy", cy);
      ra.setAttribute("rx", rxA);
      ra.setAttribute("ry", ryA);
      ra.setAttribute("transform", `rotate(-4 ${cx} ${cy})`);
      rb.setAttribute("cx", cx);
      rb.setAttribute("cy", cy);
      rb.setAttribute("rx", rxA - 20);
      rb.setAttribute("ry", ryA - 16);
      rb.setAttribute("transform", `rotate(3 ${cx} ${cy})`);
      // callout: above-right of the box (flip left if box is on the right edge)
      const right = cx < CFG.W * 0.62;
      const co = document.getElementById("callout");
      co.querySelector(".label").textContent = CFG.label;
      const coX = right ? Math.min(cx + rxA + 10, CFG.W - 360) : Math.max(cx - rxA - 360, 30);
      const coY = Math.max(cy - ryA - 60, 40);
      co.style.left = coX + "px";
      co.style.top = coY + "px";
      const conn = document.getElementById("conn");
      const ex0 = cx + (right ? rxA * 0.75 : -rxA * 0.75),
        ey0 = cy - ryA * 0.7;
      conn.setAttribute(
        "d",
        `M ${ex0} ${ey0} C ${(ex0 + coX) / 2} ${ey0 - 50}, ${coX - 20} ${coY + 70}, ${coX + 2} ${coY + 26}`,
      );

      // ====== seek-safe timeline ======
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      // GOTCHA: getTotalLength can read 0 before layout -> dash fails -> a solid
      // line shows at t=0. Fall back to a constant AND gate with autoAlpha:0.
      const lenA = ra.getTotalLength() || 1900,
        lenB = rb.getTotalLength() || 1750,
        lenC = conn.getTotalLength() || 220;
      const full = CFG.mode === "full";
      gsap.set("#wash", { autoAlpha: 0 });
      gsap.set(ra, { strokeDasharray: lenA, strokeDashoffset: lenA, autoAlpha: 0 });
      gsap.set(rb, { strokeDasharray: lenB, strokeDashoffset: lenB, autoAlpha: 0 });
      gsap.set(conn, { strokeDasharray: lenC, strokeDashoffset: lenC, autoAlpha: 0 });
      gsap.set("#callout", { autoAlpha: 0, scale: 0.6 });
      gsap.set("#scan", { autoAlpha: 0 });
      gsap.set(".tick", { autoAlpha: 0 });
      if (!full) {
        document.getElementById("conn").remove();
        document.getElementById("callout").remove();
        document.getElementById("scan").remove();
        document.querySelectorAll(".tick").forEach((t) => t.remove());
      }

      tl.to("#wash", { autoAlpha: 1, duration: 0.9, ease: "power2.out" }, 0.6);
      if (full) {
        tl.to("#scan", { autoAlpha: 1, duration: 0.8 }, 0.7);
        tl.to(".tick", { autoAlpha: 0.55, duration: 0.5, stagger: 0.06 }, 0.8);
      }
      tl.to(ra, { autoAlpha: 1, duration: 0.08 }, 1.3);
      tl.to(ra, { strokeDashoffset: 0, duration: 0.6, ease: "power2.inOut" }, 1.3);
      tl.to(rb, { autoAlpha: 1, duration: 0.08 }, 1.68);
      tl.to(rb, { strokeDashoffset: 0, duration: 0.5, ease: "power2.inOut" }, 1.68);
      if (full) {
        tl.to(conn, { autoAlpha: 1, duration: 0.05 }, 2.55);
        tl.to(conn, { strokeDashoffset: 0, duration: 0.35, ease: "power3.out" }, 2.6);
        tl.to("#callout", { autoAlpha: 1, scale: 1, duration: 0.45, ease: "back.out(2.4)" }, 2.95);
      }
      window.__timelines["circle-highlight"] = tl;
      tl.seek(0);
      if (!window.__HYPERFRAMES_HOST__) {
        const fit = () => {
          const s = Math.min(innerWidth / CFG.W, innerHeight / CFG.H) * 0.96;
          S.style.transform = `scale(${s})`;
          S.style.transformOrigin = "center center";
          S.style.position = "absolute";
          S.style.left = "50%";
          S.style.top = "50%";
          S.style.marginLeft = -(CFG.W / 2) + "px";
          S.style.marginTop = -(CFG.H / 2) + "px";
        };
        addEventListener("resize", fit);
        fit();
        tl.eventCallback("onComplete", () => setTimeout(() => tl.restart(), 1000));
        tl.play();
      }
    </script>
  </body>
</html>
SKILL.md
---
name: motion-graphics
description: >
  A short, design-led motion graphic where motion is the message — kinetic
  typography, stat count-up, chart/data-viz hit, logo sting / brand lockup,
  lower-third / callout / social overlay, animated map (highlight regions,
  connect places, zoom to a location), animated tweet / news-article /
  headline, webpage / UI animation (scroll, cursor, callouts), or fusing a
  real image's geometry into a chart. Usually under 10s (up to ~30s), no
  narration or live-action subject; renders to MP4 or transparent overlay.
  Longer / narrated / multi-scene → /general-video. Unclear → /hyperframes.
---

> **First, keep this skill fresh — confirm with the user before running:** `npx hyperframes skills update motion-graphics`. 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.

> **figma source**: If the logo/asset/animation to build from comes from a figma.com URL, run `/figma` first — asset export, brand tokens, and Motion→GSAP translation if the graphic is a Figma Motion import — then build from its output. Don't drive Figma via raw MCP tools directly: that skips SVG sanitization, `.media/manifest.jsonl` provenance, and brand-token `var()` binding, so a later brand change can't propagate without a full re-import.

# motion-graphics — dispatch entry

> **The front door is `/hyperframes`.** This skill makes a **short, design-led, unnarrated motion graphic** (motion is the message; ~under 10s, no voice-over). Anything longer, narrated, or multi-scene — or any uncertainty → read `/hyperframes` first: the intent layer owns every route decision.

This workflow is **autonomous by design** — at most one clarifying question (`agents/director.md`), then build through verification without intermediate review. The intent layer (`/hyperframes` → `references/intent-interview.md`) routes here directly without run-shape questions; a storyboard and companion session add little to a piece this short. Rendering is still user-gated: after checks and proof snapshots pass, ask the canonical “preview first, or render?” question from `../hyperframes-core/references/brief-contract.md`. When a `BRIEF.md` exists, read it before the director's question.

A short design-led motion graphic. **Asset-first**: decide the asset strategy and source real material _before_ designing the shot, then design the shot around what you have, then compose by reusing catalog capabilities. All artifacts go to `PROJECT_DIR = videos/<project-name>/` (created in Step 0); all paths below are relative to it.

| Phase    | Execution                                                             | Primary artifact                                                 | Detailed flow                 |
| -------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------- |
| init     | Bash                                                                  | `hyperframes.json`                                               | Step 0                        |
| plan     | subagent — **decide search?** + classify + asset strategy             | `shot-plan.json` (draft: category, `asset_needs` queries, brief) | `agents/director.md` (Part 1) |
| source ◇ | Bash — media-use resolve (**skip if `asset_needs` is empty**)         | `assets/` + `assets/index.md`                                    | `phases/source/guide.md`      |
| design   | subagent — shot design around resolved assets                         | `shot-plan.json` (final: block(s) + layout + motion + positions) | `agents/director.md` (Part 2) |
| build    | subagent — reuse-first composition                                    | `compositions/index.html`                                        | `agents/builder.md`           |
| verify   | Bash — `lint`, `check`, proof snapshots; repair on failure            | `snapshots/contact-sheet.jpg`                                    | Step 5                        |
| approve  | Ask preview or render; wait for the answer                            | explicit render approval                                         | Step 6                        |
| render   | Bash — `hyperframes render` (MP4, or `--format webm/mov` for overlay) | `renders/video.mp4` or transparent overlay                       | Step 6                        |

`◇ source` runs only when the chosen category declares assets. Pure code/text categories (e.g. `kinetic-type`, most `charts`/`stat`) have `asset_needs: []` and skip straight from plan to design.

## Categories — split by the search decision

`plan`'s **first decision is: does this need a search?** That fork splits the categories into two groups; then the specific category is picked — for search-driven, **by the type of content the search returns**. Each category is one `categories/<id>/module.md` (its planning + build rules); the shared motion vocabulary lives in `references/motion-vocabulary.md` (→ `hyperframes-animation` rules/blueprints + registry blocks).

**Form categories — no search; the user supplies the content:**

| Category       | Intent                                                                                                         | Leans on                                                                    |
| -------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `kinetic-type` | punchy line / quote / title, motion-first text                                                                 | `caption-*` blocks + animation rules                                        |
| `stat`         | single hero number / count-up + ring                                                                           | `apple-money-count` / `rules/{counting-dynamic-scale, stat-bars-and-fills}` |
| `charts`       | bar / line / pie / race / % from data                                                                          | `data-chart` block                                                          |
| `logo-reveal`  | logo sting / brand lockup (user logo)                                                                          | `logo-outro` / `rules/svg-path-draw`                                        |
| `lower-thirds` | name / title bars, callouts, social overlays                                                                   | `caption-*` + registry overlay blocks                                       |
| `maps`         | geographic motion — highlight regions, connect places, zoom to a location (vector lane, or baked basemap lane) | `us-map` / `world-map` family + `bake-basemap.mjs`                          |

**Search-driven categories — search first, then animate by content type** (the RWA path):

| Returned content | Category       | Animation                                                      |
| ---------------- | -------------- | -------------------------------------------------------------- |
| webpage / link   | `webpage`      | webpage / UI animation (scroll, reveal, cursor, callouts)      |
| news article     | `news`         | headline reveal + source card + key-fact callouts              |
| tweet            | `tweet`        | animated tweet card                                            |
| image / entity   | `asset-fusion` | the asset's geometry _becomes_ the chart (RWA diegetic fusion) |

Build order: one at a time, coverage-first (rough is fine). `kinetic-type` ported from the prototype; the rest follow.

## Prerequisites

macOS Apple Silicon or Linux x64. System tools: `brew install node ffmpeg`. `npx hyperframes doctor` once. macOS GPU render: `export PRODUCER_BROWSER_GPU_MODE=hardware`.

Optional keys (local fallbacks if unset) — only needed by categories that source/generate assets via media-use:

| Key                                 | Used for                                                    | Fallback                        |
| ----------------------------------- | ----------------------------------------------------------- | ------------------------------- |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | image generation (media-use resolve)                        | skip generate / search-only     |
| (asset_scout / search providers)    | `webpage`/`news`/`tweet` + `asset-fusion` real-asset search | category degrades to asset-free |

## Flow

### Step 0 — Initialize

cwd is the agent workspace root; write all artifacts under `PROJECT_DIR = videos/<project-name>/`. `<project-name>`: use the dir the user gave, else a short kebab-case name from the intent (`<subject>-motion`). Not the workspace basename or a timestamp.

Only when `$PROJECT_DIR/hyperframes.json` is absent:

```bash
PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/<project-name>}"
mkdir -p "$(dirname "$PROJECT_DIR")"
npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank --skill=motion-graphics
```

`init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date.

**Constraints:** never `hyperframes init` in the workspace root; never nest another `hyperframes/` inside `PROJECT_DIR`; every Bash command (master + subagents) is a `(cd "$PROJECT_DIR" && ...)` subshell — never bare `cd`.

### Step 1 — Plan (subagent: Director Part 1)

Dispatch one subagent. prompt = full `agents/director.md` + `## Dispatch context` (`SKILL_DIR` / `PROJECT_DIR` / the user's request / `Schema: <SKILL_DIR>/references/shot-plan-ir.md`). It must:

1. **Decide: does this need a search?** (the first fork)
   - **No** → pick a **form category** (kinetic-type / stat / charts / logo-reveal / lower-thirds); content is user-supplied; `asset_needs: []`.
   - **Yes** → emit a **search plan** into `asset_needs[]` (news / web / tweet / image; two-pole queries). The specific **search-driven category** (webpage / news / tweet / asset-fusion) is confirmed by the content type returned in Step 2, and finalized in Step 3.
2. Write a draft `shot-plan.json` (envelope + chosen form category _or_ search intent + `asset_needs` + a one-paragraph shot brief). Schema: `references/shot-plan-ir.md`.

Validation: `[ -s "$PROJECT_DIR/shot-plan.json" ] && echo ok || echo missing`.

### Step 2 — Source ◇ (Bash: media-use, conditional)

If `shot-plan.json.asset_needs` is non-empty, resolve assets (search / generate / fetch → frozen project-local paths + ledger). See `phases/source/guide.md` (wraps `media-use resolve`; the search-driven categories use the news/web/tweet/image search). If `asset_needs` is empty, **skip to Step 3**.

```bash
# illustrative — see phases/source/guide.md
(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/source/resolve.mjs --plan ./shot-plan.json --out ./assets)
```

Degrade gracefully: if a search/provider is unavailable, the category falls back to asset-free (note it in `context.log`).

### Step 3 — Design (subagent: Director Part 2)

Dispatch a subagent (prompt = `agents/director.md` Part 2 + dispatch context including the resolved `assets/index.md` if Step 2 ran + `catalog-map.md`). It designs the shot **around the available assets**: pick the catalog block(s) + the `hyperframes-animation` rules/blueprints, the layout, the motion, beats, and (for `asset-fusion`) the `element_positions` + eyedropper palette. Finalizes `shot-plan.json` (`content.block` + `content.customize` + per-category content).

### Step 4 — Build (subagent: Builder, reuse-first)

Dispatch a subagent. prompt = full `agents/builder.md` + dispatch context (`shot-plan.json`, `catalog-map.md`, the category's `module.md`, `references/motion-vocabulary.md`, `references/builder-contract.md`). **Reuse-first**: `npx hyperframes add <block>` + customize in place; hand-author only gaps + the asset-fusion affordance. Output `compositions/index.html` honoring the HF contract (paused GSAP timeline on `window.__timelines`, `class="clip"` + stable ids, `tl.seek(0)`, deterministic).

### Step 5 — Verify (Bash → repair subagent on failure)

```bash
(cd "$PROJECT_DIR" && npx hyperframes lint .)
(cd "$PROJECT_DIR" && npx hyperframes check .)
(cd "$PROJECT_DIR" && npx hyperframes snapshot --at <proof-times>)
```

Choose proof times that show the opening state, signature move, and final hold. Inspect the generated contact or snapshot sheet before continuing. On `lint`, `check`, or snapshot failure, dispatch the repair subagent (`agents/finalize.md`) for one in-place fix pass, then rerun the failed gate. Never change a fixed duration merely to hide a defect.

### Step 6 — Approve and render (Bash)

Ask one question: “preview first, or render?” If the user chooses preview, open Studio and return to the same approval gate after revisions:

```bash
(cd "$PROJECT_DIR" && npx hyperframes preview --background)
```

Render only after an explicit render answer:

```bash
(cd "$PROJECT_DIR" && npx hyperframes render . --skill=motion-graphics -q high -o ./renders/video.mp4)
# transparent overlay variant: --format webm  (or mov)
```

Verify the output exists, is non-empty, and has the intended duration. The final handoff names the artifact, actual duration, composition or frame id, proof times, and the inspected contact or snapshot sheet. Flags live in `/hyperframes-cli` → `references/preview-render.md`.

## Resume table

| State                                                    | Continue from              |
| -------------------------------------------------------- | -------------------------- |
| no `shot-plan.json`                                      | Step 1 (plan)              |
| `shot-plan.json` has `asset_needs`, no `assets/`         | Step 2 (source)            |
| `shot-plan.json` final, no `compositions/index.html`     | Step 3/4 (design+build)    |
| `compositions/index.html` exists, proof snapshots absent | Step 5 (verify)            |
| checks and proof snapshots pass, no approved render      | Step 6 (approval)          |
| approved render exists                                   | verify output, then report |

## Design notes (maintainers — execution does not read this)

- **Asset-first rationale:** sourcing is front-loaded and informs shot design (the RWA flow: analyze → search → review → compose). the search-driven categories (`webpage`/`news`/`tweet`) and `asset-fusion` both lean on media-use search (news/web/tweet/image), which is media-use's documented RWA lineage.
- **Reuse-first:** the in-ecosystem analog of LLM-generated templates is "compose catalog blocks + `hyperframes-animation` rules". HF's paused GSAP timeline ≙ Remotion's `useCurrentFrame`.
- **Category module contract:** one `categories/<id>/module.md` (planning + build), sharing `references/motion-vocabulary.md` (+ optional eval). Adding a category = drop the folder + register its classifier line in `agents/director.md` + its row in `catalog-map.md`; the phase pipeline is untouched.
- **Directory shape:**
  ```
  videos/<project-name>/
    hyperframes.json  context.log
    shot-plan.json            # the IR (Director output)
    assets/  assets/index.md  # media-use output (if sourced)
    compositions/index.html   # Builder output
    renders/video.mp4
  ```
- **Registration:** in `hyperframes` router — add the "design-led short motion graphic" intent + Workflow description; carve the motion-graphics triggers out of `/general-video`; add reverse Do-NOT-use edges. See `motion-graphics-genre.md` §5-7.