refactor(skills): rebuild faceless-explainer + pr-to-video on the shot-sequence architecture (#1778)

* refactor(skills): rebuild faceless-explainer + pr-to-video on the shot-sequence architecture

Move both skills onto the current shot-sequence authoring architecture and
the latest shared engine, then re-narrate to each domain. The engine fixes
(pre-assembly frame guards + BGM loop-extend in assemble-index, dark-ground
caption contrast, brand-accent selection + mono role in tokens, dark-mode
polarity invert / weight-clamp / icon-font filter in build-frame) had only
landed in one copy; both skills were a generation behind.

- Authoring model: visual-design now writes a time-coded shot sequence (Scene
  windows paced to the voiceover) instead of the older effects-id phased note;
  motion-language carries the move vocabulary + the tightened motion doctrine
  (smooth over bouncy) + the seek-safe core (fromTo entrances, no CSS-transition
  motion); add cut-catalog (within-frame velocity-matched seams); frame-worker
  and SKILL Step 4/5 move to blueprint instantiation + shot-sequence fidelity.
- faceless-explainer: fold the standalone composition.md into visual-design
  (inventing-the-visual / portrait / caption geometry); keep the explainer
  story doctrine; graft cue-segmented VO + candidate-blueprint-from-Step-3.
- pr-to-video: keep the ingest pipeline (fetch-pr / ingest / fetch-people-avatars)
  and code-vocabulary; preserve the code-beat (code-* block as focal, Scenes
  choreograph the surround) and mechanism-beat treatments under the new model.
- Drop stage-assets from both (no captured assets to stage); remove derivation
  references so each skill reads standalone.

bun run scripts/lint-skills.ts passes; all scripts node --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(skills): resync skills-manifest after format pass

The pre-commit skills-manifest hook hashed the skills before the format hook
reformatted cut-catalog.md, so the committed manifest lagged the on-disk
content and CI's "Skills: manifest in sync" check failed. Regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
WaterrrForever
2026-06-30 00:02:24 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c811a2750a
commit 7cb8386539
29 changed files with 2123 additions and 779 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
"hash": "8f89def06adfe745",
"hash": "b94e52c77d04c1ea",
"files": 17
},
"general-video": {
@@ -54,7 +54,7 @@
"files": 132
},
"pr-to-video": {
"hash": "f2014a51c7a052bd",
"hash": "85a7577aee41bb82",
"files": 21
},
"product-launch-video": {
+20 -15
View File
@@ -1,6 +1,6 @@
---
name: faceless-explainer
description: "turn arbitrary text — an article, notes, a topic, a brief — into a faceless explainer video, up to ~3 min (sweet spot 30-90s), where every visual is invented (typography, abstract graphics, diagrams, data-viz) rather than captured. There is no URL, no website capture, and no real assets. Use this skill for topic explainers, concept breakdowns, how-tos, listicles, and narrative explainers. Do not use it for a product launch/promo (use /product-launch-video), a tour of a real website (use /website-to-video), a GitHub PR (use /pr-to-video), captions on existing footage (use /embedded-captions), or a short unnarrated motion graphic (use /motion-graphics). If the intent is unclear, route through /hyperframes first."
description: "turn arbitrary text — an article, notes, a topic, a brief — into a faceless explainer video, up to ~3 min (sweet spot 30-90s), where every visual is invented (typography, abstract graphics, diagrams, data-viz) rather than captured. There is no URL, no website capture, and no real assets. Use this skill for topic explainers, concept breakdowns, how-tos, listicles, and narrative explainers. Do not use it for a product launch/promo (use /product-launch-video), a tour of a real website (use /website-to-video), a GitHub PR (use /pr-to-video), captions on existing footage (use /embedded-captions), or a short unnarrated motion graphic (use /motion-graphics). If the intent is unclear, route through /hyperframes first. This is the shot-sequence architecture: every frame is authored as a time-coded shot sequence picked from a menu of golden blueprints, so frames develop over their full duration instead of freezing after entrance."
---
> **media-use**: Before sourcing audio/images, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog. Run `--adopt` first to register existing assets. See `/media-use` skill.
@@ -11,7 +11,7 @@ Use this skill to turn a body of text into an explainer video: pick a design sys
> **Confirm the route before Step 0.** You are the orchestrator. Run each step, verify its gate, and only then continue. This skill is for **explaining a topic from text, with no product and no website to capture**. Route other intents elsewhere: a product launch/promo → `/product-launch-video`; a tour of a real site → `/website-to-video`; a GitHub PR → `/pr-to-video`; captions on existing footage → `/embedded-captions`; a short unnarrated motion graphic → `/motion-graphics`. If the user says only "make a video" or the route is uncertain, read `/hyperframes` first.
You are the orchestrator. Work in `videos/<project>/`. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, `hyperframes-creative`, and `hyperframes-animation`.
You are the orchestrator. Work in `videos/<project>/`. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, this skill's local `../hyperframes-animation/rules/` + `../hyperframes-animation/blueprints/`, and `hyperframes-creative`.
Workflow: Step 0 setup → `hyperframes.json`; Step 1 brief → `capture/extracted/`; Step 2 design system → `frame.md`; Step 3 storyboard/script → `STORYBOARD.md` and `SCRIPT.md`; Step 3.1 audio → `audio_meta.json`; Step 4 visual design → enriched `STORYBOARD.md`; Step 5 frames → `compositions/frames/NN-*.html` and `index.html`; Step 6 final render → `renders/video.mp4`.
@@ -40,6 +40,8 @@ Save the user's full input verbatim, then create the synthetic capture package b
- `capture/extracted/visible-text.txt` — the full article / notes / topic / brief, verbatim. This is the source of **information**, not a story template (Step 3 reshapes it).
- `capture/extracted/tokens.json``{ "title": "", "description": "", "colors": [], "fonts": [] }`. Fill `title`/`description` from the brief. Leave `colors`/`fonts` empty unless the user explicitly gave brand colors or fonts — then add them (the design preset supplies a complete palette regardless).
If the user pasted a script or wants their wording kept, save it verbatim as `user_script.txt`, ask once "use it verbatim or restructure?", and store the answer as `VO_MODE` for Step 3.
Do **not** run `npx hyperframes capture` (there is no URL). Do not create `asset-descriptions.md` or populate `capture/assets/` — faceless visuals are invented in Steps 4-5, not captured. The one exception: if the user supplied a real image, place it under `public/<basename>` and note it for Step 3.
**Gate:** `capture/extracted/visible-text.txt` and `capture/extracted/tokens.json` exist; you can state the explainer's topic and audience in one clear sentence.
@@ -68,9 +70,9 @@ A faceless explainer usually has **no brand colors/fonts** (`tokens.json` colors
Goal: Turn the text into an approved frame-by-frame teaching plan.
Read `references/story-design.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`.
Read `references/story-design.md`, `../hyperframes-animation/blueprints-index.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`.
Use `story-design.md` for the explainer structure (concept / how-to / listicle / story), hook strategy, clarity techniques, emotional beats, the type-enum mapping, and `VO_MODE`. The video's sequence comes from **narrative design, not the input text's paragraph order** — reorder, merge, omit, compress. Faceless visuals are invented downstream, so frames do **not** carry an asset inventory: leave `asset_candidates` empty unless the user supplied a real `public/<basename>` image. Use the exact required fields from the storyboard and script references.
Use `story-design.md` for the explainer structure (concept / how-to / listicle / story), hook strategy, clarity techniques, emotional beats, the type-enum mapping, and `VO_MODE`. The video's sequence comes from **narrative design, not the input text's paragraph order** — reorder, merge, omit, compress. As a **soft guide**, consult the role→blueprint menu in `../hyperframes-animation/blueprints-index.md`: for each beat, write the voiceover in the shape its candidate blueprint implies and tag that candidate `blueprint:` id when one fits. Teaching truth still decides which beats exist — never force a beat to fit a blueprint, and never invent a beat just because a proven shape is available. Faceless visuals are invented downstream, so frames do **not** carry an asset inventory: leave `asset_candidates` empty unless the user supplied a real `public/<basename>` image. Use the exact required fields from the storyboard and script references.
After drafting, show a frame-by-frame summary. In that same message ask the user two things: (a) to approve or request changes, and (b) whether they want a live preview of the storyboard scaffold (`npx hyperframes preview`) — open it only on a yes. Iterate until approved, and carry the preview choice to Step 6.
@@ -98,15 +100,15 @@ If there is no narration and no `SCRIPT.md`, skip voice generation. BGM may stil
Goal: Add the visual direction, layout intent, and motion choices to each storyboard frame.
Edit `STORYBOARD.md` in place. Do not create another storyboard. Use `frame.md` as the source of truth for color, type, layout feel, and style.
Edit `STORYBOARD.md` in place. Do not create another storyboard. Use `frame.md` as source of truth for color, type, layout feel, and style.
Read `references/visual-design.md`, `references/composition.md`, `references/motion-language.md`, and `../hyperframes-animation/`. Use `visual-design.md` for required frame fields and the required `## Video direction` block. Use `composition.md` for layout, hierarchy, focal points, and the invented-visual treatment. Use `motion-language.md` and `../hyperframes-animation/` for valid effects and blueprint IDs. Do not invent effect names or blueprint IDs.
Read `references/visual-design.md`, `../hyperframes-animation/blueprints-index.md`, `references/motion-language.md`, and `../hyperframes-animation/rules-index.md`. Use `visual-design.md` for the method (the time-coded shot sequence, the inline Layout vocabulary, and the invented-visual treatment), plus the required `## Video direction` block. Use `../hyperframes-animation/blueprints-index.md` to pick each frame's shot shape. Use `motion-language.md` (the motion vocabulary + the motion doctrine) and `../hyperframes-animation/rules-index.md` (valid rule names) for motion — do not invent motion names.
For every frame, add required visual and motion fields, including `effects` and `focal` and/or `roles`. Because the explainer is faceless, `focal`/`roles` describe **invented visual elements** (a hero word, a diagram node, a data-viz series), not captured assets. Add one video-wide `## Video direction` block for overall visual direction, motion style, pacing, and design rules.
For every frame, write a **time-coded shot sequence** into `STORYBOARD.md` per `visual-design.md`'s method: pick the frame's blueprint (or compose), instantiate it with THIS frame's **invented** content, and pace each Scene's reveal to the voiceover so the frame develops across its full duration instead of front-loading then freezing. Because the explainer is faceless, `focal`/`roles` name the **invented visual elements** (a hero word, a diagram node, a data-viz series) — you are designing them, not selecting captured assets. State layout and motion **inline** per Scene (vocabularies in `visual-design.md` and `motion-language.md`). Add one video-wide `## Video direction` block.
Do not change story, script, `transition_in`, or the source text. Do not write HTML in this step. There is **no asset-staging step** — faceless visuals are built by the workers in Step 5. If the user supplied a real `public/<basename>` image, reference it by path in the relevant frame's `focal`/`roles`; otherwise nothing to stage.
**Gate:** every frame has `effects` plus `focal` and/or `roles`; `## Video direction` exists.
**Gate:** every frame has a time-coded shot sequence whose reveals are paced to the voiceover (no front-loading); each frame names its invented `focal` and/or `roles`; `## Video direction` exists.
---
@@ -124,7 +126,7 @@ Duration sync is mechanical: real voice duration wins; silent frames keep estima
Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame.
Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `ANIM_DIR` as the absolute path to `../hyperframes-animation/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, and the recipe body for each cited effect or blueprint ID. Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`.
Each worker context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, and `RULES_DIR` as the absolute path to this skill's `../hyperframes-animation/rules/`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, and the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`). Each worker writes only `compositions/frames/NN-*.html`. Workers must never edit `STORYBOARD.md`.
As each worker returns, the orchestrator marks that frame as `animated` in `STORYBOARD.md`.
@@ -186,17 +188,20 @@ Do not rerun `lint`, `validate`, `inspect`, or `snapshot` after rendering unless
**Background scripts:** the workflow ships only these under `scripts/`: `build-frame` for adopting + brand-remixing a frame preset into `frame.md` (+ caption skin); `audio` for TTS, transcription, BGM, SFX, and duration syncing; `captions`; `transitions` for inject and verify; and `assemble-index`. Everything else is the `hyperframes` CLI.
The reusable, domain-agnostic shot shapes live in `../hyperframes-animation/blueprints/` (indexed by `../hyperframes-animation/blueprints-index.md`).
| Read | When |
| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------- |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `[../hyperframes-creative/frame-presets/](../hyperframes-creative/frame-presets/)` | Step 2: choose and adopt a frame preset. |
| `[../hyperframes-creative/references/design-spec.md](../hyperframes-creative/references/design-spec.md)` | Step 2: apply brand tokens correctly. |
| `[references/story-design.md](references/story-design.md)` | Step 3: plan the explainer story. |
| `[../hyperframes-animation/blueprints-index.md](../hyperframes-animation/blueprints-index.md)` | Step 3: role→blueprint menu. Step 4: pick the shot shape. |
| `[../hyperframes-core/references/storyboard-format.md](../hyperframes-core/references/storyboard-format.md)` | Step 3: write `STORYBOARD.md`. |
| `[../hyperframes-core/references/script-format.md](../hyperframes-core/references/script-format.md)` | Step 3: write `SCRIPT.md`. |
| `[../hyperframes-media/references/tts.md](../hyperframes-media/references/tts.md)` | Step 3.1: choose or understand TTS providers. |
| `[references/visual-design.md](references/visual-design.md)` | Step 4: enrich the storyboard visually. |
| `[references/composition.md](references/composition.md)` | Step 4: judge composition. |
| `[references/motion-language.md](references/motion-language.md)` | Step 4: judge motion language. |
| `[../hyperframes-animation/](../hyperframes-animation/)` | Step 4: cite effect and blueprint IDs. |
| `[../hyperframes-media/references/tts.md](../hyperframes-media/references/tts.md)` | Step 3.1: choose or understand TTS providers and voices. |
| `[references/visual-design.md](references/visual-design.md)` | Step 4: write the frame's shot sequence (+ Layout vocabulary). |
| `[references/motion-language.md](references/motion-language.md)` | Step 4: the motion vocabulary + the motion doctrine. |
| `[references/cut-catalog.md](references/cut-catalog.md)` | Step 4-5: the cut catalog (worker builds within-frame seams). |
| `[../hyperframes-animation/rules-index.md](../hyperframes-animation/rules-index.md)` + `[../hyperframes-animation/rules/](../hyperframes-animation/rules/)` | Step 5: local rule recipe bodies for the cited motions. |
| `[sub-agents/frame-worker.md](sub-agents/frame-worker.md)` | Step 5: dispatch per-frame workers. |
| `[../hyperframes-core/references/subagent-dispatch.md](../hyperframes-core/references/subagent-dispatch.md)` | Step 5: dispatch sub-agents safely. |
@@ -1,123 +0,0 @@
# Composition — faceless-explainer visual-design judgment
> The composition-judgment layer for **Step 4 (Visual design)**. You read it while enriching `STORYBOARD.md` frames: which layout, how much frame the hero fills, how many depth layers — **director decisions**. Concrete px (safe margins 96-150), scale (1.05 / 0.92), three-layer `box-shadow`, `perspective` values are the **frame worker's** job; you name the intent in the frame's composition note. Video composition is closer to film / poster design than webpage layout — no scrolling, no reflow; every frame is a fixed canvas, every pixel matters. Default canvas **1920×1080**; portrait `1080×1920` / square `1080×1080` per the storyboard `format`.
## Squint test
Squint (or blur the frame). Can you still pick out the most important element, the second, and clear spatial groups? If everything has equal weight after blur, hierarchy is broken — redesign before writing the note. The strongest frames pass this: one dominant block + one supporting structural element, everything else demoted.
## Canvas zones (conceptual)
```
+--------------------------------------------------+
| Optional top chrome |
| +----------------------------------------------+ |
| | Safe margin | |
| | +----------------------------------------+ | |
| | | Primary content area | | |
| | | (center 65-75% of frame) | | |
| | +----------------------------------------+ | |
| | | Caption band (bottom ~17%, HARD w/ captions) | |
| | +----------------------------------------+ | |
| +----------------------------------------------+ |
+--------------------------------------------------+
```
- **Top chrome** — rarely needed in a faceless explainer; skip unless a frame intentionally mocks an interface.
- **Safe margin** — key content stays off the edges; hero / editorial frames need more air.
- **Primary content area** — the center 65-75% is where the eye rests; body text never presses the edge.
- **Caption band (bottom ~17%, HARD-reserved when captions are on)** — when the film has captions enabled (the frame's `Captions:` flag), the bottom ~17% of **canvas height** is reserved (landscape 1080h → bottom 180px, y 900-1080; portrait 1920h → bottom 320px, y 1600-1920): primary content and key visuals **cap at the band top**, and a centered hero anchors at **y ≈ 0.42 × height** (landscape ≈454, portrait ≈806), not the canvas midpoint. Background / ambient / surface layers are exempt and may stay full-bleed. Captions disabled → keep the zone clear anyway for bottom-edge consistency across frames.
You write "hero word centered with generous safe margins"; you do not write `padding: 150px 120px 92px`.
## Portrait & square (non-16:9 canvases)
The zones, density, hierarchy, and depth principles all still apply; the **aspect ratio** changes, and a wide-frame layout does not transplant into a tall one. Design for the storyboard's `format` from the start — never plan landscape and "crop."
- **Stack vertically, not side-by-side.** Portrait has little horizontal room: split-screen / triptych / 60-40 asymmetry become **top/bottom stacks**, vertical step lists, stacked bands. Square tolerates side-by-side only for two compact items.
- **Vertical center moves with the canvas** — anchor a centered hero around **y ≈ 0.42 × height** (portrait ≈806, square ≈454), not a fixed 540.
- **Type runs larger, fewer words per line** — narrow frames wrap long headlines badly; prefer short kinetic lines, bigger type, more vertical rhythm.
- **Travels well to portrait:** Centered, Layered Depth, Full-Width Strip (stacked band), vertical Rule-of-Thirds. **Avoid** wide Split Screen and Triptych — use stacked equivalents.
- **Density still rules** — primary visual ≥ 40% of canvas, ≥ 3 depth layers, measured against the tall frame; an empty top or bottom third reads as placeholder.
## 7 composition templates
Use ≥3 different templates per video (5 frames → 3+, 9 frames → 4+). **Don't default every frame to centered**; never use the same layout class twice in a row.
1. **Centered (hero / climax)** — one dominant element, generous breathing room. Concept name, key takeaway, the hero word, the closing principle.
2. **Rule of thirds** — anchor on a thirds intersection; remaining space carries support or negative space. A mechanism step + its label.
3. **Split screen (comparison / dual focus)** — left/right halves carry separate elements. Before/after, common-belief vs reality, two options.
4. **Layered depth (immersive)** — foreground / midground / background differ in scale + opacity. Opening hooks, atmosphere, the "imagine…" scenario.
5. **Asymmetric (editorial)** — primary content pushed to one side (60/40, 70/30); intentional imbalance → tension + sophistication. A dense diagram with a caption rail.
6. **Triptych (three-panel)** — three equal zones for three items / beats at once. The rule-of-three landing.
7. **Full-width strip** — one horizontal band (a number line, a timeline, an enumeration), usually ~20% of canvas height.
## Frame density — avoid empty frames
Common failure: small elements floating in the center with empty space around them. Every frame must feel **intentionally filled**.
- **Primary visual occupies ≥ 40% of canvas** — hero text 50-75% height × 60-80% width; a centered card 30-50% × 50-70%; a diagram big enough to read its labels.
- **≥ 3 visual layers** — background (gradient / particles / grid) + midground (main content) + foreground (emphasis / decoration).
- **Openings and closings** are prone to emptiness — a bare background + a lonely line of text reads as placeholder. Add environmental layers: dual-radial swell, floating particles, brand-color ambient texture, low-opacity scanlines or a hairline grid.
- **Text-only frames still need visual elements** — a coined-term card, an icon, a halftone field, brand-derived geometry, an underline that draws on.
**Fullness test:** could this frame stand as a poster or social graphic? If it looks like a sparse slide → add layers.
## Negative space
Whitespace directs attention, it isn't waste. Tight grouping (icon + label) → small spacing; unrelated groups → large separation; asymmetric outer margins feel more designed than equal padding; a hero word keeps large side whitespace so one word carries the weight. **Failure modes:** everything equidistant (no grouping); unintended overlap; text tight against an edge; captions colliding with bottom visuals; the framework's default padding everywhere.
## In-frame visual hierarchy
Visual weight, strong → weak: **large element** **motion** (moving beats static) **high contrast** **type scale** **position** (center + upper third are golden). Combine **at least two** — an element that is large, moving, and upper-third is unquestionably primary.
A title that is only _larger_ (sharing weight/color/spacing with body) reads weak. Stack dimensions:
| Dimension | Strong contrast |
| --------- | ------------------------------------------- |
| Size | 3:1 ratio or larger |
| Weight | 800-900 vs 400 |
| Color | high contrast against background |
| Motion | one element moving vs all else static |
| Position | top / left = primary |
| Space | large surrounding whitespace vs equidistant |
## Cards and grouping
Spacing + alignment can group without a card container. **Use cards** when content is genuinely distinct (a list item, a definition, a stat callout) or when shadow-stacking communicates "lifted." **Don't** card for mere separation (use whitespace) or for a continuous diagram. **Never nest cards** — claustrophobic, muddy hierarchy.
## Inventing the visual — diagrams, type, data-viz
This is a **faceless** explainer: the frame's hero is something you design, not a screenshot. The three first-class treatments:
- **Typographic / kinetic type** — the hero word, the coined term, a number, a short enumeration. Treat type as the subject: full-bleed scale, weight contrast, one emphasized term. Strongest for hooks, concept names, takeaways.
- **Abstract graphics** — shapes, fields, paths, geometry that _embody_ the idea (the snowball, the spotlight, the staircase-not-cliff). Build the metaphor the script names; don't decorate with generic bokeh.
- **Diagram / data-viz** — nodes + edges, a chart, a number line, a formula, a process flow. The build (each part appearing on beat) is the teaching — design it to assemble, not appear whole.
Make the invented hero **fill 40-60% of the frame** — a diagram big enough to read, a hero word near full-bleed. Don't shrink the one designed element into decoration around empty space.
## Depth on a 2D canvas
Layer **2-3 depth techniques** per frame to avoid a flat poster (concrete perspective / rotate / scale values are the worker's):
| Technique | Effect |
| ---------------- | ------------------------------------------------------------------------------------------ |
| Size difference | larger = nearer, smaller = farther |
| Blur | blurred = background, sharp = foreground |
| Opacity gradient | low = receding, full = primary |
| Overlap | foreground partially covers background |
| Shadow stacking | three-layer shadow = lift + brand feel |
| Motion speed | faster parallax = closer |
| Counter-scale | camera pushes toward focus → background appears larger, focal CSS scale <1 but fills frame |
You write "3 depth layers: background swell + midground diagram + foreground label glow; background counter-scales for the push"; the worker writes the scale values.
## What should not appear
Nav bars, footers, cookie banners, scrollbars, cursor arrows, browser chrome, unclickable buttons, generic decorative shapes standing in for a designed metaphor, floating bokeh / purple-to-blue AI gradients (the "default AI cliché," banned). Faceless explainers have no real interface to show — an interface mock is correct **only** when the topic itself is about that interface and the frame intentionally reconstructs it.
## Composition note example
> "Composition: asymmetric 60/40 — the node-graph diagram occupies left 60%, the layer label + caption right 40%. Generous safe margin; text capped inside the primary content area. 3 depth layers: background hairline grid + midground graph + foreground active-node glow. Density: primary visual ~55%, ambient adds a 5% scanline."
One line per frame; never concrete px / scale / shadow recipes (the worker writes those).
@@ -0,0 +1,215 @@
# Cut catalog — within-frame seams (worker-built)
> **A worker build-recipe (Step 5) — the sibling of `../hyperframes-animation/rules/`, not a second motion doc.** These are within-frame cuts the **frame worker builds INSIDE its own composition** (Z-scale + blur + opacity tweens, or per-word x-staggers, all on the frame's own paused GSAP timeline). They are **not** the between-frame transition: story owns that via `transition_in`, which the harness's injector stamps from a **separate registry vocabulary** (`crossfade` / `blur-crossfade` / `push-slide` / `zoom-through` / `squeeze`) — the catalog names here (**cut-the-curve / inverse-zoom / waterfall**) are **not** valid `transition_in` values. Use this catalog when a frame's shot sequence has an internal seam — a within-scene text/element swap, a **Scene-to-Scene** cut (a `Scene` is a time window WITHIN one frame, **not** a frame-to-frame boundary), or a text-to-text line change — and you want it to read as one continuous move instead of a hard slideshow cut. (`zoom-through` lives in both worlds: a whole-frame wrapper transition in the registry, an element-level Z-cut here — same idea, different scope.)
Four techniques that create depth and continuity:
1. **Zoom-Through** — within-scene text swaps, Z-axis, moving TOWARD the viewer
2. **Inverse Zoom-Through** — Z-axis swaps moving AWAY from the viewer
3. **Cut the Curve** — between-scene transitions on x/y
4. **Waterfall Cut** — word-by-word cut-the-curve with staggered exits and entries
All four are the same underlying principle: **cut at peak velocity, match direction and
speed on both sides of the cut.** The differences are axis, scope, and granularity.
**Choosing which at a seam:** for an UNFINISHED phrase (building one larger idea across
several visually distinct scenes that still approach the same point — multi-line text, a
run of consecutive cards) use **cut-the-curve** / **waterfall**. For a STATE CHANGE (turning
to a NEW part of the video — most often hook → context, between two distinct chapters) use
**zoom-through**, and **inverse zoom-through** for an arrival / payoff beat. Chain these so
the frame's internal seams feel like one camera moving through the content.
---
## Blur Logic (applies to all Z-axis variants)
Blur sells the speed at the cut, but it must scale with the SUBJECT SIZE:
| Subject | Peak blur | Why |
| ------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Text-scale (headline, line, word group) | **10px** | At 20px text smears into illegibility — the eye loses the word it was tracking and the cut reads as a glitch, not speed. At 20px letterforms go mushy mid-cut; 10px keeps them readable. |
| Full-frame surface (terminal window, card, screenshot) | **1820px** | Big surfaces have edges and texture that survive heavy blur; lighter blur on a full-frame move reads as a rendering hiccup instead of motion. |
Both sides of a cut use the SAME peak blur — the value must match at the swap frame.
Apply blur to the WRAPPER, never to individual children.
---
## 1. Zoom-Through (forward)
### The Problem
Text enters, holds, exits. Then next text enters, holds, exits. Each text block is
independent — no depth, no continuity. The video feels like a slideshow.
### The Principle
A velocity-matched cut on the Z-axis. You **never see both texts at the same time.** The
outgoing text scales toward the viewer (accelerating), blur and opacity peak at the cut
point hiding a hard swap, and the incoming text continues scaling up from behind
(decelerating into the focal plane). One continuous forward motion, two different texts.
### The Three Phases
**Phase 1: Exit** — text accelerates forward (toward viewer)
- Scale: `1.0 -> 1.2`, Blur: `0px -> 10px` (text-scale; see Blur Logic), Opacity: `1.0 -> 0.15`
- Scale/blur easing: `power3.in` (steep acceleration)
- Opacity easing: `none` (linear — even dimming, separated from scale)
- Duration: 0.2s
**Phase 2: Hard cut** at peak velocity + peak blur
- Outgoing: `opacity: 0` (instant via `tl.set`)
- Incoming: `opacity: 0.15, scale: 0.75, blur: 10px` (instant via `tl.set`)
- All properties match at the cut: blur, opacity, and scale DIRECTION (both scaling up)
**Phase 3: Entry** — text continues forward (growing into focal plane)
- Scale: `0.75 -> 1.0`, Blur: `10px -> 0px`, Opacity: `0.15 -> 1.0`
- Easing: `expo.out` (steep initial burst matching exit velocity, long settle)
- Duration: 0.5s
### Why Opacity Must Be Separate on Exit
Scale uses `power3.in` but that keeps opacity near 1.0 for most of the tween. Splitting
opacity to its own tween with linear ease makes the dimming even. On entry, all properties
can share `expo.out`.
---
## 2. Inverse Zoom-Through (backward)
The mirror: the camera "pulls back" instead of pushing through. The outgoing element
RECEDES away from the viewer; the incoming element arrives OVERSIZED (as if it had been
just behind the camera) and retracts into the focal plane. Both move in the shrinking
direction — same-direction rule preserved, just reversed.
**When to use over the forward variant:** arrival beats. The incoming element lands with
presence because it comes from larger-than-frame — right for a payoff line ("That changes
today."), a giant reply, or a held end-state. Forward zoom-through reads as _progressing
through_ content; inverse reads as _arriving at_ content.
### The Three Phases
**Phase 1: Exit** — element recedes (away from viewer)
- Scale: `1.0 -> 0.8`, Blur: `0px -> 10px` (text-scale)
- Scale/blur easing: `power3.in`; Opacity: `1.0 -> 0.15` on `none` (separate tween)
- Duration: 0.2s
**Phase 2: Hard cut**
- Outgoing: `opacity: 0` via `tl.set`
- Incoming: `opacity: 0.15, scale: 1.25, blur: 10px` via `tl.set`
**Phase 3: Entry** — incoming retracts into place
- Scale: `1.25 -> 1.0`, Blur: `10px -> 0px`, Opacity: `0.15 -> 1.0`
- Easing: `expo.out`, Duration: 0.5s
---
## 3. Cut the Curve (Scene Transitions)
### The Principle
Use cut-the-curve for **all scene-to-scene transitions** on x and y axes. The outgoing
scene's hero element accelerates in one direction, the cut lands mid-motion, and the
incoming scene's hero element continues moving in the **same direction** and decelerates.
Nothing exits fully off-screen and nothing enters from fully off-screen — **speed plus
opacity fading trick the eye**; the partial moves are enough.
### Same Path, Same Direction
If Scene A's hero slides left, Scene B's hero enters from the right and continues sliding
left. Both move leftward. One continuous motion.
| Direction | Scene A exit | Scene B entry start | Scene B entry end |
| --------- | -------------- | ------------------- | ----------------- |
| Leftward | `x: 0 -> -230` | `x: +230` | `x: 0` |
| Rightward | `x: 0 -> +230` | `x: -230` | `x: 0` |
| Upward | `y: 0 -> -230` | `y: +230` | `y: 0` |
| Downward | `y: 0 -> +230` | `y: -230` | `y: 0` |
### Velocity matching via mirrored eases
The cleanest match: exit `power4.in` and entry `power4.out` with the SAME distance and
duration — mathematically the two halves of one `power4.inOut` composite, so the entering
element picks up at exactly the 50% point of the notional path at identical velocity
(e.g. 230px / 0.3s ≈ 3,070 px/s at the cut on both sides).
The fade trick: the exit's opacity completes at ~2530% of its travel (fade duration
≈ 0.180.3s vs motion 0.30.34s) — the element vanishes while still visibly accelerating,
and nothing has to reach the frame edge. Entry fades IN fast from ~0.35 under its
deceleration. Time the LAST fading element to die right at the hard cut — gaps where
nothing is moving read as awkward dead air.
### Rules
- Use cut-the-curve for all scene transitions — it's the default, not an accent
- Same direction on both sides; mirrored `.in`/`.out` eases, same distance + duration
- Exit duration short (0.20.4s), entry duration >= exit duration
- Partial travel + fade, never full off-screen moves
---
## 4. Waterfall Cut (word-by-word cut-the-curve)
Cut-the-curve at WORD granularity — the strongest version of the leftward cut for
text-to-text seams. Each word of the outgoing line ramps out on its own pronounced curve;
each word of the incoming line cascades in mid-flight. The stagger turns the cut into a
wave the eye rides across the seam.
### Exit (per word)
- Motion: `x: 0 -> -230` over 0.34s on **power4.in** — a much more pronounced ramp than
the usual power2: the word barely creeps, then RIPS
- Fade: `opacity -> 0` over 0.18s (separate tween, `power1.in`) — completes when the word
is only ~2530% into its travel
- Stagger: reading order, ~0.022s per word, timed so the LAST word finishes fading right
at the hard cut
### Entry (per word)
- `fromTo x: +230 -> 0, opacity: 0.35 -> 1` over 0.3s on **power4.out** — the mirrored
back half of the composite; every word ignites already moving at matched velocity
- Waterfall stagger with SHRINKING gaps (start 0.05s, multiply by ~0.84 per word) so the
cascade accelerates across the line — the cascade should speed up word over word, not run
at a flat per-word delay
- Pre-set all words to `x: +230, opacity: 0` at build time — `immediateRender: false`
alone leaves un-started words sitting visible at rest during the stagger window
### Whole-line variant
A single-line beat (e.g. a big intro line) exits as one group with the same pronounced
ramp, but stretch its fade to ~0.3s ending ~0.02s before the cut — a lone element that
fades early leaves dead air that a word cascade would have covered.
---
## Choosing a Variant
| | Zoom-Through | Inverse Zoom | Cut the Curve | Waterfall Cut |
| -------------- | --------------------------- | --------------------------- | ----------------- | ------------------------- |
| Scope | Within-scene text swap | Arrival/payoff beat | Between scenes | Text-to-text seam |
| Axis | Z, toward viewer | Z, away from viewer | X / Y | X, per-word |
| Peak blur | 10px text / 20px full-frame | 10px text / 20px full-frame | none required | none (fade does the work) |
| Opacity at cut | 0.15 | 0.15 | exit faded by cut | last word dies at cut |
| Feel | progressing through | arriving at | carried sideways | a wave across the seam |
---
## Anti-Patterns
| Don't | Why | Instead |
| ---------------------------------------- | ------------------------------------------- | ------------------------------------------------------ |
| Two texts visible during a zoom-through | Overlapping text breaks the Z-axis illusion | Hard cut at blur peak, one text at a time |
| 20px blur on text-scale subjects | Letterforms smear; reads as a glitch | 10px for text, 1820px only full-frame |
| Elements on different paths across a cut | Eye tracks one direction, cut goes another | Same property, same direction |
| Mismatched blur/opacity at the swap | Visible flash or brightness jump | Identical values at the cut frame |
| Gentle easing on entry (`power2.out`) | Entry velocity feels slower than exit | Mirror the exit: `power4.out` / `expo.out` |
| Full off-screen exits / entries | Wastes time and breaks the speed illusion | Partial travel + early fade |
| Lone element fading long before its cut | Dead air at the seam | Fade ends ~0.02s before the cut, or use a word cascade |
| Zoom-through on body text | Small text at 0.75 scale is unreadable | Only headlines and short phrases |
| Scene cuts without cut-the-curve | Static cuts feel like a slideshow | Cut-the-curve is the default |
@@ -1,141 +1,156 @@
# Motion language — faceless-explainer visual-design judgment
# Motion language — the move vocabulary + the motion doctrine + the seek-safe core
> The motion-judgment layer for **Step 4 (Visual design)**. You name **each shot's choreography, spring intent, beat rhythm, holds, stillness, and the idle-motion budget** while enriching `STORYBOARD.md` frames; the **frame worker** maps intent to concrete GSAP eases / ms / stagger / code (via `hyperframes-animation`). A good explainer feels like one continuous whole — one camera, one spring feel, **every shot directed across its full length** — not a pile of slides that animate once and freeze. You reference motion by **role**, never by curve: eases / durations resolve from `frame.md`'s motion tokens, named `entry` / `emphasis` / `exit` / `drift` (the pack's exact keys may differ); the worker maps the curve. Between-frame **transitions are not yours** story names `transition_in`, the harness injects it.
> The motion layer for **Step 4 (Visual design)**. When you write a frame's **time-coded shot sequence**, you name each scene's move **inline from the vocabulary below** — a named palette of the moves the golden corpus actually uses. Each move carries the **backing rule id** in this skill's local `../hyperframes-animation/rules/`; cite that id so the move resolves to a real recipe when a **frame worker** implements it in Step 5 (the worker reads the rule body in `../hyperframes-animation/rules/<id>.md` — it reproduces the move, it does not guess from the name). You name motion by **role / move name**, never by raw GSAP curve, ms, or stagger formula — the worker maps the curve. Between-frame **transitions are not yours**: story names `transition_in`, the harness injects it; that injected transition **is** the frame's exit. For cuts a worker builds INSIDE a frame (within-scene swaps, scene-to-scene seams), see the catalog in `cut-catalog.md`.
## A frame is a shot, not a slide
A good explainer feels like one continuous film — one camera, one motion feel, **smooth and timed to the voiceover** — not a pile of slides that animate once and freeze. In an explainer the development _is_ the teaching: the formula assembling, the diagram gaining a layer, the count-up landing. The doctrine in Part 2 is load-bearing: when in doubt, do what it says.
The single failure that makes an explainer read as PowerPoint: a frame whose content **animates in over the first ~0.8s, then freezes** for the rest of its duration while a slow drift plays underneath. The entrance is not the shot — it's the **first beat** of it. You direct the **whole duration**.
---
In explainers especially, the development beat _is_ the teaching: the formula assembling term by term, the diagram gaining a layer, the count-up landing. Don't waste it on a frozen hold.
# Part 1 — the move vocabulary
Three layers fill a shot, each governed by a different rule:
Reach into this palette when naming a scene's motion. Pick the move that matches the beat, name it in the shot sequence, and cite the rule id after `→`. The blueprints (`../hyperframes-animation/blueprints/`) name these same moves in their `rule mapping`; you're drawing from one shared palette. Compose 24 across a shot's scenes (entrance → sequential reveal → settle), not all at once.
| Layer | What | Rule |
| ------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| **Camera** (macro) | ONE correlated move on the frame root — slow drift / dolly / push / parallax pan | **always on, the whole shot** — this is the "someone is filming this" layer |
| **Choreography** (action) | the beat develops: entrance → mid-shot move (reveal / rearrange / morph / emphasis hit) → settle | **fill the duration** — a shot animated only at entry is a slide |
| **Idle life** (texture) | ambient continuation on the 1-2 elements that hold a live slot — breathing, glow, float | **budgeted** — this is where screensaver lives; cap it |
## Kinetic type
The reconciliation that matters: **mandate choreography, budget idle life.** Purposeful, sequential motion that carries information should fill the shot; ambient, simultaneous motion that carries none should be capped. Many elements each floating independently reads as _screensaver_; a shot that only enters then freezes reads as _slideshow_. Avoid both — **one camera move + a directed multi-phase action + 1-2 living elements**, nothing scattered.
- **hard-cut / flash word-swap** — a word or line replaces the previous one on an instant cut (no fade/roll); the swap itself is the beat. → `discrete-text-sequence`
- **in-place token cycle** — a fixed line holds and only its variable slot changes, token → token → token. → `discrete-text-sequence`
- **per-word staggered reveal** — a phrase assembles word-by-word (or chunk-by-chunk), each landing on its own beat. → `dynamic-content-sequencing`
- **kinetic beat-slam** — short phrases slam in on a shared percussive beat array, each with a distinct entrance, resolving on a locked finale; the recipe for "punchy / rhythmic" taglines. → `kinetic-beat-slam`
## Multi-phase choreography — direct the full shot
## Typewriter
Every non-still frame's timeline is choreographed across its length, not front-loaded into the entrance:
- **type-on with caret** — text types in character-by-character behind a blinking caret. → `discrete-text-sequence` (+ `context-sensitive-cursor` for the caret blink / color)
- **backspace-and-retype** — the line types, deletes the last word(s), and retypes a new one (typo-correction, reframe). → `discrete-text-sequence` (+ `context-sensitive-cursor`)
```
entrance → development → settle
```
## Count-up / data
- **entrance** — the beat's primary content arrives (hero `entry` / `heavy`; groups staggered).
- **development (the phase that's usually missing → PPT)** — mid-shot, the content _does something_: a second element reveals, elements rearrange to a new layout, a diagram gains a layer, a count-up runs, an emphasis hit lands on the keyword. This is the motion that separates video from slides.
- **settle** — the shot resolves and holds for its read; the camera + idle life continue underneath (never a hard freeze).
- **value-scaled counter** — a number counts up and its font size grows with the value, so the climb itself escalates. → `counting-dynamic-scale`
- **bars / progress / star wipe** — a number paired with a graphic that fills: bar-height stagger, a progress bar / ring filling, a fractional star-rating wipe. → `stat-bars-and-fills`
**Architecture:** in hyperframes only the **exit** is forbidden mid-video (the frame unmounts; the harness transition _is_ the exit). Everything _before_ the settle — including rich mid-shot development — is free and seek-safe. Build the development phase; skip only the exit (unless you are the final frame).
## Reveal / decode
When you name a **`blueprint`**, the development phases come from its recipe — write the composition note **shot-by-shot** to match. When you name **no blueprint**, the **≥3 cited effects ARE the phases** — sequence them (one enters, one develops, one emphasizes); **don't fire them all at t=0**.
- **3D char flip-decode** — characters flip in 3D and resolve from scrambled glyphs to the real text (decryption feel). → `hacker-flip-3d`
- **SVG self-draw** — an outline / icon / ring draws itself stroke-by-stroke. → `svg-path-draw`
## Spring intent (by role, not curve)
## Camera
| Intent | Feel | Use |
| ---------- | ------------------------------------------- | --------------------------------------- |
| **entry** | confident slight overshoot, settles quickly | primary element entry (default) |
| **gentle** | soft slide-in, no overshoot | background elements, subtle motion |
| **snappy** | tight overshoot, nearly instant | small icons, labels, list items |
| **heavy** | weighted deceleration | large diagrams, hero visuals |
| **slam** | bouncy overshoot, intentionally loud | a coined term landing, an impact moment |
- **push / focus / drift** — a sequential camera move on the frame root (pull-back → focus → push) plus continuous micro-drift; the cinematic baseline. → `multi-phase-camera`
- **zoom-to-target** — zoom into a non-centered element (scale + counter-translate to keep it framed). → `coordinate-target-zoom`
- **pan / focus-lock** — a virtual camera transforming one `.world` wrapper to pan / zoom / lock onto a region. → `viewport-change`
- **camera-cursor-tracking** — the viewport locks to a moving focal point (a typing cursor), static framing then focal-locked tracking. → `camera-cursor-tracking`
**Consistency:** similar elements share one intent (all labels `snappy`, all hero visuals `heavy`). Don't invent a unique ease + duration per element.
## Layout motion
**Forbidden:** `bounce.out` / `elastic.out` (dated; real objects decelerate, they don't bounce — low overshoot for `entry` is fine, high overshoot only for clearly playful moments); a unique ease+duration per element (visual noise).
- **cluster→outward expansion** — elements start clustered at center and expand outward to their final positions in lockstep. → `center-outward-expansion`
- **orbit** — elements flip in from 3D space and settle into a continuous elliptical orbit (entry flips in-place at the orbital position). → `orbit-3d-entry`
- **split-tilt cards** — two cards side-by-side with opposing rotationY tilts, entering from their respective sides (comparison / before-after). → `split-tilt-cards`
- **logo/avatar ring + connectors** — avatars or logos on an elliptical ring with SVG connection lines to a center point, staggered entry. → `avatar-cloud-network`
## Duration intent
## Surface / UI
Reference by **tier** ("instant feedback" / "state change" / "layout change" / "entry animation"); the worker maps concrete ms / frames at 30fps. **A single entry should not exceed ~800ms** — for a longer buildup, use multi-element stagger or a development phase, **not** one long tween.
- **3D page-scroll reveal** — a full webpage as a tilted 3D card whose internal content scrolls to reveal specific sections. → `3d-page-scroll`
- **cursor click + ripple** — a cursor moves to a target, depresses with it on click, and emits an expanding ripple. → `cursor-click-ripple`
- **button press** — a tactile press: compression then spring recovery, optional release burst / glow. → `press-release-spring` (or `physics-press-reaction` for a click that compresses cursor + target together)
- **keyword glow** — keywords light up with glow + scale + color on an attack-decay-rest envelope, synced to a word rail. → `asr-keyword-glow`
**Phase-to-phase within a shot is swift** — when one element makes way for the next (development), the outgoing move runs ~75% of an entry; arrival is deliberate, hand-off is quick. (The between-frame **exit** is the harness's transition, never your within-shot motion.)
## Morph / handoff
## Stagger cap
- **scale-swap** — two elements at the same screen center hand off: the outgoing cluster shrinks + fades as the incoming one arrives. → `scale-swap-transition`
- **card morph-anchor** — a container morphs apparent size + corner radius + surface between two shots, then fades to reveal the real target beneath (HyperFrames uses uniform `scale`, not `width`/`height`). → `card-morph-anchor`
When staggering N elements, **total ≤ 500ms** (longer feels dragged):
## Seam cuts (worker-built, inside a frame)
- **3-7 elements** — normal stagger, total 300-700ms.
- **8+ elements** — tighten per-item delay, or stagger only the first few and enter the rest with the last.
- Never let stagger run past 500ms.
The velocity-matched cuts a worker authors between a frame's own Scenes. Name the seam in the shot sequence; the recipe is in the catalog, not a single `../hyperframes-animation/rules/` id.
## Beat structure across frames (the cross-frame rhythm)
- **zoom-through / inverse zoom-through** — a within-scene swap on the Z-axis; forward reads "progressing through", inverse reads "arriving at" (payoff). → `cut-catalog.md`
- **cut-the-curve** — a scene-to-scene cut where both sides move the same direction at matched velocity. → `cut-catalog.md`
- **waterfall cut** — cut-the-curve at word granularity, a wave across a text-to-text seam. → `cut-catalog.md`
Rhythmic videos breathe: tension → release → tension → release. A clean reference shape for a ~46s explainer:
## Emphasis / marker
| Phase | Duration | Rhythm | Frame type |
| ------------ | -------- | ------------------- | ----------------------------------------- |
| Hook + gap | 4-8s | slow build | open the curiosity gap; land the hook |
| Concept name | 3-6s | deliberate | name the idea, one breathable beat |
| Body build | 12-20s | continuous, layered | the mechanism / steps / items, on a stage |
| Landing | 3-5s | still, breathable | the takeaway / principle / CTA |
- **highlight / circle / burst / scribble** — a marker-drawn emphasis on a word or element: yellow highlight sweep, hand-drawn circle, radiating burst, scribble, or rough sketch-outline. → `css-marker-patterns`
Allocate motion by a frame's energy: **high-energy** (hook, a surprising stat) → faster entry, tighter stagger, `snappy`, busier development; **breathable** (concept name, the turn in a story, the landing) → slower entry, `gentle`, longer hold, minimal development; **data / mechanism** (a step, a statistic) → medium rhythm, clean stagger, a count-up or layer-reveal as the development phase.
## Aliveness during a hold (use sparingly — see Part 2)
## Hold time — read time, not freeze time
- **subtle jitter** — the sanctioned way to keep a settled frame alive: a small, low-amplitude positional/scale jitter on the held element. The motion-graphics trick that reads "alive" without reading "weak." → `sine-wave-loop` (low-amplitude register)
- **live SVG internals** — internal SVG parts move so an icon feels alive (rotating hands, oscillating blades, pulsing dots, dash-flow); fine because it's the subject doing something, not a card breathing. → `svg-icon-enrichment`
- **finite bounded ambient** — a single bounded breathe/drift on ONE held hero, only when genuinely needed; de-emphasized — prefer sequential reveal or jitter first. → `sine-wave-loop`
After an element enters it must stay long enough to read (the worker maps concrete frames). "Hold" means **don't cut early** — the camera + idle life keep playing underneath; it is never a hard freeze.
## The added moves — now backed by local rules
| Content | Minimum hold |
| ----------------------------------- | ------------ |
| display text (1-3 words) | ~1s |
| short sentence | ~1.5s |
| data / statistic | ~1.5s |
| diagram / formula | ~2s |
| complex visual (multi-part diagram) | ~2.5s |
| hero / climax word | ~1-1.4s |
Five moves the golden corpus needs were added to this skill's `../hyperframes-animation/rules/`, rounding out the vocabulary above:
Narration shorter than the needed hold → the frame's `duration` should still give the visual its read time.
- **depth-of-field / selective-blur** — blur the off-focus subset to spotlight the focal element → `depth-of-field-blur`
- **motion-blur streak** — directional velocity blur on a fast fly-in / camera push-through → `motion-blur-streak`
- **3D depth scatter-assemble** — glyphs/elements scatter into a tumbling 3D cloud, then reassemble → `depth-scatter-assemble`
- **spring-pop entrance** — the canonical entrance pop; default to a smooth long-tail settle, overshoot only when explicitly playful → `spring-pop-entrance`
- **ambient glow / bloom** — un-triggered soft glow blooming behind a static hero → `ambient-glow-bloom`
## Stillness before climax — the marked exception
---
A **0.3-0.75s pause** between the major action and its confirmation / result — the silence builds tension before the landing (the turn in a story, the "aha" after a build). It lands **because the rest of the video is choreographed** — stillness is a contrast against motion, so it only reads when motion is the baseline. **Allocate it to only 2-3 frames per video, named in the `## Video direction` block**, where the narration lands a payoff. Stamped on every frame it becomes a tic and flattens the rhythm. Name `stillness-before-climax` in that frame's motion note; even then the camera move continues (still ≠ frozen).
# Part 2 — the motion doctrine (load-bearing)
## The idle-life budget — what may move during the hold
These four rules are the difference between a clip that reads as a serious explainer and one that reads as an agent-made PowerPoint. Follow them as written.
The 1-2 elements that keep moving _after_ the development settles. This is the layer that, overdone, becomes screensaver — so it is **capped**, not mandated:
## 1. Smooth beats bouncy — `power3` is the default
1. **Camera move** — always present (the macro layer above); it alone keeps everything coherently alive.
2. **At most 1-2 secondary live elements** — the ones carrying the beat (hero, the active node). Everything else holds.
3. Prefer **macro move + depth parallax** over many independent floats.
Elements should use **long-tail decel curves that let them settle smoothly. `power3` is enough in most cases.** No bouncy, no overshoot, no `back.out` / `bounce.out` / `elastic.out` as a default.
Secondary-slot menu (formulas are the worker's): **multiplicative breathing** (hero — small ±2-5% on final scale) · **glow pulse** (the active element) · **sine float** (one decorative cluster at most) · **rotational drift** (3D cards, hero mark) · **orbit** (surrounding icons; counts as the one decorative cluster) · **halftone breathing** (atmospheric frames).
Bouncy is the **#1 instant turn-off** in user-made Remotion / HyperFrames videos, and the agent almost never gets it right — it thinks bouncy adds emphasis, but it buys that emphasis at the cost of cleanliness. The serious motion-design shops feel the same. **Smooth always wins.** Overshoot is demoted to a **rare, explicitly-playful exception** (a consumer/fun logo slam, a deliberate bell-hit) — never the house style. Name the intent as a long-tail settle; the worker maps `power3` (or `expo.out` on a fast arrival). See `../hyperframes-animation/rules/spring-pop-entrance.md` — it now leads with the smooth settle.
Multiplicative breathing is the signature for a hero **that holds a live slot** — not stamped on every hero. **Minimum amplitude ±6px or ±2-5% scale** — a 3px micro-float doesn't count.
## 2. Sequential reveal in the back ~50%, timed to the voiceover
## Seek-safe motion — intents that don't survive the renderer
This is the anti-PowerPoint mechanism — sharper than "put development in the middle."
The frame is a **paused GSAP timeline seeked frame-by-frame**, so some "continuous" intents from a real-time engine cannot render — **don't name them**:
- **Don't dump everything on screen in the first ~25%** of the scene. Rushing all content in up front is exactly what forces the slideshow feel.
- **Reveal each piece — a line, a card, even an h1 — when the voiceover mentions it**, sequencing reveals across the **later ~50%** of the scene. Same amount of agent work, but the cut becomes coherent and gains rhythm.
- **Less is more.** Fewer things on screen, each arriving on its VO beat, beats a full canvas that animated once and froze.
- **No infinite / forever motion** — "particles loop endlessly," "logo rotates forever," "marquee scrolls on repeat." Idle life is a **finite tween over the hold** (breathe up then back), never `repeat`/`yoyo`.
- **No randomness or wall-clock** — `Math.random` particle fields, `Date.now` drift. Every motion is the same on every render; name deterministic motion only.
- **Entrance + development only** (exit = final frame only) — the cross-frame exit is the harness's transition.
- Express oscillation/breathing as a **bounded finite move**, not a loop.
Practically: a frame's shot sequence front-loads almost nothing — the entrance carries only what the VO is saying at t=0, and the rest of the elements wait in the timeline for their spoken cue. A reveal maps onto a development-class move from Part 1 (`per-word staggered reveal`, `cluster→outward expansion`, a `count-up`, an `asr-keyword-glow` synced to the word rail).
## Forbidden — both failure modes
## 3. No lazy breathing, no bad pan/push — "no motion over bad motion"
**Slideshow (under-motion):**
The agent's two reflexive ways to fake "aliveness" both read cheap:
- Content animates in, then **freezes** for the rest of the shot (the PPT tell).
- Only the entrance is animated; the remaining duration is a frozen hold under a drift.
- The ≥3 cited effects **all fire at t=0** instead of sequencing into entrance / development / emphasis.
- No mid-shot development on a non-still frame.
- **No lazy breathing.** Scaling cards/text up and down in a circular loop to look "alive" is the cheap tell. Don't reach for it.
- **No bad slow pan / push in the back half.** A slow pan or push on elements in the later ~50% of a scene **disrupts the viewer's sightline and causes eye discomfort** — it actively makes the frame worse, not better.
**Screensaver (over-motion):**
The fix for both is the same: **stagger element reveals in time with the script** (rule 2). And the governing principle: **"I'd rather have NO motion than BAD motion."** A held, still frame is better than a frame kept "alive" by breathing or a drifting camera. The **only sanctioned aliveness** during a hold is **subtle jitter** — a small low-amplitude jitter that keeps a frame from feeling dead without looking weak (it's in Claude videos now). Everything else holds.
- **Every element** floating independently; idle motion with no information.
- More than 1-2 elements idling at once; scattered sine floats as the "aliveness."
- A 3px micro-float standing in for real motion.
## 4. Internal seams are velocity-matched cuts
**Always:**
When a frame has an internal seam — a within-scene swap, a Scene-to-Scene cut, a text-to-text line change — make it a **velocity-matched cut**, not a hard slideshow cut: cut at peak velocity, match direction and speed on both sides. The catalog (the four techniques, the blur logic, and which to use when) is `cut-catalog.md`; the moves are listed under **Seam cuts** in Part 1.
- `bounce.out` / `elastic.out`; a bespoke ease+duration per element; `repeat` / `yoyo`; all elements entering simultaneously (must stagger or sequence).
## One-line summary
## Motion note example
Smooth long-tail (`power3`) over bouncy; reveal sequentially in the back ~50% timed to the VO (not dumped in the first 25%); no lazy breathing and no bad slow pan/push — prefer stillness, with subtle jitter as the only aliveness; cut at peak velocity with matched direction/speed (→ `cut-catalog.md`).
> "Macro: slow dolly-in on the frame root across the whole beat. **Entrance** — the concept word enters `EASE.entry` (heavy); supporting labels snappy-stagger (4 items, ~400ms). **Development** — the diagram gains its second layer, then a count-up runs beneath it. **Stillness-before-climax 0.6s** (allocated frame; only the dolly continues). **Settle** — the takeaway emphasis: text gentle entry + glow; idle hold with the hero word breathing ±3% as the one live element."
---
One line for a single-shot frame; **shot-by-shot when the beat is multi-phase** (always, when you named a `blueprint`). Never concrete ease curves / ms / stagger formulas / JS — the worker writes those.
# Part 3 — the seek-safe core (hard rules)
The frame is a **paused GSAP timeline seeked frame-by-frame**, so some "continuous" intents from a real-time engine can't render — don't name them. These are non-negotiable regardless of doctrine.
- **No infinite / forever motion** — "particles loop endlessly," "logo rotates forever," "marquee on repeat." Any aliveness (the subtle jitter, a live SVG internal, a needed bounded ambient) is a **finite tween over the hold**, never `repeat` / `yoyo`.
- **No randomness or wall-clock** — no `Math.random` particle fields, no `Date.now` drift. Every render must be identical; name deterministic motion only (stagger and any variation derive from the element index).
- **Entrances use `fromTo`** — state the from-state explicitly so a seek to `t=0` lands the element correctly; never rely on a CSS-hidden start (it renders visible before the tween claims it, and flickers under seek).
- **No CSS `transition` / `@keyframes` for motion** — CSS animation runs on the browser clock, independent of the HF seek clock; it desyncs and flickers. Drive all motion inside the paused GSAP timeline.
- **Entrance + sequential reveal only — no mid-video exit.** The frame unmounts via the harness transition; that injected `transition_in` **is** the exit. Exit motion belongs only to the final frame. (Worker-built seam cuts in `cut-catalog.md` are within-frame, not the frame's exit.)
## Forbidden — the failure modes
**Slideshow (the primary failure):** everything dumped on screen in the first ~25%; content enters then freezes; nothing revealed on its VO cue. Fix with rule 2 (sequential reveal timed to the VO).
**Cheap aliveness:** circular breathing as "life"; a slow pan/push in the back half disrupting the eye; many elements floating independently as "motion." Fix with rule 3 (stillness + subtle jitter only).
**Bouncy:** `back.out` / `bounce.out` / `elastic.out` as the default entrance; hand-keyed overshoot. Fix with rule 1 (`power3` long-tail; overshoot only when explicitly playful).
**Always:** no `repeat` / `yoyo`; no `Math.random` / `Date.now`; no all-elements-entering-simultaneously (sequence or stagger).
## Naming motion in a shot — example
> Scene 1 (0.01.0s): solid field; hero headline enters via **per-word staggered reveal** (`dynamic-content-sequencing`) on a smooth long-tail settle (`power3`); slow **push** on the root (`multi-phase-camera`) holds steady — no back-half re-push.
> Scene 2 (1.03.0s): as the VO names each step, five mechanism nodes reveal **sequentially** via **cluster→outward expansion** (`center-outward-expansion`), then a **value-scaled counter** (`counting-dynamic-scale`) ticks up beneath them — the back-half reveal, timed to the script, not dumped at t=0.
> Scene 3 (3.04.2s): hold on the result; **keyword glow** (`asr-keyword-glow`) lands on the payoff word as the VO says it; settles and holds still — at most **subtle jitter** (`sine-wave-loop`, low amplitude) keeps it alive; no breathing, no drift.
Name the move + its rule id (or `cut-catalog.md` for a seam cut) per scene; let the worker pick curves, ms, and stagger — defaulting to `power3`.
@@ -85,6 +85,7 @@ For every frame, define (use the storyboard format's fields, with these narrativ
- `scene` — a one-line visual idea, not detailed composition.
- `voiceover` — spoken guide text, or empty for silent frames.
- `transition_in` — a registry transition name (see Transitions).
- `blueprint` _(optional candidate)_ — consult the role→blueprint menu in `../hyperframes-animation/blueprints-index.md`; when a proven shape fits this beat, tag its id (a tag, not a commitment — Step 4 confirms or overrides). Then **write the `voiceover` in the shape that blueprint implies**, so the line is reveal-ready before Step 4 ever runs. Teaching truth still decides which beats exist — never invent, drop, or bend a beat just to fit a shape; omit `blueprint` and write the line plainly when none fits.
In the prose under each frame, state:
@@ -93,7 +94,7 @@ In the prose under each frame, state:
### Type-enum repurposing (shared enum → explainer roles)
The enum is shared with the product-launch visual layer; map your explainer roles onto it so downstream pacing matches the frame's job:
The enum is shared with the downstream visual layer; map your explainer roles onto it so downstream pacing matches the frame's job:
| Explainer role you want | Use `type` | Why this value |
| -------------------------------- | ------------------- | -------------------------------------------------------------------- |
@@ -168,7 +169,7 @@ This framework builds **one frame per worker** — there is no "continue run" th
1. **A consistent stage** — consecutive body frames share the same composition idea (same diagram growing, same number line, same desk), stated in each frame's `scene` so Step 4 and the workers keep the stage stable.
2. **A consistent transition** — pick one seam type for a sequence (usually `push-slide <DIR>` for ordered steps, `crossfade` for a soft layer reveal) and repeat it across the run, so the frames feel like one flow rather than separate slides.
When a single element genuinely _transforms_ between two ideas (a diagram node becomes a chart bar, a formula becomes its result), keep it within **one frame** as a development beat (entrance → the transform → settle) rather than splitting it across a seam — the worker owns that motion. Note the intent in the frame's `scene` / narrative; Step 4 turns it into `effects` / `blueprint`.
When a single element genuinely _transforms_ between two ideas (a diagram node becomes a chart bar, a formula becomes its result), keep it within **one frame** as a development beat (entrance → the transform → settle) rather than splitting it across a seam — the worker owns that motion. Note the intent in the frame's `scene` / narrative; Step 4 turns it into a time-coded shot sequence (instantiating the candidate `blueprint`).
## Transitions
@@ -193,6 +194,7 @@ Write tight per-frame narration:
- 1-2 sentences per spoken frame; usually 6-20 words.
- Concrete and human; teach, don't read the article aloud.
- **Write each line as discrete cues, not one run-on breath.** Step 5 reveals each on-screen piece _when the voiceover names it_ (the anti-PowerPoint mechanism). A line with clear phrase boundaries — "First the snowball — then the hill — then the speed" — hands the shot its reveal cadence for free; a single long clause leaves the frame nothing to pace to.
- **Strong** (concretization): "Compound interest isn't addition, it's a snowball — every turn picks up the snow from the last, then more."
- **Weak** (article-paraphrase): "The study, published in 2019, examined three cohorts and found that…" — that is reading, not explaining.
@@ -224,6 +226,7 @@ Use the exact fields required by the core storyboard format. This is the narrati
- type: feature_showcase
- persuasion: Progressive disclosure
- beat: comprehension
- blueprint: messaging-multi-phase — candidate shape from the role→blueprint menu; omit when none fits
narrativeRole: What this frame does in the viewer's understanding.
keyMessage: The one idea the viewer should remember.
@@ -237,6 +240,7 @@ Before asking for user approval, verify:
- The opening uses a named hook strategy.
- Each frame has one job; the body builds cumulatively (a run of `feature_showcase` / `benefit_highlight` / `product_intro`), not a single isolated body frame.
- Every frame has `type`, `persuasion` (a named technique from the catalog), and `beat` (specific, not generic).
- Each `voiceover` is phrase-segmented into cues (each a piece Step 5 can reveal on), not one run-on clause; a candidate `blueprint:` is tagged wherever a proven shape fits, and omitted where none does.
- The emotional arc has meaningful variation matching the structure.
- Transitions use only registry names and repeat 2-3 types; frame 1 is `cut`.
- A consistent stage + consistent transition carry any multi-frame sequence; a genuine element transform stays inside one frame.
@@ -1,18 +1,43 @@
# Visual design — faceless-explainer per-frame enrichment method
# Visual design — faceless-explainer per-frame shot method
> The method behind **Step 4 (Frame visual design)**. You (the orchestrator) read it to **enrich `STORYBOARD.md` frames in place** — story-design wrote the skeleton (each frame's `scene`, `voiceover`, `transition_in`, and the narrative fields); you add how each frame **looks and moves**. Each frame is a **directed shot, not a static slide** — you choreograph it across its whole duration. Because the explainer is **faceless, every visual is invented** — typography, abstract graphics, diagrams, data-viz — so you describe the visual elements rather than place captured assets. You write **no HTML** (that's the frame workers). `frame.md` is your palette/type truth. Composition / motion detail lives in `composition.md` + `motion-language.md`; effect & blueprint **bodies** live in `hyperframes-animation`. Adding palette theory or a generic font rule here? Wrong home — `frame.md` + `hyperframes-creative`.
> The method behind **Step 4 (Frame visual design)**. You (the orchestrator) read it to **enrich `STORYBOARD.md` frames in place** — story-design wrote the skeleton (each frame's `scene`, `voiceover`, `transition_in`, the narrative fields, and optionally a candidate blueprint id); you add how each frame **looks and moves**. The unit you write per frame is a **time-coded shot sequence** — a shot directed across its whole duration, not a static slide. You write **no HTML** (that's the frame workers). Because the explainer is **faceless, every visual is invented** — typography, abstract graphics, diagrams, data-viz — so you **design** the visual elements rather than select captured assets (there is no `capture/` to read). `frame.md` is your palette/type truth by role. Layout is a compact vocabulary in this file (the **Layout** section below), stated inline per Scene; motion vocabulary + the motion doctrine + the seek-safe core → `motion-language.md`; the proven shapes → `../hyperframes-animation/blueprints-index.md` + `blueprints/<id>.md`; concrete rules resolve in Step 5 from this skill's local `../hyperframes-animation/rules/`. Adding palette theory or a generic font rule here? Wrong home — `frame.md` + `hyperframes-creative`.
## Every frame is a directed shot
## The unit is a time-coded shot sequence
A frame's visual layer is choreographed across its **full duration**, not front-loaded into an entrance. The failure that reads as PowerPoint: content animates in over the first ~0.8s, then **freezes** while a slow drift plays under it. So every frame's metadata + note describe a **shot with phases**`entrance → development → settle` — where _development_ (a reveal, a rearrange, a morph, an emphasis hit, a count-up) is the mid-shot motion that separates video from slides. The shot model and the choreography-vs-idle budget live in `motion-language.md`; here you **encode it into the frame**: the `effects` / `blueprint` ids are the motion vocabulary, and the **composition note sequences them into phases**.
A frame's visual layer is **a sequence of time windows paced to the voiceover**, not a bag of effect tags. The failure that reads as PowerPoint is **front-loading**: the agent rushes the whole canvas on screen in the first ~25%, and then it just sits. A time-coded shot sequence written **against the VO** makes that impossible: each window states what is on screen and what is moving, and **nothing appears before the voiceover reaches it.** In an explainer the development _is_ the teaching — the formula assembling term by term, the diagram gaining a layer, the count-up landing the statistic. Let the build _be_ the message.
In explainers, _development_ is often the teaching itself — the formula assembling term by term, the diagram gaining a layer, the count-up landing the statistic. Let the build _be_ the message.
Write each frame as a handful of windows cued by the spoken line:
Deliberate **stillness** is the marked exception — the 2-3 climax/breather frames you allocate in `## Video direction`. Every other frame develops; a held frame outside that allocation is just a slide.
```
Scene 1 (0.0Xs): only what the VO is saying at t=0 enters — never the whole canvas
Scene 2 (XsYs): the next piece reveals as the VO names it (a line / layer / node / stat)
… one window per spoken cue — as many or as few as the line calls for
Scene N (…–end): content has resolved; hold the read (stillness; subtle jitter at most)
```
- Each `Scene` line names **what's on screen**, **what moves in this window**, and **where it sits** (layout, inline). Times are real seconds across the frame's `duration`.
- **Pace reveals to the voiceover; never front-load.** This is the core anti-PowerPoint mechanism (→ `motion-language.md` Part 2 Rule 2). At t=0 show only what the VO is saying then; reveal each further piece — a line, a layer, even an h1 — **when the VO names it**, spreading reveals across the shot and especially the **back ~50%**. **The window count = the number of spoken cues the line calls for** — a two-beat line is two windows, a five-item enumeration is five or six. There is **no fixed count and no mandatory "middle" act**; the only sin is dumping everything up front. (A **silent** frame — a diagram assembling itself, a worked example animating — paces its reveals to the beat instead of the VO; same discipline, no spoken cue.)
- **End on a held read.** Once the content has resolved it holds and reads — **prefer stillness to bad motion**: no forced camera drift, no lazy breathing, no back-half pan/push; at most a subtle jitter keeps it alive (→ `motion-language.md`). On a short shot the final reveal and the hold are the **same window** — the hold is not a separate mandatory act. Only the final frame has a real exit; every other frame's exit is the harness transition (story's `transition_in`).
- A **deliberately held** frame — content already revealed, now reading still — is legitimate and often right (a climax, a breather, a beat of held tension before a turn). The failure is never "too still"; it is **front-loaded-then-frozen** (everything dumped by ~25%, nothing cued to the VO). Place held beats deliberately for rhythm so the video isn't uniformly busy (allocate them in `## Video direction`). Reveal pacing + holds + the idle budget → `motion-language.md`.
## Pick the shape — instantiate a blueprint
Don't invent each shot from scratch. The frame's **role** (its `type` / `beat`) points to a proven shape:
1. **Match the role to a blueprint.** Open `../hyperframes-animation/blueprints-index.md`, find the frame's role in the **role→blueprint menu**, and pick the blueprint whose intent fits this beat (story may already have named a candidate id — confirm or override it). Read that `blueprints/<id>.md`: it is a short, domain-agnostic, **time-coded shot template with `[slots]`** and a named **signature move** (the thing that makes the shape itself — the SVG ring, the push-THROUGH, the in-place token swap).
2. **Instantiate its `[slots]` with THIS frame's invented content** — three postures:
- **Reproduce** — the blueprint fits the beat and your content maps onto its slots cleanly. Fill every `[slot]` with this frame's word / shape / stat and follow its Scene timing. Write the resulting Scene lines.
- **Adapt** — the _structure_ fits but the content / element-count / surface doesn't (or you want a fresher surface to avoid templating). State **what you keep / what you change** in one line, then write the adapted Scene lines. You may extend or vary; you may **never** drop the **signature move** (drop it and you picked the wrong blueprint), and you keep the reveals **paced to the VO** — never collapse the shape to a single front-loaded dump.
- **Compose** — no blueprint fits the beat. Build the shot from the **motion vocabulary** in `motion-language.md`: still pace the reveals to the VO across the shot, never fire everything at t=0. Mark it `blueprint: compose`.
3. **Keep the signature move.** Whichever posture, the blueprint's signature move (named in its file) is the spine of the shot — it usually lands on the shot's key reveal. Carry it through.
The blueprint's own Scene lines, motion vocabulary, and `rule mapping` are your raw material; you are choosing a shape and casting this frame's invented content into it, not copying an engineering spec.
## What you add to each frame
Story-design's `## Frame N` block already carries the narrative. You append the visual layer as frame metadata + one composition note (story's role/message prose stays):
Story-design's `## Frame N` block already carries the narrative. You append the shot. Story's `scene` / `voiceover` / `transition_in` / role fields stay untouched.
```
## Frame 3 — How interest compounds
@@ -22,63 +47,100 @@ Story-design's `## Frame N` block already carries the narrative. You append the
- type: feature_showcase ← story's
- persuasion: Concretization + progressive disclosure
- beat: comprehension
- effects: scale-in, layer-reveal, count-up ← you add: cite effect ids (≥3, sequenced into the phases below)
- blueprint: messaging-multi-phase ← you add (optional): one multi-phase blueprint id
- focal: the snowball ← you add: which INVENTED element is the hero
- roles: snowball = foreground subject; hill = background gradient; ring labels = supporting ← you add: each invented element's role
- blueprint: dataviz-countup (Adapt) ← you add: the id you instantiated (or "compose")
- focal: the snowball ← you add: the INVENTED hero element of this beat
- roles: snowball = foreground subject · hill = background gradient (dim ~40%) · ring labels = supporting ← you add: role per invented element
- sfx: whoosh-soft, tick ← you add: the sound the beat wants (fetched + mounted at root; never yours to embed)
Entrance: the snowball seats upper-left on a dim hill gradient. Development: it rolls down across the beat, gaining one labeled ring per turn (layer-reveal) while a small total ticks up (count-up). Settle: the final ring emphasis holds; only the slow camera drift continues. A dense, left-anchored frame.
Adapt: keep the count-up-ring signature; the trend chart becomes the snowball's labeled rings climbing.
Scene 1 (0.01.2s): solid hill gradient (dim ~40%); the snowball seats upper-left, a circular progress ring + bold center number anchor it — Centered template, ~50% of frame. Slow push-in runs underneath.
Scene 2 (1.23.4s): as the VO names each turn, the snowball rolls down and gains one labeled ring per turn (layer-reveal); a small total ticks up beside it (the count-up reveals on its spoken cue, not at t=0). Asymmetric 60/40, 3 depth layers.
Scene 3 (3.45.0s): land the final ring emphasis dead-center, accent glow blooms behind it and holds; the total reads clean and STILL — no continuing push, no breathing (a held beat beats bad motion). The stillness reads against the prior motion.
```
- **`effects`** — name atomic effect **ids** from `hyperframes-animation`'s rules index. **Cite ≥3 when you name no `blueprint`** (the worker composes them into the beat; fewer than 3 reads as generic motion); 1+ as accents when a blueprint already carries the choreography. With no blueprint, those **≥3 effects are the shot's phases** — your note must **sequence them** (one enters, one develops, one emphasizes), not list them as a flat set that all fires at entry. You cite; the worker **reads the recipe body and reproduces it** (not a name-guess).
- **`blueprint`** — name **one** multi-phase blueprint id from `hyperframes-animation/blueprints-index.md` when a frame's beat wants a proven multi-phase shape. Two postures — both require the worker to read the recipe body first:
- **Reproduce** — the blueprint fits the beat cleanly and the frame's content maps onto its slots; write the composition note shot-by-shot to match.
- **Adapt** — the blueprint is the right _structure_ but the content / beat doesn't fit its exact form (or you want a fresher surface). Lead the note with a **`Base / Keep / Depart`** line — `Base:` the blueprint id · `Keep:` its **signature** move (never drop this) · `Depart:` what you change and why. Adapt may **extend or vary, never reduce below the shot model** — never flatten a multi-phase blueprint into a single entrance.
The lightweight tags:
Choose **Reproduce** when the shape fits as-is, **Adapt** when the structure fits but the form doesn't; **omit** `blueprint` entirely when none fits — then the cited `effects` (≥3) carry the phases (**Compose**).
- **`blueprint:`** — the id you instantiated (with `(Reproduce)` / `(Adapt)`), or `compose`. One id per frame.
- **`focal:`** — which **invented** element is the hero of this beat (a hero word, a diagram node, a chart series, a coined-term card).
- **`roles:`** — each invented element's role: `foreground subject` (the thing the eye lands on, text laid around it) · `background` (full-bleed field / gradient / grid, dim 3050%) · `supporting` (labels, secondary shapes, ambient layers). Since there are no captured assets, you are **designing** these elements, not selecting them — keep them **few and load-bearing**. A user-supplied `public/<basename>` image, if any (named in story's `asset_candidates`), is treated as the `focal` cutout or a `background`.
- **`sfx:`** — name the sound the beat wants (an impact for a slam, a whoosh for a push, a tick for a count). The audio script's `fetch-sfx` pass retrieves it and the assembler mounts it at the root — you only **name** it, never embed an `<audio>` element.
- **`focal` / `roles`** — name the **invented** visual elements and their roles. `focal` is the hero element (a hero word, a diagram node, a chart series, a coined-term card). `roles` assigns each element a role: `foreground subject` (the thing the eye lands on, text laid around it), `background` (full-bleed field/gradient/grid, dim 30-50%), `supporting` (labels, secondary shapes, ambient layers). Since there are no captured assets, you are _designing_ these elements, not selecting them — keep them few and load-bearing. A user-supplied `public/<basename>` image, if any, is named in story's `asset_candidates`; treat it as the `focal` cutout or a `background`.
- **`sfx`** — name the sound the beat wants (an impact for a slam, a whoosh for a push, a tick for a count). The audio script's `fetch-sfx` pass retrieves it and the assembler mounts it at the root — you only **name** it, never embed an `<audio>` element.
- **composition note** — the frame's visual brief: layout, hero, depth layers, the macro move, **and the shot's phases**. **Default to a phased note**`entrance: … → development: … → settle: …` (mandatory when you named a `blueprint`). A **single still line** is correct only for a deliberately held climax or an allocated stillness frame. Full method → `composition.md` (layout) + `motion-language.md` (phases).
**Layout is stated INLINE in each Scene line** — name the template, density, depth, and hierarchy as part of "where it sits" (`Centered, ~50% of frame`, `asymmetric 60/40, 3 depth layers`), drawing on the **Layout** vocabulary below; never write px / scale / shadow recipes (the worker writes those).
## Video direction — write the invariants ONCE
**Motion is named INLINE in each Scene line** — name the move from `motion-language.md`'s vocabulary (`per-word reveal`, `layer-reveal`, `count-up`, `glow blooms`) and let it settle on a long-tail curve (`power3` default — smooth beats bouncy; see `motion-language.md`). Never write ease curves / ms / stagger (those resolve in Step 5 from this skill's local `../hyperframes-animation/rules/`).
The whole video shares one look and one motion grammar. State it **once**, at the top of `STORYBOARD.md` (a `## Video direction` block), so every frame inherits it and per-frame metadata carries only the **delta**:
## Inventing the visual — diagrams, type, data-viz
This is a **faceless** explainer: the frame's hero is something you **design**, not a screenshot. Three first-class treatments, each invented from the frame's `narrativeRole` / `keyMessage` / `scene`:
- **Typographic / kinetic type** — the hero word, the coined term, a number, a short enumeration. Treat type as the subject: full-bleed scale, weight contrast, one emphasized term. Strongest for hooks, concept names, takeaways. In a faceless explainer **type is often the primary visual** — lean on the type ramp hard.
- **Abstract graphics** — shapes, fields, paths, geometry that _embody_ the idea (the snowball, the spotlight, the staircase-not-cliff). Build the metaphor the script names; don't decorate with generic bokeh.
- **Diagram / data-viz** — nodes + edges, a chart, a number line, a formula, a process flow. The build (each part appearing on beat) is the teaching — design it to assemble across the Scenes, not appear whole.
Make the invented hero **fill 4060% of the frame** — a diagram big enough to read its labels, a hero word near full-bleed. Don't shrink the one designed element into decoration around empty space.
## Layout — named inline per Scene
State each Scene's layout as part of "where it sits." **If the blueprint already implies a composition** (a ring around a center, stations on a wide canvas, two cards from opposite wings), that wins — describe it directly; the vocabulary below is for **composing freely** or a generic beat, not a menu you must pick from. Never write px / scale / shadow (the worker does). One frame's layout can EVOLVE across its Scenes (Scene 1 centered hero → Scene 2 rearranges to a grid). Use **≥3 different framings per video** so it doesn't read as one repeated template; never the same framing twice in a row.
- **Framing vocabulary** — centered (hero / climax) · rule-of-thirds · split-screen (comparison) · layered-depth (immersive opening / atmosphere) · asymmetric 60/40 or 70/30 (editorial — a dense diagram + a caption rail) · triptych (three items / the rule-of-three landing) · full-width strip (a number line / timeline / enumeration). Vary the framing across the video so it doesn't read as one repeated template — let the beat decide, not a quota.
- **Density** — primary visual ≥ 40% of canvas; ≥ 3 depth layers (background + midground + foreground); never a lone small cluster floating in empty space. Openings and closings are prone to emptiness — add environmental layers (dual-radial swell, low-opacity scanlines, a hairline grid, brand-color ambient). Squint test: after blur you can still pick out the #1 element.
- **Hierarchy** — combine ≥ 2 of size (3:1) / weight (800 vs 400) / contrast / position (upper-third is golden) / motion, so one element clearly dominates. A title that is only _larger_ (sharing weight/color/spacing with body) reads weak.
- **Depth** — layer 23 of: size, blur, opacity gradient, overlap, shadow-stack, counter-scale on a push.
- **Don't show**: nav bars, footers, scrollbars, real cursors / browser chrome, generic decorative shapes standing in for a designed metaphor, floating bokeh / purple-blue "AI" gradients (the default-AI cliché, banned). A faceless explainer has no real interface — an interface mock is correct **only** when the topic itself is about that interface and the frame intentionally reconstructs it.
## Portrait & square (non-16:9 canvases)
The zones, density, hierarchy, and depth principles all still apply; the **aspect ratio** changes, and a wide-frame layout does not transplant into a tall one — design for the storyboard's `format` from the start, never plan landscape and "crop."
- **Stack vertically, not side-by-side** — split-screen / triptych / 60-40 become top/bottom stacks, vertical step lists, stacked bands. Square tolerates side-by-side only for two compact items.
- **Vertical center moves with the canvas** — anchor a centered hero around **y ≈ 0.42 × height** (portrait ≈806, square ≈454), not a fixed 540.
- **Type runs larger, fewer words per line** — narrow frames wrap long headlines badly; prefer short kinetic lines, bigger type, more vertical rhythm.
- **Travels well to portrait:** Centered, Layered Depth, Full-Width Strip (stacked band), vertical Rule-of-Thirds. **Avoid** wide Split Screen and Triptych — use stacked equivalents.
## `## Video direction` — write the invariants ONCE
The whole video shares one look and one motion grammar. Write a **`## Video direction`** block ONCE at the top of `STORYBOARD.md` so every frame inherits it and per-frame Scene lines carry only the **delta**. This block is load-bearing — it is what binds many independent shots into one film. **Keep it.**
- **palette system** — from `frame.md`: which roles map to which hues. Never invent.
- **motion defaults + shot model** — default eases + the **choreography baseline** (every frame a directed shot: entrance → development → settle) + the **idle-life budget** (what may keep moving during the hold) (→ `motion-language.md`).
- **negative list** — what never appears: off-brand textures the pack forbids, **plus both motion failure modes** — slideshow (enter-then-freeze) and screensaver (everything floating independently) (`motion-language.md`).
- **stillness allocation** — name the 2-3 frames that hold still before a climax; every other frame develops.
- **motion grammar + reveal model** — long-tail eases (`power3` default, smooth over bouncy) + the **VO-paced reveal** model every frame follows (reveal each piece on its spoken cue; never front-load) + what may stay alive during a hold (subtle jitter at most; no lazy breathing) (→ `motion-language.md`).
- **rhythm / held-frame allocation** — name the **held / breather frames** (often before a climax or the turn in a story) so the video varies its energy: most frames reveal to the VO, a few hold still (a held read beats bad motion; the anti-monotony discipline; `motion-language.md`).
- **negative list** — what never appears: off-brand textures / effects the pack forbids, **plus both motion failure modes** — slideshow (front-load then freeze) and screensaver (everything floating independently) (→ `motion-language.md`).
Do **not** repeat these in every frame — each frame's metadata is the delta on top of Video direction.
Do **not** repeat these per frame — restating video-level rules in every frame is exactly the bloat this layer prevents.
## Palette & type — from `frame.md`, never invented
- **Palette** — `frame.md` (the adopted pack) is the color truth; apply its roles per frame. Generic basics (one accent, tint neutrals, avoid pure `#000`/`#fff`) → `hyperframes-creative/references/house-style.md`.
- **Type** — fonts resolve via `frame.md`'s type tokens; reference them **by role** (display / body / mono / the pack's ramp), never by raw family or px. Typography craft (embedded fonts, dark-bg optical compensation, `tabular-nums`) → `hyperframes-creative/references/typography.md`. In a faceless explainer, **type is often the primary visual** — the hero word, the coined term, the kinetic enumeration — so lean on the type ramp hard.
- **Type** — fonts resolve via `frame.md`'s type tokens; reference them **by role** (display / body / mono / the pack's ramp), never by raw family or px. Generic typography craft (embedded fonts, dark-bg optical compensation, `tabular-nums`) → `hyperframes-creative/references/typography.md`. In a faceless explainer type is often the primary visual — the hero word, the coined term, the kinetic enumeration — so lean on the type ramp hard.
## Caption-band keep-out (plan side)
The bottom ~17% of the canvas is reserved for the caption pill. Plan every frame's content into the **top ~83%** so nothing important lands in the band (the worker enforces the pixel cutoff; you plan the layout). Holds even when captions are disabled — bottom-edge consistency. Geometry detail → `composition.md`.
The bottom ~17% of the canvas is reserved for the caption pill. Plan every frame's content into the **top ~83%** so nothing important lands in the band (the worker enforces the pixel cutoff; you plan the layout). When captions are enabled, primary content and key visuals **cap at the band top**, and a centered hero anchors at **y ≈ 0.42 × height** (landscape ≈454, portrait ≈806), not the canvas midpoint; background / ambient / surface layers are exempt and may stay full-bleed. Holds even when captions are disabled — bottom-edge consistency.
## Where the detail lives
| For… | Read |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| composition — zones, density, templates, invented-visual prominence, caption geometry | `composition.md` (local) |
| motion — the shot model, phases, idle budget, beat structure, stillness | `motion-language.md` (local) |
| effect ids + blueprint ids (vocabulary + recipes) | `../hyperframes-animation/blueprints-index.md` + `../hyperframes-animation/rules-index.md` |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| the proven shapes + role→blueprint menu + how to pick | `../hyperframes-animation/blueprints-index.md``blueprints/<id>.md` |
| motion — shot model, vocabulary, holds, idle budget, stillness, seek-safe | `motion-language.md` (local) |
| layout — framing, density, depth, hierarchy, inventing the visual, caption band | the **Layout** + **Inventing the visual** sections in this file |
| concrete eases / ms / stagger + rule recipe bodies (Step 5) | local `../hyperframes-animation/rules/` (the frame worker reads it; you don't) |
| palette + type tokens | the project's `frame.md`; basics → `hyperframes-creative` `house-style.md` / `typography.md` |
| "produced, not generated" foreground density | `hyperframes-creative/references/video-composition.md` |
| within-frame cuts / seams (zoom-through · cut-the-curve · waterfall) | `cut-catalog.md` (the worker builds them inside the composition) |
| transitions | story-design owns `transition_in`; you don't touch it |
## Before you finish — checklist
- Every frame has `effects` (≥1 cited id; **≥3 when no `blueprint`** is named); a `blueprint` where the frame matches one, with a shot-by-shot composition note.
- **Every frame's composition note is phased** (entrance → development → settle) — not a single entry that then freezes; the ≥3 effects are **sequenced across phases**, not all fired at t=0.
- **Stillness is only the 2-3 frames allocated in Video direction**; every other frame develops mid-shot.
- **`## Video direction`** written once at the top (palette · motion grammar + shot model + idle budget · stillness allocation · negative list incl. both failure modes); per-frame entries are deltas, not restatements.
- Every frame is a **time-coded shot sequence** with real second windows across its `duration` — not a tag bag.
- **No frame front-loads** — at t=0 only what the VO is saying enters; each further piece reveals on its spoken cue, across the back ~50%. Window count follows the VO (or the beat, on a silent frame), not a fixed number.
- Every frame names a **`blueprint:`** id (Reproduce / Adapt) or `compose`; an Adapt states keep/change and **keeps the signature move**; nothing collapses to a single front-loaded dump — reveals stay paced to the VO.
- **Held frames are deliberate** — allocated in Video direction for rhythm; a held read is fine (prefer stillness to bad motion), but no frame may be front-loaded-then-frozen.
- Each frame names its **invented** `focal` + per-element `roles` (foreground / background / supporting), kept few and load-bearing.
- **Video direction** stated once at the top (palette · shot model + idle budget · negative list incl. both failure modes · stillness allocation); per-frame entries are deltas.
- The invented hero fills 4060% of the frame (a diagram big enough to read, a hero word near full-bleed) — not shrunk into decoration around empty space.
- Layout named **inline** per Scene (template / density / depth / hierarchy — the **Layout** vocabulary here); motion named **inline** per Scene from the vocabulary (`motion-language.md`). No px / ease curves / ms / JS.
- Content planned into the top ~83% (caption band clear).
- Palette / type pulled from `frame.md` by role — nothing invented.
- You wrote no HTML.
- You wrote no HTML; every visual is invented (no captured assets).
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// assemble-index.mjs — deterministic top-level index.html assembly for a
// product-launch project. No subagent, no judgment: turns STORYBOARD.md + the
// HyperFrames project. No subagent, no judgment: turns STORYBOARD.md + the
// built frame files (+ optional audio_meta.json) into the standalone index.html
// the renderer consumes, and stages the frame-named capture assets into assets/.
//
@@ -31,13 +31,25 @@
// Reads: --storyboard STORYBOARD.md, --hyperframes <project root>,
// [--audio-meta audio_meta.json]. On disk: each built frame's src html,
// capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
// Writes: <project>/index.html + stages assets/<basename>.
// Writes: <project>/index.html + stages assets/<basename> + (guard ① below)
// repairs a frame file in place when its root is missing data-width/height.
//
// Pre-assembly frame guards (run in the same pass that reads each frame, so common
// `lint` failures surface HERE instead of after assembly + a wasted render):
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
// dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
// ② HARD FAIL — <video>/<audio> inside a sub-comp: the runtime only drives media that
// is a DIRECT child of the host root, so sub-comp media renders blank/black.
// ③ HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
// and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
//
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
// frames, a built/animated frame missing its src/file, a frame with no
// duration, an inner data-composition-id mismatch). No backstop: fix upstream.
// duration, an inner data-composition-id mismatch, or a guard ②/③ violation).
// No backstop: fix upstream.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { basename, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
@@ -55,6 +67,52 @@ function die(msg) {
process.exit(1);
}
// Ensure the BGM track is at least `total` seconds long. HeyGen (and most music
// libraries) return a short loopable clip (~1530s); mounting it at data-duration=total
// would leave the video's TAIL SILENT. If the file is short, loop-extend it to `total`
// (with a 0.4s fade-in + 1.5s fade-out) into a sibling *.loop.mp3 and return that path.
// Needs ffprobe+ffmpeg (present in the render env); degrades to the original + a warning
// when they're absent, so assembly never hard-fails on audio tooling.
function ensureBgmCovers(relPath, hyperframesDir, total) {
const abs = join(hyperframesDir, relPath);
const probe = spawnSync(
"ffprobe",
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", abs],
{ encoding: "utf8" },
);
if (probe.status !== 0) return { looped: false, short: false, reason: "ffprobe unavailable" };
const dur = parseFloat(String(probe.stdout || "").trim());
if (!Number.isFinite(dur) || dur <= 0)
return { looped: false, short: false, reason: "unreadable duration" };
if (dur >= total - 0.1) return { looped: false, short: false, dur }; // already covers
const relOut = relPath.replace(/\.([^./]+)$/, ".loop.$1");
const absOut = join(hyperframesDir, relOut);
const fadeOut = Math.max(0, total - 1.5);
const ff = spawnSync(
"ffmpeg",
[
"-y",
"-stream_loop",
"-1",
"-i",
abs,
"-t",
String(total),
"-af",
`afade=t=in:st=0:d=0.4,afade=t=out:st=${fadeOut}:d=1.5`,
"-c:a",
"libmp3lame",
"-q:a",
"2",
absOut,
],
{ encoding: "utf8" },
);
if (ff.status !== 0 || !existsSync(absOut))
return { looped: false, short: true, dur, reason: "ffmpeg unavailable" };
return { looped: true, rel: relOut, from: dur };
}
const hyperframesDir = resolve(flag("hyperframes", "."));
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audioMetaPath = resolve(flag("audio-meta", join(hyperframesDir, "audio_meta.json")));
@@ -62,12 +120,127 @@ const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));
const r3 = (x) => Math.round(x * 1000) / 1000;
const anomalies = [];
const frameErrors = []; // fatal per-frame composition violations (guards ②/③) — reported together
const repairs = []; // auto-repairs applied to frame files in place (guard ①)
// ---------- parse storyboard ----------
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const { width: WIDTH, height: HEIGHT } = parseFormat(manifest.globals.format);
// ---------- per-frame composition guards (see header ①②③) ----------
// String-level checks on each frame's HTML — no DOM parse, deterministic, run in
// the same pass that already reads the file. OPEN_TAG matches one opening tag while
// tolerating quoted attribute values that contain ">" (e.g. inline styles).
const OPEN_TAG = "<([a-zA-Z][a-zA-Z0-9-]*)((?:[^>\"']|\"[^\"]*\"|'[^']*')*)>";
const attrPresent = (attrs, name) => new RegExp(`(?:^|\\s)${name}(?:[\\s=]|$)`).test(attrs);
const attrValue = (attrs, name) => {
const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`));
return m ? (m[1] ?? m[2]) : null;
};
// The root (or a nested-comp mount) legitimately carries timing without class="clip".
const isRootish = (attrs) =>
/(?:^|\s)id\s*=\s*["']root["']/.test(attrs) ||
attrPresent(attrs, "data-composition-id") ||
attrPresent(attrs, "data-composition-src");
// Locate the composition root opening tag: prefer id="root", else the first element
// carrying data-composition-id. Returns { start, end, full, attrs } or null.
function findRootTag(html) {
const re = new RegExp(OPEN_TAG, "g");
let m;
let firstCompId = null;
while ((m = re.exec(html))) {
const attrs = m[2];
if (/(?:^|\s)id\s*=\s*["']root["']/.test(attrs))
return { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
if (attrPresent(attrs, "data-composition-id") && !firstCompId)
firstCompId = { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
}
return firstCompId;
}
// Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
function guardFrame(html, label) {
const errors = [];
// Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
// in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
// trip ②/③. ① still splices into the ORIGINAL html, so its offsets stay correct.
const scan = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
.replace(/<style\b[\s\S]*?<\/style[^>]*>/gi, " ");
// ② media inside a sub-comp — never driven by the runtime (renders blank/black).
const media = scan.match(/<(video|audio)(?=[\s/>])/i);
if (media) {
errors.push(
`${label}: has a <${media[1].toLowerCase()}> inside the sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html) — sub-comp media renders blank/black. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).`,
);
}
// ③ timed-element checks: missing class="clip", and same-track window overlap.
const re = new RegExp(OPEN_TAG, "g");
const clips = [];
let m;
while ((m = re.exec(scan))) {
const attrs = m[2];
if (
!attrPresent(attrs, "data-start") ||
!attrPresent(attrs, "data-duration") ||
!attrPresent(attrs, "data-track-index")
)
continue;
if (isRootish(attrs)) continue;
if (!/(?:^|\s)class\s*=\s*["'][^"']*\bclip\b[^"']*["']/.test(attrs)) {
errors.push(
`${label}: a timed <${m[1]}> (data-start/duration/track-index) has no class="clip" — it renders for the whole frame instead of only its window. Add class="clip", or remove the timing attrs if it is a GSAP-animated element meant to be present throughout.`,
);
}
const track = attrValue(attrs, "data-track-index");
const start = parseFloat(attrValue(attrs, "data-start"));
const dur = parseFloat(attrValue(attrs, "data-duration"));
if (track != null && Number.isFinite(start) && Number.isFinite(dur))
clips.push({ track, start, end: start + dur });
}
const EPS = 1e-3; // adjacent clips that merely touch are legal
const byTrack = new Map();
for (const c of clips) {
const arr = byTrack.get(c.track);
if (arr) arr.push(c);
else byTrack.set(c.track, [c]);
}
for (const [track, list] of byTrack) {
list.sort((a, b) => a.start - b.start);
for (let i = 1; i < list.length; i++) {
if (list[i].start < list[i - 1].end - EPS) {
errors.push(
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
);
break; // one report per track is enough
}
}
}
// ① auto-repair: ensure the root carries data-width / data-height.
let repairedHtml = null;
let repairNote = null;
const root = findRootTag(html);
if (root) {
const needW = !attrPresent(root.attrs, "data-width");
const needH = !attrPresent(root.attrs, "data-height");
if (needW || needH) {
const inject =
(needW ? ` data-width="${WIDTH}"` : "") + (needH ? ` data-height="${HEIGHT}"` : "");
const newTag = root.full.replace(/(\/?>)$/, `${inject}$1`);
repairedHtml = html.slice(0, root.start) + newTag + html.slice(root.end);
repairNote = `${label}: injected${needW ? " data-width" : ""}${needH ? " data-height" : ""} (${WIDTH}×${HEIGHT}) on the root — was missing (would lint root_missing_dimensions)`;
}
}
return { errors, repairedHtml, repairNote };
}
// ---------- resolve mountable frames in document order ----------
// A frame mounts when its src html exists on disk. A built/animated frame
// missing its src/file is a contract break (die). An outline frame with no
@@ -82,7 +255,12 @@ for (const f of manifest.frames) {
continue;
}
const compAbs = join(hyperframesDir, f.src);
if (!existsSync(compAbs)) {
// Read directly and handle ENOENT here rather than an existsSync precheck — the
// check→read/write pair is a TOCTOU race CodeQL flags (js/file-system-race).
let html;
try {
html = readFileSync(compAbs, "utf8");
} catch {
if (built)
die(`${label} is ${f.status} but its src ${f.src} is not on disk — re-dispatch the worker`);
anomalies.push(`${label}: src ${f.src} not on disk (status ${f.status}) — skipped`);
@@ -97,7 +275,6 @@ for (const f of manifest.frames) {
// finds the timeline. frame_id = src basename (frame-worker contract); verify
// the inner html actually declares it.
const compId = basename(f.src).replace(/\.html?$/i, "");
const html = readFileSync(compAbs, "utf8");
// Guard against blank/partial scene files: a worker that errors or is
// interrupted mid-write leaves an empty (or markup-less) file that exists but
// fails at render with "Composition HTML is empty or could not be parsed".
@@ -107,6 +284,14 @@ for (const f of manifest.frames) {
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
);
}
// pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
const guard = guardFrame(html, label);
if (guard.repairedHtml) {
writeFileSync(compAbs, guard.repairedHtml);
html = guard.repairedHtml;
repairs.push(guard.repairNote);
}
for (const e of guard.errors) frameErrors.push(e);
if (
!html.includes(`data-composition-id="${compId}"`) &&
!html.includes(`data-composition-id='${compId}'`)
@@ -115,6 +300,12 @@ for (const f of manifest.frames) {
}
mounted.push({ frame: f, compId, durationSeconds: r3(f.durationSeconds) });
}
if (frameErrors.length) {
die(
`${frameErrors.length} frame composition violation(s) — fix the worker output and re-assemble:\n` +
frameErrors.map((e) => `${e}`).join("\n"),
);
}
if (mounted.length === 0) die("no mountable frames (none built with an on-disk src)");
// cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
@@ -181,16 +372,28 @@ for (const m of mounted) {
body.push("");
}
// (track 11) BGM — duck under narration when any voice is present.
// (track 11) BGM — duck under narration when any voice is present. Loop-extend a short
// track to the full video length so the tail isn't silent (libraries return ~1530s clips).
let bgmEmitted = false;
let bgmNote = "";
if (audio.bgm?.path) {
if (existsSync(join(hyperframesDir, audio.bgm.path))) {
let bgmSrc = audio.bgm.path;
const cov = ensureBgmCovers(audio.bgm.path, hyperframesDir, TOTAL);
if (cov.looped) {
bgmSrc = cov.rel;
bgmNote = ` (looped ${cov.from.toFixed(1)}s→${TOTAL}s)`;
} else if (cov.short) {
anomalies.push(
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
);
}
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
body.push(
` <!-- BGM -->`,
` <audio`,
` id="el-bgm"`,
` src="${audio.bgm.path}"`,
` src="${bgmSrc}"`,
` data-start="0"`,
` data-duration="${TOTAL}"`,
` data-track-index="11"`,
@@ -352,11 +555,15 @@ console.log(`✓ wrote ${outPath}`);
console.log(` canvas: ${WIDTH}×${HEIGHT}`);
console.log(` frames (track 1): ${mounted.length}`);
console.log(` voice (track 10): ${voiceCount}`);
console.log(` bgm (track 11): ${bgmEmitted ? "yes" : "no"}`);
console.log(` bgm (track 11): ${bgmEmitted ? "yes" + bgmNote : "no"}`);
console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`);
console.log(` sfx (track 20+): ${sfxEmitted}`);
console.log(` assets staged: ${staged}/${wanted.size}`);
console.log(` total duration: ${TOTAL}s`);
if (repairs.length) {
console.log(`\nrepaired (frame files updated in place):`);
for (const rp of repairs) console.log(` - ${rp}`);
}
if (anomalies.length) {
console.log(`\nanomalies (non-fatal):`);
for (const a of anomalies) console.log(` - ${a}`);
+7 -7
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env node
// audio.mjs — product-launch audio ADAPTER. The TTS / BGM / SFX implementation
// audio.mjs — faceless-explainer audio ADAPTER. The TTS / BGM / SFX implementation
// no longer lives here: it is the shared engine at
// ../../hyperframes-media/scripts/audio.mjs. This file only (a) maps the
// product-launch model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the
// frame model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the
// engine's neutral audio_request.json, (b) converts the engine's id-keyed
// audio_meta back into the frame-keyed shape captions.mjs / assemble-index.mjs
// already consume, and (c) keeps the local `sync-durations` pass (it rewrites
// STORYBOARD.md, which is product-launch-specific).
// STORYBOARD.md, which is this skill's concern).
//
// Three modes (unchanged CLI surface):
// (default) generate — engine --only tts,bgm. BGM mode is "retrieve" (strict:
@@ -86,9 +86,9 @@ function runEngine({ request, hyperframesDir, neutral, only, extra = [] }, die)
if (r.status !== 0) die(`media audio engine exited ${r.status}`);
}
// Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by
// Engine neutral meta (id-keyed) → frame-keyed meta consumed by
// captions.mjs / assemble-index.mjs. id is the zero-padded frame number.
function toProductLaunchMeta(neutral) {
function toFrameKeyedMeta(neutral) {
const voices = (neutral.voices ?? []).map((v) => ({
frame: Number(v.id),
path: v.path,
@@ -152,7 +152,7 @@ function runGenerate(argv) {
const neutral = neutralPath(outPath);
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
const meta = toFrameKeyedMeta(JSON.parse(readFileSync(neutral, "utf8")));
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(
`✓ audio generate: ${meta.voices.length} voice + ${meta.bgm ? "1 bgm" : "no bgm"}${outPath}`,
@@ -189,7 +189,7 @@ function runFetchSfx(argv) {
// voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved.
runEngine({ request, hyperframesDir, neutral, only: "sfx" }, die);
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
const meta = toFrameKeyedMeta(JSON.parse(readFileSync(neutral, "utf8")));
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`);
}
+268 -23
View File
@@ -121,11 +121,51 @@ const hueDist = (a, b) => {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
};
function hexToRgb(hex) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgbToHsl = (r, g, b) =>
hexToHsl("#" + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join(""));
// Repaint a chromatic rgba()/rgb() tint with the brand accent's RGB, keeping its alpha.
// A near-neutral rgb (shadow / scrim overlay) is left untouched; a non-rgba string → null.
function remapRgbaToAccent(val, brAccent, brAccent2, prAccentHsl, prAccent2Hsl) {
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+%?))?\s*\)$/i.exec(
String(val).trim(),
);
if (!m) return null;
const r = +m[1],
g = +m[2],
b = +m[3],
a = m[4];
if (Math.max(r, g, b) - Math.min(r, g, b) < 16) return null; // neutral overlay — keep as-is
const src = rgbToHsl(r, g, b);
const useSecond =
brAccent2 &&
prAccentHsl &&
prAccent2Hsl &&
src &&
hueDist(src.h, prAccent2Hsl.h) < hueDist(src.h, prAccentHsl.h);
const t = hexToRgb(useSecond ? brAccent2 : brAccent);
if (!t) return null;
return a !== undefined
? `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`
: `rgb(${t[0]}, ${t[1]}, ${t[2]})`;
}
// ── brand tokens ──────────────────────────────────────────────────────────────
let brandColors = [];
let brandFonts = [];
let brandFontWeights = []; // weights the brand text font actually ships (tokens fonts[].weights)
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
const isIconFont = (name) =>
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
String(name),
);
if (existsSync(tokensPath)) {
try {
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
@@ -137,7 +177,19 @@ if (existsSync(tokensPath)) {
brandFonts = (t.fonts ?? [])
.map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? "")))
.map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim())
.filter(Boolean);
.filter(Boolean)
.filter((f) => !isIconFont(f));
// Union of the (non-icon) brand fonts' available weights — used to clamp the preset's
// type ramp so a font shipping only 400/500 never faux-bolds a 600/700 heading.
brandFontWeights = [
...new Set(
(t.fonts ?? [])
.filter((f) => f && typeof f === "object" && !isIconFont(f.family ?? f.name ?? ""))
.flatMap((f) => (Array.isArray(f.weights) ? f.weights : []))
.map((w) => parseInt(w, 10))
.filter((w) => Number.isFinite(w)),
),
].sort((a, b) => a - b);
brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : [];
} catch (e) {
die(`tokens.json parse: ${e.message}`);
@@ -156,7 +208,7 @@ if (brandColors.length && presetColors.length) {
// Fall back to the legacy luminance/chroma heuristic only when stats are absent —
// but pick the accent via pickAccent either way so a UA-default link color never wins.
const br =
brandRolesFromStats(brandColorStats) ??
brandRolesFromStats(brandColorStats, brandColors) ??
(() => {
// strip UA-default link colors so a stray <a> color can't become ink/canvas/accent
const clean = brandColors.filter((h) => !UA_DEFAULT_COLORS.has(h.toUpperCase()));
@@ -174,39 +226,66 @@ if (brandColors.length && presetColors.length) {
` ⚠ accent ${br.accent} 彩度很低 (${chroma(br.accent)}) — 确认这是品牌色而非中性/默认色`,
);
}
// Map by LUMINANCE POLARITY, not by role name: the preset's darker neutral takes the
// brand's darker neutral, the lighter takes the lighter. So a dark-ground preset stays
// dark and a light-ground preset stays light — both land on the brand's real values,
// even when the brand's canvas is dark (dark-mode brand) and ink is light.
// Map by LUMINANCE POLARITY. The preset's darker value takes the brand's darker value and
// the lighter takes the lighter — UNLESS the brand's GROUND polarity differs from the
// preset's. Every shipped preset is light-ground; a dark-mode brand (Linear, Vercel,
// Raycast…) has its canvas darker than its ink (colorStats already resolved the real
// ground as the largest-area background). On a polarity MISMATCH we INVERT the mapping so a
// light preset becomes the dark brand (canvas↔ink swap) instead of forcing the brand onto
// an off-brand light video; neutral/tint lightness is then mirrored (L→1L) so the whole
// palette flips to the brand's ground. Same-polarity (the common case) is unchanged.
const darker = (a, b) => ((lum(a) ?? 0) <= (lum(b) ?? 0) ? a : b);
const prDark = darker(pr.ink, pr.canvas);
const prLight = prDark === pr.ink ? pr.canvas : pr.ink;
const brDark = darker(br.ink, br.canvas);
const brLight = brDark === br.ink ? br.canvas : br.ink;
const presetGroundDark = (lum(pr.canvas) ?? 255) < (lum(pr.ink) ?? 0);
const brandGroundDark = (lum(br.canvas) ?? 255) < (lum(br.ink) ?? 0);
const invert = presetGroundDark !== brandGroundDark;
const mapDark = invert ? brLight : brDark; // preset's dark value → this brand value
const mapLight = invert ? brDark : brLight; // preset's light value → this brand value
const flipL = (l) => (invert ? 1 - l : l); // mirror tint/neutral lightness when flipping
const prAccentHsl = hexToHsl(pr.accent);
const prAccent2Hsl = hexToHsl(pr.accent2);
const newByKey = new Map();
for (const [key, val] of presetColors) {
const ph = hexToHsl(val);
let next;
if (val === prDark) next = brDark;
else if (val === prLight) next = brLight;
if (val === prDark) next = mapDark;
else if (val === prLight) next = mapLight;
else if (
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
key,
)
)
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
// MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
// color and would otherwise be claimed as accent2 and recolored to the brand hue.
next = val;
else if (val === pr.accent)
next = br.accent; // primary accent → the EXACT brand color
else if (pr.accent2 !== pr.accent && val === pr.accent2)
next = br.accent2; // exact 2nd accent
else if (!ph)
next = val; // non-hex (rgba) → leave as-is
else {
// repaint the remaining tints: pick the brand accent whose preset counterpart is
// nearest in hue, then keep THIS color's own lightness so tint families stay families.
else if (!ph) {
// rgba()/rgb() tint → repaint its rgb with the brand accent, keep alpha (a neutral
// overlay is kept). A non-color non-hex value (var(), named) falls through unchanged.
next = remapRgbaToAccent(val, br.accent, br.accent2, prAccentHsl, prAccent2Hsl) ?? val;
} else if (chroma(val) < 16) {
// NEUTRAL source (grey text-ladder, hairline borders) → keep it NEUTRAL. Apply at most a
// whisper of the brand hue (sat ≤ 0.06); never the accent's full saturation — that is what
// turned the grey ladder into saturated blue.
const bh = hexToHsl(br.accent);
next = bh ? hslToHex(bh.h, Math.min(ph.s, 0.06), flipL(ph.l)) : val;
} else {
// chromatic tint → repaint with the nearest brand accent's hue+sat, keep THIS color's
// lightness so tint families stay families.
const useSecond =
pr.accent !== pr.accent2 &&
prAccentHsl &&
prAccent2Hsl &&
hueDist(ph.h, prAccent2Hsl.h) < hueDist(ph.h, prAccentHsl.h);
const bh = hexToHsl(useSecond ? br.accent2 : br.accent);
next = bh ? hslToHex(bh.h, bh.s, ph.l) : val;
next = bh ? hslToHex(bh.h, bh.s, flipL(ph.l)) : val;
}
if (next !== val) newByKey.set(key, next);
}
@@ -229,7 +308,7 @@ if (brandColors.length && presetColors.length) {
})
.join("\n");
summary.push(
`colors: dark ${prDark}${brDark}, light ${prLight}${brLight}, accent ${pr.accent}${br.accent}` +
`colors: ${invert ? "INVERTED (dark-mode brand on light preset) · " : ""}dark ${prDark}${mapDark}, light ${prLight}${mapLight}, accent ${pr.accent}${br.accent}` +
` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`,
);
} else {
@@ -246,15 +325,178 @@ if (brandFonts.length) {
const strip = (q) => (q ? q.replace(/^"|"$/g, "") : null);
const pDisplay = strip(pf.display);
const pBody = strip(pf.body);
const bDisplay = brandFonts[0];
const bBody = brandFonts[1] ?? brandFonts[0];
if (pDisplay && bDisplay) md = md.split(`"${pDisplay}"`).join(`"${bDisplay}"`);
if (pBody && pBody !== pDisplay && bBody) md = md.split(`"${pBody}"`).join(`"${bBody}"`);
summary.push(`fonts: display ${pDisplay}${bDisplay}, body ${pBody}${bBody}`);
const pMono = strip(pf.mono);
// A monospace brand face is for code / labels / chrome — never the reading display or body.
// Split the brand fonts: the primary NON-mono family carries display AND body (the common
// single-sans case, e.g. Inter for everything), and a captured mono (Berkeley Mono,
// JetBrains Mono…) is routed onto the preset's mono role instead of turning the body
// monospace. (Distinct display/body brands still resolve to a clean sans; hand-tune the
// display in frame.md if a separate display face is wanted.)
const isMonoFont = (n) =>
/(?:^|[\s_-])mono(?:[\s_-]|$)|monospace|consol|courier|menlo|monaco|jetbrains|berkeley|space\s*mono|ibm\s*plex\s*mono|sf\s*mono|roboto\s*mono|source\s*code|fira\s*code|geist\s*mono|dm\s*mono/i.test(
String(n),
);
const nonMono = brandFonts.filter((f) => !isMonoFont(f));
const monoFonts = brandFonts.filter(isMonoFont);
const bDisplay = nonMono[0] ?? brandFonts[0];
const bBody = nonMono[0] ?? brandFonts[0];
const bMono = monoFonts[0] ?? null;
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Replace the preset family as a WHOLE WORD/PHRASE everywhere — frontmatter values,
// component strings like "Space Grotesk 600", AND prose — case-sensitive with word
// boundaries so a single-word family ("Inter") can never corrupt a substring
// ("interactive"). Quote-exact replace alone missed names baked into longer strings + prose.
const swapFamily = (from, to) => {
if (from && to && from !== to) md = md.replace(new RegExp(`\\b${escRe(from)}\\b`, "g"), to);
};
swapFamily(pDisplay, bDisplay);
if (pBody !== pDisplay) swapFamily(pBody, bBody);
// route the brand mono onto the preset's mono role (only if the preset has a DISTINCT mono
// family — never collapse body/display into mono)
if (bMono && pMono && pMono !== pBody && pMono !== pDisplay) swapFamily(pMono, bMono);
summary.push(
`fonts: display ${pDisplay}${bDisplay}, body ${pBody}${bBody}` +
(bMono && pMono && pMono !== pBody && pMono !== pDisplay ? `, mono ${pMono}${bMono}` : ""),
);
} else {
summary.push("fonts: no brand fonts — preset fonts kept");
}
// ── cap type weights to the brand font's available faces ──────────────────────
// The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
// only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
// to the NEAREST weight the brand font actually provides (tokens.json fonts[].weights).
if (brandFonts.length && brandFontWeights.length) {
const avail = brandFontWeights;
const nearest = (n) =>
avail.reduce((best, w) => {
const dw = Math.abs(w - n),
db = Math.abs(best - n);
return dw < db || (dw === db && w > best) ? w : best;
}, avail[0]);
let capped = 0;
const cap = (num) => {
const n = parseInt(num, 10);
if (avail.includes(n)) return String(n);
const c = nearest(n);
if (c !== n) capped++;
return String(c);
};
let inType = false;
md = md
.split(/\r?\n/)
.map((line) => {
if (/^typography:\s*$/.test(line)) {
inType = true;
return line;
}
if (inType && /^\S/.test(line)) inType = false;
let out = line;
// (a) structured `weight: NNN` in the typography ramp
if (inType) out = out.replace(/(\bweight:\s*)(\d{3})\b/g, (m, pfx, num) => pfx + cap(num));
// (b) a weight baked into a quoted `typography:` component value, e.g.
// cta-button → typography: "Basier Square 600" (NNN not followed by a unit like px)
out = out.replace(
/(typography:\s*"[^"]*?\b)(\d{3})\b(?![a-z%])/gi,
(m, pfx, num) => pfx + cap(num),
);
return out;
})
.join("\n");
if (capped)
summary.push(`fonts: capped ${capped} type weight(s) to brand faces {${avail.join(", ")}}`);
}
// ── brand-adaptation note ─────────────────────────────────────────────────────
// The remix fixes the NORMATIVE frontmatter, but the preset's PROSE still carries its
// original weight ranges / color-names. Prepend a short "frontmatter is truth" header so a
// reader (or frame worker) interprets any lingering preset prose THROUGH the brand values —
// instead of fragile per-sentence prose surgery.
if (brandFonts.length || (brandColors.length && presetColors.length)) {
const bD = brandFonts[0];
const bB = brandFonts[1] ?? brandFonts[0];
const note =
`## Brand adaptation (READ FIRST — the frontmatter is the source of truth)\n\n` +
`This is the **${presetName}** preset remixed onto the captured brand. The YAML frontmatter above ` +
`(colors · typography · components) is **normative and already correct — use it verbatim.** The prose ` +
`below is the ORIGINAL preset's intent; read it THROUGH the frontmatter:\n\n` +
(brandFonts.length
? `- **Fonts** — already set to **${bD}** (display) / **${bB}** (body); ignore any preset font name lingering in prose.\n`
: "") +
(brandFontWeights.length
? `- **Weights** — the brand font ships \`{${brandFontWeights.join(", ")}}\` only; every weight is clamped to these — ignore higher preset weights (e.g. 600/700) in prose.\n`
: "") +
`- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values.\n`;
if (/^# .*$/m.test(md)) md = md.replace(/^# .*$/m, (m) => `${m}\n\n${note}`);
else md = `${note}\n${md}`;
summary.push("brand-adaptation note prepended");
}
// ── stage brand font files + emit @font-face ──────────────────────────────────
// A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
// nothing loads the actual face. If the capture downloaded font files, copy them to
// assets/fonts/ under CLEAN, weight-named names (so captions.mjs' family-prefix matcher
// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
if (brandFonts.length) {
const norm = (s) =>
String(s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase();
const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
const weightInfo = (name) => {
const s = name.toLowerCase();
if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" };
if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" };
if (/bold/.test(s)) return { n: 700, w: "Bold" };
if (/medium/.test(s)) return { n: 500, w: "Medium" };
if (/light|thin/.test(s)) return { n: 300, w: "Light" };
return { n: 400, w: "Regular" };
};
const fams = [...new Set(brandFonts)];
const srcDirs = [
join(hyperframesDir, "capture/assets/fonts"),
join(hyperframesDir, "assets/fonts"),
].filter((d) => existsSync(d));
const files = [];
for (const d of srcDirs)
for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f });
// Single family → all font files belong to it (the common captured case, hash-named files
// included). Multiple families → assign each file to the longest family key its name contains.
const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length);
const famOf = (f) =>
fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x)));
const outDir = join(hyperframesDir, "assets/fonts");
const faces = [];
const stagedNames = new Set();
for (const { d, f } of files) {
const fam = famOf(f);
if (!fam) continue;
const { n, w } = weightInfo(f);
const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}.${extOf(f)}`;
if (stagedNames.has(clean)) continue;
mkdirSync(outDir, { recursive: true });
if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean));
stagedNames.add(clean);
faces.push(
`@font-face{font-family:"${fam}";font-weight:${n};font-style:normal;font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`,
);
}
if (faces.length) {
md +=
`\n\n## Font loading (auto-generated)\n\n` +
`The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` +
`Paste this \`<style>\` into every frame's \`<head>\`/\`<template>\` (captions use the same files) ` +
`so \`font-family\` resolves in preview, snapshot, and render alike:\n\n` +
"```html\n<style>\n" +
faces.join("\n") +
"\n</style>\n```\n";
summary.push(
`fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`,
);
}
}
// ── write frame.md ────────────────────────────────────────────────────────────
const framePath = join(hyperframesDir, "frame.md");
writeFileSync(framePath, md);
@@ -277,9 +519,12 @@ if (outColors.length !== presetColors.length) {
const outRoles = semanticColors(outColors);
const li = lum(outRoles.ink),
lc = lum(outRoles.canvas);
if (li != null && lc != null && li >= lc) {
// ink (type) and canvas (ground) must differ enough to READ — in EITHER direction. A
// light-mode spec has ink darker than canvas; a dark-mode spec (the polarity flip above)
// the reverse. Assert luminance SEPARATION, not a fixed polarity.
if (li != null && lc != null && Math.abs(li - lc) < 40) {
die(
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) is not darker than canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) — bad brand mapping`,
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) and canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) lack contrast — bad brand mapping`,
);
}
@@ -288,4 +533,4 @@ for (const s of summary) console.log(` ${s}`);
console.log(
` .hyperframes/caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`,
);
console.log(` self-check: keys preserved, ink darker than canvas`);
console.log(` self-check: keys preserved, ink/canvas contrast ok`);
+20 -3
View File
@@ -36,7 +36,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
import { dirname, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { captionBand, parseFormat } from "./lib/dimensions.mjs";
import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs";
import { parseColors, parseFonts, semanticColors, lum } from "./lib/tokens.mjs";
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
@@ -276,6 +276,20 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts
// overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that
// balloons the pill, which is worse. Override only if a brand font genuinely clips.
out += "\n<style>\n .caption-line { line-height: 1.1 !important; }\n</style>";
// dark-ground caption contrast (auto). The preset caption skins are tuned for a LIGHT
// pill (cream); on a dark brand ground the pill goes near-black, so the skin's faint
// upcoming-word mix and light highlight block turn unreadable. When the resolved caption
// canvas is dark, override the three word states: a brighter muted upcoming color + an
// on-brand ACCENT highlight block with light text. Light grounds are left untouched.
const capCanvas = (tokens.match(/--cap-canvas:\s*(#[0-9a-fA-F]{6})/) || [])[1];
if (capCanvas && (lum(capCanvas) ?? 255) < 90) {
out +=
"\n<style>\n" +
" .caption-word { color: color-mix(in srgb, var(--cap-ink) 64%, var(--cap-canvas)); }\n" +
" .caption-word.is-active { color: var(--cap-ink); background: var(--cap-accent); box-shadow: 0 0 0 0.06em var(--cap-accent); }\n" +
" .caption-word.is-spoken { color: var(--cap-ink); background: transparent; box-shadow: none; }\n" +
"</style>";
}
return `<template id="captions-template" data-composition-id="captions" data-width="${W}" data-height="${H}">\n${out.trim()}\n</template>\n`;
}
@@ -291,8 +305,11 @@ function brandFontFaces(framePath, hyperframesDir) {
];
if (!families.length) return "";
const dirs = [
{ abs: join(hyperframesDir, "assets/fonts"), rel: "../assets/fonts" },
{ abs: join(hyperframesDir, "capture/assets/fonts"), rel: "../capture/assets/fonts" },
// ROOT-RELATIVE — compositions are served with the project root as their base URL, so a
// "../" prefix escapes the root (lint: invalid_parent_traversal_in_asset_path) and 404s in
// Studio/preview. Mirror what the frame workers use for images.
{ abs: join(hyperframesDir, "assets/fonts"), rel: "assets/fonts" },
{ abs: join(hyperframesDir, "capture/assets/fonts"), rel: "capture/assets/fonts" },
].filter((d) => existsSync(d.abs));
const weightOf = (n) => {
const s = n.toLowerCase();
@@ -1,4 +1,4 @@
// dimensions.mjs — canvas size + caption-band geometry for the product-launch
// dimensions.mjs — canvas size + caption-band geometry for the video
// pipeline. Single source of truth = the STORYBOARD frontmatter `format` global
// ("1920x1080" / "1080x1920" / "1080x1080", or a named orientation). Every
// script and the index assembler reads the size from here; none hardcodes it.
@@ -50,18 +50,38 @@ export const UA_DEFAULT_COLORS = new Set(
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
// Priority:
// 1) with capture colorStats → the colorful color used MOST as an interactive
// background (buttons / pills). That is, by definition, the brand action color.
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
// accent shows up in MANY roles (link text + icon + button + badge), whereas a one-off
// CTA fill appears in just one. So rank chromatic (chroma>40) candidates by role
// diversity first, then total prevalence, then interactive use, then chroma. This keeps
// a pervasive brand color (e.g. an indigo used everywhere) ahead of a single bright
// button fill (e.g. a lime used once) — the old "top interactiveBg" rule picked the
// latter. Requiring interactiveBg>0 is dropped so a text/icon-only accent can still win.
// 2) no stats → most chromatic color AFTER removing UA defaults + `exclude`.
// A stray default link color (e.g. #0000EE) can win under neither path.
export function pickAccent(stats, colors, exclude = []) {
const ban = new Set([...exclude, ...UA_DEFAULT_COLORS].map((c) => String(c).toUpperCase()));
const ok = (h) => /^#[0-9a-fA-F]{6}$/.test(String(h)) && !ban.has(String(h).toUpperCase());
// Prominence rank from the (frequency-ordered) `colors` palette: index 0 = most used.
// A saturated color sitting at the TAIL is almost always a one-off (a single CTA fill),
// not the brand accent — capture colorStats counts are too sparse to tell these apart
// (e.g. Linear's indigo and a lime CTA both register count≈1), but palette ORDER does.
const rank = new Map((colors ?? []).map((h, i) => [String(h).toUpperCase(), i]));
const prom = (h) => (rank.has(String(h).toUpperCase()) ? rank.get(String(h).toUpperCase()) : 1e9);
if (Array.isArray(stats) && stats.length) {
const roles = (s) =>
((s.interactiveBg || 0) > 0 ? 1 : 0) +
((s.textCount || 0) > 0 ? 1 : 0) +
((s.bgCount || 0) > 0 ? 1 : 0);
const a = stats
.filter((s) => ok(s?.hex) && (s.interactiveBg || 0) > 0 && chroma(s.hex) > 40)
.filter((s) => ok(s?.hex) && chroma(s.hex) > 40)
.sort(
(x, y) => (y.interactiveBg || 0) - (x.interactiveBg || 0) || chroma(y.hex) - chroma(x.hex),
(x, y) =>
roles(y) - roles(x) || // used in MORE roles (link+icon+button) = the brand accent
prom(x.hex) - prom(y.hex) || // earlier in the palette = more prominent
(y.count || 0) - (x.count || 0) ||
(y.interactiveBg || 0) - (x.interactiveBg || 0) ||
chroma(y.hex) - chroma(x.hex),
);
if (a.length) return a[0].hex;
}
@@ -77,7 +97,7 @@ export function pickAccent(stats, colors, exclude = []) {
// are unusable, so the caller can fall back. canvas = the color painting the most real
// background area (the page ground, dark or light); ink = the dominant text color that
// actually contrasts with the canvas; accent via pickAccent.
export function brandRolesFromStats(stats) {
export function brandRolesFromStats(stats, colorsInOrder) {
if (!Array.isArray(stats) || !stats.length) return null;
const v = stats.filter((s) => /^#[0-9a-fA-F]{6}$/.test(s?.hex || ""));
if (!v.length) return null;
@@ -87,11 +107,9 @@ export function brandRolesFromStats(stats) {
(b.maxArea || 0) - (a.maxArea || 0) ||
(b.bgCount || 0) - (a.bgCount || 0),
)[0]?.hex;
const accent = pickAccent(
v,
v.map((s) => s.hex),
[canvas],
);
// pass the frequency-ordered palette (tokens.colors) so pickAccent can use palette
// PROMINENCE — colorStats counts alone are too sparse to rank rare accents.
const accent = pickAccent(v, colorsInOrder ?? v.map((s) => s.hex), [canvas]);
if (!canvas || !accent) return null;
const cl = lum(canvas) ?? 0;
const ink =
@@ -170,5 +188,17 @@ export function parseFonts(md) {
roles.title ??
roles.hero ??
body;
return { display: q(display), body: q(body) };
// the monospace / chrome family (code, tags, ticks, page numbers) — so the remix can
// route a captured brand mono (Berkeley Mono, JetBrains Mono…) onto this role instead
// of the reading body. null when the preset has no distinct mono role.
const mono =
roles.mono ??
roles["mono-tag"] ??
roles["mono-chrome"] ??
roles["mono-tick"] ??
roles.code ??
roles.data ??
roles.pagenum ??
null;
return { display: q(display), body: q(body), mono: q(mono) };
}
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// transitions.mjs — inter-frame transition injector + verifier for product-launch.
// transitions.mjs — inter-frame transition injector + verifier for the video workflow.
//
// inject — read STORYBOARD frame order + each frame's transition_in, overlap
// the frame clip wrappers in index.html, and stamp the GSAP template.
@@ -5,25 +5,25 @@
**INPUT** — your dispatch context provides:
- `PROJECT_DIR` — the project root; all paths are relative to it.
- `frame_id` — e.g. `03-mechanism`. Use it **verbatim** as the composition id, the `window.__timelines` key, and the file name (`compositions/frames/03-mechanism.html`) — that path **is** the frame's `src` in `STORYBOARD.md`, so writing there is how the assembler finds your frame.
- `frame_id` — e.g. `03-compounds`. Use it **verbatim** as the composition id, the `window.__timelines` key, and the file name (`compositions/frames/03-compounds.html`) — that path **is** the frame's `src` in `STORYBOARD.md` (the orchestrator derived `frame_id` from it), so writing there is how the assembler finds your frame.
- Your **`## Frame N` block** in `STORYBOARD.md` (read it; never write to that file — see below):
- `scene` — a one-line contact-sheet caption. **Design intent, never visible DOM text.**
- `voiceover` — the narration line. **Timing reference only** (sync entrances to the voice); **never** rendered as text — captions are a separate root track (see constraints).
- `duration` — your render length in seconds. **Fixed upstream; never change it or tween to fill a different length.**
- `transition_in` — informational. The injector stamps it at the root; **you do not author transitions.**
- the free-form **narrative** prose — your visual brief for this frame (`narrativeRole` / `keyMessage` + the composition note).
- `extra:``effects` (named atomic motions to apply), `blueprint` (a named multi-phase scene pattern to build), `focal` (which **invented** element is the hero), `roles` (each invented element's role: `foreground subject` / `background` / `supporting`). (`sfx` also rides in `extra` but is the orchestrator's — you mount no audio.)
- the **time-coded shot sequence** — your build spec. A sequence of Scene lines (`Scene 1 (0.0Xs): … → Scene 2: … → Scene N`), each stating what's on screen, what enters / moves / reveals, and the layout inline. Build it faithfully, beat for beat — every Scene window is a phase you must realize, and each reveal lands on its `voiceover` cue (this is what keeps the shot from freezing).
- `blueprint:` — an id (or the literal `compose`). The id points to `../hyperframes-animation/blueprints/<id>.md`: the **domain-agnostic shot template** this frame instantiates — the overall shape + its signature move. Read it for the shape; `compose` means there's no template, sequence the shot from the Scene lines directly.
- `focal:` — which **invented** element is the hero.
- `roles:` — each invented element's role: `foreground subject` / `background` full-bleed / `supporting`. Because the explainer is **faceless, these are elements you design** (a hero word, a diagram node, a chart series, a coined-term card), not captured assets. The **only** real media is a user-supplied image, when present: `public/<basename> — description` (a **`[video]`** tag marks a `.mp4` clip the user provided).
- `sfx:` — the orchestrator's; you mount no audio.
- `frame.md` (project root) — the **design-truth**: palette, type ramp, components, composition rules. The LOOK. Pull every visual token from here.
- `ANIM_DIR` — absolute path to the shared `hyperframes-animation/` skill. Resolve every cited id under it: `ANIM_DIR/rules/<id>.md` (effect recipe), `ANIM_DIR/blueprints/<id>.md` (blueprint recipe), `ANIM_DIR/examples/<id>.html` (a worked, runnable source for each blueprint).
- `RULES_DIR` — absolute path to this skill's local `../hyperframes-animation/rules/`. The **named motion verbs in the Scene lines** (and the moves the blueprint cites) resolve to rule recipes here: `RULES_DIR/<id>.md` is the mechanics for a motion. (A few rules link an optional runnable demo in the shared `../hyperframes-animation/examples/<id>.html` — open it only when a recipe is unclear.)
- `../references/cut-catalog.md` — the **cut catalog** (zoom-through / inverse / cut-the-curve / waterfall). When a Scene seam is a within-scene swap, a scene-to-scene cut, or a text-to-text line change, build it INSIDE your composition per this catalog (Z-scale + blur + opacity, or per-word x-staggers). You never author the between-frame transition — story's `transition_in` + the injector own that.
- Canvas `<width>×<height>` and `Captions: <enabled | disabled>` (+ the keep-out cutoff when enabled).
**Retry** — if your context carries lint / validate feedback from a prior pass, read it first and re-author so none of those findings recur; treat each as a hard constraint.
**OUTPUT**`compositions/frames/<frame_id>.html`, one self-contained sub-composition. Writing it (past the self-check) is your **terminal action** — you do not edit `STORYBOARD.md`, mint audio, assemble the index, or report back. The orchestrator picks up the file and marks the frame's `status`.
## Faceless — you INVENT the visual
This is a faceless explainer: there are **no captured assets, no screenshots, no product UI**. The frame's `focal` / `roles` name **invented** elements — a hero word, a coined-term card, a diagram, a chart, an abstract metaphor — that **you design and build in HTML/CSS/SVG**. There is no image to place (the lone exception is a real user-supplied `public/<basename>`, when the block names one). Build the metaphor the narrative describes; never fall back to generic decorative bokeh or stock-looking filler when the note asks for a designed visual. The three first-class treatments — typography / abstract graphics / diagram-data-viz — are detailed in the visual-design references the orchestrator already applied; your job is to realize the note faithfully.
**OUTPUT**`compositions/frames/<frame_id>.html`, one self-contained sub-composition. Writing it (past the self-check below) is your **terminal action** — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the file and marks the frame's `status`.
## You do NOT decide
@@ -31,42 +31,44 @@ These belong to other steps — touching them collides with a sibling or breaks
- **What is SAID** — narration is locked in `SCRIPT.md` / the `voiceover` line. You only show; you never write or restate narration text.
- **Duration** — fixed from real voice timing. Build your entrance to land within it; don't stretch or trim it.
- **Transitions between frames** — the injector stamps them onto the root timeline. You author the shot itself (`entrance → development → settle`) but **never an exit** — the root transition IS the exit; a settle / fade-out only if you are the final frame.
- **Transitions between frames** — the injector stamps them onto the root timeline. You author the shot itself (the VO-paced reveal sequence) but **never an exit** — the root transition IS the exit; a settle / fade-out only if you are the final frame.
- **Audio** (narration / BGM / SFX) — assembled at the root by the orchestrator. **No `<audio>` element in your composition.**
- **Design tokens** — palette / fonts / components come from `frame.md`. Don't invent them, and **never lift a word, label, or wordmark out of `frame.md` as your copy** — it is a style spec, not content. Visible text comes from your frame's `scene` / narrative.
- **Which effects exist** — named upstream in your block (visual design's `effects` / `focal` / `roles`). Implement them; don't invent new ones.
- **Design tokens** — palette / fonts / components come from `frame.md`. Don't invent them, and **never lift a word, label, or wordmark out of `frame.md` as your copy** — it is a style spec, not the video's content. On-screen text comes from your frame's `scene` / narrative.
- **Which motions exist** — named upstream in your block (the shot sequence's motion verbs + `blueprint:`). Implement them; don't invent new ones. You design the **invented visuals** the Scene lines describe (typography / abstract graphics / diagrams / data-viz), but you have **no asset-fetch tool** — never fabricate an image URL or reference a file that isn't the user-supplied `public/<basename>`.
- **The shared `STORYBOARD.md`** — read your block, never write it. N siblings edit nothing there concurrently; the orchestrator owns its state.
## Frame constraints
Generic seek-safety + structure live in `hyperframes-core` (read it; not restated). These are the **faceless-explainer deltas**, each load-bearing:
- **Caption keep-out — all content in the top ~83%.** A karaoke caption pill owns the bottom ~17% of the canvas. Keep every element (headline, cards, diagram, labels) above `y ≈ 0.83 × height` — compute the pixel cutoff from your canvas (e.g. `≤ 900` on a 1080-tall frame, `≤ 1600` on a 1920-tall portrait). Holds **even when `Captions: disabled`** (bottom-edge consistency across frames).
- **Fill the content area — especially portrait.** Compose the whole top-83% region; don't float one small cluster mid-frame. Anchor the hero high (~0.20.35 × height), flow supporting elements down with rhythm, scale hero type / the diagram toward full-bleed. (Landscape's region is short, so vertical centering near 0.42 × height is fine.)
- **Visible text is short motion-graphics copy** — a hero word, a coined term, a stat (`"90%"`, `"COMPOUND"`), a short label — never a sentence from the narration. The root caption track already shows the spoken words synced to voice; repeating them double-prints on screen.
- **Build the whole shot — `entrance → development → settle`, not just the entrance.** A frame that animates in over ~0.8s then freezes for the rest of its `duration` reads as a PowerPoint slide. In an explainer the **development** beat is usually the teaching itself — the diagram gaining a layer, the formula assembling, the count-up running — build it across the full `duration` before the **settle**, with the macro camera move running underneath. **Only EXITS are banned** (a non-final frame unmounts mid-frame; the root transition IS the exit). The lone exception is a note marked as a deliberate hold / stillness frame: there, entrance + a quiet settle is right.
- **Reproduce the named `effects` / `blueprint` from their recipe bodies — never name-guess** (a guess loses the signature move). Every id has a real recipe under `ANIM_DIR` (`rules/<id>.md` per effect, `blueprints/<id>.md` per blueprint, `examples/<id>.html` to watch it run). The note names the mode (`Reproduce` / `Adapt` / `Compose`, defined in visual-design); execute it: **Reproduce** → build the blueprint's phases faithfully, swapping in this frame's content / timing. **Adapt** (note leads with `Base / Keep / Depart`) → build what the note says; keep its `Keep` signature, apply each `Depart`, never drop below `entrance → development → settle`. **Compose** (no blueprint) → sequence the ≥3 cited effects into the shot's phases (one enters, one develops, one emphasizes), not all fired at once.
- **Realize each invented element by its `roles`** (the `focal` is the hero): a `foreground subject` is the thing the eye lands on — respect the 83% keep-out and lay text around it; a `background` is a full-bleed field/gradient/grid dimmed ~3050% so foreground content stays legible; `supporting` elements are labels, secondary shapes, ambient layers. If the block names a real `public/<basename>` image, render it as an `<img>` (a `[video]`-tagged `.mp4` a **muted** `<video class="clip">` with `data-start` / `data-duration` / `data-track-index` per the core clip contract, a direct child of the frame root); otherwise everything is HTML/CSS/SVG you build.
- **Caption keep-out — all content in the top ~83%.** A karaoke caption pill owns the bottom ~17% of the canvas. Keep every element (headline, diagram, coined-term card, stats) above `y ≈ 0.83 × height` — compute the pixel cutoff from your canvas (e.g. `≤ 900` on a 1080-tall frame, `≤ 1600` on a 1920-tall portrait). Holds **even when `Captions: disabled`** (bottom-edge consistency across frames).
- **Fill the content area — especially portrait.** Compose the whole top-83% region; don't float one small cluster mid-frame. Anchor the hero high (~0.20.35 × height), flow supporting elements down with rhythm, scale hero type toward full-bleed. (Landscape's region is short, so vertical centering near 0.42 × height is fine.)
- **Visible text is short motion-graphics copy** — a hero word / coined term / stat / one-word emphasis (`"COMPOUNDING"`, `"2×"`), never a sentence from the narration. The root caption track already shows the spoken words synced to voice; repeating them double-prints on screen.
- **Build the whole shot — reveal across the full `duration`, never front-load.** Dumping the whole canvas in the first ~25% then holding it is exactly what reads as a PowerPoint slide. Instead reveal each piece — a line, a layer, a node, a stat — **as the `voiceover` reaches it** (on a silent frame, on the beat), sequencing reveals across the shot and especially the back ~50%, with the macro camera move running underneath. **Only EXITS are banned** a non-final frame unmounts mid-frame, so an exit tween truncates and reads as a glitch (the root transition IS the exit); mid-shot reveals are free and seek-safe. The lone exception is a note marked as a deliberate hold / stillness frame: there, an entrance + a quiet settle is right (a held read beats bad motion).
- **Implement the shot sequence faithfully — every Scene is a timeline phase.** The Scene lines ARE the build: map each Scene onto a phase of the one timeline, each piece revealing as the `voiceover` reaches it. For each **named motion** in a Scene, open its rule recipe under `RULES_DIR/<id>.md` and reproduce its mechanics — **never name-guess** (a guess loses the signature move). The **`blueprint:` template** (`../hyperframes-animation/blueprints/<id>.md`) gives the overall shape; read it and keep its **signature move** recognizable, then instantiate it with this frame's invented content / timing. `compose`no template; sequence the shot straight from the Scene lines. Whichever, never front-load the whole sequence at `t=0` — pace the reveals to the voiceover.
- **Design each element by its `roles`** (the `focal` is the hero): a `foreground subject` is the thing the eye lands on — respect the 83% keep-out, lay text around it, not over it; a `background` is full-bleed and dimmed ~3050% so foreground content stays legible; `supporting` elements (labels, secondary shapes, ambient layers) stay quiet. These are **invented** — you build them in SVG / CSS / type, not from a file. **If the user supplied a real image** named in `roles:`/`focal:`, place it: a `[video]` candidate (`.mp4`) renders as a **muted** `<video class="clip">` (`data-start` / `data-duration` / `data-track-index` per the core clip contract), a **direct child of the frame root** — never nested in another timed element, or the renderer freezes it; an untagged image → `<img>`.
## Workflow
1. **Read**`hyperframes-core`'s composition contract (the structural law), then `frame.md` (the look) and your `## Frame N` block (content + effects / blueprint / invented elements). **Then open the recipe body of every id the block cites** `ANIM_DIR/rules/<id>.md` per effect and `ANIM_DIR/blueprints/<id>.md` for the blueprint (plus its linked `examples/<id>.html` when unclear): you reproduce these, not improvise them. Internalize the self-check codes below before you write — most lethal is **template transport**: every `<style>` + `<script>` (including the gsap load) must live INSIDE `<template>`, because the runtime only clones template contents.
2. **Design** — translate `scene` + the (sometimes shot-by-shot) narrative + the recipes you just read into a visual plan using `frame.md`'s components and type ramp. Honor the note's phases shot-by-shot per the whole-shot rule above, and find a visual idea that reinforces the beat, not a literal restyle of the words. Build the invented hero (diagram / type / metaphor).
3. **Author** — write the full sub-composition to `compositions/frames/<frame_id>.html`. `<template>`-wrapped root carrying `data-composition-id="<frame_id>"`, exactly one `gsap.timeline({ paused: true })` registered at `window.__timelines["<frame_id>"]`, built synchronously — per the core contract.
4. **Self-check, then finish** — run the checklist below and fix in place. Writing the passing file is your terminal action.
1. **Read**`hyperframes-core`'s composition contract (the structural law), then `frame.md` (the look) and your `## Frame N` block (the shot sequence + `blueprint:` / `focal:` / `roles:`). **Then read the blueprint template** `../hyperframes-animation/blueprints/<id>.md` (skip if `compose`) for the shot's shape and signature move, and **open the rule recipe `RULES_DIR/<id>.md` for every named motion** in the Scene lines (plus the shared `../hyperframes-animation/examples/<id>.html` when the recipe is unclear): you reproduce these mechanics, not improvise them. Internalize the self-check codes below before you write — most lethal is **template transport**: every `<style>` + `<script>` (including the gsap load) must live INSIDE `<template>`, because the runtime only clones template contents and `lint` / `validate` / `inspect` can miss the resulting blank sub-composition.
2. **Design** — turn the time-coded shot sequence into a timeline using `frame.md`'s components and type ramp: each Scene window becomes a phase revealed on its `voiceover` cue, each named motion built from the recipe you just read, the blueprint's signature move kept recognizable. Invent the visual elements the Scene lines describe (the diagram, the hero word, the metaphor), and find a visual idea that reinforces the beat, not a literal restyle of the words.
3. **Author** — write the full sub-composition to `compositions/frames/<frame_id>.html` (rewrite to iterate; last write wins). `<template>`-wrapped root carrying `data-composition-id="<frame_id>"` and styled via `#root` (not a class on that element — see the self-check below), exactly one `gsap.timeline({ paused: true })` registered at `window.__timelines["<frame_id>"]`, built synchronously — per the core contract.
4. **Self-check, then finish** — re-read your file against the checklist below and fix in place. Writing the file is your terminal action; you do **not** run the CLI.
## Self-check (fix before finishing)
## Self-check before finishing (you do NOT run the CLI)
The orchestrator runs `lint` / `validate` / `inspect`; catch these yourself first (codes are `hyperframes lint`'s; the rules behind them are in `hyperframes-core`):
You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: they operate on the **assembled project** (the `index.html` graph / bundle), and your frame isn't wired in yet — so they report on _other_ files, not yours (a false green). The **orchestrator** runs them at **Step 6, after assembly** (the correct unit), and **re-dispatches you with the finding** if your frame fails (see **Retry** above). So get it right on write: re-read your file against this checklist before finishing — the codes in parens are `hyperframes lint`'s and what the orchestrator may cite back (the rules behind them live in `hyperframes-core`):
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
- `subcomposition_root_styled_by_class`**style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
- `gsap_css_transform_conflict` — never put a CSS `transform` (e.g. `translateY(-50%)` centering) on an element you then GSAP-animate a transform prop on (`x` / `y` / `scale` / `rotation`): GSAP overwrites the whole `transform` and silently drops the CSS centering (the element jumps). Center with `margin` / `inset` (or `top`/`left` + offset), fold the offset into the tween via `xPercent` / `yPercent`, or use `fromTo` (the rule exempts it).
- **Hero visibility** — the main subject is visible by `t <= 0.5s`; entrance tweens use `fromTo` instead of CSS-hidden starting states.
- `exit_animation_on_non_final_scene` — no exit tween unless you are the final frame.
- **Shot develops (not a slide)** — a non-still frame carries a development beat between entrance and settle; cited effects are sequenced into phases, not all fired at `t=0`.
- **Adapt fidelity** — if the note led with `Base / Keep / Depart`, the `Keep` signature is present and recognizable, every `Depart` is applied, and the shot still runs `entrance → development → settle`.
- **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%.
- **Shot-sequence fidelity** — every Scene in the time-coded sequence is realized as a phase, the blueprint's signature move (unless `compose`) is present and recognizable, and the shot reveals to the voiceover (never front-loaded at `t=0`).
- `font_family_without_font_face` — every font you name has a matching `@font-face` (or `@import`) **inside this file**. **Only use fonts that ship as files** with the project: the families declared in `frame.md` (their `.woff2` live in `assets/fonts/` or `capture/assets/fonts/` — point the `@font-face` `src` at the real file you find there). **Never name a font that has no file**, including system CJK / Japanese / Devanagari families (`Hiragino Sans`, `Yu Gothic`, `Noto Sans CJK`, `Noto Sans Devanagari`, …): the render machine is a clean headless Chrome with none of them installed, so the text silently falls back to a generic font and the typography is wrong in the MP4. For non-Latin or multilingual visible text, either use a shipped font that covers the script, or romanize / transliterate it (e.g. `日本語``Japanese`); if neither is possible it is out of scope for this frame — do not invent a font name.
- **Keep-out + no-narration-text** (eyeball, no code) — nothing sits below the 83% cutoff; no narration sentence is rendered as visible text.
+17 -14
View File
@@ -1,6 +1,6 @@
---
name: pr-to-video
description: "turn a GitHub pull request (a PR URL like github.com/<owner>/<repo>/pull/<N>, an <owner>/<repo>#<N> ref, or 'this PR' in a checked-out repo) into a code-change explainer video, up to ~3 min (sweet spot 30-90s) — changelog, feature reveal, fix, or refactor walkthrough, rendered from the diff / commits / files. The input is a CODE CHANGE read via the gh CLI; there is no website capture. Use this skill for a GitHub PR. Do not use it for a product launch/promo (use /product-launch-video), a tour of a real website (use /website-to-video), a topic explainer with no PR (use /faceless-explainer), captions on existing footage (use /embedded-captions), or a short unnarrated motion graphic (use /motion-graphics). If the intent is unclear, route through /hyperframes first."
description: "turn a GitHub pull request (a PR URL like github.com/<owner>/<repo>/pull/<N>, an <owner>/<repo>#<N> ref, or 'this PR' in a checked-out repo) into a code-change explainer video, up to ~3 min (sweet spot 30-90s) — changelog, feature reveal, fix, or refactor walkthrough, rendered from the diff / commits / files. The input is a CODE CHANGE read via the gh CLI; there is no website capture. Use this skill for a GitHub PR. Do not use it for a product launch/promo (use /product-launch-video), a tour of a real website (use /website-to-video), a topic explainer with no PR (use /faceless-explainer), captions on existing footage (use /embedded-captions), or a short unnarrated motion graphic (use /motion-graphics). If the intent is unclear, route through /hyperframes first. This is the shot-sequence architecture: every frame is authored as a time-coded shot sequence picked from a menu of golden blueprints, so frames develop over their full duration instead of freezing after entrance."
---
> **media-use**: Before sourcing audio/images, call `/media-use` to resolve BGM/SFX/images from the HeyGen catalog. Run `--adopt` first to register existing assets. See `/media-use` skill.
@@ -11,7 +11,7 @@ Use this skill to ingest a GitHub pull request, understand the change, plan a co
> **Confirm the route before Step 0.** You are the orchestrator. Run each step, verify its gate, and only then continue. This skill is for a **GitHub pull request** (a code change). Route other intents elsewhere: a product launch/promo → `/product-launch-video`; a general website tour → `/website-to-video`; a topic explainer with no PR → `/faceless-explainer`; captions on existing footage → `/embedded-captions`; a short unnarrated motion graphic → `/motion-graphics`; a whole-repo or multi-PR release walkthrough → `/general-video`. **Out of scope:** live / at-render-time data — PR facts are read once at author time and baked in. If the user says only "make a video" or the route is uncertain, read `/hyperframes` first.
You are the orchestrator. Work in `videos/<project>/`. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, `hyperframes-creative`, and `hyperframes-animation`.
You are the orchestrator. Work in `videos/<project>/`. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, this skill's local `../hyperframes-animation/rules/` + `../hyperframes-animation/blueprints/`, and `hyperframes-creative`.
Workflow: Step 0 setup → `hyperframes.json`; Step 1 ingest → `capture/extracted/` + `assets/<login>.png`; Step 2 design system → `frame.md`; Step 3 storyboard/script → `STORYBOARD.md` and `SCRIPT.md`; Step 3.1 audio → `audio_meta.json`; Step 4 visual design → enriched `STORYBOARD.md`; Step 5 frames → `compositions/frames/NN-*.html` and `index.html`; Step 6 final render → `renders/video.mp4`.
@@ -99,9 +99,9 @@ The script copies the claude preset's `FRAME.md` → `frame.md`, remixes it onto
Goal: Turn the PR into an approved frame-by-frame explanation plan.
Read `references/story-design.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`.
Read `references/story-design.md`, `../hyperframes-animation/blueprints-index.md`, `../hyperframes-core/references/storyboard-format.md`, and `../hyperframes-core/references/script-format.md`. Use them to write `STORYBOARD.md` and, when narration is needed, `SCRIPT.md`.
Use `story-design.md` for the PR archetype (changelog / feature-reveal / fix-explainer / refactor-walkthrough), the PR-native frame types, hook, persuasion, beats, the per-frame word budget, and the optional credits close. The sequence comes from **narrative design, not the diff's file order** — explain the change, don't read the diff aloud. Feature 24 real diff hunks (from `capture/diff.patch`), each a small legible snippet; name the `code-*` block each wants in the frame's `scene`. Frames carry no `asset_candidates` except an optional `credits` close (26 `assets/<login>.png` avatars). Use the exact required fields from the storyboard and script references.
Use `story-design.md` for the PR archetype (changelog / feature-reveal / fix-explainer / refactor-walkthrough), the PR-native frame types, hook, persuasion, beats, the per-frame word budget, and the optional credits close. The sequence comes from **narrative design, not the diff's file order** — explain the change, don't read the diff aloud. As a **soft guide**, consult the role→blueprint menu in `../hyperframes-animation/blueprints-index.md`: for each beat, write the voiceover in the shape its candidate blueprint implies and tag that candidate `blueprint:` id when one fits (story truth still decides which beats exist — never force a beat to fit a shape). Feature 24 real diff hunks (from `capture/diff.patch`), each a small legible snippet; name the `code-*` block each wants in the frame's `scene`. Frames carry no `asset_candidates` except an optional `credits` close (26 `assets/<login>.png` avatars). Use the exact required fields from the storyboard and script references.
After drafting, show a frame-by-frame summary. In that same message ask the user (a) to approve or request changes, and (b) whether they want a live preview of the storyboard scaffold (`npx hyperframes preview`) — open it only on a yes. Iterate until approved; carry the preview choice to Step 6.
@@ -129,15 +129,15 @@ If there is no narration and no `SCRIPT.md`, skip voice generation. BGM may stil
Goal: Add the visual direction, layout intent, and motion choices to each storyboard frame.
Edit `STORYBOARD.md` in place. Do not create another storyboard. Use `frame.md` as the source of truth for color, type, layout feel, and style.
Edit `STORYBOARD.md` in place. Do not create another storyboard. Use `frame.md` as source of truth for color, type, layout feel, and style.
Read `references/visual-design.md`, `references/composition.md`, `references/motion-language.md`, `references/code-vocabulary.md`, and `../hyperframes-animation/`. Use `visual-design.md` for required frame fields and the required `## Video direction` block, and for how a code beat names a `code-*` block as its `focal`. Use `code-vocabulary.md` to pick the right block per beat (diff = `code-diff`, refactor = `code-morph`, new code = `code-typing`, …). Use `composition.md` for layout/hierarchy/focal points and `motion-language.md` + `../hyperframes-animation/` for valid effect and blueprint IDs. Do not invent effect names or block/blueprint IDs.
Read `references/visual-design.md`, `../hyperframes-animation/blueprints-index.md`, `references/motion-language.md`, `references/code-vocabulary.md`, and `../hyperframes-animation/rules-index.md`. Use `visual-design.md` for the method (the time-coded shot sequence, the inline Layout vocabulary, and the code-beat treatment), plus the required `## Video direction` block. Use `../hyperframes-animation/blueprints-index.md` to pick each frame's shot shape. Use `code-vocabulary.md` to pick the right `code-*` block per code beat (diff = `code-diff`, refactor = `code-morph`, new code = `code-typing`, …). Use `motion-language.md` (the motion vocabulary + the motion doctrine) and `../hyperframes-animation/rules-index.md` (valid rule names) for motion — do not invent motion or block/blueprint names.
For every frame, add required visual and motion fields, including `effects` and `focal` and/or `roles`. For a code beat, name the `code-*` block as the `focal` and let `effects` choreograph the surrounding claude Code Surface (not the code animation, which the block owns). Add one video-wide `## Video direction` block.
For every frame, write a **time-coded shot sequence** into `STORYBOARD.md` per `visual-design.md`'s method: pick the frame's blueprint (or compose), instantiate it with THIS frame's content, and pace each Scene's reveal to the voiceover so the frame develops across its full duration instead of front-loading then freezing. **For a code beat, the `code-*` block is the frame's `focal`** and the Scenes choreograph the surrounding claude Code Surface (the entry of the file/header, the camera onto the hunk, the landing line) — **not** the code animation itself, which the block owns. State layout and motion **inline** per Scene (vocabularies in `visual-design.md` and `motion-language.md`). Add one video-wide `## Video direction` block.
Do not change story, script, `transition_in`, `asset_candidates`, or the PR source. Do not write HTML in this step. There is **no asset-staging step** — the only real assets are the credits avatars, already in `assets/`.
**Gate:** every frame has `effects` plus `focal` and/or `roles`; code frames name a `code-*` block; `## Video direction` exists.
**Gate:** every frame has a time-coded shot sequence whose reveals are paced to the voiceover (no front-loading); code frames name a `code-*` block as the `focal`; `## Video direction` exists.
---
@@ -157,7 +157,7 @@ Duration sync is mechanical: real voice duration wins; silent frames keep estima
`for b in <each registry block named in the storyboard>; do npx hyperframes add "$b"; done`
Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame. Each worker's context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, `ANIM_DIR` (absolute path to `../hyperframes-animation/`), and the absolute path to `references/code-vocabulary.md`. Each worker reads `frame.md`, its own `## Frame N` block, the recipe body for each cited effect/blueprint ID, and — for a code beat — `code-vocabulary.md` for the named block's inputs. Each worker writes only `compositions/frames/NN-*.html`; workers never edit `STORYBOARD.md`.
Before dispatch, read `sub-agents/frame-worker.md` and `../hyperframes-core/references/subagent-dispatch.md`. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame. Each worker's context must include `PROJECT_DIR`, `frame_id`, canvas size, caption status and keep-out band if captions are enabled, `RULES_DIR` (absolute path to this skill's `../hyperframes-animation/rules/`), and the absolute path to `references/code-vocabulary.md`. Each worker reads `frame.md`, its own `## Frame N` block from `STORYBOARD.md`, the local rule recipe (`../hyperframes-animation/rules/<id>.md`) for each cited motion, the frame's blueprint template (`../hyperframes-animation/blueprints/<id>.md`), and — for a code beat — `code-vocabulary.md` for the named block's inputs. Each worker writes only `compositions/frames/NN-*.html`; workers never edit `STORYBOARD.md`.
As each worker returns, mark that frame `animated` in `STORYBOARD.md`.
@@ -219,17 +219,20 @@ Do not rerun `lint`, `validate`, `inspect`, or `snapshot` after rendering unless
**Background scripts:** the workflow ships these under `scripts/`: `fetch-pr` (PR → `capture/pr.json` + `diff.patch` via `gh`; large-PR-safe, no scratch), `ingest` (→ synthetic capture package; offline), and `fetch-people-avatars` (contributor avatars → `assets/`); plus the shared engine — `build-frame` (adopt + brand-remix a preset into `frame.md` + caption skin), `audio` (TTS, BGM, SFX, duration sync), `captions`, `transitions` (inject + verify), and `assemble-index`. Everything else is the `hyperframes` CLI. Code blocks install via `npx hyperframes add <name>`.
The reusable, domain-agnostic shot shapes live in `../hyperframes-animation/blueprints/` (indexed by `../hyperframes-animation/blueprints-index.md`); the `code-*` registry blocks are the code-beat vocabulary (`references/code-vocabulary.md`).
| Read | When |
| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `[references/story-design.md](references/story-design.md)` | Step 3: plan the PR explanation. |
| `[../hyperframes-animation/blueprints-index.md](../hyperframes-animation/blueprints-index.md)` | Step 3: role→blueprint menu. Step 4: pick the shot shape. |
| `[../hyperframes-core/references/storyboard-format.md](../hyperframes-core/references/storyboard-format.md)` | Step 3: write `STORYBOARD.md`. |
| `[../hyperframes-core/references/script-format.md](../hyperframes-core/references/script-format.md)` | Step 3: write `SCRIPT.md`. |
| `[../hyperframes-media/references/tts.md](../hyperframes-media/references/tts.md)` | Step 3.1: choose or understand TTS providers. |
| `[references/visual-design.md](references/visual-design.md)` | Step 4: enrich the storyboard visually. |
| `[references/visual-design.md](references/visual-design.md)` | Step 4: write the frame's shot sequence (+ Layout vocabulary). |
| `[references/code-vocabulary.md](references/code-vocabulary.md)` | Step 4 + 5: pick + fill the `code-*` block for a code beat. |
| `[references/composition.md](references/composition.md)` | Step 4: judge composition. |
| `[references/motion-language.md](references/motion-language.md)` | Step 4: judge motion language. |
| `[../hyperframes-animation/](../hyperframes-animation/)` | Step 4: cite effect and blueprint IDs. |
| `[references/motion-language.md](references/motion-language.md)` | Step 4: the motion vocabulary + the motion doctrine. |
| `[references/cut-catalog.md](references/cut-catalog.md)` | Step 4-5: the cut catalog (worker builds within-frame seams). |
| `[../hyperframes-animation/rules-index.md](../hyperframes-animation/rules-index.md)` + `[../hyperframes-animation/rules/](../hyperframes-animation/rules/)` | Step 5: local rule recipe bodies for the cited motions. |
| `[sub-agents/frame-worker.md](sub-agents/frame-worker.md)` | Step 5: dispatch per-frame workers. |
| `[../hyperframes-core/references/subagent-dispatch.md](../hyperframes-core/references/subagent-dispatch.md)` | Step 5: dispatch sub-agents safely. |
| `[../hyperframes-creative/frame-presets/claude/FRAME.md](../hyperframes-creative/frame-presets/claude/FRAME.md)` | Step 2: the claude preset (fixed style). |
@@ -1,123 +0,0 @@
# Composition — PR-to-video visual-design judgment
> The composition-judgment layer for **Step 4 (Visual design)**. You read it while enriching `STORYBOARD.md` frames: which layout, how much frame the hero fills, how many depth layers — **director decisions**. Concrete px (safe margins 96-150), scale (1.05 / 0.92), three-layer `box-shadow`, `perspective` values are the **frame worker's** job; you name the intent in the frame's composition note. Video composition is closer to film / poster design than webpage layout — no scrolling, no reflow; every frame is a fixed canvas, every pixel matters. Default canvas **1920×1080**; portrait `1080×1920` / square `1080×1080` per the storyboard `format`.
## Squint test
Squint (or blur the frame). Can you still pick out the most important element, the second, and clear spatial groups? If everything has equal weight after blur, hierarchy is broken — redesign before writing the note. The strongest frames pass this: one dominant block + one supporting structural element, everything else demoted.
## Canvas zones (conceptual)
```
+--------------------------------------------------+
| Optional top chrome |
| +----------------------------------------------+ |
| | Safe margin | |
| | +----------------------------------------+ | |
| | | Primary content area | | |
| | | (center 65-75% of frame) | | |
| | +----------------------------------------+ | |
| | | Caption band (bottom ~17%, HARD w/ captions) | |
| | +----------------------------------------+ | |
| +----------------------------------------------+ |
+--------------------------------------------------+
```
- **Top chrome** — rarely needed in a faceless explainer; skip unless a frame intentionally mocks an interface.
- **Safe margin** — key content stays off the edges; hero / editorial frames need more air.
- **Primary content area** — the center 65-75% is where the eye rests; body text never presses the edge.
- **Caption band (bottom ~17%, HARD-reserved when captions are on)** — when the film has captions enabled (the frame's `Captions:` flag), the bottom ~17% of **canvas height** is reserved (landscape 1080h → bottom 180px, y 900-1080; portrait 1920h → bottom 320px, y 1600-1920): primary content and key visuals **cap at the band top**, and a centered hero anchors at **y ≈ 0.42 × height** (landscape ≈454, portrait ≈806), not the canvas midpoint. Background / ambient / surface layers are exempt and may stay full-bleed. Captions disabled → keep the zone clear anyway for bottom-edge consistency across frames.
You write "hero word centered with generous safe margins"; you do not write `padding: 150px 120px 92px`.
## Portrait & square (non-16:9 canvases)
The zones, density, hierarchy, and depth principles all still apply; the **aspect ratio** changes, and a wide-frame layout does not transplant into a tall one. Design for the storyboard's `format` from the start — never plan landscape and "crop."
- **Stack vertically, not side-by-side.** Portrait has little horizontal room: split-screen / triptych / 60-40 asymmetry become **top/bottom stacks**, vertical step lists, stacked bands. Square tolerates side-by-side only for two compact items.
- **Vertical center moves with the canvas** — anchor a centered hero around **y ≈ 0.42 × height** (portrait ≈806, square ≈454), not a fixed 540.
- **Type runs larger, fewer words per line** — narrow frames wrap long headlines badly; prefer short kinetic lines, bigger type, more vertical rhythm.
- **Travels well to portrait:** Centered, Layered Depth, Full-Width Strip (stacked band), vertical Rule-of-Thirds. **Avoid** wide Split Screen and Triptych — use stacked equivalents.
- **Density still rules** — primary visual ≥ 40% of canvas, ≥ 3 depth layers, measured against the tall frame; an empty top or bottom third reads as placeholder.
## 7 composition templates
Use ≥3 different templates per video (5 frames → 3+, 9 frames → 4+). **Don't default every frame to centered**; never use the same layout class twice in a row.
1. **Centered (hero / climax)** — one dominant element, generous breathing room. Concept name, key takeaway, the hero word, the closing principle.
2. **Rule of thirds** — anchor on a thirds intersection; remaining space carries support or negative space. A mechanism step + its label.
3. **Split screen (comparison / dual focus)** — left/right halves carry separate elements. Before/after, common-belief vs reality, two options.
4. **Layered depth (immersive)** — foreground / midground / background differ in scale + opacity. Opening hooks, atmosphere, the "imagine…" scenario.
5. **Asymmetric (editorial)** — primary content pushed to one side (60/40, 70/30); intentional imbalance → tension + sophistication. A dense diagram with a caption rail.
6. **Triptych (three-panel)** — three equal zones for three items / beats at once. The rule-of-three landing.
7. **Full-width strip** — one horizontal band (a number line, a timeline, an enumeration), usually ~20% of canvas height.
## Frame density — avoid empty frames
Common failure: small elements floating in the center with empty space around them. Every frame must feel **intentionally filled**.
- **Primary visual occupies ≥ 40% of canvas** — hero text 50-75% height × 60-80% width; a centered card 30-50% × 50-70%; a diagram big enough to read its labels.
- **≥ 3 visual layers** — background (gradient / particles / grid) + midground (main content) + foreground (emphasis / decoration).
- **Openings and closings** are prone to emptiness — a bare background + a lonely line of text reads as placeholder. Add environmental layers: dual-radial swell, floating particles, brand-color ambient texture, low-opacity scanlines or a hairline grid.
- **Text-only frames still need visual elements** — a coined-term card, an icon, a halftone field, brand-derived geometry, an underline that draws on.
**Fullness test:** could this frame stand as a poster or social graphic? If it looks like a sparse slide → add layers.
## Negative space
Whitespace directs attention, it isn't waste. Tight grouping (icon + label) → small spacing; unrelated groups → large separation; asymmetric outer margins feel more designed than equal padding; a hero word keeps large side whitespace so one word carries the weight. **Failure modes:** everything equidistant (no grouping); unintended overlap; text tight against an edge; captions colliding with bottom visuals; the framework's default padding everywhere.
## In-frame visual hierarchy
Visual weight, strong → weak: **large element** **motion** (moving beats static) **high contrast** **type scale** **position** (center + upper third are golden). Combine **at least two** — an element that is large, moving, and upper-third is unquestionably primary.
A title that is only _larger_ (sharing weight/color/spacing with body) reads weak. Stack dimensions:
| Dimension | Strong contrast |
| --------- | ------------------------------------------- |
| Size | 3:1 ratio or larger |
| Weight | 800-900 vs 400 |
| Color | high contrast against background |
| Motion | one element moving vs all else static |
| Position | top / left = primary |
| Space | large surrounding whitespace vs equidistant |
## Cards and grouping
Spacing + alignment can group without a card container. **Use cards** when content is genuinely distinct (a list item, a definition, a stat callout) or when shadow-stacking communicates "lifted." **Don't** card for mere separation (use whitespace) or for a continuous diagram. **Never nest cards** — claustrophobic, muddy hierarchy.
## Inventing the visual — diagrams, type, data-viz
This is a **faceless** explainer: the frame's hero is something you design, not a screenshot. The three first-class treatments:
- **Typographic / kinetic type** — the hero word, the coined term, a number, a short enumeration. Treat type as the subject: full-bleed scale, weight contrast, one emphasized term. Strongest for hooks, concept names, takeaways.
- **Abstract graphics** — shapes, fields, paths, geometry that _embody_ the idea (the snowball, the spotlight, the staircase-not-cliff). Build the metaphor the script names; don't decorate with generic bokeh.
- **Diagram / data-viz** — nodes + edges, a chart, a number line, a formula, a process flow. The build (each part appearing on beat) is the teaching — design it to assemble, not appear whole.
Make the invented hero **fill 40-60% of the frame** — a diagram big enough to read, a hero word near full-bleed. Don't shrink the one designed element into decoration around empty space.
## Depth on a 2D canvas
Layer **2-3 depth techniques** per frame to avoid a flat poster (concrete perspective / rotate / scale values are the worker's):
| Technique | Effect |
| ---------------- | ------------------------------------------------------------------------------------------ |
| Size difference | larger = nearer, smaller = farther |
| Blur | blurred = background, sharp = foreground |
| Opacity gradient | low = receding, full = primary |
| Overlap | foreground partially covers background |
| Shadow stacking | three-layer shadow = lift + brand feel |
| Motion speed | faster parallax = closer |
| Counter-scale | camera pushes toward focus → background appears larger, focal CSS scale <1 but fills frame |
You write "3 depth layers: background swell + midground diagram + foreground label glow; background counter-scales for the push"; the worker writes the scale values.
## What should not appear
Nav bars, footers, cookie banners, scrollbars, cursor arrows, browser chrome, unclickable buttons, generic decorative shapes standing in for a designed metaphor, floating bokeh / purple-to-blue AI gradients (the "default AI cliché," banned). Faceless explainers have no real interface to show — an interface mock is correct **only** when the topic itself is about that interface and the frame intentionally reconstructs it.
## Composition note example
> "Composition: asymmetric 60/40 — the node-graph diagram occupies left 60%, the layer label + caption right 40%. Generous safe margin; text capped inside the primary content area. 3 depth layers: background hairline grid + midground graph + foreground active-node glow. Density: primary visual ~55%, ambient adds a 5% scanline."
One line per frame; never concrete px / scale / shadow recipes (the worker writes those).
@@ -0,0 +1,215 @@
# Cut catalog — within-frame seams (worker-built)
> **A worker build-recipe (Step 5) — the sibling of `../hyperframes-animation/rules/`, not a second motion doc.** These are within-frame cuts the **frame worker builds INSIDE its own composition** (Z-scale + blur + opacity tweens, or per-word x-staggers, all on the frame's own paused GSAP timeline). They are **not** the between-frame transition: story owns that via `transition_in`, which the harness's injector stamps from a **separate registry vocabulary** (`crossfade` / `blur-crossfade` / `push-slide` / `zoom-through` / `squeeze`) — the catalog names here (**cut-the-curve / inverse-zoom / waterfall**) are **not** valid `transition_in` values. Use this catalog when a frame's shot sequence has an internal seam — a within-scene text/element swap, a **Scene-to-Scene** cut (a `Scene` is a time window WITHIN one frame, **not** a frame-to-frame boundary), or a text-to-text line change — and you want it to read as one continuous move instead of a hard slideshow cut. (`zoom-through` lives in both worlds: a whole-frame wrapper transition in the registry, an element-level Z-cut here — same idea, different scope.)
Four techniques that create depth and continuity:
1. **Zoom-Through** — within-scene text swaps, Z-axis, moving TOWARD the viewer
2. **Inverse Zoom-Through** — Z-axis swaps moving AWAY from the viewer
3. **Cut the Curve** — between-scene transitions on x/y
4. **Waterfall Cut** — word-by-word cut-the-curve with staggered exits and entries
All four are the same underlying principle: **cut at peak velocity, match direction and
speed on both sides of the cut.** The differences are axis, scope, and granularity.
**Choosing which at a seam:** for an UNFINISHED phrase (building one larger idea across
several visually distinct scenes that still approach the same point — multi-line text, a
run of consecutive cards) use **cut-the-curve** / **waterfall**. For a STATE CHANGE (turning
to a NEW part of the video — most often hook → context, between two distinct chapters) use
**zoom-through**, and **inverse zoom-through** for an arrival / payoff beat. Chain these so
the frame's internal seams feel like one camera moving through the content.
---
## Blur Logic (applies to all Z-axis variants)
Blur sells the speed at the cut, but it must scale with the SUBJECT SIZE:
| Subject | Peak blur | Why |
| ------------------------------------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Text-scale (headline, line, word group) | **10px** | At 20px text smears into illegibility — the eye loses the word it was tracking and the cut reads as a glitch, not speed. At 20px letterforms go mushy mid-cut; 10px keeps them readable. |
| Full-frame surface (terminal window, card, screenshot) | **1820px** | Big surfaces have edges and texture that survive heavy blur; lighter blur on a full-frame move reads as a rendering hiccup instead of motion. |
Both sides of a cut use the SAME peak blur — the value must match at the swap frame.
Apply blur to the WRAPPER, never to individual children.
---
## 1. Zoom-Through (forward)
### The Problem
Text enters, holds, exits. Then next text enters, holds, exits. Each text block is
independent — no depth, no continuity. The video feels like a slideshow.
### The Principle
A velocity-matched cut on the Z-axis. You **never see both texts at the same time.** The
outgoing text scales toward the viewer (accelerating), blur and opacity peak at the cut
point hiding a hard swap, and the incoming text continues scaling up from behind
(decelerating into the focal plane). One continuous forward motion, two different texts.
### The Three Phases
**Phase 1: Exit** — text accelerates forward (toward viewer)
- Scale: `1.0 -> 1.2`, Blur: `0px -> 10px` (text-scale; see Blur Logic), Opacity: `1.0 -> 0.15`
- Scale/blur easing: `power3.in` (steep acceleration)
- Opacity easing: `none` (linear — even dimming, separated from scale)
- Duration: 0.2s
**Phase 2: Hard cut** at peak velocity + peak blur
- Outgoing: `opacity: 0` (instant via `tl.set`)
- Incoming: `opacity: 0.15, scale: 0.75, blur: 10px` (instant via `tl.set`)
- All properties match at the cut: blur, opacity, and scale DIRECTION (both scaling up)
**Phase 3: Entry** — text continues forward (growing into focal plane)
- Scale: `0.75 -> 1.0`, Blur: `10px -> 0px`, Opacity: `0.15 -> 1.0`
- Easing: `expo.out` (steep initial burst matching exit velocity, long settle)
- Duration: 0.5s
### Why Opacity Must Be Separate on Exit
Scale uses `power3.in` but that keeps opacity near 1.0 for most of the tween. Splitting
opacity to its own tween with linear ease makes the dimming even. On entry, all properties
can share `expo.out`.
---
## 2. Inverse Zoom-Through (backward)
The mirror: the camera "pulls back" instead of pushing through. The outgoing element
RECEDES away from the viewer; the incoming element arrives OVERSIZED (as if it had been
just behind the camera) and retracts into the focal plane. Both move in the shrinking
direction — same-direction rule preserved, just reversed.
**When to use over the forward variant:** arrival beats. The incoming element lands with
presence because it comes from larger-than-frame — right for a payoff line ("That changes
today."), a giant reply, or a held end-state. Forward zoom-through reads as _progressing
through_ content; inverse reads as _arriving at_ content.
### The Three Phases
**Phase 1: Exit** — element recedes (away from viewer)
- Scale: `1.0 -> 0.8`, Blur: `0px -> 10px` (text-scale)
- Scale/blur easing: `power3.in`; Opacity: `1.0 -> 0.15` on `none` (separate tween)
- Duration: 0.2s
**Phase 2: Hard cut**
- Outgoing: `opacity: 0` via `tl.set`
- Incoming: `opacity: 0.15, scale: 1.25, blur: 10px` via `tl.set`
**Phase 3: Entry** — incoming retracts into place
- Scale: `1.25 -> 1.0`, Blur: `10px -> 0px`, Opacity: `0.15 -> 1.0`
- Easing: `expo.out`, Duration: 0.5s
---
## 3. Cut the Curve (Scene Transitions)
### The Principle
Use cut-the-curve for **all scene-to-scene transitions** on x and y axes. The outgoing
scene's hero element accelerates in one direction, the cut lands mid-motion, and the
incoming scene's hero element continues moving in the **same direction** and decelerates.
Nothing exits fully off-screen and nothing enters from fully off-screen — **speed plus
opacity fading trick the eye**; the partial moves are enough.
### Same Path, Same Direction
If Scene A's hero slides left, Scene B's hero enters from the right and continues sliding
left. Both move leftward. One continuous motion.
| Direction | Scene A exit | Scene B entry start | Scene B entry end |
| --------- | -------------- | ------------------- | ----------------- |
| Leftward | `x: 0 -> -230` | `x: +230` | `x: 0` |
| Rightward | `x: 0 -> +230` | `x: -230` | `x: 0` |
| Upward | `y: 0 -> -230` | `y: +230` | `y: 0` |
| Downward | `y: 0 -> +230` | `y: -230` | `y: 0` |
### Velocity matching via mirrored eases
The cleanest match: exit `power4.in` and entry `power4.out` with the SAME distance and
duration — mathematically the two halves of one `power4.inOut` composite, so the entering
element picks up at exactly the 50% point of the notional path at identical velocity
(e.g. 230px / 0.3s ≈ 3,070 px/s at the cut on both sides).
The fade trick: the exit's opacity completes at ~2530% of its travel (fade duration
≈ 0.180.3s vs motion 0.30.34s) — the element vanishes while still visibly accelerating,
and nothing has to reach the frame edge. Entry fades IN fast from ~0.35 under its
deceleration. Time the LAST fading element to die right at the hard cut — gaps where
nothing is moving read as awkward dead air.
### Rules
- Use cut-the-curve for all scene transitions — it's the default, not an accent
- Same direction on both sides; mirrored `.in`/`.out` eases, same distance + duration
- Exit duration short (0.20.4s), entry duration >= exit duration
- Partial travel + fade, never full off-screen moves
---
## 4. Waterfall Cut (word-by-word cut-the-curve)
Cut-the-curve at WORD granularity — the strongest version of the leftward cut for
text-to-text seams. Each word of the outgoing line ramps out on its own pronounced curve;
each word of the incoming line cascades in mid-flight. The stagger turns the cut into a
wave the eye rides across the seam.
### Exit (per word)
- Motion: `x: 0 -> -230` over 0.34s on **power4.in** — a much more pronounced ramp than
the usual power2: the word barely creeps, then RIPS
- Fade: `opacity -> 0` over 0.18s (separate tween, `power1.in`) — completes when the word
is only ~2530% into its travel
- Stagger: reading order, ~0.022s per word, timed so the LAST word finishes fading right
at the hard cut
### Entry (per word)
- `fromTo x: +230 -> 0, opacity: 0.35 -> 1` over 0.3s on **power4.out** — the mirrored
back half of the composite; every word ignites already moving at matched velocity
- Waterfall stagger with SHRINKING gaps (start 0.05s, multiply by ~0.84 per word) so the
cascade accelerates across the line — the cascade should speed up word over word, not run
at a flat per-word delay
- Pre-set all words to `x: +230, opacity: 0` at build time — `immediateRender: false`
alone leaves un-started words sitting visible at rest during the stagger window
### Whole-line variant
A single-line beat (e.g. a big intro line) exits as one group with the same pronounced
ramp, but stretch its fade to ~0.3s ending ~0.02s before the cut — a lone element that
fades early leaves dead air that a word cascade would have covered.
---
## Choosing a Variant
| | Zoom-Through | Inverse Zoom | Cut the Curve | Waterfall Cut |
| -------------- | --------------------------- | --------------------------- | ----------------- | ------------------------- |
| Scope | Within-scene text swap | Arrival/payoff beat | Between scenes | Text-to-text seam |
| Axis | Z, toward viewer | Z, away from viewer | X / Y | X, per-word |
| Peak blur | 10px text / 20px full-frame | 10px text / 20px full-frame | none required | none (fade does the work) |
| Opacity at cut | 0.15 | 0.15 | exit faded by cut | last word dies at cut |
| Feel | progressing through | arriving at | carried sideways | a wave across the seam |
---
## Anti-Patterns
| Don't | Why | Instead |
| ---------------------------------------- | ------------------------------------------- | ------------------------------------------------------ |
| Two texts visible during a zoom-through | Overlapping text breaks the Z-axis illusion | Hard cut at blur peak, one text at a time |
| 20px blur on text-scale subjects | Letterforms smear; reads as a glitch | 10px for text, 1820px only full-frame |
| Elements on different paths across a cut | Eye tracks one direction, cut goes another | Same property, same direction |
| Mismatched blur/opacity at the swap | Visible flash or brightness jump | Identical values at the cut frame |
| Gentle easing on entry (`power2.out`) | Entry velocity feels slower than exit | Mirror the exit: `power4.out` / `expo.out` |
| Full off-screen exits / entries | Wastes time and breaks the speed illusion | Partial travel + early fade |
| Lone element fading long before its cut | Dead air at the seam | Fade ends ~0.02s before the cut, or use a word cascade |
| Zoom-through on body text | Small text at 0.75 scale is unreadable | Only headlines and short phrases |
| Scene cuts without cut-the-curve | Static cuts feel like a slideshow | Cut-the-curve is the default |
+105 -90
View File
@@ -1,141 +1,156 @@
# Motion language — PR-to-video visual-design judgment
# Motion language — the move vocabulary + the motion doctrine + the seek-safe core
> The motion-judgment layer for **Step 4 (Visual design)**. You name **each shot's choreography, spring intent, beat rhythm, holds, stillness, and the idle-motion budget** while enriching `STORYBOARD.md` frames; the **frame worker** maps intent to concrete GSAP eases / ms / stagger / code (via `hyperframes-animation`). A good explainer feels like one continuous whole — one camera, one spring feel, **every shot directed across its full length** — not a pile of slides that animate once and freeze. You reference motion by **role**, never by curve: eases / durations resolve from `frame.md`'s motion tokens, named `entry` / `emphasis` / `exit` / `drift` (the pack's exact keys may differ); the worker maps the curve. Between-frame **transitions are not yours** story names `transition_in`, the harness injects it.
> The motion layer for **Step 4 (Visual design)**. When you write a frame's **time-coded shot sequence**, you name each scene's move **inline from the vocabulary below** — a named palette of the moves the golden corpus actually uses. Each move carries the **backing rule id** in this skill's local `../hyperframes-animation/rules/`; cite that id so the move resolves to a real recipe when a **frame worker** implements it in Step 5 (the worker reads the rule body in `../hyperframes-animation/rules/<id>.md` — it reproduces the move, it does not guess from the name). You name motion by **role / move name**, never by raw GSAP curve, ms, or stagger formula — the worker maps the curve. Between-frame **transitions are not yours**: story names `transition_in`, the harness injects it; that injected transition **is** the frame's exit. For cuts a worker builds INSIDE a frame (within-scene swaps, scene-to-scene seams), see the catalog in `cut-catalog.md`.
## A frame is a shot, not a slide
A good code-change explainer feels like one continuous film — one camera, one motion feel, **smooth and timed to the voiceover** — not a pile of slides that animate once and freeze. In a PR explainer the development often _is_ the reveal: the diff hunk typing in, the before→after morph, the impact stat landing. The doctrine in Part 2 is load-bearing: when in doubt, do what it says.
The single failure that makes an explainer read as PowerPoint: a frame whose content **animates in over the first ~0.8s, then freezes** for the rest of its duration while a slow drift plays underneath. The entrance is not the shot — it's the **first beat** of it. You direct the **whole duration**.
---
In explainers especially, the development beat _is_ the teaching: the formula assembling term by term, the diagram gaining a layer, the count-up landing. Don't waste it on a frozen hold.
# Part 1 — the move vocabulary
Three layers fill a shot, each governed by a different rule:
Reach into this palette when naming a scene's motion. Pick the move that matches the beat, name it in the shot sequence, and cite the rule id after `→`. The blueprints (`../hyperframes-animation/blueprints/`) name these same moves in their `rule mapping`; you're drawing from one shared palette. Compose 24 across a shot's scenes (entrance → sequential reveal → settle), not all at once.
| Layer | What | Rule |
| ------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| **Camera** (macro) | ONE correlated move on the frame root — slow drift / dolly / push / parallax pan | **always on, the whole shot** — this is the "someone is filming this" layer |
| **Choreography** (action) | the beat develops: entrance → mid-shot move (reveal / rearrange / morph / emphasis hit) → settle | **fill the duration** — a shot animated only at entry is a slide |
| **Idle life** (texture) | ambient continuation on the 1-2 elements that hold a live slot — breathing, glow, float | **budgeted** — this is where screensaver lives; cap it |
## Kinetic type
The reconciliation that matters: **mandate choreography, budget idle life.** Purposeful, sequential motion that carries information should fill the shot; ambient, simultaneous motion that carries none should be capped. Many elements each floating independently reads as _screensaver_; a shot that only enters then freezes reads as _slideshow_. Avoid both — **one camera move + a directed multi-phase action + 1-2 living elements**, nothing scattered.
- **hard-cut / flash word-swap** — a word or line replaces the previous one on an instant cut (no fade/roll); the swap itself is the beat. → `discrete-text-sequence`
- **in-place token cycle** — a fixed line holds and only its variable slot changes, token → token → token. → `discrete-text-sequence`
- **per-word staggered reveal** — a phrase assembles word-by-word (or chunk-by-chunk), each landing on its own beat. → `dynamic-content-sequencing`
- **kinetic beat-slam** — short phrases slam in on a shared percussive beat array, each with a distinct entrance, resolving on a locked finale; the recipe for "punchy / rhythmic" taglines. → `kinetic-beat-slam`
## Multi-phase choreography — direct the full shot
## Typewriter
Every non-still frame's timeline is choreographed across its length, not front-loaded into the entrance:
- **type-on with caret** — text types in character-by-character behind a blinking caret. → `discrete-text-sequence` (+ `context-sensitive-cursor` for the caret blink / color)
- **backspace-and-retype** — the line types, deletes the last word(s), and retypes a new one (typo-correction, reframe). → `discrete-text-sequence` (+ `context-sensitive-cursor`)
```
entrance → development → settle
```
## Count-up / data
- **entrance** — the beat's primary content arrives (hero `entry` / `heavy`; groups staggered).
- **development (the phase that's usually missing → PPT)** — mid-shot, the content _does something_: a second element reveals, elements rearrange to a new layout, a diagram gains a layer, a count-up runs, an emphasis hit lands on the keyword. This is the motion that separates video from slides.
- **settle** — the shot resolves and holds for its read; the camera + idle life continue underneath (never a hard freeze).
- **value-scaled counter** — a number counts up and its font size grows with the value, so the climb itself escalates. → `counting-dynamic-scale`
- **bars / progress / star wipe** — a number paired with a graphic that fills: bar-height stagger, a progress bar / ring filling, a fractional star-rating wipe. → `stat-bars-and-fills`
**Architecture:** in hyperframes only the **exit** is forbidden mid-video (the frame unmounts; the harness transition _is_ the exit). Everything _before_ the settle — including rich mid-shot development — is free and seek-safe. Build the development phase; skip only the exit (unless you are the final frame).
## Reveal / decode
When you name a **`blueprint`**, the development phases come from its recipe — write the composition note **shot-by-shot** to match. When you name **no blueprint**, the **≥3 cited effects ARE the phases** — sequence them (one enters, one develops, one emphasizes); **don't fire them all at t=0**.
- **3D char flip-decode** — characters flip in 3D and resolve from scrambled glyphs to the real text (decryption feel). → `hacker-flip-3d`
- **SVG self-draw** — an outline / icon / ring draws itself stroke-by-stroke. → `svg-path-draw`
## Spring intent (by role, not curve)
## Camera
| Intent | Feel | Use |
| ---------- | ------------------------------------------- | --------------------------------------- |
| **entry** | confident slight overshoot, settles quickly | primary element entry (default) |
| **gentle** | soft slide-in, no overshoot | background elements, subtle motion |
| **snappy** | tight overshoot, nearly instant | small icons, labels, list items |
| **heavy** | weighted deceleration | large diagrams, hero visuals |
| **slam** | bouncy overshoot, intentionally loud | a coined term landing, an impact moment |
- **push / focus / drift** — a sequential camera move on the frame root (pull-back → focus → push) plus continuous micro-drift; the cinematic baseline. → `multi-phase-camera`
- **zoom-to-target** — zoom into a non-centered element (scale + counter-translate to keep it framed). → `coordinate-target-zoom`
- **pan / focus-lock** — a virtual camera transforming one `.world` wrapper to pan / zoom / lock onto a region. → `viewport-change`
- **camera-cursor-tracking** — the viewport locks to a moving focal point (a typing cursor), static framing then focal-locked tracking. → `camera-cursor-tracking`
**Consistency:** similar elements share one intent (all labels `snappy`, all hero visuals `heavy`). Don't invent a unique ease + duration per element.
## Layout motion
**Forbidden:** `bounce.out` / `elastic.out` (dated; real objects decelerate, they don't bounce — low overshoot for `entry` is fine, high overshoot only for clearly playful moments); a unique ease+duration per element (visual noise).
- **cluster→outward expansion** — elements start clustered at center and expand outward to their final positions in lockstep. → `center-outward-expansion`
- **orbit** — elements flip in from 3D space and settle into a continuous elliptical orbit (entry flips in-place at the orbital position). → `orbit-3d-entry`
- **split-tilt cards** — two cards side-by-side with opposing rotationY tilts, entering from their respective sides (comparison / before-after). → `split-tilt-cards`
- **logo/avatar ring + connectors** — avatars or logos on an elliptical ring with SVG connection lines to a center point, staggered entry. → `avatar-cloud-network`
## Duration intent
## Surface / UI
Reference by **tier** ("instant feedback" / "state change" / "layout change" / "entry animation"); the worker maps concrete ms / frames at 30fps. **A single entry should not exceed ~800ms** — for a longer buildup, use multi-element stagger or a development phase, **not** one long tween.
- **3D page-scroll reveal** — a full webpage as a tilted 3D card whose internal content scrolls to reveal specific sections. → `3d-page-scroll`
- **cursor click + ripple** — a cursor moves to a target, depresses with it on click, and emits an expanding ripple. → `cursor-click-ripple`
- **button press** — a tactile press: compression then spring recovery, optional release burst / glow. → `press-release-spring` (or `physics-press-reaction` for a click that compresses cursor + target together)
- **keyword glow** — keywords light up with glow + scale + color on an attack-decay-rest envelope, synced to a word rail. → `asr-keyword-glow`
**Phase-to-phase within a shot is swift** — when one element makes way for the next (development), the outgoing move runs ~75% of an entry; arrival is deliberate, hand-off is quick. (The between-frame **exit** is the harness's transition, never your within-shot motion.)
## Morph / handoff
## Stagger cap
- **scale-swap** — two elements at the same screen center hand off: the outgoing cluster shrinks + fades as the incoming one arrives. → `scale-swap-transition`
- **card morph-anchor** — a container morphs apparent size + corner radius + surface between two shots, then fades to reveal the real target beneath (HyperFrames uses uniform `scale`, not `width`/`height`). → `card-morph-anchor`
When staggering N elements, **total ≤ 500ms** (longer feels dragged):
## Seam cuts (worker-built, inside a frame)
- **3-7 elements** — normal stagger, total 300-700ms.
- **8+ elements** — tighten per-item delay, or stagger only the first few and enter the rest with the last.
- Never let stagger run past 500ms.
The velocity-matched cuts a worker authors between a frame's own Scenes. Name the seam in the shot sequence; the recipe is in the catalog, not a single `../hyperframes-animation/rules/` id.
## Beat structure across frames (the cross-frame rhythm)
- **zoom-through / inverse zoom-through** — a within-scene swap on the Z-axis; forward reads "progressing through", inverse reads "arriving at" (payoff). → `cut-catalog.md`
- **cut-the-curve** — a scene-to-scene cut where both sides move the same direction at matched velocity. → `cut-catalog.md`
- **waterfall cut** — cut-the-curve at word granularity, a wave across a text-to-text seam. → `cut-catalog.md`
Rhythmic videos breathe: tension → release → tension → release. A clean reference shape for a ~46s explainer:
## Emphasis / marker
| Phase | Duration | Rhythm | Frame type |
| ------------ | -------- | ------------------- | ----------------------------------------- |
| Hook + gap | 4-8s | slow build | open the curiosity gap; land the hook |
| Concept name | 3-6s | deliberate | name the idea, one breathable beat |
| Body build | 12-20s | continuous, layered | the mechanism / steps / items, on a stage |
| Landing | 3-5s | still, breathable | the takeaway / principle / CTA |
- **highlight / circle / burst / scribble** — a marker-drawn emphasis on a word or element: yellow highlight sweep, hand-drawn circle, radiating burst, scribble, or rough sketch-outline. → `css-marker-patterns`
Allocate motion by a frame's energy: **high-energy** (hook, a surprising stat) → faster entry, tighter stagger, `snappy`, busier development; **breathable** (concept name, the turn in a story, the landing) → slower entry, `gentle`, longer hold, minimal development; **data / mechanism** (a step, a statistic) → medium rhythm, clean stagger, a count-up or layer-reveal as the development phase.
## Aliveness during a hold (use sparingly — see Part 2)
## Hold time — read time, not freeze time
- **subtle jitter** — the sanctioned way to keep a settled frame alive: a small, low-amplitude positional/scale jitter on the held element. The motion-graphics trick that reads "alive" without reading "weak." → `sine-wave-loop` (low-amplitude register)
- **live SVG internals** — internal SVG parts move so an icon feels alive (rotating hands, oscillating blades, pulsing dots, dash-flow); fine because it's the subject doing something, not a card breathing. → `svg-icon-enrichment`
- **finite bounded ambient** — a single bounded breathe/drift on ONE held hero, only when genuinely needed; de-emphasized — prefer sequential reveal or jitter first. → `sine-wave-loop`
After an element enters it must stay long enough to read (the worker maps concrete frames). "Hold" means **don't cut early** — the camera + idle life keep playing underneath; it is never a hard freeze.
## The added moves — now backed by local rules
| Content | Minimum hold |
| ----------------------------------- | ------------ |
| display text (1-3 words) | ~1s |
| short sentence | ~1.5s |
| data / statistic | ~1.5s |
| diagram / formula | ~2s |
| complex visual (multi-part diagram) | ~2.5s |
| hero / climax word | ~1-1.4s |
Five moves the golden corpus needs were added to this skill's `../hyperframes-animation/rules/`, rounding out the vocabulary above:
Narration shorter than the needed hold → the frame's `duration` should still give the visual its read time.
- **depth-of-field / selective-blur** — blur the off-focus subset to spotlight the focal element → `depth-of-field-blur`
- **motion-blur streak** — directional velocity blur on a fast fly-in / camera push-through → `motion-blur-streak`
- **3D depth scatter-assemble** — glyphs/elements scatter into a tumbling 3D cloud, then reassemble → `depth-scatter-assemble`
- **spring-pop entrance** — the canonical entrance pop; default to a smooth long-tail settle, overshoot only when explicitly playful → `spring-pop-entrance`
- **ambient glow / bloom** — un-triggered soft glow blooming behind a static hero → `ambient-glow-bloom`
## Stillness before climax — the marked exception
---
A **0.3-0.75s pause** between the major action and its confirmation / result — the silence builds tension before the landing (the turn in a story, the "aha" after a build). It lands **because the rest of the video is choreographed** — stillness is a contrast against motion, so it only reads when motion is the baseline. **Allocate it to only 2-3 frames per video, named in the `## Video direction` block**, where the narration lands a payoff. Stamped on every frame it becomes a tic and flattens the rhythm. Name `stillness-before-climax` in that frame's motion note; even then the camera move continues (still ≠ frozen).
# Part 2 — the motion doctrine (load-bearing)
## The idle-life budget — what may move during the hold
These four rules are the difference between a clip that reads as a serious code-change explainer and one that reads as an agent-made PowerPoint. Follow them as written.
The 1-2 elements that keep moving _after_ the development settles. This is the layer that, overdone, becomes screensaver — so it is **capped**, not mandated:
## 1. Smooth beats bouncy — `power3` is the default
1. **Camera move** — always present (the macro layer above); it alone keeps everything coherently alive.
2. **At most 1-2 secondary live elements** — the ones carrying the beat (hero, the active node). Everything else holds.
3. Prefer **macro move + depth parallax** over many independent floats.
Elements should use **long-tail decel curves that let them settle smoothly. `power3` is enough in most cases.** No bouncy, no overshoot, no `back.out` / `bounce.out` / `elastic.out` as a default.
Secondary-slot menu (formulas are the worker's): **multiplicative breathing** (hero — small ±2-5% on final scale) · **glow pulse** (the active element) · **sine float** (one decorative cluster at most) · **rotational drift** (3D cards, hero mark) · **orbit** (surrounding icons; counts as the one decorative cluster) · **halftone breathing** (atmospheric frames).
Bouncy is the **#1 instant turn-off** in user-made Remotion / HyperFrames videos, and the agent almost never gets it right — it thinks bouncy adds emphasis, but it buys that emphasis at the cost of cleanliness. The serious motion-design shops feel the same. **Smooth always wins.** Overshoot is demoted to a **rare, explicitly-playful exception** (a consumer/fun logo slam, a deliberate bell-hit) — never the house style. Name the intent as a long-tail settle; the worker maps `power3` (or `expo.out` on a fast arrival). See `../hyperframes-animation/rules/spring-pop-entrance.md` — it now leads with the smooth settle.
Multiplicative breathing is the signature for a hero **that holds a live slot** — not stamped on every hero. **Minimum amplitude ±6px or ±2-5% scale** — a 3px micro-float doesn't count.
## 2. Sequential reveal in the back ~50%, timed to the voiceover
## Seek-safe motion — intents that don't survive the renderer
This is the anti-PowerPoint mechanism — sharper than "put development in the middle."
The frame is a **paused GSAP timeline seeked frame-by-frame**, so some "continuous" intents from a real-time engine cannot render — **don't name them**:
- **Don't dump everything on screen in the first ~25%** of the scene. Rushing all content in up front is exactly what forces the slideshow feel.
- **Reveal each piece — a line, a card, even an h1 — when the voiceover mentions it**, sequencing reveals across the **later ~50%** of the scene. Same amount of agent work, but the cut becomes coherent and gains rhythm.
- **Less is more.** Fewer things on screen, each arriving on its VO beat, beats a full canvas that animated once and froze.
- **No infinite / forever motion** — "particles loop endlessly," "logo rotates forever," "marquee scrolls on repeat." Idle life is a **finite tween over the hold** (breathe up then back), never `repeat`/`yoyo`.
- **No randomness or wall-clock**`Math.random` particle fields, `Date.now` drift. Every motion is the same on every render; name deterministic motion only.
- **Entrance + development only** (exit = final frame only) — the cross-frame exit is the harness's transition.
- Express oscillation/breathing as a **bounded finite move**, not a loop.
Practically: a frame's shot sequence front-loads almost nothing — the entrance carries only what the VO is saying at t=0, and the rest of the elements wait in the timeline for their spoken cue. A reveal maps onto a development-class move from Part 1 (`per-word staggered reveal`, `cluster→outward expansion`, a `count-up`, an `asr-keyword-glow` synced to the word rail).
## Forbidden — both failure modes
## 3. No lazy breathing, no bad pan/push — "no motion over bad motion"
**Slideshow (under-motion):**
The agent's two reflexive ways to fake "aliveness" both read cheap:
- Content animates in, then **freezes** for the rest of the shot (the PPT tell).
- Only the entrance is animated; the remaining duration is a frozen hold under a drift.
- The ≥3 cited effects **all fire at t=0** instead of sequencing into entrance / development / emphasis.
- No mid-shot development on a non-still frame.
- **No lazy breathing.** Scaling cards/text up and down in a circular loop to look "alive" is the cheap tell. Don't reach for it.
- **No bad slow pan / push in the back half.** A slow pan or push on elements in the later ~50% of a scene **disrupts the viewer's sightline and causes eye discomfort** — it actively makes the frame worse, not better.
**Screensaver (over-motion):**
The fix for both is the same: **stagger element reveals in time with the script** (rule 2). And the governing principle: **"I'd rather have NO motion than BAD motion."** A held, still frame is better than a frame kept "alive" by breathing or a drifting camera. The **only sanctioned aliveness** during a hold is **subtle jitter** — a small low-amplitude jitter that keeps a frame from feeling dead without looking weak (it's in Claude videos now). Everything else holds.
- **Every element** floating independently; idle motion with no information.
- More than 1-2 elements idling at once; scattered sine floats as the "aliveness."
- A 3px micro-float standing in for real motion.
## 4. Internal seams are velocity-matched cuts
**Always:**
When a frame has an internal seam — a within-scene swap, a Scene-to-Scene cut, a text-to-text line change — make it a **velocity-matched cut**, not a hard slideshow cut: cut at peak velocity, match direction and speed on both sides. The catalog (the four techniques, the blur logic, and which to use when) is `cut-catalog.md`; the moves are listed under **Seam cuts** in Part 1.
- `bounce.out` / `elastic.out`; a bespoke ease+duration per element; `repeat` / `yoyo`; all elements entering simultaneously (must stagger or sequence).
## One-line summary
## Motion note example
Smooth long-tail (`power3`) over bouncy; reveal sequentially in the back ~50% timed to the VO (not dumped in the first 25%); no lazy breathing and no bad slow pan/push — prefer stillness, with subtle jitter as the only aliveness; cut at peak velocity with matched direction/speed (→ `cut-catalog.md`).
> "Macro: slow dolly-in on the frame root across the whole beat. **Entrance** — the concept word enters `EASE.entry` (heavy); supporting labels snappy-stagger (4 items, ~400ms). **Development** — the diagram gains its second layer, then a count-up runs beneath it. **Stillness-before-climax 0.6s** (allocated frame; only the dolly continues). **Settle** — the takeaway emphasis: text gentle entry + glow; idle hold with the hero word breathing ±3% as the one live element."
---
One line for a single-shot frame; **shot-by-shot when the beat is multi-phase** (always, when you named a `blueprint`). Never concrete ease curves / ms / stagger formulas / JS — the worker writes those.
# Part 3 — the seek-safe core (hard rules)
The frame is a **paused GSAP timeline seeked frame-by-frame**, so some "continuous" intents from a real-time engine can't render — don't name them. These are non-negotiable regardless of doctrine.
- **No infinite / forever motion** — "particles loop endlessly," "logo rotates forever," "marquee on repeat." Any aliveness (the subtle jitter, a live SVG internal, a needed bounded ambient) is a **finite tween over the hold**, never `repeat` / `yoyo`.
- **No randomness or wall-clock** — no `Math.random` particle fields, no `Date.now` drift. Every render must be identical; name deterministic motion only (stagger and any variation derive from the element index).
- **Entrances use `fromTo`** — state the from-state explicitly so a seek to `t=0` lands the element correctly; never rely on a CSS-hidden start (it renders visible before the tween claims it, and flickers under seek).
- **No CSS `transition` / `@keyframes` for motion** — CSS animation runs on the browser clock, independent of the HF seek clock; it desyncs and flickers. Drive all motion inside the paused GSAP timeline.
- **Entrance + sequential reveal only — no mid-video exit.** The frame unmounts via the harness transition; that injected `transition_in` **is** the exit. Exit motion belongs only to the final frame. (Worker-built seam cuts in `cut-catalog.md` are within-frame, not the frame's exit.)
## Forbidden — the failure modes
**Slideshow (the primary failure):** everything dumped on screen in the first ~25%; content enters then freezes; nothing revealed on its VO cue. Fix with rule 2 (sequential reveal timed to the VO).
**Cheap aliveness:** circular breathing as "life"; a slow pan/push in the back half disrupting the eye; many elements floating independently as "motion." Fix with rule 3 (stillness + subtle jitter only).
**Bouncy:** `back.out` / `bounce.out` / `elastic.out` as the default entrance; hand-keyed overshoot. Fix with rule 1 (`power3` long-tail; overshoot only when explicitly playful).
**Always:** no `repeat` / `yoyo`; no `Math.random` / `Date.now`; no all-elements-entering-simultaneously (sequence or stagger).
## Naming motion in a shot — example
> Scene 1 (0.01.0s): solid field; hero headline enters via **per-word staggered reveal** (`dynamic-content-sequencing`) on a smooth long-tail settle (`power3`); slow **push** on the root (`multi-phase-camera`) holds steady — no back-half re-push.
> Scene 2 (1.03.0s): as the VO names each changed file, five file chips reveal **sequentially** via **cluster→outward expansion** (`center-outward-expansion`), then a **value-scaled counter** (`counting-dynamic-scale`) ticks the +/ line total up beneath them — the back-half reveal, timed to the script, not dumped at t=0.
> Scene 3 (3.04.2s): hold on the result; **keyword glow** (`asr-keyword-glow`) lands on the payoff word as the VO says it; settles and holds still — at most **subtle jitter** (`sine-wave-loop`, low amplitude) keeps it alive; no breathing, no drift.
Name the move + its rule id (or `cut-catalog.md` for a seam cut) per scene; let the worker pick curves, ms, and stagger — defaulting to `power3`.
@@ -111,7 +111,7 @@ This framework builds **one frame per worker** — there is no multi-frame "cont
1. **A consistent stage** — consecutive body frames share one composition idea (the same navy code window filling in, the same before|after split, the same counter advancing), stated in each frame's `scene` so Step 4 and the workers keep the stage stable.
2. **A consistent transition** — pick one seam type for a run (`crossfade` for a soft code reveal, `push-slide` for the next change item) and repeat it.
When a single element genuinely _transforms_ between two ideas (the failing test flips green, the old function becomes the new one), keep it **within one frame** as a development beat (entrance → transform → settle) — the worker owns that motion (a `code-diff` or `code-morph` block). Note the intent in `scene` / narrative; Step 4 turns it into the block + `effects`.
When a single element genuinely _transforms_ between two ideas (the failing test flips green, the old function becomes the new one), keep it **within one frame** as a development beat (entrance → transform → settle) — the worker owns that motion (a `code-diff` or `code-morph` block). Note the intent in `scene` / narrative; Step 4 wraps it in a time-coded shot sequence around the block (a `code-diff` / `code-morph`).
## Transitions
@@ -133,7 +133,7 @@ Code beats live on the **navy code surface** (claude's Code Surface treatment)
A diff shows **what changed in the code**. It does **not** show **what the change does** — and "what it does" is usually the more memorable, more explanatory beat. The single biggest reason a PR video feels flat is that every body frame is a code surface or a number: it _tells_ (here are the lines, here is the stat) but never _shows_ (here is the request actually recovering).
A **`mechanism` frame animates the runtime behavior** the PR changes — built as an **invented animated diagram** (SVG / HTML / GSAP on claude's cream ground: hairline-ink nodes / edges / lanes, one coral marker on the active or changed element), where **the build _is_ the teaching** — each part appears on beat, the flow plays out across the shot. It is **not** a code block and **not** a headline. Reach for the `flowchart` / `flowchart-vertical` / `data-chart` registry blocks where they fit; otherwise invent it (composition.md's diagram / abstract-graphics register).
A **`mechanism` frame animates the runtime behavior** the PR changes — built as an **invented animated diagram** (SVG / HTML / GSAP on claude's cream ground: hairline-ink nodes / edges / lanes, one coral marker on the active or changed element), where **the build _is_ the teaching** — each part appears on beat, the flow plays out across the shot. It is **not** a code block and **not** a headline. Reach for the `flowchart` / `flowchart-vertical` / `data-chart` registry blocks where they fit; otherwise invent it (visual-design.md's diagram / abstract-graphics register).
Plan **at least one `mechanism` beat** for any PR with a visible runtime behavior (most feature and fix PRs have one). What to animate, by what the change touches:
@@ -172,6 +172,8 @@ The largest quality bug in PR videos is **scripts that talk too long**. TTS runs
Estimate while writing: `duration ≈ ceil(word_count / 2.2)`. 29 words → 13s (trim); 17 words → 8s; 12 words → 6s. Trim techniques: cut the lead-in clause ("Until now, the agent shipped…" → "The agent shipped…"); move numbers off-script onto a counter; split only when the halves carry distinct beats (cause then effect). **Silent frames are allowed and common** — a diff typing on, a before→after morph, a counter running. Set `voiceover` empty, omit from `SCRIPT.md`, and make `narrativeRole` carry it. A complex change does not need a long script; it needs a careful one — if you can't headline the change in 19 words, the headline isn't sharp yet.
**Write each line as discrete cues, not one run-on breath.** Step 5 reveals each on-screen piece _when the voiceover names it_ (the anti-PowerPoint mechanism). A line with clear phrase boundaries — "Three retries — then it backs off — then it gives up clean" — hands the shot its reveal cadence for free; a single long clause leaves the frame nothing to pace to.
## Frame template
```md
@@ -186,6 +188,7 @@ Estimate while writing: `duration ≈ ceil(word_count / 2.2)`. 29 words → 13s
- type: diff
- persuasion: Before/after contrast
- beat: comprehension
- blueprint: <candidate id from the role→blueprint menu, or omit — a code beat usually omits it (the code-\* block is the shape)>
narrativeRole: What this frame does in the viewer's understanding (its job, not what's on screen).
keyMessage: The one thing the viewer should understand after this frame (one sentence).
@@ -199,6 +202,7 @@ The `credits` frame additionally carries an `asset_candidates:` line (see the cr
- The opening uses a named hook strategy; you do not read the PR description aloud.
- Each frame has one job; the body builds cumulatively, **alternating `diff` (the code) with `mechanism` (the behavior)** + `impact` / `evidence` — not a single isolated body frame, and not an unbroken stack of code surfaces.
- Every frame has `type` (PR-native), `persuasion` (a named technique), and `beat` (specific). The emotional arc matches the archetype (fix = frustration → relief; feature = curiosity → confidence).
- Each `voiceover` is phrase-segmented into cues (each a piece Step 5 can reveal on), not one run-on clause; a candidate `blueprint:` is tagged from the role→blueprint menu where a proven shape fits (a code beat usually omits it — the `code-*` block is the shape).
- **24 real diff hunks** featured, each a small legible snippet (not a whole file), each naming its `code-*` block in `scene`.
- **At least one `mechanism` beat** animates what the change _does_ at runtime (an invented diagram, or a `flowchart` / `data-chart`), named in its `scene` — unless the PR genuinely has no visible behavior (a pure docs / config bump). The body is not an unbroken run of code surfaces.
- Transitions use only registry names and repeat 23 types; frame 1 is `cut`.
+119 -61
View File
@@ -1,106 +1,164 @@
# Visual design — PR-to-video per-frame enrichment method
# Visual design — PR-to-video per-frame shot method
> The method behind **Step 4 (Frame visual design)**. You (the orchestrator) read it to **enrich `STORYBOARD.md` frames in place** — story-design wrote the skeleton (each frame's `scene`, `voiceover`, `transition_in`, and the narrative fields); you add how each frame **looks and moves**. Each frame is a **directed shot, not a static slide** — you choreograph it across its whole duration. Because a PR video is **mostly faceless, most visuals are invented** — typography, number-lockups, diagrams — so you describe visual elements rather than place captured assets; the exceptions are **code beats** (a ready-made `code-*` block) and the **credits close** (real contributor avatars), both covered below. You write **no HTML** (that's the frame workers). `frame.md` is your palette/type truth. Composition / motion detail lives in `composition.md` + `motion-language.md`; effect & blueprint **bodies** live in `hyperframes-animation`. Adding palette theory or a generic font rule here? Wrong home — `frame.md` + `hyperframes-creative`.
> The method behind **Step 4 (Frame visual design)**. You (the orchestrator) read it to **enrich `STORYBOARD.md` frames in place** — story-design wrote the skeleton (each frame's `scene`, `voiceover`, `transition_in`, the narrative fields, and optionally a candidate blueprint id); you add how each frame **looks and moves**. The unit you write per frame is a **time-coded shot sequence** — a shot directed across its whole duration, not a static slide. You write **no HTML** (that's the frame workers). A PR video is **mostly invented** — typography, number-lockups, mechanism diagrams — so you **design** those elements; the two exceptions are **code beats** (a ready-made `code-*` registry block) and the **credits close** (real contributor avatars), both covered below. `frame.md` is your palette/type truth by role. Layout is a compact vocabulary in this file (the **Layout** section below), stated inline per Scene; motion vocabulary + the motion doctrine + the seek-safe core → `motion-language.md`; the proven shapes → `../hyperframes-animation/blueprints-index.md` + `blueprints/<id>.md`; the `code-*` blocks → `code-vocabulary.md`; concrete rules resolve in Step 5 from this skill's local `../hyperframes-animation/rules/`. Adding palette theory or a generic font rule here? Wrong home — `frame.md` + `hyperframes-creative`.
## Every frame is a directed shot
## The unit is a time-coded shot sequence
A frame's visual layer is choreographed across its **full duration**, not front-loaded into an entrance. The failure that reads as PowerPoint: content animates in over the first ~0.8s, then **freezes** while a slow drift plays under it. So every frame's metadata + note describe a **shot with phases**`entrance → development → settle` — where _development_ (a reveal, a rearrange, a morph, an emphasis hit, a count-up) is the mid-shot motion that separates video from slides. The shot model and the choreography-vs-idle budget live in `motion-language.md`; here you **encode it into the frame**: the `effects` / `blueprint` ids are the motion vocabulary, and the **composition note sequences them into phases**.
A frame's visual layer is **a sequence of time windows paced to the voiceover**, not a bag of effect tags. The failure that reads as PowerPoint is **front-loading**: the agent rushes the whole canvas on screen in the first ~25%, and then it just sits. A time-coded shot sequence written **against the VO** makes that impossible: each window states what is on screen and what is moving, and **nothing appears before the voiceover reaches it.** In a PR explainer the development often _is_ the reveal — the diff hunk typing in, the before→after morph, the request-retry diagram running, the impact stat landing. Let the build _be_ the message.
In explainers, _development_ is often the teaching itself — the formula assembling term by term, the diagram gaining a layer, the count-up landing the statistic. Let the build _be_ the message.
Write each frame as a handful of windows cued by the spoken line:
Deliberate **stillness** is the marked exception — the 2-3 climax/breather frames you allocate in `## Video direction`. Every other frame develops; a held frame outside that allocation is just a slide.
```
Scene 1 (0.0Xs): only what the VO is saying at t=0 enters — never the whole canvas
Scene 2 (XsYs): the next piece reveals as the VO names it (a file chip / the hunk / a node / a stat)
… one window per spoken cue — as many or as few as the line calls for
Scene N (…–end): content has resolved; hold the read (stillness; subtle jitter at most)
```
- Each `Scene` line names **what's on screen**, **what moves in this window**, and **where it sits** (layout, inline). Times are real seconds across the frame's `duration`.
- **Pace reveals to the voiceover; never front-load.** This is the core anti-PowerPoint mechanism (→ `motion-language.md` Part 2 Rule 2). At t=0 show only what the VO is saying then; reveal each further piece — a line, a file chip, the hunk, a stat — **when the VO names it**, spreading reveals across the shot and especially the **back ~50%**. **The window count = the number of spoken cues the line calls for.** There is **no fixed count and no mandatory "middle" act**; the only sin is dumping everything up front.
- **End on a held read.** Once the content has resolved it holds and reads — **prefer stillness to bad motion**: no forced camera drift, no lazy breathing, no back-half pan/push; at most a subtle jitter keeps it alive (→ `motion-language.md`). Only the final frame has a real exit; every other frame's exit is the harness transition (story's `transition_in`).
- A **deliberately held** frame — content already revealed, now reading still — is legitimate and often right (a climax, a breather). The failure is never "too still"; it is **front-loaded-then-frozen**. Place held beats deliberately for rhythm (allocate them in `## Video direction`).
## Pick the shape — instantiate a blueprint
Don't invent each shot from scratch. The frame's **role** (its `type` / `beat`) points to a proven shape:
1. **Match the role to a blueprint.** Open `../hyperframes-animation/blueprints-index.md`, find the frame's role in the **role→blueprint menu**, and pick the blueprint whose intent fits this beat (story may already have named a candidate id — confirm or override it). Read that `blueprints/<id>.md`: it is a short, domain-agnostic, **time-coded shot template with `[slots]`** and a named **signature move**.
2. **Instantiate its `[slots]` with THIS frame's content** — three postures:
- **Reproduce** — the blueprint fits the beat and your content maps onto its slots cleanly. Fill every `[slot]` and follow its Scene timing.
- **Adapt** — the _structure_ fits but the content / surface doesn't. State **what you keep / what you change** in one line, then write the adapted Scene lines. You may never drop the **signature move**, and you keep the reveals **paced to the VO**.
- **Compose** — no blueprint fits the beat. Build the shot from the **motion vocabulary** in `motion-language.md`: still pace reveals to the VO. Mark it `blueprint: compose`.
3. **Keep the signature move.** Whichever posture, the blueprint's signature move is the spine of the shot — carry it through.
> A **code beat** is the one place you don't pick a blueprint for the centerpiece — the `code-*` block _is_ the shape (see **PR code beats** below). You still write the Scene sequence for the surrounding surface.
## What you add to each frame
Story-design's `## Frame N` block already carries the narrative. You append the visual layer as frame metadata + one composition note (story's role/message prose stays):
Story-design's `## Frame N` block already carries the narrative. You append the shot. Story's `scene` / `voiceover` / `transition_in` / role fields stay untouched.
```
## Frame 3How interest compounds
- scene: a snowball rolls downhill, gaining a labeled ring each turn ← refine only if it could read sharper
## Frame 4The retry fix
- scene: the request() retry hunk lands on the navy code surface ← refine only if it could read sharper
- voiceover: "…" ← story's; leave it
- transition_in: crossfade ← story's; leave it
- type: feature_showcase ← story's
- persuasion: Concretization + progressive disclosure
- beat: comprehension
- effects: scale-in, layer-reveal, count-upyou add: cite effect ids (≥3, sequenced into the phases below)
- blueprint: messaging-multi-phase ← you add (optional): one multi-phase blueprint id
- focal: the snowball ← you add: which INVENTED element is the hero
- roles: snowball = foreground subject; hill = background gradient; ring labels = supporting ← you add: each invented element's role
- sfx: whoosh-soft, tick ← you add: the sound the beat wants (fetched + mounted at root; never yours to embed)
- type: diff ← story's (PR-native)
- persuasion: Show-the-change
- beat: clarity
- blueprint: compose code beats compose the surround; the block owns the code motion
- focal: code-diff — the request() retry block, ~6 lines ← you add: the code-* block IS the focal
- roles: code surface = foreground subject · file header = supporting · dim grid = background
- sfx: keyclack-soft, soft-confirm
Entrance: the snowball seats upper-left on a dim hill gradient. Development: it rolls down across the beat, gaining one labeled ring per turn (layer-reveal) while a small total ticks up (count-up). Settle: the final ring emphasis holds; only the slow camera drift continues. A dense, left-anchored frame.
Scene 1 (0.01.0s): the navy Code Surface window seats in (scale-in + soft shadow), file header "client/request.ts" types on — Centered, ~60% of frame. Slow push-in underneath.
Scene 2 (1.03.2s): the camera settles onto the hunk; the `code-diff` block runs its own before→after on its cadence (the worker fits it to the duration) — you do not re-specify the code motion.
Scene 3 (3.24.5s): a coral underline draws on the changed line as the VO names it; a `+6/2` count-up ticks beside the header; settles and holds STILL.
```
- **`effects`** — name atomic effect **ids** from `hyperframes-animation`'s rules index. **Cite ≥3 when you name no `blueprint`** (the worker composes them into the beat; fewer than 3 reads as generic motion); 1+ as accents when a blueprint already carries the choreography. With no blueprint, those **≥3 effects are the shot's phases** — your note must **sequence them** (one enters, one develops, one emphasizes), not list them as a flat set that all fires at entry. You cite; the worker **reads the recipe body and reproduces it** (not a name-guess).
- **`blueprint`** — name **one** multi-phase blueprint id from `hyperframes-animation/blueprints-index.md` when a frame's beat wants a proven multi-phase shape. Two postures — both require the worker to read the recipe body first:
- **Reproduce** — the blueprint fits the beat cleanly and the frame's content maps onto its slots; write the composition note shot-by-shot to match.
- **Adapt** — the blueprint is the right _structure_ but the content / beat doesn't fit its exact form (or you want a fresher surface). Lead the note with a **`Base / Keep / Depart`** line — `Base:` the blueprint id · `Keep:` its **signature** move (never drop this) · `Depart:` what you change and why. Adapt may **extend or vary, never reduce below the shot model** — never flatten a multi-phase blueprint into a single entrance.
The lightweight tags:
Choose **Reproduce** when the shape fits as-is, **Adapt** when the structure fits but the form doesn't; **omit** `blueprint` entirely when none fits — then the cited `effects` (≥3) carry the phases (**Compose**).
- **`blueprint:`** — the id you instantiated (with `(Reproduce)` / `(Adapt)`), or `compose`. One id per frame.
- **`focal:`** — for a concept/mechanism beat, the **invented** hero (a hero word, a diagram, a number-lockup); for a **code beat**, the **`code-*` block** (name the block + the hunk); for the **credits** close, the avatar row.
- **`roles:`** — each element's role: `foreground subject` · `background` (full-bleed, dim 3050%) · `supporting`. Invented elements you **design**; the only real assets are the credits `assets/<login>.png` avatars (named in story's `asset_candidates`).
- **`sfx:`** — name the sound the beat wants; the audio script's `fetch-sfx` retrieves it and the assembler mounts it at root — you only **name** it, never embed `<audio>`.
- **`focal` / `roles`** — name the **invented** visual elements and their roles. `focal` is the hero element (a hero word, a diagram node, a chart series, a coined-term card). `roles` assigns each element a role: `foreground subject` (the thing the eye lands on, text laid around it), `background` (full-bleed field/gradient/grid, dim 30-50%), `supporting` (labels, secondary shapes, ambient layers). Since there are no captured assets, you are _designing_ these elements, not selecting them — keep them few and load-bearing. A user-supplied `public/<basename>` image, if any, is named in story's `asset_candidates`; treat it as the `focal` cutout or a `background`.
- **`sfx`** — name the sound the beat wants (an impact for a slam, a whoosh for a push, a tick for a count). The audio script's `fetch-sfx` pass retrieves it and the assembler mounts it at the root — you only **name** it, never embed an `<audio>` element.
- **composition note** — the frame's visual brief: layout, hero, depth layers, the macro move, **and the shot's phases**. **Default to a phased note**`entrance: … → development: … → settle: …` (mandatory when you named a `blueprint`). A **single still line** is correct only for a deliberately held climax or an allocated stillness frame. Full method → `composition.md` (layout) + `motion-language.md` (phases).
**Layout + motion are stated INLINE in each Scene line** — name the template / density / depth as part of "where it sits", and name the move from `motion-language.md`'s vocabulary; let it settle on a long-tail curve (`power3` default). Never write px / scale / ease curves / ms (the worker writes those).
## PR code beats — name a `code-*` block (the one place you don't invent)
## PR code beats — name a `code-*` block
(Frame `type` values are PR-native — `diff` / `before_after` / `mechanism` / `impact` / `credits` / … from story-design; the enrichment method below is the same regardless of type.)
For a `diff` / `before_after` / code beat, the frame's centerpiece is a **ready-made `code-*` registry block**, not an invented HTML visual — the one exception to "invent every visual." In that frame:
For a `diff` / `before_after` / code beat, the frame's centerpiece is a **ready-made `code-*` registry block**, not an invented HTML visual — the one exception to "invent every visual."
- **Name the block in `scene` + `focal`.** Pick the one that fits the beat (before→after = `code-diff`; refactor/rename = `code-morph`; new code written on = `code-typing`; spotlight a line = `code-highlight`; walk a long file = `code-scroll`; a hero reveal = `code-3d-extrude` / `code-particle-assemble`). Full map → `code-vocabulary.md`. Name the hunk too ("the `request()` retry block, ~6 lines"). The block is the `focal`; the Step-5 worker installs + fills it with the real diff.
- **`effects` choreograph the surrounding chrome, not the code motion.** The block owns the diff/typewriter/morph animation (it _is_ the development beat). Your `effects` / `blueprint` move the claude **Code Surface** around it the navy window seating in, a `+N/M` `count-up`, a coral underline drawing on. Cite 13 for that chrome; do not re-specify the code animation itself — the worker only fits the block's cadence to the frame's `data-duration` (a long snippet overruns a short frame otherwise).
Numbers (`+1,204 / 318`, files touched, perf delta) go on an `impact` / `evidence` frame as a `number-lockup` (claude's Number/Impact treatment) — name it the `focal`, with a `count-up`. The **`credits`** close uses the real `assets/<login>.png` avatars (named in story's `asset_candidates`) as the `focal` — an avatar row; the visual phase features non-empty `asset_candidates` like real assets. A **`mechanism`** frame is also invented — but an **animated diagram of the behavior**, not typography (see the next section). Every other frame (`hook` / `change` / `cta`) is invented typography/graphics per the method above.
- **The block owns the code animation; your Scenes choreograph the surrounding Code Surface.** The block _is_ the development beat (the diff/typewriter/morph plays on its own cadence — the worker only fits it to the frame's `data-duration` so a long snippet doesn't overrun). Your Scene windows move the claude **Code Surface** around it: the navy window seating in, the file header typing on, the camera settling onto the hunk, a `+N/M` `count-up`, a coral underline drawing on the landed line. Name those moves inline; **do not re-specify the code animation itself.** A code beat is usually `blueprint: compose` (the block is the shape).
## PR mechanism beats — invent an animated diagram of the behavior
A **`mechanism`** frame is the **show-the-behavior** beat — the antidote to a video that only shows code + text. Its `focal` is an **invented animated diagram** that plays out what the change _does_ at runtime (the request retrying, the cache filling, serial→parallel, the race resolved), **not** a `code-*` block and **not** a headline.
A **`mechanism`** frame is the **show-the-behavior** beat — the antidote to a video that only shows code + text. Its `focal` is an **invented animated diagram** that plays out what the change _does_ at runtime (the request retrying, the cache filling, serial→parallel, the race resolved) **not** a `code-*` block and **not** a headline.
- **Name the behavior + the diagram in `scene` + `focal`.** e.g. `scene: "animate the request lifecycle — fire → 500 → backoff (delay growing) → retry → 200, invented SVG flow"`; `focal: the request-lifecycle flow`. story-design's "what to animate, by what the change touches" table is the menu. Reach for the `flowchart` / `flowchart-vertical` / `data-chart` registry blocks where they fit (name them in `scene` like a `code-*` block so Step 5 pre-installs them); otherwise the worker builds it in SVG / HTML / GSAP from claude's atoms.
- **The build _is_ the development beat.** Unlike a code block (which owns its own animation), the diagram is yours to choreograph: cite **≥3 `effects`** (or a `blueprint`) and **sequence them into the phases** — the lanes / nodes draw on (entrance), the flow runs / the lane splits / the front advances (development), the resolved state + one coral emphasis lands (settle). This is exactly `composition.md`'s "diagram / data-viz where the build is the teaching" register — here the mechanism _is_ the teaching, so never let it enter then freeze.
- **Stay on claude's cream ground, hairline-ink.** Nodes / edges / lanes in hairline ink on cream; **one coral marker** on the active or changed element (the retry hop, the cache hit, the new lane); mono labels. Not the navy code surface (that's for code), not heavy shapes / bokeh. Plan it into the top ~83% (caption keep-out).
- **Name the behavior + the diagram in `scene` + `focal`.** e.g. `scene: "animate the request lifecycle — fire → 500 → backoff → retry → 200, invented SVG flow"`; `focal: the request-lifecycle flow`. Reach for the `flowchart` / `flowchart-vertical` / `data-chart` registry blocks where they fit (name them in `scene` so Step 5 pre-installs them); otherwise the worker builds it in SVG / HTML / GSAP from claude's atoms.
- **The build IS the shot sequence.** Unlike a code block (which owns its own animation), the diagram is yours to choreograph across the Scene windows — the lanes / nodes draw on (Scene 1), the flow runs / the lane splits / the front advances as the VO names each step (middle Scenes), the resolved state + one coral emphasis lands (final Scene). Never let it enter then freeze.
- **Stay on claude's cream ground, hairline-ink.** Nodes / edges / lanes in hairline ink on cream; **one coral marker** on the active or changed element; mono labels. Not the navy code surface (that's for code), not heavy shapes / bokeh. Plan it into the top ~83% (caption keep-out).
`focal` / `roles` name the invented diagram and its parts (the lanes = `foreground subject`; a timeline axis = `supporting`; a dim grid = `background`). A `mechanism` frame carries **no** `asset_candidates` (it's invented, like every non-credits frame).
A `mechanism` frame carries **no** `asset_candidates` (it's invented, like every non-credits frame).
## Video direction — write the invariants ONCE
## Impact & credits
The whole video shares one look and one motion grammar. State it **once**, at the top of `STORYBOARD.md` (a `## Video direction` block), so every frame inherits it and per-frame metadata carries only the **delta**:
- **Impact / evidence** — numbers (`+1,204 / 318`, files touched, perf delta) go on an `impact` frame as a **`number-lockup`** (claude's Number/Impact treatment): name it the `focal`, reveal it with a `count-up` paced to the VO.
- **Credits close** — the optional `credits` frame uses the real `assets/<login>.png` avatars (named in story's `asset_candidates`) as the `focal`: an avatar row that staggers in. This is the one frame with non-empty `asset_candidates` and real assets.
- **palette system** — from `frame.md`: which roles map to which hues. Never invent.
- **motion defaults + shot model** — default eases + the **choreography baseline** (every frame a directed shot: entrance → development → settle) + the **idle-life budget** (what may keep moving during the hold) (→ `motion-language.md`).
- **negative list** — what never appears: off-brand textures the pack forbids, **plus both motion failure modes** — slideshow (enter-then-freeze) and screensaver (everything floating independently) (→ `motion-language.md`).
- **stillness allocation** — name the 2-3 frames that hold still before a climax; every other frame develops.
## Inventing the visual (non-code beats)
Do **not** repeat these in every frame — each frame's metadata is the delta on top of Video direction.
Every non-code, non-credits beat (`hook` / `change` / `cta` / concept) is **designed**, not captured. Three first-class treatments:
- **Typographic / kinetic type** — a hero word, the PR's headline claim, a stat. Treat type as the subject: full-bleed scale, weight contrast, one emphasized term. Strongest for hooks and the cta.
- **Abstract graphics** — shapes / paths / geometry that _embody_ the idea the script names; don't decorate with generic bokeh.
- **Diagram / data-viz** — the mechanism diagrams above, a `data-chart` for a perf delta, a number-lockup. The build (each part on beat) is the teaching — design it to assemble across the Scenes.
Make the invented hero **fill 4060% of the frame** — big enough to read; don't shrink the one designed element into decoration around empty space.
## Layout — named inline per Scene
State each Scene's layout as part of "where it sits." **If the blueprint (or the code-\* block) already implies a composition, that wins** — describe it directly; the vocabulary below is for composing freely. Never write px / scale / shadow (the worker does). One frame's layout can EVOLVE across its Scenes. Use **≥3 different framings per video**; never the same framing twice in a row.
- **Framing vocabulary** — centered (hero / climax / a single code surface) · rule-of-thirds · split-screen (before/after, two surfaces) · layered-depth (immersive) · asymmetric 60/40 or 70/30 (a code surface + a caption rail) · triptych (three changes at once) · full-width strip (a file list / timeline). Let the beat decide, not a quota.
- **Density** — primary visual ≥ 40% of canvas; ≥ 3 depth layers; never a lone small cluster floating in empty space. Openings/closings are prone to emptiness — add environmental layers (a dim grid, low-opacity scanlines, brand-color ambient). Squint test: after blur you can still pick out the #1 element.
- **Hierarchy** — combine ≥ 2 of size (3:1) / weight (800 vs 400) / contrast / position (upper-third is golden) / motion, so one element clearly dominates.
- **Depth** — layer 23 of: size, blur, opacity gradient, overlap, shadow-stack, counter-scale on a push.
- **Don't show**: nav bars, footers, scrollbars, real cursors / browser chrome, generic decorative shapes, floating bokeh / purple-blue "AI" gradients (banned). The navy code surface is for code beats only; mechanism diagrams stay on cream.
## Portrait & square (non-16:9 canvases)
The zones, density, hierarchy, and depth principles all still apply; the **aspect ratio** changes, and a wide layout doesn't transplant into a tall one — design for the storyboard's `format` from the start.
- **Stack vertically, not side-by-side** — split-screen / triptych / 60-40 become top/bottom stacks. A code surface runs nearly full-width in portrait with fewer visible lines.
- **Vertical center moves with the canvas** — anchor a centered hero around **y ≈ 0.42 × height** (portrait ≈806, square ≈454), not a fixed 540.
- **Type runs larger, fewer words per line.** **Travels well to portrait:** Centered, Layered Depth, Full-Width Strip; **avoid** wide Split Screen / Triptych — use stacked equivalents.
## `## Video direction` — write the invariants ONCE
The whole video shares one look and one motion grammar. Write a **`## Video direction`** block ONCE at the top of `STORYBOARD.md` so every frame inherits it and per-frame Scene lines carry only the **delta**. This block is load-bearing — **keep it.**
- **palette system** — from `frame.md` (claude): which roles map to which hues. Never invent.
- **motion grammar + reveal model** — long-tail eases (`power3` default, smooth over bouncy) + the **VO-paced reveal** model + what may stay alive during a hold (subtle jitter at most) (→ `motion-language.md`).
- **rhythm / held-frame allocation** — name the **held / breather frames** so the video varies its energy.
- **negative list** — off-brand textures, **plus both motion failure modes** — slideshow (front-load then freeze) and screensaver (everything floating independently) (→ `motion-language.md`).
Do **not** repeat these per frame.
## Palette & type — from `frame.md`, never invented
- **Palette**`frame.md` (the adopted pack) is the color truth; apply its roles per frame. Generic basics (one accent, tint neutrals, avoid pure `#000`/`#fff`)`hyperframes-creative/references/house-style.md`.
- **Type** — fonts resolve via `frame.md`'s type tokens; reference them **by role** (display / body / mono / the pack's ramp), never by raw family or px. Typography craft (embedded fonts, dark-bg optical compensation, `tabular-nums`) → `hyperframes-creative/references/typography.md`. In a faceless explainer, **type is often the primary visual** — the hero word, the coined term, the kinetic enumeration — so lean on the type ramp hard.
- **Palette**`frame.md` (claude) is the color truth; apply its roles per frame. Generic basics → `hyperframes-creative/references/house-style.md`.
- **Type** — fonts resolve via `frame.md`'s type tokens; reference them **by role** (display / body / mono / the pack's ramp), never by raw family or px. Code surfaces and mechanism labels use the **mono** role. Typography craft → `hyperframes-creative/references/typography.md`.
## Caption-band keep-out (plan side)
The bottom ~17% of the canvas is reserved for the caption pill. Plan every frame's content into the **top ~83%** so nothing important lands in the band (the worker enforces the pixel cutoff; you plan the layout). Holds even when captions are disabled — bottom-edge consistency. Geometry detail → `composition.md`.
The bottom ~17% of the canvas is reserved for the caption pill. Plan every frame's content into the **top ~83%** (the worker enforces the pixel cutoff). When captions are enabled, primary content caps at the band top, and a centered hero anchors at **y ≈ 0.42 × height** (landscape ≈454, portrait ≈806); background / ambient layers are exempt and may stay full-bleed. Holds even when captions are disabled — bottom-edge consistency.
## Where the detail lives
| For… | Read |
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| composition — zones, density, templates, invented-visual prominence, caption geometry | `composition.md` (local) |
| motion — the shot model, phases, idle budget, beat structure, stillness | `motion-language.md` (local) |
| effect ids + blueprint ids (vocabulary + recipes) | `../hyperframes-animation/blueprints-index.md` + `../hyperframes-animation/rules-index.md` |
| palette + type tokens | the project's `frame.md`; basics → `hyperframes-creative` `house-style.md` / `typography.md` |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| the proven shapes + role→blueprint menu + how to pick | `../hyperframes-animation/blueprints-index.md``blueprints/<id>.md` |
| the `code-*` blocks (pick + fill for a code beat) | `code-vocabulary.md` (local) |
| motion — shot model, vocabulary, holds, idle budget, stillness, seek-safe | `motion-language.md` (local) |
| layout — framing, density, depth, hierarchy, inventing the visual, caption band | the **Layout** + **Inventing the visual** sections in this file |
| concrete eases / ms / stagger + rule recipe bodies (Step 5) | local `../hyperframes-animation/rules/` (the frame worker reads it; you don't) |
| palette + type tokens | the project's `frame.md` (claude); basics → `hyperframes-creative` |
| within-frame cuts / seams (zoom-through · cut-the-curve · waterfall) | `cut-catalog.md` (the worker builds them inside the composition) |
| transitions | story-design owns `transition_in`; you don't touch it |
## Before you finish — checklist
- Every frame has `effects` (≥1 cited id; **≥3 when no `blueprint`** is named); a `blueprint` where the frame matches one, with a shot-by-shot composition note.
- **Every frame's composition note is phased** (entrance → development → settle) — not a single entry that then freezes; the ≥3 effects are **sequenced across phases**, not all fired at t=0.
- **Stillness is only the 2-3 frames allocated in Video direction**; every other frame develops mid-shot.
- Each frame names its **invented** `focal` + per-element `roles` (foreground / background / supporting), kept few and load-bearing.
- A `mechanism` frame's `focal` is an **invented animated diagram of the behavior** (or a `flowchart` / `data-chart`), choreographed across its phases — not a code block, not typography; the body is not an unbroken run of code surfaces.
- **Video direction** stated once at the top (palette · shot model + idle budget · negative list incl. both failure modes · stillness allocation); per-frame entries are deltas.
- Content planned into the top ~83% (caption band clear).
- Palette / type pulled from `frame.md` by role — nothing invented.
- **`## Video direction`** written once at the top (palette · motion grammar + shot model + idle budget · stillness allocation · negative list incl. both failure modes); per-frame entries are deltas.
- Every frame is a **time-coded shot sequence** with real second windows across its `duration` — not a tag bag.
- **No frame front-loads** — at t=0 only what the VO is saying enters; each further piece reveals on its spoken cue, across the back ~50%. Window count follows the VO.
- Every frame names a **`blueprint:`** id (Reproduce / Adapt) or `compose`; an Adapt keeps the signature move; nothing collapses to a single front-loaded dump.
- **Code beats** name a `code-*` block as the `focal`, let the block own the code animation, and choreograph only the surrounding Code Surface in the Scenes.
- **Mechanism beats** name an **invented animated diagram of the behavior** (or a `flowchart` / `data-chart`), choreographed across the Scenes on claude's cream ground with one coral marker — not a code block, not typography; the body is not an unbroken run of code surfaces.
- **Impact** uses a `number-lockup` with a `count-up`; the **credits** close uses the real avatars as the `focal`.
- Each non-code, non-credits frame names its **invented** `focal` + per-element `roles`, kept few and load-bearing.
- Layout + motion named **inline** per Scene (no px / ease curves / ms / JS).
- Content planned into the top ~83% (caption band clear); palette / type pulled from `frame.md` by role.
- You wrote no HTML.
+215 -8
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// assemble-index.mjs — deterministic top-level index.html assembly for a
// product-launch project. No subagent, no judgment: turns STORYBOARD.md + the
// HyperFrames project. No subagent, no judgment: turns STORYBOARD.md + the
// built frame files (+ optional audio_meta.json) into the standalone index.html
// the renderer consumes, and stages the frame-named capture assets into assets/.
//
@@ -31,13 +31,25 @@
// Reads: --storyboard STORYBOARD.md, --hyperframes <project root>,
// [--audio-meta audio_meta.json]. On disk: each built frame's src html,
// capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
// Writes: <project>/index.html + stages assets/<basename>.
// Writes: <project>/index.html + stages assets/<basename> + (guard ① below)
// repairs a frame file in place when its root is missing data-width/height.
//
// Pre-assembly frame guards (run in the same pass that reads each frame, so common
// `lint` failures surface HERE instead of after assembly + a wasted render):
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
// dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
// ② HARD FAIL — <video>/<audio> inside a sub-comp: the runtime only drives media that
// is a DIRECT child of the host root, so sub-comp media renders blank/black.
// ③ HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
// and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
//
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
// frames, a built/animated frame missing its src/file, a frame with no
// duration, an inner data-composition-id mismatch). No backstop: fix upstream.
// duration, an inner data-composition-id mismatch, or a guard ②/③ violation).
// No backstop: fix upstream.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { basename, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
@@ -55,6 +67,52 @@ function die(msg) {
process.exit(1);
}
// Ensure the BGM track is at least `total` seconds long. HeyGen (and most music
// libraries) return a short loopable clip (~1530s); mounting it at data-duration=total
// would leave the video's TAIL SILENT. If the file is short, loop-extend it to `total`
// (with a 0.4s fade-in + 1.5s fade-out) into a sibling *.loop.mp3 and return that path.
// Needs ffprobe+ffmpeg (present in the render env); degrades to the original + a warning
// when they're absent, so assembly never hard-fails on audio tooling.
function ensureBgmCovers(relPath, hyperframesDir, total) {
const abs = join(hyperframesDir, relPath);
const probe = spawnSync(
"ffprobe",
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", abs],
{ encoding: "utf8" },
);
if (probe.status !== 0) return { looped: false, short: false, reason: "ffprobe unavailable" };
const dur = parseFloat(String(probe.stdout || "").trim());
if (!Number.isFinite(dur) || dur <= 0)
return { looped: false, short: false, reason: "unreadable duration" };
if (dur >= total - 0.1) return { looped: false, short: false, dur }; // already covers
const relOut = relPath.replace(/\.([^./]+)$/, ".loop.$1");
const absOut = join(hyperframesDir, relOut);
const fadeOut = Math.max(0, total - 1.5);
const ff = spawnSync(
"ffmpeg",
[
"-y",
"-stream_loop",
"-1",
"-i",
abs,
"-t",
String(total),
"-af",
`afade=t=in:st=0:d=0.4,afade=t=out:st=${fadeOut}:d=1.5`,
"-c:a",
"libmp3lame",
"-q:a",
"2",
absOut,
],
{ encoding: "utf8" },
);
if (ff.status !== 0 || !existsSync(absOut))
return { looped: false, short: true, dur, reason: "ffmpeg unavailable" };
return { looped: true, rel: relOut, from: dur };
}
const hyperframesDir = resolve(flag("hyperframes", "."));
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audioMetaPath = resolve(flag("audio-meta", join(hyperframesDir, "audio_meta.json")));
@@ -62,12 +120,127 @@ const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));
const r3 = (x) => Math.round(x * 1000) / 1000;
const anomalies = [];
const frameErrors = []; // fatal per-frame composition violations (guards ②/③) — reported together
const repairs = []; // auto-repairs applied to frame files in place (guard ①)
// ---------- parse storyboard ----------
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const { width: WIDTH, height: HEIGHT } = parseFormat(manifest.globals.format);
// ---------- per-frame composition guards (see header ①②③) ----------
// String-level checks on each frame's HTML — no DOM parse, deterministic, run in
// the same pass that already reads the file. OPEN_TAG matches one opening tag while
// tolerating quoted attribute values that contain ">" (e.g. inline styles).
const OPEN_TAG = "<([a-zA-Z][a-zA-Z0-9-]*)((?:[^>\"']|\"[^\"]*\"|'[^']*')*)>";
const attrPresent = (attrs, name) => new RegExp(`(?:^|\\s)${name}(?:[\\s=]|$)`).test(attrs);
const attrValue = (attrs, name) => {
const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`));
return m ? (m[1] ?? m[2]) : null;
};
// The root (or a nested-comp mount) legitimately carries timing without class="clip".
const isRootish = (attrs) =>
/(?:^|\s)id\s*=\s*["']root["']/.test(attrs) ||
attrPresent(attrs, "data-composition-id") ||
attrPresent(attrs, "data-composition-src");
// Locate the composition root opening tag: prefer id="root", else the first element
// carrying data-composition-id. Returns { start, end, full, attrs } or null.
function findRootTag(html) {
const re = new RegExp(OPEN_TAG, "g");
let m;
let firstCompId = null;
while ((m = re.exec(html))) {
const attrs = m[2];
if (/(?:^|\s)id\s*=\s*["']root["']/.test(attrs))
return { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
if (attrPresent(attrs, "data-composition-id") && !firstCompId)
firstCompId = { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
}
return firstCompId;
}
// Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
function guardFrame(html, label) {
const errors = [];
// Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
// in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
// trip ②/③. ① still splices into the ORIGINAL html, so its offsets stay correct.
const scan = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
.replace(/<style\b[\s\S]*?<\/style[^>]*>/gi, " ");
// ② media inside a sub-comp — never driven by the runtime (renders blank/black).
const media = scan.match(/<(video|audio)(?=[\s/>])/i);
if (media) {
errors.push(
`${label}: has a <${media[1].toLowerCase()}> inside the sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html) — sub-comp media renders blank/black. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).`,
);
}
// ③ timed-element checks: missing class="clip", and same-track window overlap.
const re = new RegExp(OPEN_TAG, "g");
const clips = [];
let m;
while ((m = re.exec(scan))) {
const attrs = m[2];
if (
!attrPresent(attrs, "data-start") ||
!attrPresent(attrs, "data-duration") ||
!attrPresent(attrs, "data-track-index")
)
continue;
if (isRootish(attrs)) continue;
if (!/(?:^|\s)class\s*=\s*["'][^"']*\bclip\b[^"']*["']/.test(attrs)) {
errors.push(
`${label}: a timed <${m[1]}> (data-start/duration/track-index) has no class="clip" — it renders for the whole frame instead of only its window. Add class="clip", or remove the timing attrs if it is a GSAP-animated element meant to be present throughout.`,
);
}
const track = attrValue(attrs, "data-track-index");
const start = parseFloat(attrValue(attrs, "data-start"));
const dur = parseFloat(attrValue(attrs, "data-duration"));
if (track != null && Number.isFinite(start) && Number.isFinite(dur))
clips.push({ track, start, end: start + dur });
}
const EPS = 1e-3; // adjacent clips that merely touch are legal
const byTrack = new Map();
for (const c of clips) {
const arr = byTrack.get(c.track);
if (arr) arr.push(c);
else byTrack.set(c.track, [c]);
}
for (const [track, list] of byTrack) {
list.sort((a, b) => a.start - b.start);
for (let i = 1; i < list.length; i++) {
if (list[i].start < list[i - 1].end - EPS) {
errors.push(
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
);
break; // one report per track is enough
}
}
}
// ① auto-repair: ensure the root carries data-width / data-height.
let repairedHtml = null;
let repairNote = null;
const root = findRootTag(html);
if (root) {
const needW = !attrPresent(root.attrs, "data-width");
const needH = !attrPresent(root.attrs, "data-height");
if (needW || needH) {
const inject =
(needW ? ` data-width="${WIDTH}"` : "") + (needH ? ` data-height="${HEIGHT}"` : "");
const newTag = root.full.replace(/(\/?>)$/, `${inject}$1`);
repairedHtml = html.slice(0, root.start) + newTag + html.slice(root.end);
repairNote = `${label}: injected${needW ? " data-width" : ""}${needH ? " data-height" : ""} (${WIDTH}×${HEIGHT}) on the root — was missing (would lint root_missing_dimensions)`;
}
}
return { errors, repairedHtml, repairNote };
}
// ---------- resolve mountable frames in document order ----------
// A frame mounts when its src html exists on disk. A built/animated frame
// missing its src/file is a contract break (die). An outline frame with no
@@ -82,7 +255,12 @@ for (const f of manifest.frames) {
continue;
}
const compAbs = join(hyperframesDir, f.src);
if (!existsSync(compAbs)) {
// Read directly and handle ENOENT here rather than an existsSync precheck — the
// check→read/write pair is a TOCTOU race CodeQL flags (js/file-system-race).
let html;
try {
html = readFileSync(compAbs, "utf8");
} catch {
if (built)
die(`${label} is ${f.status} but its src ${f.src} is not on disk — re-dispatch the worker`);
anomalies.push(`${label}: src ${f.src} not on disk (status ${f.status}) — skipped`);
@@ -97,7 +275,6 @@ for (const f of manifest.frames) {
// finds the timeline. frame_id = src basename (frame-worker contract); verify
// the inner html actually declares it.
const compId = basename(f.src).replace(/\.html?$/i, "");
const html = readFileSync(compAbs, "utf8");
// Guard against blank/partial scene files: a worker that errors or is
// interrupted mid-write leaves an empty (or markup-less) file that exists but
// fails at render with "Composition HTML is empty or could not be parsed".
@@ -107,6 +284,14 @@ for (const f of manifest.frames) {
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
);
}
// pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
const guard = guardFrame(html, label);
if (guard.repairedHtml) {
writeFileSync(compAbs, guard.repairedHtml);
html = guard.repairedHtml;
repairs.push(guard.repairNote);
}
for (const e of guard.errors) frameErrors.push(e);
if (
!html.includes(`data-composition-id="${compId}"`) &&
!html.includes(`data-composition-id='${compId}'`)
@@ -115,6 +300,12 @@ for (const f of manifest.frames) {
}
mounted.push({ frame: f, compId, durationSeconds: r3(f.durationSeconds) });
}
if (frameErrors.length) {
die(
`${frameErrors.length} frame composition violation(s) — fix the worker output and re-assemble:\n` +
frameErrors.map((e) => `${e}`).join("\n"),
);
}
if (mounted.length === 0) die("no mountable frames (none built with an on-disk src)");
// cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
@@ -181,16 +372,28 @@ for (const m of mounted) {
body.push("");
}
// (track 11) BGM — duck under narration when any voice is present.
// (track 11) BGM — duck under narration when any voice is present. Loop-extend a short
// track to the full video length so the tail isn't silent (libraries return ~1530s clips).
let bgmEmitted = false;
let bgmNote = "";
if (audio.bgm?.path) {
if (existsSync(join(hyperframesDir, audio.bgm.path))) {
let bgmSrc = audio.bgm.path;
const cov = ensureBgmCovers(audio.bgm.path, hyperframesDir, TOTAL);
if (cov.looped) {
bgmSrc = cov.rel;
bgmNote = ` (looped ${cov.from.toFixed(1)}s→${TOTAL}s)`;
} else if (cov.short) {
anomalies.push(
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
);
}
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
body.push(
` <!-- BGM -->`,
` <audio`,
` id="el-bgm"`,
` src="${audio.bgm.path}"`,
` src="${bgmSrc}"`,
` data-start="0"`,
` data-duration="${TOTAL}"`,
` data-track-index="11"`,
@@ -352,11 +555,15 @@ console.log(`✓ wrote ${outPath}`);
console.log(` canvas: ${WIDTH}×${HEIGHT}`);
console.log(` frames (track 1): ${mounted.length}`);
console.log(` voice (track 10): ${voiceCount}`);
console.log(` bgm (track 11): ${bgmEmitted ? "yes" : "no"}`);
console.log(` bgm (track 11): ${bgmEmitted ? "yes" + bgmNote : "no"}`);
console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`);
console.log(` sfx (track 20+): ${sfxEmitted}`);
console.log(` assets staged: ${staged}/${wanted.size}`);
console.log(` total duration: ${TOTAL}s`);
if (repairs.length) {
console.log(`\nrepaired (frame files updated in place):`);
for (const rp of repairs) console.log(` - ${rp}`);
}
if (anomalies.length) {
console.log(`\nanomalies (non-fatal):`);
for (const a of anomalies) console.log(` - ${a}`);
+7 -7
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env node
// audio.mjs — product-launch audio ADAPTER. The TTS / BGM / SFX implementation
// audio.mjs — pr-to-video audio ADAPTER. The TTS / BGM / SFX implementation
// no longer lives here: it is the shared engine at
// ../../hyperframes-media/scripts/audio.mjs. This file only (a) maps the
// product-launch model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the
// frame model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the
// engine's neutral audio_request.json, (b) converts the engine's id-keyed
// audio_meta back into the frame-keyed shape captions.mjs / assemble-index.mjs
// already consume, and (c) keeps the local `sync-durations` pass (it rewrites
// STORYBOARD.md, which is product-launch-specific).
// STORYBOARD.md, which is this skill's concern).
//
// Three modes (unchanged CLI surface):
// (default) generate — engine --only tts,bgm. BGM mode is "retrieve" (strict:
@@ -86,9 +86,9 @@ function runEngine({ request, hyperframesDir, neutral, only, extra = [] }, die)
if (r.status !== 0) die(`media audio engine exited ${r.status}`);
}
// Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by
// Engine neutral meta (id-keyed) → frame-keyed meta consumed by
// captions.mjs / assemble-index.mjs. id is the zero-padded frame number.
function toProductLaunchMeta(neutral) {
function toFrameKeyedMeta(neutral) {
const voices = (neutral.voices ?? []).map((v) => ({
frame: Number(v.id),
path: v.path,
@@ -152,7 +152,7 @@ function runGenerate(argv) {
const neutral = neutralPath(outPath);
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
const meta = toFrameKeyedMeta(JSON.parse(readFileSync(neutral, "utf8")));
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(
`✓ audio generate: ${meta.voices.length} voice + ${meta.bgm ? "1 bgm" : "no bgm"}${outPath}`,
@@ -189,7 +189,7 @@ function runFetchSfx(argv) {
// voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved.
runEngine({ request, hyperframesDir, neutral, only: "sfx" }, die);
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
const meta = toFrameKeyedMeta(JSON.parse(readFileSync(neutral, "utf8")));
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`);
}
+268 -23
View File
@@ -121,11 +121,51 @@ const hueDist = (a, b) => {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
};
function hexToRgb(hex) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgbToHsl = (r, g, b) =>
hexToHsl("#" + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join(""));
// Repaint a chromatic rgba()/rgb() tint with the brand accent's RGB, keeping its alpha.
// A near-neutral rgb (shadow / scrim overlay) is left untouched; a non-rgba string → null.
function remapRgbaToAccent(val, brAccent, brAccent2, prAccentHsl, prAccent2Hsl) {
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+%?))?\s*\)$/i.exec(
String(val).trim(),
);
if (!m) return null;
const r = +m[1],
g = +m[2],
b = +m[3],
a = m[4];
if (Math.max(r, g, b) - Math.min(r, g, b) < 16) return null; // neutral overlay — keep as-is
const src = rgbToHsl(r, g, b);
const useSecond =
brAccent2 &&
prAccentHsl &&
prAccent2Hsl &&
src &&
hueDist(src.h, prAccent2Hsl.h) < hueDist(src.h, prAccentHsl.h);
const t = hexToRgb(useSecond ? brAccent2 : brAccent);
if (!t) return null;
return a !== undefined
? `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`
: `rgb(${t[0]}, ${t[1]}, ${t[2]})`;
}
// ── brand tokens ──────────────────────────────────────────────────────────────
let brandColors = [];
let brandFonts = [];
let brandFontWeights = []; // weights the brand text font actually ships (tokens fonts[].weights)
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
const isIconFont = (name) =>
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
String(name),
);
if (existsSync(tokensPath)) {
try {
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
@@ -137,7 +177,19 @@ if (existsSync(tokensPath)) {
brandFonts = (t.fonts ?? [])
.map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? "")))
.map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim())
.filter(Boolean);
.filter(Boolean)
.filter((f) => !isIconFont(f));
// Union of the (non-icon) brand fonts' available weights — used to clamp the preset's
// type ramp so a font shipping only 400/500 never faux-bolds a 600/700 heading.
brandFontWeights = [
...new Set(
(t.fonts ?? [])
.filter((f) => f && typeof f === "object" && !isIconFont(f.family ?? f.name ?? ""))
.flatMap((f) => (Array.isArray(f.weights) ? f.weights : []))
.map((w) => parseInt(w, 10))
.filter((w) => Number.isFinite(w)),
),
].sort((a, b) => a - b);
brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : [];
} catch (e) {
die(`tokens.json parse: ${e.message}`);
@@ -156,7 +208,7 @@ if (brandColors.length && presetColors.length) {
// Fall back to the legacy luminance/chroma heuristic only when stats are absent —
// but pick the accent via pickAccent either way so a UA-default link color never wins.
const br =
brandRolesFromStats(brandColorStats) ??
brandRolesFromStats(brandColorStats, brandColors) ??
(() => {
// strip UA-default link colors so a stray <a> color can't become ink/canvas/accent
const clean = brandColors.filter((h) => !UA_DEFAULT_COLORS.has(h.toUpperCase()));
@@ -174,39 +226,66 @@ if (brandColors.length && presetColors.length) {
` ⚠ accent ${br.accent} 彩度很低 (${chroma(br.accent)}) — 确认这是品牌色而非中性/默认色`,
);
}
// Map by LUMINANCE POLARITY, not by role name: the preset's darker neutral takes the
// brand's darker neutral, the lighter takes the lighter. So a dark-ground preset stays
// dark and a light-ground preset stays light — both land on the brand's real values,
// even when the brand's canvas is dark (dark-mode brand) and ink is light.
// Map by LUMINANCE POLARITY. The preset's darker value takes the brand's darker value and
// the lighter takes the lighter — UNLESS the brand's GROUND polarity differs from the
// preset's. Every shipped preset is light-ground; a dark-mode brand (Linear, Vercel,
// Raycast…) has its canvas darker than its ink (colorStats already resolved the real
// ground as the largest-area background). On a polarity MISMATCH we INVERT the mapping so a
// light preset becomes the dark brand (canvas↔ink swap) instead of forcing the brand onto
// an off-brand light video; neutral/tint lightness is then mirrored (L→1L) so the whole
// palette flips to the brand's ground. Same-polarity (the common case) is unchanged.
const darker = (a, b) => ((lum(a) ?? 0) <= (lum(b) ?? 0) ? a : b);
const prDark = darker(pr.ink, pr.canvas);
const prLight = prDark === pr.ink ? pr.canvas : pr.ink;
const brDark = darker(br.ink, br.canvas);
const brLight = brDark === br.ink ? br.canvas : br.ink;
const presetGroundDark = (lum(pr.canvas) ?? 255) < (lum(pr.ink) ?? 0);
const brandGroundDark = (lum(br.canvas) ?? 255) < (lum(br.ink) ?? 0);
const invert = presetGroundDark !== brandGroundDark;
const mapDark = invert ? brLight : brDark; // preset's dark value → this brand value
const mapLight = invert ? brDark : brLight; // preset's light value → this brand value
const flipL = (l) => (invert ? 1 - l : l); // mirror tint/neutral lightness when flipping
const prAccentHsl = hexToHsl(pr.accent);
const prAccent2Hsl = hexToHsl(pr.accent2);
const newByKey = new Map();
for (const [key, val] of presetColors) {
const ph = hexToHsl(val);
let next;
if (val === prDark) next = brDark;
else if (val === prLight) next = brLight;
if (val === prDark) next = mapDark;
else if (val === prLight) next = mapLight;
else if (
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down)(?:[-_]|$)/i.test(
key,
)
)
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
// MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
// color and would otherwise be claimed as accent2 and recolored to the brand hue.
next = val;
else if (val === pr.accent)
next = br.accent; // primary accent → the EXACT brand color
else if (pr.accent2 !== pr.accent && val === pr.accent2)
next = br.accent2; // exact 2nd accent
else if (!ph)
next = val; // non-hex (rgba) → leave as-is
else {
// repaint the remaining tints: pick the brand accent whose preset counterpart is
// nearest in hue, then keep THIS color's own lightness so tint families stay families.
else if (!ph) {
// rgba()/rgb() tint → repaint its rgb with the brand accent, keep alpha (a neutral
// overlay is kept). A non-color non-hex value (var(), named) falls through unchanged.
next = remapRgbaToAccent(val, br.accent, br.accent2, prAccentHsl, prAccent2Hsl) ?? val;
} else if (chroma(val) < 16) {
// NEUTRAL source (grey text-ladder, hairline borders) → keep it NEUTRAL. Apply at most a
// whisper of the brand hue (sat ≤ 0.06); never the accent's full saturation — that is what
// turned the grey ladder into saturated blue.
const bh = hexToHsl(br.accent);
next = bh ? hslToHex(bh.h, Math.min(ph.s, 0.06), flipL(ph.l)) : val;
} else {
// chromatic tint → repaint with the nearest brand accent's hue+sat, keep THIS color's
// lightness so tint families stay families.
const useSecond =
pr.accent !== pr.accent2 &&
prAccentHsl &&
prAccent2Hsl &&
hueDist(ph.h, prAccent2Hsl.h) < hueDist(ph.h, prAccentHsl.h);
const bh = hexToHsl(useSecond ? br.accent2 : br.accent);
next = bh ? hslToHex(bh.h, bh.s, ph.l) : val;
next = bh ? hslToHex(bh.h, bh.s, flipL(ph.l)) : val;
}
if (next !== val) newByKey.set(key, next);
}
@@ -229,7 +308,7 @@ if (brandColors.length && presetColors.length) {
})
.join("\n");
summary.push(
`colors: dark ${prDark}${brDark}, light ${prLight}${brLight}, accent ${pr.accent}${br.accent}` +
`colors: ${invert ? "INVERTED (dark-mode brand on light preset) · " : ""}dark ${prDark}${mapDark}, light ${prLight}${mapLight}, accent ${pr.accent}${br.accent}` +
` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`,
);
} else {
@@ -246,15 +325,178 @@ if (brandFonts.length) {
const strip = (q) => (q ? q.replace(/^"|"$/g, "") : null);
const pDisplay = strip(pf.display);
const pBody = strip(pf.body);
const bDisplay = brandFonts[0];
const bBody = brandFonts[1] ?? brandFonts[0];
if (pDisplay && bDisplay) md = md.split(`"${pDisplay}"`).join(`"${bDisplay}"`);
if (pBody && pBody !== pDisplay && bBody) md = md.split(`"${pBody}"`).join(`"${bBody}"`);
summary.push(`fonts: display ${pDisplay}${bDisplay}, body ${pBody}${bBody}`);
const pMono = strip(pf.mono);
// A monospace brand face is for code / labels / chrome — never the reading display or body.
// Split the brand fonts: the primary NON-mono family carries display AND body (the common
// single-sans case, e.g. Inter for everything), and a captured mono (Berkeley Mono,
// JetBrains Mono…) is routed onto the preset's mono role instead of turning the body
// monospace. (Distinct display/body brands still resolve to a clean sans; hand-tune the
// display in frame.md if a separate display face is wanted.)
const isMonoFont = (n) =>
/(?:^|[\s_-])mono(?:[\s_-]|$)|monospace|consol|courier|menlo|monaco|jetbrains|berkeley|space\s*mono|ibm\s*plex\s*mono|sf\s*mono|roboto\s*mono|source\s*code|fira\s*code|geist\s*mono|dm\s*mono/i.test(
String(n),
);
const nonMono = brandFonts.filter((f) => !isMonoFont(f));
const monoFonts = brandFonts.filter(isMonoFont);
const bDisplay = nonMono[0] ?? brandFonts[0];
const bBody = nonMono[0] ?? brandFonts[0];
const bMono = monoFonts[0] ?? null;
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Replace the preset family as a WHOLE WORD/PHRASE everywhere — frontmatter values,
// component strings like "Space Grotesk 600", AND prose — case-sensitive with word
// boundaries so a single-word family ("Inter") can never corrupt a substring
// ("interactive"). Quote-exact replace alone missed names baked into longer strings + prose.
const swapFamily = (from, to) => {
if (from && to && from !== to) md = md.replace(new RegExp(`\\b${escRe(from)}\\b`, "g"), to);
};
swapFamily(pDisplay, bDisplay);
if (pBody !== pDisplay) swapFamily(pBody, bBody);
// route the brand mono onto the preset's mono role (only if the preset has a DISTINCT mono
// family — never collapse body/display into mono)
if (bMono && pMono && pMono !== pBody && pMono !== pDisplay) swapFamily(pMono, bMono);
summary.push(
`fonts: display ${pDisplay}${bDisplay}, body ${pBody}${bBody}` +
(bMono && pMono && pMono !== pBody && pMono !== pDisplay ? `, mono ${pMono}${bMono}` : ""),
);
} else {
summary.push("fonts: no brand fonts — preset fonts kept");
}
// ── cap type weights to the brand font's available faces ──────────────────────
// The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
// only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
// to the NEAREST weight the brand font actually provides (tokens.json fonts[].weights).
if (brandFonts.length && brandFontWeights.length) {
const avail = brandFontWeights;
const nearest = (n) =>
avail.reduce((best, w) => {
const dw = Math.abs(w - n),
db = Math.abs(best - n);
return dw < db || (dw === db && w > best) ? w : best;
}, avail[0]);
let capped = 0;
const cap = (num) => {
const n = parseInt(num, 10);
if (avail.includes(n)) return String(n);
const c = nearest(n);
if (c !== n) capped++;
return String(c);
};
let inType = false;
md = md
.split(/\r?\n/)
.map((line) => {
if (/^typography:\s*$/.test(line)) {
inType = true;
return line;
}
if (inType && /^\S/.test(line)) inType = false;
let out = line;
// (a) structured `weight: NNN` in the typography ramp
if (inType) out = out.replace(/(\bweight:\s*)(\d{3})\b/g, (m, pfx, num) => pfx + cap(num));
// (b) a weight baked into a quoted `typography:` component value, e.g.
// cta-button → typography: "Basier Square 600" (NNN not followed by a unit like px)
out = out.replace(
/(typography:\s*"[^"]*?\b)(\d{3})\b(?![a-z%])/gi,
(m, pfx, num) => pfx + cap(num),
);
return out;
})
.join("\n");
if (capped)
summary.push(`fonts: capped ${capped} type weight(s) to brand faces {${avail.join(", ")}}`);
}
// ── brand-adaptation note ─────────────────────────────────────────────────────
// The remix fixes the NORMATIVE frontmatter, but the preset's PROSE still carries its
// original weight ranges / color-names. Prepend a short "frontmatter is truth" header so a
// reader (or frame worker) interprets any lingering preset prose THROUGH the brand values —
// instead of fragile per-sentence prose surgery.
if (brandFonts.length || (brandColors.length && presetColors.length)) {
const bD = brandFonts[0];
const bB = brandFonts[1] ?? brandFonts[0];
const note =
`## Brand adaptation (READ FIRST — the frontmatter is the source of truth)\n\n` +
`This is the **${presetName}** preset remixed onto the captured brand. The YAML frontmatter above ` +
`(colors · typography · components) is **normative and already correct — use it verbatim.** The prose ` +
`below is the ORIGINAL preset's intent; read it THROUGH the frontmatter:\n\n` +
(brandFonts.length
? `- **Fonts** — already set to **${bD}** (display) / **${bB}** (body); ignore any preset font name lingering in prose.\n`
: "") +
(brandFontWeights.length
? `- **Weights** — the brand font ships \`{${brandFontWeights.join(", ")}}\` only; every weight is clamped to these — ignore higher preset weights (e.g. 600/700) in prose.\n`
: "") +
`- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values.\n`;
if (/^# .*$/m.test(md)) md = md.replace(/^# .*$/m, (m) => `${m}\n\n${note}`);
else md = `${note}\n${md}`;
summary.push("brand-adaptation note prepended");
}
// ── stage brand font files + emit @font-face ──────────────────────────────────
// A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
// nothing loads the actual face. If the capture downloaded font files, copy them to
// assets/fonts/ under CLEAN, weight-named names (so captions.mjs' family-prefix matcher
// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
if (brandFonts.length) {
const norm = (s) =>
String(s)
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase();
const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
const weightInfo = (name) => {
const s = name.toLowerCase();
if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" };
if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" };
if (/bold/.test(s)) return { n: 700, w: "Bold" };
if (/medium/.test(s)) return { n: 500, w: "Medium" };
if (/light|thin/.test(s)) return { n: 300, w: "Light" };
return { n: 400, w: "Regular" };
};
const fams = [...new Set(brandFonts)];
const srcDirs = [
join(hyperframesDir, "capture/assets/fonts"),
join(hyperframesDir, "assets/fonts"),
].filter((d) => existsSync(d));
const files = [];
for (const d of srcDirs)
for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f });
// Single family → all font files belong to it (the common captured case, hash-named files
// included). Multiple families → assign each file to the longest family key its name contains.
const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length);
const famOf = (f) =>
fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x)));
const outDir = join(hyperframesDir, "assets/fonts");
const faces = [];
const stagedNames = new Set();
for (const { d, f } of files) {
const fam = famOf(f);
if (!fam) continue;
const { n, w } = weightInfo(f);
const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}.${extOf(f)}`;
if (stagedNames.has(clean)) continue;
mkdirSync(outDir, { recursive: true });
if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean));
stagedNames.add(clean);
faces.push(
`@font-face{font-family:"${fam}";font-weight:${n};font-style:normal;font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`,
);
}
if (faces.length) {
md +=
`\n\n## Font loading (auto-generated)\n\n` +
`The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` +
`Paste this \`<style>\` into every frame's \`<head>\`/\`<template>\` (captions use the same files) ` +
`so \`font-family\` resolves in preview, snapshot, and render alike:\n\n` +
"```html\n<style>\n" +
faces.join("\n") +
"\n</style>\n```\n";
summary.push(
`fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`,
);
}
}
// ── write frame.md ────────────────────────────────────────────────────────────
const framePath = join(hyperframesDir, "frame.md");
writeFileSync(framePath, md);
@@ -277,9 +519,12 @@ if (outColors.length !== presetColors.length) {
const outRoles = semanticColors(outColors);
const li = lum(outRoles.ink),
lc = lum(outRoles.canvas);
if (li != null && lc != null && li >= lc) {
// ink (type) and canvas (ground) must differ enough to READ — in EITHER direction. A
// light-mode spec has ink darker than canvas; a dark-mode spec (the polarity flip above)
// the reverse. Assert luminance SEPARATION, not a fixed polarity.
if (li != null && lc != null && Math.abs(li - lc) < 40) {
die(
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) is not darker than canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) — bad brand mapping`,
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) and canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) lack contrast — bad brand mapping`,
);
}
@@ -288,4 +533,4 @@ for (const s of summary) console.log(` ${s}`);
console.log(
` .hyperframes/caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`,
);
console.log(` self-check: keys preserved, ink darker than canvas`);
console.log(` self-check: keys preserved, ink/canvas contrast ok`);
+28 -32
View File
@@ -36,7 +36,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
import { dirname, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { captionBand, parseFormat } from "./lib/dimensions.mjs";
import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs";
import { parseColors, parseFonts, semanticColors, lum } from "./lib/tokens.mjs";
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
@@ -243,32 +243,11 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts
prev = out;
out = out.replace(/<!--[\s\S]*?-->/g, "");
}
// brand :root tokens + font loading, both into the reserved hole. Prefer local
// @font-face files (offline-safe, lint-clean). When NONE exist (the common PR case:
// the project uses the preset's own default font and ships no woff2), fall back to a
// Google Fonts @import for the resolved families — otherwise the caption text silently
// drops to the skin's generic fallback (e.g. Fraunces → Georgia), since the renderer's
// auto-resolved font list does not cover preset display fonts. Matches how the frame
// compositions load fonts. @import is valid here because it precedes the :root tokens in
// this <style>. Assumes the family exposes wght 400/500 (true for the variable editorial
// fonts the presets default to); shipping a local woff2 (the faces path) avoids the
// network dependency and the google_fonts_import lint warning entirely.
let fontLoad = faces;
if (!fontLoad) {
const fams = [
...new Set(
[fonts.display, fonts.body].filter(Boolean).map((f) => f.replace(/^"|"$/g, "").trim()),
),
];
if (fams.length) {
const q = fams.map((f) => `family=${f.replace(/\s+/g, "+")}:wght@400;500`).join("&");
fontLoad = ` @import url("https://fonts.googleapis.com/css2?${q}&display=swap");`;
}
}
// brand :root tokens + @font-face for the brand fonts, both into the reserved hole
out = fillOnce(
out,
/<style data-brand-tokens>\s*<\/style>/,
`<style data-brand-tokens>\n${fontLoad ? fontLoad + "\n" : ""}${tokens}\n </style>`,
`<style data-brand-tokens>\n${faces ? faces + "\n" : ""}${tokens}\n </style>`,
"<style data-brand-tokens></style> hole",
);
// Resolve the skin's font-family var()s to the brand family LITERAL. Two reasons:
@@ -297,6 +276,20 @@ function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts
// overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that
// balloons the pill, which is worse. Override only if a brand font genuinely clips.
out += "\n<style>\n .caption-line { line-height: 1.1 !important; }\n</style>";
// dark-ground caption contrast (auto). The preset caption skins are tuned for a LIGHT
// pill (cream); on a dark brand ground the pill goes near-black, so the skin's faint
// upcoming-word mix and light highlight block turn unreadable. When the resolved caption
// canvas is dark, override the three word states: a brighter muted upcoming color + an
// on-brand ACCENT highlight block with light text. Light grounds are left untouched.
const capCanvas = (tokens.match(/--cap-canvas:\s*(#[0-9a-fA-F]{6})/) || [])[1];
if (capCanvas && (lum(capCanvas) ?? 255) < 90) {
out +=
"\n<style>\n" +
" .caption-word { color: color-mix(in srgb, var(--cap-ink) 64%, var(--cap-canvas)); }\n" +
" .caption-word.is-active { color: var(--cap-ink); background: var(--cap-accent); box-shadow: 0 0 0 0.06em var(--cap-accent); }\n" +
" .caption-word.is-spoken { color: var(--cap-ink); background: transparent; box-shadow: none; }\n" +
"</style>";
}
return `<template id="captions-template" data-composition-id="captions" data-width="${W}" data-height="${H}">\n${out.trim()}\n</template>\n`;
}
@@ -312,8 +305,11 @@ function brandFontFaces(framePath, hyperframesDir) {
];
if (!families.length) return "";
const dirs = [
{ abs: join(hyperframesDir, "assets/fonts"), rel: "../assets/fonts" },
{ abs: join(hyperframesDir, "capture/assets/fonts"), rel: "../capture/assets/fonts" },
// ROOT-RELATIVE — compositions are served with the project root as their base URL, so a
// "../" prefix escapes the root (lint: invalid_parent_traversal_in_asset_path) and 404s in
// Studio/preview. Mirror what the frame workers use for images.
{ abs: join(hyperframesDir, "assets/fonts"), rel: "assets/fonts" },
{ abs: join(hyperframesDir, "capture/assets/fonts"), rel: "capture/assets/fonts" },
].filter((d) => existsSync(d.abs));
const weightOf = (n) => {
const s = n.toLowerCase();
@@ -370,19 +366,19 @@ function brandFontFaces(framePath, hyperframesDir) {
}
}
}
// Loud signal instead of a silent "". If frame.md named a brand font but no local file
// matched, the caller falls back to a Google Fonts @import (or, failing that, a generic
// font) — surface the cause here at build time rather than letting it surface 2 steps
// later as a font_family_without_font_face lint error disconnected from its root cause.
// Loud signal instead of a silent "". If frame.md named a brand font but no file
// matched, the caption text WILL fall back to a generic font in the render — surface
// the cause here (at build time) rather than letting it surface 2 steps later as a
// font_family_without_font_face lint error disconnected from its root cause.
if (!faces.length) {
const where = dirs.length
? dirs.map((d) => d.rel).join(" / ")
: "assets/fonts or capture/assets/fonts (neither exists)";
console.warn(
` ⚠ captions: frame.md names font ${families.map((f) => `"${f}"`).join(", ")} ` +
`but no matching .woff2/.woff/.ttf/.otf was found in ${where} — falling back to @import/generic ` +
`but no matching .woff2/.woff/.ttf/.otf was found in ${where} captions will fall back ` +
`(text may render in the wrong font). Stage a font file whose name starts with the family ` +
`(e.g. "TT Norms Pro" → TT_Norms_Pro_Bold.woff2) to ship it locally.`,
`(e.g. "TT Norms Pro" → TT_Norms_Pro_Bold.woff2) so it ships with the project.`,
);
}
return faces.join("\n");
@@ -1,4 +1,4 @@
// dimensions.mjs — canvas size + caption-band geometry for the product-launch
// dimensions.mjs — canvas size + caption-band geometry for the video
// pipeline. Single source of truth = the STORYBOARD frontmatter `format` global
// ("1920x1080" / "1080x1920" / "1080x1080", or a named orientation). Every
// script and the index assembler reads the size from here; none hardcodes it.
+41 -11
View File
@@ -50,18 +50,38 @@ export const UA_DEFAULT_COLORS = new Set(
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
// Priority:
// 1) with capture colorStats → the colorful color used MOST as an interactive
// background (buttons / pills). That is, by definition, the brand action color.
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
// accent shows up in MANY roles (link text + icon + button + badge), whereas a one-off
// CTA fill appears in just one. So rank chromatic (chroma>40) candidates by role
// diversity first, then total prevalence, then interactive use, then chroma. This keeps
// a pervasive brand color (e.g. an indigo used everywhere) ahead of a single bright
// button fill (e.g. a lime used once) — the old "top interactiveBg" rule picked the
// latter. Requiring interactiveBg>0 is dropped so a text/icon-only accent can still win.
// 2) no stats → most chromatic color AFTER removing UA defaults + `exclude`.
// A stray default link color (e.g. #0000EE) can win under neither path.
export function pickAccent(stats, colors, exclude = []) {
const ban = new Set([...exclude, ...UA_DEFAULT_COLORS].map((c) => String(c).toUpperCase()));
const ok = (h) => /^#[0-9a-fA-F]{6}$/.test(String(h)) && !ban.has(String(h).toUpperCase());
// Prominence rank from the (frequency-ordered) `colors` palette: index 0 = most used.
// A saturated color sitting at the TAIL is almost always a one-off (a single CTA fill),
// not the brand accent — capture colorStats counts are too sparse to tell these apart
// (e.g. Linear's indigo and a lime CTA both register count≈1), but palette ORDER does.
const rank = new Map((colors ?? []).map((h, i) => [String(h).toUpperCase(), i]));
const prom = (h) => (rank.has(String(h).toUpperCase()) ? rank.get(String(h).toUpperCase()) : 1e9);
if (Array.isArray(stats) && stats.length) {
const roles = (s) =>
((s.interactiveBg || 0) > 0 ? 1 : 0) +
((s.textCount || 0) > 0 ? 1 : 0) +
((s.bgCount || 0) > 0 ? 1 : 0);
const a = stats
.filter((s) => ok(s?.hex) && (s.interactiveBg || 0) > 0 && chroma(s.hex) > 40)
.filter((s) => ok(s?.hex) && chroma(s.hex) > 40)
.sort(
(x, y) => (y.interactiveBg || 0) - (x.interactiveBg || 0) || chroma(y.hex) - chroma(x.hex),
(x, y) =>
roles(y) - roles(x) || // used in MORE roles (link+icon+button) = the brand accent
prom(x.hex) - prom(y.hex) || // earlier in the palette = more prominent
(y.count || 0) - (x.count || 0) ||
(y.interactiveBg || 0) - (x.interactiveBg || 0) ||
chroma(y.hex) - chroma(x.hex),
);
if (a.length) return a[0].hex;
}
@@ -77,7 +97,7 @@ export function pickAccent(stats, colors, exclude = []) {
// are unusable, so the caller can fall back. canvas = the color painting the most real
// background area (the page ground, dark or light); ink = the dominant text color that
// actually contrasts with the canvas; accent via pickAccent.
export function brandRolesFromStats(stats) {
export function brandRolesFromStats(stats, colorsInOrder) {
if (!Array.isArray(stats) || !stats.length) return null;
const v = stats.filter((s) => /^#[0-9a-fA-F]{6}$/.test(s?.hex || ""));
if (!v.length) return null;
@@ -87,11 +107,9 @@ export function brandRolesFromStats(stats) {
(b.maxArea || 0) - (a.maxArea || 0) ||
(b.bgCount || 0) - (a.bgCount || 0),
)[0]?.hex;
const accent = pickAccent(
v,
v.map((s) => s.hex),
[canvas],
);
// pass the frequency-ordered palette (tokens.colors) so pickAccent can use palette
// PROMINENCE — colorStats counts alone are too sparse to rank rare accents.
const accent = pickAccent(v, colorsInOrder ?? v.map((s) => s.hex), [canvas]);
if (!canvas || !accent) return null;
const cl = lum(canvas) ?? 0;
const ink =
@@ -170,5 +188,17 @@ export function parseFonts(md) {
roles.title ??
roles.hero ??
body;
return { display: q(display), body: q(body) };
// the monospace / chrome family (code, tags, ticks, page numbers) — so the remix can
// route a captured brand mono (Berkeley Mono, JetBrains Mono…) onto this role instead
// of the reading body. null when the preset has no distinct mono role.
const mono =
roles.mono ??
roles["mono-tag"] ??
roles["mono-chrome"] ??
roles["mono-tick"] ??
roles.code ??
roles.data ??
roles.pagenum ??
null;
return { display: q(display), body: q(body), mono: q(mono) };
}
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// transitions.mjs — inter-frame transition injector + verifier for product-launch.
// transitions.mjs — inter-frame transition injector + verifier for the video workflow.
//
// inject — read STORYBOARD frame order + each frame's transition_in, overlap
// the frame clip wrappers in index.html, and stamp the GSAP template.
+49 -34
View File
@@ -5,75 +5,90 @@
**INPUT** — your dispatch context provides:
- `PROJECT_DIR` — the project root; all paths are relative to it.
- `frame_id` — e.g. `03-mechanism`. Use it **verbatim** as the composition id, the `window.__timelines` key, and the file name (`compositions/frames/03-mechanism.html`) — that path **is** the frame's `src` in `STORYBOARD.md`, so writing there is how the assembler finds your frame.
- `frame_id` — e.g. `04-the-fix`. Use it **verbatim** as the composition id, the `window.__timelines` key, and the file name (`compositions/frames/04-the-fix.html`) — that path **is** the frame's `src` in `STORYBOARD.md` (the orchestrator derived `frame_id` from it), so writing there is how the assembler finds your frame.
- Your **`## Frame N` block** in `STORYBOARD.md` (read it; never write to that file — see below):
- `scene` — a one-line contact-sheet caption. **Design intent, never visible DOM text.**
- `voiceover` — the narration line. **Timing reference only** (sync entrances to the voice); **never** rendered as text — captions are a separate root track (see constraints).
- `duration` — your render length in seconds. **Fixed upstream; never change it or tween to fill a different length.**
- `transition_in` — informational. The injector stamps it at the root; **you do not author transitions.**
- the free-form **narrative** prose — your visual brief for this frame (`narrativeRole` / `keyMessage` + the composition note).
- `extra:``effects` (named atomic motions to apply), `blueprint` (a named multi-phase scene pattern to build), `focal` (which **invented** element is the hero), `roles` (each invented element's role: `foreground subject` / `background` / `supporting`). (`sfx` also rides in `extra` but is the orchestrator's — you mount no audio.)
- `frame.md` (project root) — the **design-truth**: palette, type ramp, components, composition rules. The LOOK. Pull every visual token from here.
- `ANIM_DIR` — absolute path to the shared `hyperframes-animation/` skill. Resolve every cited id under it: `ANIM_DIR/rules/<id>.md` (effect recipe), `ANIM_DIR/blueprints/<id>.md` (blueprint recipe), `ANIM_DIR/examples/<id>.html` (a worked, runnable source for each blueprint).
- the **time-coded shot sequence** — your build spec. A sequence of Scene lines (`Scene 1 (0.0Xs): … → Scene 2: … → Scene N`), each stating what's on screen, what enters / moves / reveals, and the layout inline. Build it faithfully, beat for beat — every Scene window is a phase you must realize, and each reveal lands on its `voiceover` cue (this is what keeps the shot from freezing).
- `blueprint:` — an id (or the literal `compose`). The id points to `../hyperframes-animation/blueprints/<id>.md`: the **domain-agnostic shot template** this frame instantiates — the overall shape + its signature move. Read it for the shape; `compose` means there's no template (common for a code beat — the `code-*` block is the shape), sequence the shot from the Scene lines directly.
- `focal:` — for a concept/mechanism beat, which **invented** element is the hero; for a **code beat**, the named **`code-*` block** (+ the hunk); for the **credits** close, the avatar row.
- `roles:` — each element's role: `foreground subject` / `background` full-bleed / `supporting`. Most are invented elements you design; the only real assets are the credits `assets/<login>.png` avatars.
- `sfx:` — the orchestrator's; you mount no audio.
- `frame.md` (project root) — the **design-truth**: palette (claude), type ramp, components, composition rules. The LOOK. Pull every visual token from here.
- `RULES_DIR` — absolute path to this skill's local `../hyperframes-animation/rules/`. The **named motion verbs in the Scene lines** (and the moves the blueprint cites) resolve to rule recipes here: `RULES_DIR/<id>.md` is the mechanics for a motion. The blueprint templates are the sibling `../hyperframes-animation/blueprints/<id>.md`; an optional runnable demo is `../hyperframes-animation/examples/<id>.html`.
- `code-vocabulary.md` — absolute path provided in your dispatch. For a **code beat**, read it for the named `code-*` block's exact inputs (`window.__TOKENS`, `window.__BLOCK`, line indexing).
- `../references/cut-catalog.md` — the **cut catalog** (zoom-through / inverse / cut-the-curve / waterfall) for a within-frame seam. You never author the between-frame transition — story's `transition_in` + the injector own that.
- Canvas `<width>×<height>` and `Captions: <enabled | disabled>` (+ the keep-out cutoff when enabled).
**Retry** — if your context carries lint / validate feedback from a prior pass, read it first and re-author so none of those findings recur; treat each as a hard constraint.
**OUTPUT** — `compositions/frames/<frame_id>.html`, one self-contained sub-composition. Writing it (to the contract below) is your **terminal action** — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the file and marks the frame's `status`.
**OUTPUT** — `compositions/frames/<frame_id>.html`, one self-contained sub-composition. Writing it (past the self-check below) is your **terminal action** — you do not edit `STORYBOARD.md`, mint audio, assemble the index, run the CLI, or report back. The orchestrator picks up the file and marks the frame's `status`.
## Mostly faceless — you INVENT the visual (except code blocks + the credits avatars)
## Mostly invented — you build the visual (except code blocks + the credits avatars)
A PR video is **mostly faceless**: there are **no screenshots and no product UI**. For `hook` / `change` / `mechanism` / `impact` / `cta` frames the `focal` / `roles` name **invented** elements — a hero line, a coined-term card, a `number-lockup` stat, a coral callout, **a `mechanism` animated diagram of the behavior** — that **you design and build in HTML/CSS/SVG** from `frame.md`. Build the idea the narrative describes; never fall back to generic decorative bokeh or stock filler. Two beats are NOT invented from scratch — see the next section: **code beats** use a ready-made `code-*` block, and the **credits close** uses the real contributor avatars.
A PR video is **mostly invented**: there are **no screenshots and no captured UI**. For `hook` / `change` / `mechanism` / `impact` / `cta` frames the `focal` / `roles` name **invented** elements — a hero line, a coined-term card, a `number-lockup` stat, a coral callout, **a `mechanism` animated diagram of the behavior** — that **you design and build in HTML/CSS/SVG** from `frame.md`. Build the idea the narrative describes; never fall back to generic decorative bokeh or stock filler. Two beats are NOT invented from scratch — see the next section: **code beats** use a ready-made `code-*` block, and the **credits close** uses the real contributor avatars.
## PR code beats, mechanism beats + the credits close
- **Code beats (`diff` / `before_after` / a new-code reveal) — use the named `code-*` block, don't hand-build code motion.** Your `## Frame N` `scene`/note names which block (e.g. `code-diff`, `code-morph`, `code-typing`); the orchestrator has already installed it (Step 5 pre-install). Read **`code-vocabulary.md`** (path in your dispatch) for that block's exact inputs, then:
- **Code beats (`diff` / `before_after` / a new-code reveal) — use the named `code-*` block, don't hand-build code motion.** Your `## Frame N` `scene` / `focal` names which block (e.g. `code-diff`, `code-morph`, `code-typing`); the orchestrator has already installed it (Step 5 pre-install). Read **`code-vocabulary.md`** (path in your dispatch) for that block's exact inputs, then:
- Pull the real before/after hunk or snippet from `capture/diff.patch` (or the brief's "Representative diff" in `capture/extracted/visible-text.txt`).
- Fill the block's `window.__TOKENS` with that real code (the baked Shiki tokens) and set `window.__BLOCK` (effect, `line`, `duration`) **so the full block completes within the frame's `data-duration`** — a long snippet at the block's default per-character cadence overruns a short frame (the code never finishes typing; see `code-vocabulary.md`). `code-diff` / `code-morph` need **2 states** (before, after); the others take one. **Line indexing differs — `code-highlight` is 0-based, `code-scroll` 1-based** — don't off-by-one.
- Fill the block's `window.__TOKENS` with that real code (the baked Shiki tokens) and set `window.__BLOCK` (effect, `line`, `duration`) **so the full block completes within the frame's `data-duration`** — a long snippet at the block's default per-character cadence overruns a short frame (the code never finishes typing). `code-diff` / `code-morph` need **2 states** (before, after); the others take one. **Line indexing differs — `code-highlight` is 0-based, `code-scroll` 1-based** — don't off-by-one.
- Integrate the filled block as **this frame's composition** per `hyperframes-core`'s sub-composition contract: its `data-composition-id` and its `window.__timelines[...]` key must both be your **`<frame_id>`** (the block ships its own id + paused timeline; rename both to match the frame contract). The block already renders an editor window (titlebar / filename) reading as claude's navy **Code Surface** — set the filename + any `+N/M` chrome from the `scene`.
- **The block owns the code animation; your Scene windows choreograph the surrounding Code Surface** — the navy window seating in, the file header typing on, the camera settling onto the hunk, a coral underline on the landed line. **Do not re-specify the code motion** (the block is the development beat). A code beat is usually `blueprint: compose`.
- **The block has no caption-safe band.** When `Captions: enabled`, inset/scale the code panel into the top ~83% so it clears the keep-out band; never let code run under the caption pill.
- **Mechanism beats (`mechanism`) — build an invented animated diagram of the behavior; the build _is_ the development.** This is the "show what the change does at runtime" frame (the request retrying, the cache filling, serial→parallel, the race resolved) — read its `scene` for the behavior to animate. Unlike a code beat, **the motion is yours to author** (no block owns it):
- **Mechanism beats (`mechanism`) — build an invented animated diagram of the behavior; the build _is_ the shot.** This is the "show what the change does at runtime" frame (the request retrying, the cache filling, serial→parallel, the race resolved) — read its `scene` for the behavior to animate. Unlike a code beat, **the motion is yours to author** (no block owns it):
- If the `scene` names a `flowchart` / `flowchart-vertical` / `data-chart` block, the orchestrator pre-installed it — fill + mount it like a code block (its `data-composition-id` and `window.__timelines[...]` key both become your `<frame_id>`). Otherwise **hand-build the diagram in SVG / HTML / GSAP** from `frame.md`'s atoms.
- **Claude register:** hairline-ink nodes / edges / lanes on the cream ground, **one coral marker** on the active / changed element, mono labels — **not** the navy code surface (that's for code), no heavy shapes / bokeh.
- **Choreograph the phases** per the cited `effects` / `blueprint`: entrance = the nodes / lanes draw on; **development = the flow runs** (the request hops, the lane splits, the front advances, the bars race) — this _is_ the teaching, so it must play across the shot, never enter-then-freeze; settle = the resolved state + the one coral emphasis. Keep it in the top ~83% (caption keep-out).
- **The `credits` close — the one frame with real assets.** Its `asset_candidates` names 26 `assets/<login>.png` avatars (downloaded in Step 1). Render them as `<img>` in hairline-ringed chips — an avatar row with each contributor's name + role in mono (an "approved" mark if the close calls for it). Avatars appear **only** here, never decorating a code frame.
- **Choreograph the Scene windows:** the nodes / lanes draw on (Scene 1); **the flow runs** as the VO names each step (middle Scenes — the request hops, the lane splits, the front advances, the bars race) — this _is_ the teaching, so it must play across the shot, never enter-then-freeze; the resolved state + the one coral emphasis lands (final Scene). Keep it in the top ~83% (caption keep-out).
- **The `credits` close — the one frame with real assets.** Its `asset_candidates` names 26 `assets/<login>.png` avatars (downloaded in Step 1). Render them as `<img>` in hairline-ringed chips — an avatar row with each contributor's name + role in mono (an "approved" mark if the close calls for it), staggered in across the Scene windows. Avatars appear **only** here, never decorating a code frame.
## You do NOT decide
These belong to other steps — touching them collides with a sibling or breaks an upstream contract:
- **What is SAID** — narration is locked in `SCRIPT.md` / the `voiceover` line. You only show; you never write or restate narration text.
- **Duration** — fixed from real voice timing. Build your entrance to land within it; don't stretch or trim it.
- **Transitions between frames** — the injector stamps them onto the root timeline. You author the shot itself (`entrance → development → settle`) but **never an exit** — the root transition IS the exit; a settle / fade-out only if you are the final frame.
- **Duration** — fixed from real voice timing. Build your shot to land within it; don't stretch or trim it.
- **Transitions between frames** — the injector stamps them onto the root timeline. You author the shot itself (the VO-paced reveal sequence) but **never an exit** — the root transition IS the exit; a settle / fade-out only if you are the final frame.
- **Audio** (narration / BGM / SFX) — assembled at the root by the orchestrator. **No `<audio>` element in your composition.**
- **Design tokens** — palette / fonts / components come from `frame.md`. Don't invent them, and **never lift a word, label, or wordmark out of `frame.md` as your copy** — it is a style spec, not content. Visible text comes from your frame's `scene` / narrative.
- **Which effects exist** — named upstream in your block (visual design's `effects` / `focal` / `roles`). Implement them; don't invent new ones.
- **Design tokens** — palette / fonts / components come from `frame.md`. Don't invent them, and **never lift a word, label, or wordmark out of `frame.md` as your copy** — it is a style spec, not content. Visible text comes from your frame's `scene` / narrative (and the real code, for a code beat).
- **Which motions exist** — named upstream in your block (the shot sequence's motion verbs + `blueprint:` + the `code-*` block). Implement them; don't invent new ones.
- **The shared `STORYBOARD.md`** — read your block, never write it. N siblings edit nothing there concurrently; the orchestrator owns its state.
## Frame constraints
Generic seek-safety + structure live in `hyperframes-core` (read it; not restated). These are the **PR-to-video deltas**, each load-bearing:
- **Caption keep-out — all content in the top ~83%.** A karaoke caption pill owns the bottom ~17% of the canvas. Keep every element (headline, cards, diagram, labels) above `y ≈ 0.83 × height` — compute the pixel cutoff from your canvas (e.g. `≤ 900` on a 1080-tall frame, `≤ 1600` on a 1920-tall portrait). Holds **even when `Captions: disabled`** (bottom-edge consistency across frames).
- **Fill the content area — especially portrait.** Compose the whole top-83% region; don't float one small cluster mid-frame. Anchor the hero high (~0.20.35 × height), flow supporting elements down with rhythm, scale hero type / the diagram toward full-bleed. (Landscape's region is short, so vertical centering near 0.42 × height is fine.)
- **Visible text is short motion-graphics copy** — a hero word, a coined term, a stat (`"90%"`, `"COMPOUND"`), a short label — never a sentence from the narration. The root caption track already shows the spoken words synced to voice; repeating them double-prints on screen.
- **Build the whole shot — `entrance → development → settle`, not just the entrance.** A frame that animates in over ~0.8s then freezes for the rest of its `duration` reads as a PowerPoint slide. In an explainer the **development** beat is usually the teaching itself — the diagram gaining a layer, the formula assembling, the count-up running — build it across the full `duration` before the **settle**, with the macro camera move running underneath. **Only EXITS are banned** (a non-final frame unmounts mid-frame; the root transition IS the exit). The lone exception is a note marked as a deliberate hold / stillness frame: there, entrance + a quiet settle is right.
- **Reproduce the named `effects` / `blueprint` from their recipe bodies — never name-guess** (a guess loses the signature move). Every id has a real recipe under `ANIM_DIR` (`rules/<id>.md` per effect, `blueprints/<id>.md` per blueprint, `examples/<id>.html` to watch it run). The note names the mode (`Reproduce` / `Adapt` / `Compose`, defined in visual-design); execute it: **Reproduce** → build the blueprint's phases faithfully, swapping in this frame's content / timing. **Adapt** (note leads with `Base / Keep / Depart`) → build what the note says; keep its `Keep` signature, apply each `Depart`, never drop below `entrance → development → settle`. **Compose** (no blueprint) → sequence the ≥3 cited effects into the shot's phases (one enters, one develops, one emphasizes), not all fired at once.
- **Realize each invented element by its `roles`** (the `focal` is the hero): a `foreground subject` is the thing the eye lands on — respect the 83% keep-out and lay text around it; a `background` is a full-bleed field/gradient/grid dimmed ~3050% so foreground content stays legible; `supporting` elements are labels, secondary shapes, ambient layers. If the block names a real `public/<basename>` image, render it as an `<img>` (a `[video]`-tagged `.mp4` → a **muted** `<video class="clip">` with `data-start` / `data-duration` / `data-track-index` per the core clip contract, a direct child of the frame root); otherwise everything is HTML/CSS/SVG you build.
- **Caption keep-out — all content in the top ~83%.** A karaoke caption pill owns the bottom ~17% of the canvas. Keep every element (headline, code panel, diagram, labels) above `y ≈ 0.83 × height` — compute the pixel cutoff from your canvas (e.g. `≤ 900` on a 1080-tall frame, `≤ 1600` on a 1920-tall portrait). Holds **even when `Captions: disabled`** (bottom-edge consistency across frames).
- **Fill the content area — especially portrait.** Compose the whole top-83% region; don't float one small cluster mid-frame. Anchor the hero high (~0.20.35 × height), flow supporting elements down with rhythm, scale the hero / code panel / diagram toward full-bleed. (Landscape's region is short, so vertical centering near 0.42 × height is fine.)
- **Visible text is short motion-graphics copy** — a hero word, a stat (`"+1,204"`, `"2× faster"`), a file/label — never a sentence from the narration. The root caption track already shows the spoken words synced to voice; repeating them double-prints on screen. (Real code inside a `code-*` block is the exception — that is the content, not narration.)
- **Build the whole shot — reveal across the full `duration`, never front-load.** Dumping the whole canvas in the first ~25% then holding it is exactly what reads as a PowerPoint slide. Instead reveal each piece — a file chip, the hunk, a node, a stat — **as the `voiceover` reaches it**, sequencing reveals across the shot and especially the back ~50%, with the macro camera move running underneath. **Only EXITS are banned** a non-final frame unmounts mid-frame, so an exit tween truncates (the root transition IS the exit); mid-shot reveals are free and seek-safe. The lone exception is a note marked as a deliberate hold / stillness frame: there, an entrance + a quiet settle is right.
- **Implement the shot sequence faithfully — every Scene is a timeline phase.** The Scene lines ARE the build: map each Scene onto a phase of the one timeline, each piece revealing as the `voiceover` reaches it. For each **named motion** in a Scene, open its rule recipe under `RULES_DIR/<id>.md` and reproduce its mechanics — **never name-guess** (a guess loses the signature move). The **`blueprint:` template** (`../hyperframes-animation/blueprints/<id>.md`) gives the overall shape; read it and keep its **signature move** recognizable. `compose` → no template; sequence the shot straight from the Scene lines (a code beat composes the surround around the block). Whichever, never front-load the whole sequence at `t=0`.
- **Realize each element by its `roles`** (the `focal` is the hero): a `foreground subject` is the thing the eye lands on — respect the 83% keep-out and lay text around it; a `background` is a full-bleed field / gradient / grid dimmed ~3050% so foreground content stays legible; `supporting` elements are labels, secondary shapes, ambient layers. Invented elements are HTML/CSS/SVG you build; a `code-*` block / `flowchart` / `data-chart` is filled + mounted per the section above; the credits avatars render as `<img>`.
## Workflow
1. **Read**`hyperframes-core`'s composition contract (the structural law), then `frame.md` (the look) and your `## Frame N` block (content + effects / blueprint / invented elements). **Then open the recipe body of every id the block cites** `ANIM_DIR/rules/<id>.md` per effect and `ANIM_DIR/blueprints/<id>.md` for the blueprint (plus its linked `examples/<id>.html` when unclear): you reproduce these, not improvise them. Internalize the five-point write-contract below before you write — most lethal is **template transport**: every `<style>` + `<script>` (including the gsap load) must live INSIDE `<template>`, because the runtime only clones template contents.
2. **Design** — translate `scene` + the (sometimes shot-by-shot) narrative + the recipes you just read into a visual plan using `frame.md`'s components and type ramp. Honor the note's phases shot-by-shot per the whole-shot rule above, and find a visual idea that reinforces the beat, not a literal restyle of the words. Build the invented hero (diagram / type / metaphor).
3. **Author** — write the full sub-composition to `compositions/frames/<frame_id>.html`. `<template>`-wrapped root carrying `data-composition-id="<frame_id>"` and styled via `#root` (not a class on that element — see contract pt 1), exactly one `gsap.timeline({ paused: true })` registered at `window.__timelines["<frame_id>"]`, built synchronously — per the core contract.
4. **Check the contract, then finish** — re-read your file against the five-point contract below and fix in place. Writing the file is your terminal action; you do **not** run the CLI.
1. **Read**`hyperframes-core`'s composition contract (the structural law), then `frame.md` (the look) and your `## Frame N` block (the shot sequence + `blueprint:` / `focal:` / `roles:`). **Then read the blueprint template** `../hyperframes-animation/blueprints/<id>.md` (skip if `compose`) and **open the rule recipe `RULES_DIR/<id>.md` for every named motion** in the Scene lines; **for a code beat, read `code-vocabulary.md`** for the block's inputs. You reproduce these mechanics, not improvise them. Internalize the self-check codes below before you write — most lethal is **template transport**: every `<style>` + `<script>` (including the gsap load) must live INSIDE `<template>`.
2. **Design** — turn the time-coded shot sequence into a timeline using `frame.md`'s components and type ramp: each Scene window becomes a phase revealed on its `voiceover` cue, each named motion built from the recipe you just read, the blueprint's signature move kept recognizable. Build the invented hero (diagram / type / number-lockup), or fill + mount the `code-*` block and choreograph the surround.
3. **Author** — write the full sub-composition to `compositions/frames/<frame_id>.html` (rewrite to iterate; last write wins). `<template>`-wrapped root carrying `data-composition-id="<frame_id>"` and styled via `#root` (not a class on that element — see the self-check below), exactly one `gsap.timeline({ paused: true })` registered at `window.__timelines["<frame_id>"]`, built synchronously — per the core contract.
4. **Self-check, then finish** — re-read your file against the checklist below and fix in place. Writing the file is your terminal action; you do **not** run the CLI.
## Write to the contract (you do NOT run the CLI)
## Self-check before finishing (you do NOT run the CLI)
You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: they operate on the **assembled project** (the `index.html` graph / bundle), and your frame isn't wired in yet — so they report on _other_ files, not yours (a false green). The **orchestrator** runs them at **Step 6, after assembly** (the correct unit), and **re-dispatches you with the finding** if your frame fails (see **Retry** above). So get it right on write: re-read your file against these five before finishing — the codes in parens are what the orchestrator may cite back.
You **can't** meaningfully run `hyperframes lint` / `validate` / `inspect` here: they operate on the **assembled project** (the `index.html` graph / bundle), and your frame isn't wired in yet — so they report on _other_ files, not yours (a false green). The **orchestrator** runs them at **Step 6, after assembly** (the correct unit), and **re-dispatches you with the finding** if your frame fails (see **Retry** above). So get it right on write: re-read your file against this checklist before finishing — the codes in parens are `hyperframes lint`'s and what the orchestrator may cite back (the rules behind them live in `hyperframes-core`):
1. **Structure, template transport & root styling** (`missing_template_wrapper` / `missing_composition_id` / `clip_missing_data_attrs` / `subcomposition_root_styled_by_class`) — root is `<template>`-wrapped with `data-composition-id="<frame_id>"`; **every `<style>` and `<script>` (incl. the GSAP load) lives INSIDE `<template>`** (the runtime clones only template contents — the most lethal miss); **style the frame root via `#root`, never a class on the `data-composition-id` element** — at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (and Studio preview still looks right — trust this rule, not the preview; descendants use plain selectors); every `class="clip"` carries `data-start` / `data-duration` / `data-track-index`.
2. **One paused timeline** (`timeline_not_paused` / `timeline_not_registered`) — exactly one `gsap.timeline({ paused: true })`, registered at `window.__timelines["<frame_id>"]`, built synchronously.
3. **Determinism & visibility** (`css_transition_used` / `exit_animation_on_non_final_scene`) — no CSS transitions, no `repeat` / `yoyo`, no `Math.random` / `Date.now` (the renderer seeks frame-by-frame); entrance via `fromTo` (not CSS-hidden starts) so the hero is visible by `t ≤ 0.5s`; no exit tween unless you are the final frame.
4. **The whole shot fits `data-duration`**`entrance → development → settle` all land inside it, and a non-still frame develops mid-shot (cited effects sequenced into phases, not all fired at `t=0`; an `Adapt` note keeps its `Keep` signature). **This includes any `code-*` block's internal cadence**: a long snippet at the block's default per-character speed _overruns_ a short frame — the code never finishes typing and the chrome beats never play. Set the cadence so the full block completes within `data-duration` (see `code-vocabulary.md`). Only a deliberate hold / stillness note skips development.
5. **Fonts, keep-out, no narration text** (`font_family_without_font_face`) — every font you name has a matching `@font-face` (or `@import`) **inside this file**, and you **only use fonts that ship as files**: the families declared in `frame.md` (their `.woff2` live in `assets/fonts/` or `capture/assets/fonts/` — point `src` at the real file). **Never name a font with no file**, including system CJK / Japanese / Devanagari families (`Hiragino Sans`, `Yu Gothic`, `Noto Sans CJK`, `Noto Sans Devanagari`, …): the render machine is a clean headless Chrome without them, so the text silently falls back and the MP4 typography is wrong — for non-Latin / multilingual visible text use a shipped font that covers the script or romanize it, else it is out of scope. Plus: all content sits in the top ~83% (above the caption band); no narration sentence is rendered as visible text.
- `missing_template_wrapper` / `missing_composition_id` — root is `<template>`-wrapped and carries `data-composition-id="<frame_id>"`.
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
- `subcomposition_root_styled_by_class`**style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
- `clip_missing_data_attrs` — every `class="clip"` element has `data-start` / `data-duration` / `data-track-index`.
- `timeline_not_paused` / `timeline_not_registered` — one paused timeline, registered at `window.__timelines["<frame_id>"]`.
- `css_transition_used` + repeat / yoyo / non-deterministic logic — none present (the renderer seeks frame-by-frame).
- `gsap_css_transform_conflict` — never put a CSS `transform` (e.g. `translateY(-50%)` centering) on an element you then GSAP-animate a transform prop on (`x` / `y` / `scale` / `rotation`): GSAP overwrites the whole `transform` and silently drops the CSS centering (the element jumps). Center with `margin` / `inset` (or `top`/`left` + offset), fold the offset into the tween via `xPercent` / `yPercent`, or use `fromTo` (the rule exempts it).
- **Hero visibility** — the main subject is visible by `t <= 0.5s`; entrance tweens use `fromTo` instead of CSS-hidden starting states.
- `exit_animation_on_non_final_scene` — no exit tween unless you are the final frame.
- **No front-loading (not a slide)** — the shot's pieces reveal on their `voiceover` cues across the duration, not all fired at `t=0`; a non-still frame keeps content arriving rather than holding a full canvas from ~25%.
- **Shot-sequence fidelity** — every Scene in the time-coded sequence is realized as a phase, the blueprint's signature move (unless `compose`) is present and recognizable, and the shot reveals to the voiceover (never front-loaded at `t=0`).
- **Code-block cadence fits `data-duration`** — for a code beat, the `code-*` block's internal cadence is set so the full block completes within the frame's `data-duration` (a long snippet at the default per-character speed overruns — the code never finishes and the chrome beats never play; see `code-vocabulary.md`).
- `font_family_without_font_face` — every font you name has a matching `@font-face` (or `@import`) **inside this file**. **Only use fonts that ship as files** with the project: the families declared in `frame.md` (their `.woff2` live in `assets/fonts/` or `capture/assets/fonts/` — point the `@font-face` `src` at the real file you find there). **Never name a font that has no file**, including system CJK / Japanese / Devanagari families (`Hiragino Sans`, `Yu Gothic`, `Noto Sans CJK`, `Noto Sans Devanagari`, …): the render machine is a clean headless Chrome with none of them installed, so the text silently falls back to a generic font and the typography is wrong in the MP4. For non-Latin or multilingual visible text, either use a shipped font that covers the script, or romanize / transliterate it (e.g. `日本語``Japanese`); if neither is possible it is out of scope for this frame — do not invent a font name.
- **Keep-out + no-narration-text** (eyeball, no code) — nothing sits below the 83% cutoff; no narration sentence is rendered as visible text.