feat(skills): add changelog-video skill for repo-native CC + Codex discovery (#2552)

Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.

Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor

Layout:
- .claude/skills/  - Claude Code project-local auto-discover
- .agents/skills/  - Codex CLI project-local auto-discover (verified via
                     Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)

Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.

.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.

CI:
- Extends changes.skills filter to match .claude/skills/**,
  .agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
  lint-skills.ts (schema-driven; required { name, description } + optional
  { license, allowed-tools, metadata }, name pattern check, description
  length check) plus a new check-skill-mirror.mjs byte-integrity script
  (24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.

Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
  (non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.

Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).

Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.

Co-authored-by: Jake Moran <jake@heygen.com>
This commit is contained in:
James Russo
2026-07-16 17:29:19 -04:00
committed by GitHub
co-authored by Jake Moran
parent 172f2757f0
commit e96ebd74de
59 changed files with 5826 additions and 77 deletions
+48
View File
@@ -0,0 +1,48 @@
# Project-local Claude Code skills
Skills in this directory are auto-discovered by Claude Code when the
`hyperframes` repo is opened as the working directory. They are NOT part of
the marketplace-distributed plugin (that set lives under `skills/` and is
manifested by `.claude-plugin/`). Two separate namespaces, on purpose:
- `.claude/skills/`**repo-native**, run only against this repo (weekly
changelog videos, doctrine-heavy authoring flows). Claude Code's
project-local skill dir.
- `skills/`**marketplace-distributable**, installed into other projects
via `npx hyperframes skills` or `npx skills add heygen-com/hyperframes`.
## Weekly changelog video
The `changelog-video` skill turns a weekly changelog markdown into a
~4560s branded 1080×1080 MP4 (motion-doctrine layout, Annie VO,
seam-gated cuts, caption rail). It ships pre-configured — fonts,
background pattern, house BGM, lexicon, and the align-captions script all
live inside `changelog-video/`. Its five dependency skills
(`motion-doctrine`, `cut-the-curve`, `captions-overlay`, `seam-craft`,
`oversized-cursor`) sit alongside so the router graph is complete on
clone.
Weekly usage:
1. Regenerate the digest markdown for the target range:
`bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD` (this
only reads git; the `--write` variant is what the docs cron uses).
2. In Claude Code at the repo root, invoke `/changelog-video` with the
generated markdown. The agent will present its script + visualization
plan for review before rendering.
3. Accept, and the agent produces `weekly-changelog-<range>.mp4` gated by
`hyperframes check` (0 errors) + `seam-gate verify` (0 fail/warn).
TTS uses the tracked `skills/hyperframes-media/scripts/heygen-tts.mjs`
(no extra install needed). Runtime dependencies you need on PATH:
- Node ≥ 22
- HeyGen CLI ≥ 0.3.0, authenticated via `heygen auth login --oauth`
- `ffmpeg` (for VO wav conversion + frame QA)
- A headless Chrome for HyperFrames rendering (`hyperframes doctor` will
point out the exact ask if it's missing)
The parallel set at `.agents/skills/` is a byte-identical copy so Codex
CLI users get the same auto-discover behaviour — keep the two in sync
when editing. A `scripts/check-skill-mirror.mjs` check enforces this at
CI time.
+81
View File
@@ -0,0 +1,81 @@
---
name: captions-overlay
description: Overlay doctrine for the embedded-captions workflow — the caption MODEL (drop / rail / embed) and the rule that captions are an OVERLAY composited on top of the film, never a reserved bottom band you shift content up to avoid. Load when adding captions/subtitles to a talking-head or launch video, when deciding whether a phrase should be dropped, ride the verbatim rail, or be promoted to a scarce embedded climax, when laying out a composition that will carry captions (do NOT reserve a keep-out band), or when centering a composition on the true frame center under captions. Quotes the rail+embed model from embedded-captions and constraint #13 (captions overlay, keep-out band retired) from the product-launch-video scene agent. Applies ON TOP of embedded-captions.
---
# Captions Overlay Doctrine
> **Overlay doctrine — supplements the upstream `embedded-captions` skill. Applies ON TOP of it; do not expect it folded into the upstream skill.**
Two ideas combine here. First, the **caption model** — every spoken phrase is `drop`,
`rail`, or `embed`, and embed is the scarce earned peak, not the default. Second, the
**overlay law** — a caption line is composited ON TOP of the film as an overlay; it is
NOT a reserved zone, so you never shift content up or leave a dead band to "make room"
for it. The two reinforce each other: because captions ride as an overlay (the verbatim
rail in front, the occasional embed behind the subject), the composition keeps its full
frame and centers on the true vertical center.
## The caption model — drop / rail / embed
Every spoken phrase is one of three things (verbatim from `embedded-captions`):
| | What | How it's shown |
| --------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **drop** | filler — um/uh, stutters, self-corrections | not shown |
| **rail** | the default — ordinary spoken content (verbatim) | clean lower-third subtitle, **in front**, readable. A punch word can get an inline `emphasis` highlight (accent colour / active-word pop) — it stays on the rail. |
| **embed** | a promoted peak — the headline beat | one big word composited **behind the subject** (matte occlusion), designed entrance + exit |
**The rail carries most of the text; embed is the scarce, earned peak** — ≤1 per beat,
never two adjacent/co-visible, spaced ≥ a beat apart. A short clip → usually one embed;
a long explainer → ~one per section. Embedding every word is the common mistake.
This is the **Standard** mode shape (rail = the verbatim lower-third; embed = the climax
composited behind the subject). **Cinematic** mode drops the rail and makes everything
embed-style — use it only for pure-cinematic asks, never for explainer / voiceover where
the words must read.
### Rail-first, embed-scarce (the load-bearing rules)
Quoted from the `embedded-captions` non-negotiables:
- **Rail-first for talking-head / explainer.** Don't embed the whole transcript — most
text is the rail; embed only peaks. Embedding everything is the default mistake.
- **Embed is scarce + spaced.** ≤1 embed per sentence/beat, never two adjacent or
co-visible, ≥ a beat apart, at most one `apex`. climax = per-beat peak, **not** "the
single payoff of the entire clip."
## The overlay law — captions are NOT a reserved band
In a generated launch composition, when captions are enabled, finalize composites a
**small, minimal word-by-word caption line** as an overlay layer ON TOP of the whole
film (a single text line, bottom-centered, roughly the bottom ~5-8% of canvas height).
It is an overlay, not a reserved zone (verbatim from constraint #13 of the
product-launch-video scene agent):
- **Center the composition on the TRUE vertical center — y = H / 2** (landscape 540,
portrait 960). Do not shift content up to "make room" for captions; a composition
centered at 0.42 × H with a dead lower band is the bug, not the fix.
- Content may extend to the canvas bottom. Full-bleed subjects, rails, and backgrounds
all welcome.
- **One soft courtesy rule:** avoid parking _critical small readable text_ (a URL line,
a legal line, a sub-caption) exactly in the bottom ~80px center span where the caption
line sits — the overlay would fight it. Large imagery / cards / ambient content under
the captions is fine; the caption skin is designed to read over content.
- There is no machine keep-out gate (the old `captions.mjs keepout` check is retired).
Finalize snapshot QA judges caption-over-content legibility visually.
**When captions are disabled:** identical positioning freedom — the overlay simply
doesn't exist.
## Why these two rules are one doctrine
The model says the rail rides **in front** and an embed is a rare word composited
**behind the subject** — both are layers added to footage that ships untouched. The
overlay law says the caption line is a layer composited **on top** of the whole film,
not a band carved out of the layout. So in both the captioning pipeline and the
launch-video pipeline, captions are an overlay you add, not a zone you reserve:
- Keep the full frame; center on true center; let content run to the edges.
- Make the rail (or the small overlay caption line) carry the verbatim words.
- Promote a word to an embed only at a genuine peak — scarce, spaced, never two at once.
- Reserve nothing; judge legibility of captions-over-content visually, not by a keep-out gate.
+127
View File
@@ -0,0 +1,127 @@
---
name: changelog-video
description: Turn a weekly changelog .md into a finished branded changelog video (square 1080, ~45-60s, Annie VO, animated brand background, mock-UI visualizations, lowkey captions). Use when the user provides a changelog/digest markdown and wants the weekly video, or says "changelog video". Self-contained — fonts, background, lexicon, and scripts ship in this skill.
---
# Changelog → Branded Video
Input: a changelog .md (themes + items, like the weekly HyperFrames digest).
Output: a lint-clean, seam-gate-green HyperFrames project in
`projects/active/weekly-changelog-<range>/`. Render only when asked.
**Load first, non-negotiable:** `motion-doctrine` (+ `cut-the-curve`,
`oversized-cursor` if a cursor appears, `seam-craft`) and `captions-overlay`.
This skill supplies the changelog-specific pipeline; the doctrine supplies the
motion law.
## The prime directive: visualize, don't list
Every theme is illustrated by an **animated mock of the actual UI or a
faithful analog** acting out the change in experience — never text bullets.
Route every theme/item through `references/visualization-registry.md` BEFORE
writing the script; the registry decides ui-recreate / ui-analog / terminal /
checklist. Text checklist is the LAST resort, reserved for genuinely
non-visual items (reliability fix lists).
## Pipeline
### 1 · Parse + editorial cut
- Extract: week range, headline stats (releases, commits), themes, items.
- **Budget: 45-60s total.** Title ≤2s, outro ≤3.5s, 4 themes ≈ 9-12s each.
- Per theme keep ONE hero visualization + at most 3 spoken items. Everything
else exists only as the outro's "full digest" pointer. Cutting is the job:
a changelog with 30 items still yields ≤14 spoken beats.
- Order themes by story: marquee feature → product surface → performance →
reliability (the digest usually already reads this way).
### 2 · Visualization routing
For each theme, pick the surface from `references/visualization-registry.md`
and write one line: `theme → surface → the 2-4 sequenced actions the mock
performs, each tied to a script phrase`. If no registry surface fits and no
faithful analog exists, it's a checklist scene — don't invent fake UI for
something we can't represent honestly.
### 3 · Two-layer script (spoken vs display)
Write the script as **token lines** per `references/script-voice.md`:
conversational register, every technical term carrying a `spoken` phonetic
form from `references/lexicon.json` while `display` keeps standard spelling.
Captions show `display`; the VO reads `spoken`. Any term not in the lexicon:
STOP and ask the user how it's pronounced, then add it to the lexicon.
Save as `script-tokens.json` in the project.
### 4 · VO — Annie (HeyGen, pinned)
```bash
# spoken-layer text only; words JSON = ground-truth timestamps of the SPOKEN text
# Repo-native path: the changelog-video skill runs from the hyperframes repo root,
# so it uses the tracked hyperframes-media TTS helper directly (no `npx hyperframes
# skills` install step). If you've copied the skill into another repo, swap in
# your own path to the media-use / hyperframes-media heygen-tts.mjs.
node skills/hyperframes-media/scripts/heygen-tts.mjs ./vo-spoken.txt \
-o voiceover.mp3 --words vo-words.json \
--voice 330290724a1b470fb63153f34d4c0183 # Annie — lifelike (do not substitute)
```
Requires `heygen` CLI ≥0.3.0 authenticated (`heygen auth login --oauth`).
Then align spoken timestamps back to display tokens:
```bash
node <SKILL_DIR>/scripts/align-captions.mjs \
--tokens script-tokens.json --words vo-words.json --out captions.json
```
`captions.json` is the caption-rail input (display spelling, spoken timing).
The aligner prints `MISMATCH` warnings — resolve every one before building
(usually a lexicon spelling the TTS renders as multiple words). **The audio
is the clock**: all beat times come from `vo-words.json`; a VO regen re-opens
every seam.
### 5 · Build
Follow `references/build-spec.md` exactly: brand tokens + fonts (bundled in
`<SKILL_DIR>/assets/`), the animated background encode, scene scaffold,
chrome, caption rail, one rationed green moment per scene. Then the doctrine
order: `ledger.json` (all ordinary seams cut-the-curve LEFT) → seam-stamp →
internal beats on VO words → seam-gate verify.
### 6 · Gates (all green before presenting)
1. `bun run --cwd packages/cli hyperframes check` (or the installed
`hyperframes` CLI from the repo-local `skills/hyperframes-cli/` skill) —
0 errors (contrast: dim text ≥ .66 alpha). Do NOT reach for
`npx hyperframes@latest`; the tracked repo-local CLI is the source of
truth for the composition contract this skill produces against.
2. `seam-gate.mjs verify` — 0 fail.
3. Restart the preview server (it caches the bundle), spot-check 3-4 beats
via `__player.seek` on the raw comp page.
4. Do NOT render unless the user asks. After a requested render, verify
frames from the MP4 (`ffmpeg -ss <t> … -frames:v 1`): captions present,
background video not black, no tiny/frozen frames.
## Project layout
```
projects/active/weekly-changelog-<range>/
├── index.html # single-doc master (scenes as slides, stamped seams)
├── ledger.json # vector ledger (seam-stamp input)
├── script-tokens.json # two-layer script (source of truth for VO + captions)
├── vo-spoken.txt # generated: spoken layer, one line
├── voiceover.mp3 + vo-words.json + captions.json
├── bgm.mp3 # copy from <SKILL_DIR>/assets/bgm.mp3 (the house track) unless the user supplies one
└── assets/fonts/ + assets/bg-pattern-<dur>s.mp4
```
## Anti-patterns
| Don't | Instead |
| ---------------------------------------- | ---------------------------------------------- |
| Bullet-point slides for UI changes | Mock the surface acting out the change |
| Fake UI for un-representable items | Honest checklist scene |
| Plain "JSON"/"CLI" in the TTS text | Lexicon spoken forms; display stays standard |
| Phonetic spellings in captions | Captions always render the display layer |
| Guessing an unknown term's pronunciation | Ask, then grow the lexicon |
| Speaking every changelog item | ≤3 per theme; the digest link carries the rest |
| Green accents everywhere | One green moment per scene (#5ef17c) |
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6cdd36e1e57f0b8349088db97d813863e87a7b7974d472ad9efc904737039f2
size 12817182
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e86390155022691fa09a31a28e256a717aaf079d5f9ccbb5561d52ab8a49300
size 5318438
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<!-- master-skeleton — the verbatim scaffold for a changelog master. Mirrors the
proven build. Replace <RANGE>, <TOTAL>, scene bodies; keep the mechanics. -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080, height=1080">
<title>HyperFrames Weekly — <RANGE></title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
@font-face { font-family:'ABC Solar Display'; src:url('assets/fonts/ABCSolarDisplay-Bold.woff2') format('woff2'); font-weight:700; font-display:block; }
@font-face { font-family:'TT Norms Pro'; src:url('assets/fonts/TT_Norms_Pro_Normal.woff2') format('woff2'); font-weight:400; font-display:block; }
@font-face { font-family:'TT Norms Pro'; src:url('assets/fonts/TT_Norms_Pro_Medium.woff2') format('woff2'); font-weight:500; font-display:block; }
@font-face { font-family:'TT Norms Mono'; src:url('assets/fonts/tt_norms_pro_mono_regular-webfont.woff2') format('woff2'); font-weight:400; font-display:block; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 1080px; height: 1080px; background: #0a0c0b; overflow: hidden;
font-family: 'TT Norms Pro', sans-serif; color: #f5f6f4; }
#bg-video { position: absolute; inset: 0; width: 1080px; height: 1080px; object-fit: cover; }
#bg-scrim { position: absolute; inset: 0; background: rgba(8,10,9,.25); }
.chrome { position: absolute; z-index: 6; }
#kicker-chip { top: 44px; left: 76px; font-family: 'TT Norms Mono', monospace; font-size: 20px;
letter-spacing: .06em; color: rgba(245,246,244,.72); background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; padding: 10px 18px; }
#progress { top: 58px; right: 76px; display: flex; gap: 10px; }
#progress .dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(245,246,244,.18); }
.slide { position: absolute; inset: 0; opacity: 0; }
.sec-chip { position: absolute; top: 128px; left: 76px; font-family: 'TT Norms Mono', monospace;
font-size: 22px; letter-spacing: .05em; color: rgba(245,246,244,.72);
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.14);
border-radius: 12px; padding: 10px 16px; }
.sec-head { position: absolute; top: 186px; left: 76px; font-family: 'ABC Solar Display', sans-serif;
font-weight: 700; font-size: 54px; letter-spacing: -.01em; color: #f5f6f4; }
.glass { background: rgba(10,12,11,.78); border: 1px solid rgba(190,255,205,.32);
border-radius: 22px; box-shadow: 0 24px 60px rgba(0,0,0,.5); }
/* caption rail — overlay on top of the film, never a reserved band */
#cap-line { position: absolute; left: 0; right: 0; top: 1002px; height: 40px; text-align: center;
z-index: 7; font-family: 'TT Norms Pro', sans-serif; font-weight: 500; font-size: 25px;
letter-spacing: .01em; color: rgba(245,246,244,.92);
text-shadow: 0 2px 14px rgba(0,0,0,.85), 0 0 3px rgba(0,0,0,.6);
white-space: nowrap; pointer-events: none; }
.cap-phrase { position: absolute; left: 0; right: 0; }
.cap-w { display: inline-block; margin: 0 4px; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="<TOTAL>" data-width="1080" data-height="1080">
<video id="bg-video" class="clip" src="assets/bg-pattern-<TOTAL>s.mp4" muted data-start="0" data-duration="<TOTAL>" data-track-index="0"></video>
<div id="bg-scrim"></div>
<div id="kicker-chip" class="chrome">HYPERFRAMES WEEKLY <span style="color:rgba(245,246,244,.66)">· <RANGE></span></div>
<div id="progress" class="chrome"><span class="dot" id="dot1"></span><!-- one per theme --></div>
<div id="cap-line" class="chrome" data-layout-allow-overlap></div>
<!-- Title (film open — authors its OWN entry; exit is stamped) -->
<div id="s-title" class="slide clip" data-start="0" data-duration="<CUT1>" data-track-index="1">
<!-- mono kicker date · ABC Solar h1 · green rule -->
</div>
<!-- One slide per theme. data-start = EXACTLY the ledger cut time.
Shell composed at local t=0; internal beats ≥0.4s after the cut,
done ≥0.45s before the next. data-layout-allow-overlap on mock slides. -->
<div id="s-t1" class="slide clip" data-start="<CUT1>" data-duration="<DUR1>" data-track-index="1" data-layout-allow-overlap>
<div class="sec-chip">01 · <THEME></div>
<div class="sec-head"><HEADLINE></div>
<!-- the mock, from references/visualization-registry.md, y ∈ [288, 944] -->
</div>
<!-- s-t2..tN, then outro (kicker · "See what shipped." · green rule · URL chip) -->
<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="<TOTAL>" data-track-index="2" data-volume="0.14" data-media-start="0"></audio>
<audio id="vo" src="voiceover.mp3" data-start="0" data-duration="<TOTAL>" data-track-index="3" data-volume="1.0"></audio>
</div>
<script>
gsap.defaults({ overwrite: 'auto' });
const tl = gsap.timeline({ paused: true });
tl.to({}, { duration: /*<TOTAL>*/ 47 }, 0);
// chrome in; progress dots via tl.set at each cut (never tl.call)
gsap.set('#kicker-chip', { autoAlpha: 0, y: -10 });
tl.to('#kicker-chip', { autoAlpha: 1, y: 0, duration: 0.5, ease: 'power3.out' }, 0.1);
// tl.set('#dot1', { backgroundColor: '#f5f6f4' }, <CUT1>); …
// ══ per-scene internal beats ══
// init states: gsap.set(...) at build time; motion: sequential tl.to(...) only.
// Every beat lands on a vo-words.json timestamp (local = master scene start).
// One green (#5ef17c) moment per scene.
// ══ CAPTION RAIL — paste captions.json "lines" from align-captions.mjs ══
(function () {
const LINES = /* paste captions.json lines — [{id, end, w:[[display, start],…]},…] */ [];
const host = document.getElementById('cap-line');
LINES.forEach((line, li) => {
const ph = document.createElement('div');
ph.className = 'cap-phrase'; ph.id = 'cap-p' + li;
line.w.forEach(([txt], wi) => {
const s = document.createElement('span');
s.className = 'cap-w'; s.id = 'cap-p' + li + '-w' + wi; s.textContent = txt;
ph.appendChild(s);
});
host.appendChild(ph);
});
gsap.set('.cap-phrase', { autoAlpha: 0 });
gsap.set('.cap-w', { autoAlpha: 0 });
LINES.forEach((line, li) => {
tl.set('#cap-p' + li, { autoAlpha: 1 }, line.w[0][1]);
tl.set('#cap-p' + li, { autoAlpha: 0 }, line.end);
line.w.forEach(([txt, at], wi) => {
tl.to('#cap-p' + li + '-w' + wi, { autoAlpha: 1, duration: 0.12, ease: 'power1.out' }, at);
});
});
})();
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
// seam-stamp.mjs inserts the <seams:auto> block after the registration above.
</script>
</body>
</html>
@@ -0,0 +1,34 @@
{
"_readme": "Two-layer script example. Bare string = display and spoken identical; object = they diverge (display for captions, spoken for TTS). One line = one caption phrase (≤ ~40 display chars). vo-spoken.txt = all spoken forms joined.",
"lines": [
{ "id": "l1", "tokens": ["This", "week", "at", "HyperFrames", "—"] },
{ "id": "l2", "tokens": ["fifteen", "releases,", "and", "one", "big", "story."] },
{ "id": "l3", "tokens": ["Feed", "it", "a", "Figma", "frame,"] },
{ "id": "l4", "tokens": ["get", "a", "working", "composition", "back", "—"] },
{ "id": "l5", "tokens": ["tokens,", "components,", "and", "motion", "all", "mapped."] },
{
"id": "l6",
"tokens": [
"Even",
"the",
{ "display": "JSON", "spoken": "jay-sawn" },
"manifest",
"comes",
"along."
]
},
{
"id": "l7",
"tokens": ["And", "it's", "all", "one", { "display": "CLI", "spoken": "C L I" }, "command."]
},
{
"id": "l8",
"tokens": [
"See",
"everything",
"at",
{ "display": "hyperframes.heygen.com", "spoken": "hyperframes dot hey-jen dot com" }
]
}
]
}
@@ -0,0 +1,115 @@
# Build Spec — square 1080 changelog master
Single-doc `index.html`: scenes are absolutely-positioned `.slide` clips on
track 1; the master timeline `tl` (MUST be named `tl` — seam-stamp emits
`tl.*`) is paused, padded to total duration, registered as
`window.__timelines["main"]`. See `examples/master-skeleton.html` for the
verbatim scaffold.
## Brand tokens (HeyGen for Developers)
```css
@font-face {
font-family: "ABC Solar Display";
font-weight: 700;
src: url(assets/fonts/ABCSolarDisplay-Bold.woff2) format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-weight: 400;
src: url(assets/fonts/TT_Norms_Pro_Normal.woff2) format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-weight: 500;
src: url(assets/fonts/TT_Norms_Pro_Medium.woff2) format("woff2");
}
@font-face {
font-family: "TT Norms Pro";
font-weight: 700;
src: url(assets/fonts/TT_Norms_Pro_Bold.woff2) format("woff2");
}
@font-face {
font-family: "TT Norms Mono";
font-weight: 400;
src: url(assets/fonts/tt_norms_pro_mono_regular-webfont.woff2) format("woff2");
}
/* ink #f5f6f4 · ink2 rgba(245,246,244,.72) · dim rgba(245,246,244,.66) —
NOT .45: the contrast gate fails small text under 4.5:1 over the glass.
green #5ef17c (RATIONED: one moment per scene) · bg #0a0c0b ·
chip-bg rgba(10,12,11,.78) · glass-line rgba(190,255,205,.32) */
```
Copy fonts from `<SKILL_DIR>/assets/fonts/` into the project's
`assets/fonts/` (local @font-face with format('woff2') embeds at render).
Glass card: chip-bg fill, 1px glass-line border, radius 22,
`box-shadow: 0 24px 60px rgba(0,0,0,.5)`. NO backdrop-filter. Chips: mono
18-22px, radius 12, 1px rgba(255,255,255,.14) border on rgba(255,255,255,.05).
Display type: ABC Solar Display 700. Body: TT Norms Pro. Everything
code/UI-label: TT Norms Mono. Safe margins x/y ∈ [76, 1004].
## Animated background (the house pattern)
Encode the bundled source to the film's exact duration — the render
compiler shortens a video's slot to the media length, so the encode must be
≥ total:
```bash
ffmpeg -y -stream_loop 15 -i <SKILL_DIR>/assets/bg-pattern.mp4 -t <TOTAL> \
-vf "scale=1080:1080,fps=30,eq=saturation=0.72,drawbox=c=black@0.5:t=fill" \
-an -c:v libx264 -crf 20 -pix_fmt yuv420p assets/bg-pattern-<TOTAL>s.mp4
```
Keep the darkening crush — the raw pattern is far too loud. Mount as
`<video id="bg-video" class="clip" muted>` on track 0 + a static
`rgba(8,10,9,.25)` scrim div. `<video>` needs the id (silent/black
otherwise) and must stay flat 2D (no 3D ancestors).
## Scene anatomy
- Chrome (untimed, z 6): kicker chip top-left `HYPERFRAMES WEEKLY · <RANGE>`,
progress dots top-right (one per theme; `tl.set` backgroundColor at each
cut — active #f5f6f4, done .45; never tl.call for state).
- Title (≤2s): mono kicker date, ABC Solar h1 ~104px, green rule sweep.
- Theme scene: sec-chip `0N · THEME NAME` (top 128) + ABC Solar headline
~54px (top 186) + the mock (from the visualization registry) filling
y ∈ [288, 944].
- Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule,
mono URL chip, tag line. Fade all + chrome ~0.5s before end.
- Caption rail per `script-voice.md` (top: 1002).
## Seams + internal life (doctrine mechanics)
- `ledger.json`: every ordinary seam `cut-the-curve LEFT` (x, dir 1), exit
and entry selectors = the slide wrappers. Outro entry `travel: 8`.
- `seam-stamp.mjs --ledger ledger.json --write index.html` owns ALL wrapper
entries/exits — author none yourself. Title (film open) authors its own
entry only.
- Slides: CSS `opacity: 0` base; `data-start` = exactly the cut time.
- Each scene's shell (chip, headline, mock chrome, initial state) is
COMPOSED at local t=0 — the wrapper flies it in. Internal reveals start
≥0.4s after the cut and end ≥0.45s before the next cut (stamped exits
begin at cut 0.34s).
- Every internal beat lands on a VO word from `vo-words.json`. Name each
scene's sustained-motion route in the plan (sequenced UI life for mocks,
staged reveals for checklists). One green moment per scene.
- Init states via `gsap.set(...)` at build time; animation via sequential
`tl.to(...)` only (no plain-object keyframes, no repeat:-1, no tweening
left/top — set base position in CSS, tween x/y). Counters: object tween
with an `onUpdate` in the tween config (cache DOM refs; never
`tl.eventCallback`).
## Lint/check gotchas (all hit before, all pre-solved)
- Mock containers with intentional stacking: `data-layout-allow-overlap` on
the slide root; elements a playhead/line crosses:
`data-layout-allow-occlusion`.
- Dim text: `rgba(245,246,244,.66)` minimum (contrast gate).
- Audio: every `<audio>` carries an `id`. BGM: the house track ships at
`<SKILL_DIR>/assets/bgm.mp3` (159s instrumental) — copy it to the project
as `bgm.mp3` and mount on its own track:
`<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="<TOTAL>" data-track-index="2" data-volume="0.14" data-media-start="0">`
(0.14 sits under the VO; use a user-supplied track only when given one).
- Preview server caches the bundle — RESTART after edits, then verify on the
raw comp page (`/api/projects/<id>/preview/comp/index.html`) via
`window.__player.seek(t)`.
@@ -0,0 +1,66 @@
{
"_readme": "display → spoken. Plain-text phonetics for HeyGen TTS (no SSML). Grow this file; never guess a missing term — ask, add, then listen to the generated line. Slash commands: caption /name, speak 'slash <name>'.",
"/figma": "slash Figma",
"/hyperframes": "slash hyper-frames",
"JSON": "jay-sawn",
"GSAP": "jee-sap",
"CLI": "C L I",
"CDP": "C D P",
"API": "A P I",
"SDK": "S D K",
"UI": "U I",
"UX": "U X",
"CSS": "C S S",
"HTML": "H T M L",
"SVG": "S V G",
"URL": "U R L",
"TTS": "T T S",
"ASR": "A S R",
"MCP": "M C P",
"LRU": "L R U",
"GC": "G C",
"VFR": "V F R",
"CFR": "C F R",
"SDR": "S D R",
"HDR": "H D R",
"GPU": "G P U",
"CPU": "C P U",
"PNG": "P N G",
"MP4": "em pee four",
"MP3": "em pee three",
"WebM": "web em",
"WebGL": "web G L",
"wav": "wave",
"ffmpeg": "ff-mpeg",
"ffprobe": "ff-probe",
"OAuth": "oh-auth",
"SSML": "S S M L",
"OOM": "out of memory",
"e2e": "end to end",
"DX": "developer experience",
"QA": "Q A",
"npm": "N P M",
"npx": "N P X",
"regen": "ree-jen",
"repo": "repo",
"HeyGen": "hey-jen",
"heygen.com": "hey-jen dot com",
"hyperframes.heygen.com": "hyperframes dot hey-jen dot com",
"MusicGen": "music-jen",
"Kokoro": "koh-koh-roh",
"Figma": "Figma",
"drawElement": "draw element",
"DrawElement": "draw element",
"moveElement": "move element",
"storyboard": "storyboard",
"keyframes": "key-frames",
"sub-compositions": "sub compositions",
"WSL": "W S L",
"Linux": "linnucks",
"symlink": "sim-link",
"lint": "lint",
"toast": "toast",
"vite": "veet",
"Chromium": "chromium",
"headless": "headless"
}
@@ -0,0 +1,111 @@
# Script + Voice: the two-layer contract
The script is the single source of truth for BOTH the VO and the captions,
written as token lines. The VO reads the `spoken` layer; captions render the
`display` layer. This is a hard quality gate: a caption showing "jay-sawn" or
a VO saying "juh-son" ("JSON" read literally) are both build failures.
## Register (how it should sound)
- Conversational, not release-notes. "The big one this week —" beats
"Theme 1:". Contractions welcome. Second person allowed ("your clips").
- Informational, never salesy; no superlatives the changelog doesn't earn.
- One breath per beat: sentences ≤ ~14 words; let punctuation pace the read.
- Numbers with meaning stay ("fifteen releases"); commit hashes, PR numbers,
and version micro-detail are never spoken.
- Open with the week + the marquee, close with the digest pointer
("See everything at hyperframes dot heygen dot com").
- **Teach the simple command.** When a feature has a one-line invocation (a
slash command, a CLI one-liner), the script says it verbatim ("start your
prompt with /figma…") and the mock shows it being typed — the command is
the visible CAUSE of the result. Slash commands speak as "slash <name>",
caption as `/name`.
## Token-line format (`script-tokens.json`)
```json
{
"lines": [
{
"id": "l1",
"tokens": ["This", "week", "at", "HyperFrames,", { "display": "JSON", "spoken": "jay-sawn" }]
}
]
}
```
- A bare string = display and spoken identical.
- An object = the layers diverge. `display` keeps standard spelling AND the
punctuation captions should show; `spoken` is what the TTS reads.
- One line = one caption phrase (≤ ~40 chars of display text). Line grouping
is an authoring decision made here, not downstream.
- Build `vo-spoken.txt` by joining every token's spoken form with spaces,
lines joined into sentences/paragraphs as punctuated.
## Phonetics rules (ElevenLabs-style best practices, plain-text)
HeyGen TTS takes plain text (no SSML), so pronunciation is controlled by
spelling, hyphens, and spacing:
1. **Initialisms** (each letter said): space or hyphen the letters —
`CLI → "C L I"`, `CDP → "C D P"`, `API → "A P I"`.
2. **Acronyms said as words**: respell phonetically —
`JSON → "jay-sawn"`, `GSAP → "jee-sap"`.
3. **Mixed / pronounceable compounds**: hyphenated LOWERCASE phonetics, one
fluid run — `ffmpeg → "ff-mpeg"` (ear-tested; the TTS reads "ff" as a
fluid "eff-eff"), `WebM → "web em"`, `OAuth → "oh-auth"`. Never spaced
capitals here: the TTS reads spaced caps as isolated letter names with
hard stops ("F F em-peg" comes out "eff… eff… em-peg"). Reserve spaced
capitals for TRUE initialisms (CLI, API) where a deliberate
letter-by-letter read is the goal. When candidates are close, generate
A/B takes of the real sentence and let the user pick by ear.
4. **Versions/numbers**: expand — `v0.7.36 → "version zero point seven
point thirty-six"` (usually: don't speak versions at all),
`1080×1080 → "ten-eighty by ten-eighty"`.
5. **URLs**: `hyperframes.heygen.com → "hyperframes dot hey-jen dot com"`.
6. **Filenames/extensions**: `.mp4 → "dot em pee four"` — or rephrase so the
extension isn't spoken.
7. **Emphasis/pauses**: commas and em-dashes, never caps. Ellipses are
unreliable in TTS — use an em-dash.
The shared vocabulary lives in `references/lexicon.json`
(`display → spoken`). Consult it for EVERY technical term; if a term is
missing, ask the user for the pronunciation and add the entry — never guess,
never ship unheard. New entries: listen to that line in the generated VO
before accepting.
## Alignment (spoken timestamps → display captions)
`heygen-tts.mjs --words` returns word timestamps of the SPOKEN text.
`scripts/align-captions.mjs` walks the spoken stream against the token lines
(one display token may cover several spoken words — "C L I" is three) and
emits `captions.json`:
```json
{
"lines": [
{
"end": 3.1,
"w": [
["This", 0.22],
["week", 0.4],
["JSON", 1.1]
]
}
]
}
```
Each display word carries the start time of its FIRST spoken word; a line's
`end` = the next line's start (last line: last word end + 0.6). The aligner
warns `MISMATCH` when the heard word doesn't fuzzy-match the expected spoken
form — every warning must be resolved (fix the lexicon spelling or the
transcript) before the captions are trusted.
## Caption rail (rendering)
Per `captions-overlay`: a quiet OVERLAY, never a reserved band. One line,
bottom-center (top: 1002px on 1080-square), TT Norms Pro 500 25px,
ink .92, soft dark text-shadow, words fading in (0.12s) on their timestamps,
phrase swaps as sets. Keep critical small text out of the bottom ~80px
center span; everything else may run under the rail.
@@ -0,0 +1,48 @@
# Visualization Registry
The routing table for "show, don't tell". Classes, strongest first:
1. **ui-recreate** — the change lives in a surface we can faithfully mock.
2. **ui-analog** — no exact surface, but an honest UI metaphor exists
(panel, meter, pipeline) whose behavior IS the change.
3. **terminal** — the change is a CLI command/flag; type it, show the result.
4. **checklist** — non-visual (fix lists, dependency bumps). Last resort.
Never invent UI that implies a screen that doesn't exist — an analog must
depict the _behavior_ (speed, batching, caching), not a fake product page.
## Known surfaces (ui-recreate)
| Surface | Mock anatomy | Proven choreography |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Studio editor / timeline** | glass app frame: titlebar (traffic dots, mono app name, Export pill), preview strip w/ gradient art, ruler + ticks, lanes, playhead, mono-labeled clips | clips drop/stack into lanes; drag→edge/playhead snap w/ green snap-line flash; marquee → group move → group resize; playhead scrub drives preview art (hue-rotate) |
| **Inspector / design panel** | panel: MONO section headers (INSPECTOR / VARIABLES), key-value rows, hairline dividers, dashed empty slot | binding pill (`{{ var }}`) flies into a property slot; value swap via masked slide (old up-out, new up-in); selection box draws on canvas |
| **Canvas + element** | mini stage card, dashed selection box, live text element | text updates same-frame with panel edits (green underline pulse = the live-preview moment) |
| **Variant renders** | small cards: display-font title + mono filename | cascade out diagonally, stagger ≤0.15s |
| **Storyboard view** | row of scene thumbnails w/ mono scene labels | thumbnails file in as a waterfall; one gets dragged to reorder |
| **Terminal / CLI** | glass strip, mono 19-20px, `$ ` prompt dim | chars type (stagger .02), result line lands after a 0.3-0.5s beat |
| **Render panel** | RENDER header, big tabular-nums frame counter, progress bar (green fill = the moment), status chips | bar + counter run with `power2.in` (slow→fast reads as "faster"); chips land on their VO words |
## Proven analogs (ui-analog)
| Change type | Analog | Behavior |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| Color grading / LUT | slider rows (label + track + knob) beside footage art | each knob move re-filters the art SAME frame (causal) |
| Render/extraction speed | render panel counter + bar | ease tells the story; add a before/after time chip if the claim is numeric |
| Batching (frames, requests) | row of small ticks | brackets draw around groups; ticks nudge into clusters |
| Caching | two identical request rows | first row runs a full bar; second short-circuits instantly to ✓ with a `cache` chip |
| Import/translation pipelines (e.g. Figma→HF) | source artifact card morphs/docks into a HF comp card — a pure DOM/CSS mock of the source (NO Figma API, no tokens, nothing fetched) | staged: source card → arrow/flight of extracted chips (tokens, components, motion) → assembled comp; chips are the carriers |
| One-pass / dedupe | N parallel item rows collapse onto one shared lane | rows glide onto one bar; count chip decrements |
| Concurrency caps / locks | queue of chips entering a gate | first k pass, rest hold; gate chip shows the cap |
| Error surfacing (toast, reason) | the surface's corner grows a toast card | action fails subtly → toast slides in, mono reason text |
## Checklist scene (last resort)
Glass card, ≤6 mono rows, green ✓ ticks landing on each item's VO word
(back.out(1.5), 0.3s) + row brightness pulse. Items beyond 6: cut, they live
in the digest link.
## Adding a surface
When a new UI area ships, add a row here (anatomy + choreography) the first
time it's mocked, so the next changelog reuses it instead of re-deriving it.
@@ -0,0 +1,127 @@
#!/usr/bin/env node
// align-captions.mjs — map SPOKEN-layer word timestamps back onto DISPLAY tokens.
//
// node align-captions.mjs --tokens script-tokens.json --words vo-words.json \
// --out captions.json [--tail 0.6]
//
// tokens: { lines: [{ id, tokens: [ "word" | {display, spoken} ] }] }
// words: [ { text, start, end } ] — timestamps of the spoken text (heygen-tts --words)
// out: { lines: [{ id, end, w: [[display, start], ...] }] } — caption-rail input
//
// Each display token consumes the spoken words of its `spoken` form (one display
// token may be several spoken words: "C L I" = 3). The display word's time = its
// FIRST spoken word's start. Line end = next line's first word start (last line:
// last spoken end + tail). Fuzzy matching absorbs TTS/timestamp quirks; anything
// it can't absorb prints MISMATCH — resolve every one before trusting captions.
import { readFileSync, writeFileSync } from "node:fs";
const argv = process.argv.slice(2);
const flag = (n, d) => {
const i = argv.indexOf("--" + n);
return i >= 0 ? argv[i + 1] : d;
};
const die = (m) => {
console.error("align-captions:", m);
process.exit(2);
};
const tokensFile = flag("tokens", null) ?? die("--tokens required");
const wordsFile = flag("words", null) ?? die("--words required");
const outFile = flag("out", "captions.json");
const tail = parseFloat(flag("tail", "0.6"));
const script = JSON.parse(readFileSync(tokensFile, "utf8"));
const stream = JSON.parse(readFileSync(wordsFile, "utf8"));
if (!script.lines?.length) die("tokens file has no lines[]");
if (!stream.length) die("words file is empty");
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const lev = (a, b) => {
if (a === b) return 0;
const m = a.length,
n = b.length;
if (!m || !n) return Math.max(m, n);
let prev = Array.from({ length: n + 1 }, (_, j) => j);
for (let i = 1; i <= m; i++) {
const cur = [i];
for (let j = 1; j <= n; j++)
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = cur;
}
return prev[n];
};
const close = (a, b) => {
if (!a || !b) return false;
if (a === b || a.startsWith(b) || b.startsWith(a)) return true;
return lev(a, b) <= Math.max(1, Math.floor(Math.min(a.length, b.length) / 3));
};
let si = 0; // stream cursor
let mismatches = 0;
const outLines = [];
// Greedily consume stream words from `from` whose concatenated norm builds the
// token's full spoken norm ("hey-jen" may arrive as one word or several; "C L I"
// as three). Returns { start, next } or null.
function consume(from, spokenNorm) {
let acc = "",
start = null,
k = from;
while (k < stream.length) {
const wn = norm(stream[k].text);
if (!wn) {
k++;
continue;
}
const cand = acc + wn;
if (spokenNorm.startsWith(cand) || close(cand, spokenNorm)) {
if (start === null) start = stream[k].start;
acc = cand;
k++;
if (close(acc, spokenNorm)) return { start, next: k };
continue;
}
break;
}
return acc && close(acc, spokenNorm) ? { start, next: k } : null;
}
for (const line of script.lines) {
const w = [];
for (const tok of line.tokens) {
const display = typeof tok === "string" ? tok : tok.display;
const spoken = typeof tok === "string" ? tok : tok.spoken;
const spokenNorm = norm(spoken);
if (!spokenNorm) {
w.push([display, si < stream.length ? stream[si].start : 0]);
continue;
}
// try at the cursor, then resync up to 4 words ahead
let hit = null;
for (let off = 0; off <= 4 && !hit; off++) hit = consume(si + off, spokenNorm);
if (!hit) {
console.error(
`MISMATCH line=${line.id} display="${display}" expected~"${spoken}" heard="${stream[si]?.text ?? "<eof>"}" @${stream[si]?.start?.toFixed(2) ?? "?"}s`,
);
mismatches++;
w.push([display, si < stream.length ? stream[si].start : stream.at(-1).end]);
continue;
}
si = hit.next;
w.push([display, +hit.start.toFixed(2)]);
}
outLines.push({ id: line.id, w });
}
for (let i = 0; i < outLines.length; i++) {
outLines[i].end =
i + 1 < outLines.length ? outLines[i + 1].w[0][1] : +(stream.at(-1).end + tail).toFixed(2);
}
writeFileSync(outFile, JSON.stringify({ lines: outLines }, null, 1));
const status = mismatches ? `${mismatches} MISMATCH(ES) — resolve before building` : "clean";
console.log(
`aligned ${outLines.length} lines / ${stream.length} spoken words → ${outFile} (${status})`,
);
process.exit(mismatches ? 1 : 0);
+258
View File
@@ -0,0 +1,258 @@
---
name: cut-the-curve
description: "The technique catalog: five velocity-matched SEAMS (zoom-through, INVERSE zoom-through, cut-the-curve, waterfall cut, rack-focus blur-cut) plus the two in-scene techniques — waterfall ENTRY (staggered arrival cascades for title cards / segment openers) and the nudge curve (slow-fast-slow three-phase group slides). Covers partial-travel (~12% of frame) velocity matching via mirrored power4 eases, the Z scale-sign rule, size-scaled blur (10px text / 18-20px full-frame), word-by-word staggered cuts, cascade pacing by element weight, and the 10/65/25 slide ratio. Read before authoring any transition, text-beat handoff, kinetic text entry, or group reposition. [depth, zoom, inverse-zoom, scale-sign, mirrored-zoom, rack-focus, pacing, velocity, cut-the-curve, waterfall, stagger, cascade, kinetic-text, title-card, segment-opener, nudge, slide, easing, group-motion, z-depth, motion-graphics, cinematic, transition, blur, directional-continuity]"
---
# Cut the Curve — the technique catalog
Five SEAM techniques, one principle: **cut at peak velocity, match direction and speed
on both sides of the cut** — plus the two in-scene techniques (§6 arrivals, §7 slides).
The seam LAW — vector law, the current, the ledger, the Seam Gate — lives in
`motion-doctrine`; read it first. This skill is the parameters and mechanics.
All GSAP code templates (worker + registry): `examples/gsap-implementation.md`.
## Catalog
| # | Technique | Scope | Axis | Use for |
| --- | -------------------------- | ------------------------------ | ------------------- | ------------------------------------------------------ |
| 1 | **Zoom-Through** (forward) | Within-scene text swap | Z, toward viewer | progressing deeper into the same thought |
| 2 | **Inverse Zoom-Through** | Arrival / payoff beat | Z, away from viewer | something bigger lands |
| 3 | **Cut the Curve** | Between scenes | X / Y | the default boundary, the film's current |
| 4 | **Waterfall Cut** | Text-to-text seam | X, per-word | word-level handoff between big-text beats |
| 5 | **Rack-Focus Blur-Cut** | Same-surface state swap | X / Y / Z | the one cut you want SEEN — a DSLR focus-pull flourish |
| 6 | **Waterfall Entry** | In-scene ARRIVAL (no seam) | Y, from below | title cards, segment openers, list intros |
| 7 | **Nudge Curve** | In-scene group slide (no seam) | X / Y | repositioning a composed group to make room |
## Z direction is a sign
"Same axis" is not enough on Z — the sign of d(scale)/dt must match across the cut:
| Z vector | Exit scale | Entry scale | Variant |
| -------------- | ------------------- | -------------------- | -------------------- |
| Push (forward) | growing `1 → 1.2` | growing `0.75 → 1` | zoom-through |
| Pull (back) | shrinking `1 → 0.8` | shrinking `1.25 → 1` | inverse zoom-through |
Banned mirrors: a receding exit answered by a grow-from-small entry (pull flips to push —
the common one, since grow-from-small is the default element entrance), and a push exit
answered by an oversized retraction. This binds the incoming scene's OWN entrances during
the seam window (cut + ~0.5s), not just the wrapper tween: hold the incoming frame
composed, or author its entrance to match the sign. Verify per Seam Gate rule 7.
## Blur logic (all Z variants)
| Subject | Peak blur | Why |
| --------------------------------------------- | ----------- | -------------------------------------------------------------- |
| Text-scale (headline, word group) | **10px** | 20px smears letterforms — the cut reads as a glitch, not speed |
| Full-frame surface (window, card, screenshot) | **1820px** | lighter blur on a big surface reads as a rendering hiccup |
Same peak blur on both sides at the swap frame. Blur the WRAPPER, never children.
---
## 1. Zoom-Through (forward)
Z-axis velocity-matched cut; **never both texts visible.** Everything GROWS: the outgoing
text accelerates toward camera, a hard swap hides at peak blur, the incoming text keeps
growing into the focal plane. Headlines and short phrases only. Total ≈ 0.4s.
| Phase | Scale | Blur | Opacity | Ease | Duration |
| -------------- | -------- | -------- | ----------------- | ------------------------------------------ | -------- |
| Exit | 1 → 1.2 | 0 → 10px | 1 → 0.15 | power3.in (opacity: separate `none` tween) | 0.2s |
| Cut (`tl.set`) | in: 0.75 | 10px | out: 0 / in: 0.15 | — | — |
| Entry | 0.75 → 1 | 10 → 0px | 0.15 → 1 | expo.out | 0.5s |
Exit opacity MUST be its own linear tween — `power3.in` holds opacity near 1 too long.
On entry all properties share `expo.out`.
## 2. Inverse Zoom-Through (backward)
The pull-back mirror: the outgoing element RECEDES; the incoming arrives OVERSIZED (as if
just behind camera) and retracts into the focal plane. Everything SHRINKS. Spend on
ARRIVAL/payoff beats — a payoff line, a giant reply, a held end-state — never ordinary
boundaries. Total ≈ 0.7s (30% exit / 70% entry).
| Phase | Scale | Blur | Opacity | Ease | Duration |
| -------------- | -------- | -------- | ----------------- | ------------------------------------------ | -------- |
| Exit | 1 → 0.8 | 0 → 10px | 1 → 0.15 | power3.in (opacity: separate `none` tween) | ~0.2s |
| Cut (`tl.set`) | in: 1.25 | 10px | out: 0 / in: 0.15 | — | — |
| Entry | 1.25 → 1 | 10 → 0px | 0.15 → 1 | expo.out | ~0.5s |
Blur is 10px text-scale; 1820px only when both sides are full-bleed surfaces.
**Sign discipline:** the incoming scene arrives as a composed frame inside the retracting
wrapper — no grow-from-small intro in the seam window. Staged entrances happen after the
retraction settles, or start ≥1 and retract.
## 3. Cut the Curve (default scene boundary)
X/Y velocity-matched cut — the default for ALL scene-to-scene boundaries, in the film's
current, not an accent. The outgoing hero accelerates in one direction, the cut lands
mid-motion, the incoming hero continues the SAME direction and decelerates. Total ≈ 0.6s;
directions LEFT / RIGHT / UP / DOWN (default LEFT).
**Partial travel:** ~12% of frame (≈230px at 1920) — never full off-screen moves.
| Direction | Exit | Entry start → end |
| --------- | ------------- | ----------------- |
| Leftward | `x: 0 → 230` | `x: +230 → 0` |
| Rightward | `x: 0 → +230` | `x: 230 → 0` |
| Upward | `y: 0 → 230` | `y: +230 → 0` |
| Downward | `y: 0 → +230` | `y: 230 → 0` |
Mechanics:
- **Mirrored eases:** exit `power4.in` + entry `power4.out`, same distance and duration —
the two halves of one `power4.inOut`, so velocity matches exactly at the cut.
- **The fade trick:** exit opacity completes at ~2530% of its travel (fade ≈ 0.180.3s
vs motion 0.30.34s); entry ignites at ~0.35 opacity mid-path. Time the last fading
element to die right at the cut — a gap where nothing moves reads as dead air.
- Exit 0.20.4s; entry ≥ exit. Optional blur 810px.
- **Stage ground:** `#root` must be opaque
(`background: var(--canvas-deep, var(--canvas, #000))`) — the mid-window cut opens a
summed-opacity < 1 window that flashes white otherwise (see `seam-craft`).
`push-slide` exists but violates partial-travel and mid-motion phase; prefer cut-the-curve.
## 4. Waterfall Cut (word-by-word cut-the-curve)
Cut-the-curve at WORD granularity — the strongest leftward cut for text-to-text seams.
Outgoing words ramp out on their own curves; incoming words cascade in mid-flight — a
wave the eye rides across the seam.
**Scope:** worker-authored inside one multi-beat comp (stacked full-frame `.beat` layers),
NOT a registry/injector type — it tweens word spans, not clip wrappers. The boundary into
and out of the text-beat block still gets a normal registry transition. Does not count
against the 23 transition budget.
| Parameter | Value | Why |
| ------------------- | --------------------- | ------------------------------------------- |
| Travel | ±230px (~12% frame) | partial travel + velocity > full-frame push |
| Exit | 0.34s `power4.in` | the acceleration IS the cut |
| Exit fade | 0.18s, starts with x | word gone by ~2530% of travel — no smear |
| Exit stagger | +0.022s reading order | the line peels, not a block slide |
| Entry | 0.3s `power4.out` | back half of the composite — velocity match |
| Entry start opacity | 0.35 | mid-path ignition; binary 0→1 pops |
| Entry gaps | 0.05s × 0.84 decay | accelerating cascade, resolves composed |
Rules:
- One direction per chain, riding the current. Inverse zoom is the chain's ARRIVAL beat only.
- Pre-set all words to `x: +230, opacity: 0` at build time — `immediateRender: false`
alone leaves un-started words visible at rest.
- A short first beat may exit whole-line: its fade ends ~0.02s before the cut so it is
still streaking when the next words ignite — no dead gap.
- Transform/opacity only (seek-safe); opaque stage ground applies.
## 5. Rack-Focus Blur-Cut (the visible cut)
The one variant where the cut is SEEN: a defocus blur SPIKE hides a single-frame hard
swap — a handheld-DSLR focus-pull. Use as an occasional flourish for a state swap of the
SAME surface within one visual theme; never the default boundary.
Differences from the others: outgoing stays FULLY OPAQUE until the cut (the blur hides
the swap — no early fade); eases `power2.in` / `power2.out` (soft optics, not momentum).
Rules:
- Fire only at a narrative beat, ≤ once per ~8s; never mid-caption or during a hold.
- Cut at PEAK blur (≥6px; peak 812px, ≤1618px max) — swapping on the way up shows the cut.
- A subtle scale (~1.06 lens-breathing) sells it as optics.
- Same direction on both sides — the vector law still holds. Entry ≥ exit duration.
- Blur the wrapper; never blur + opacity in one tween on one element (headless
compositing bug); never blur a `<video>` directly (wrap it).
---
## 6. Waterfall Entry (in-scene arrival — not a seam)
Staggered ARRIVAL cascade: words/elements whip in from below (one consistent direction),
each starting before the previous settles — an accelerating wave that resolves into a
composed layout. Title cards, segment openers, list/feature intros. The seam sibling is
§4; do not mix their rules:
| | §6 Entry (arrival) | §4 Waterfall Cut (seam) |
| ------------- | --------------------------------------------- | --------------------------------------------------------- |
| Opacity | BINARY 0→1 via `tl.set` at entry — never fade | ignites at 0.35 mid-path — the fade IS the velocity trick |
| Axis default | Y, from below | X, riding the current |
| Outgoing side | none | words ramp out on mirrored power4.in |
Choreography:
- **Overlap, don't queue** — next element starts within ±2 frames of the previous
settling; gaps SHRINK across the cascade; the last element snaps.
- **Velocity varies by weight** — heavy/anchor elements travel further and longer;
light words/punctuation snap in tight:
| Parameter | Anchor/heavy | Normal word | Light/punctuation |
| --------- | ------------ | ----------- | ----------------- |
| Y offset | 6080px | 4050px | 3048px |
| Duration | 0.160.20s | 0.130.16s | 0.100.13s |
| Overlap | 02f gap | 1f overlap | 12f overlap |
- Ease `power4.out` (expo.out for extra snap); never `.inOut` on an entry.
- One direction per cascade.
- Split the FINAL word into fragments to extend the climax; fragments travel further.
- Post-settle, the group usually slides to make room for the next beat — that's §7.
## 7. Nudge Curve (in-scene group slide — not a seam)
Slow-fast-slow repositioning of a composed group (word rows, card stacks, lists) to
reveal content or make room. No single built-in ease produces it — `power4.inOut`
smacks to a stop. Chain three tweens on one property:
| Phase | Ease | Distance | Time | Feel |
| --------- | --------------- | -------- | ---- | ---------------------------------------- |
| 1 ramp-in | `power3.in` | ~10% | ~20% | barely moves — motion registers, no jolt |
| 2 burst | `none` (linear) | ~65% | ~18% | ~2× average px/frame — purposeful |
| 3 tail | `power4.out` | ~25% | ~62% | decaying creep to rest — kills the smack |
Rules:
- The tail is ≥3× the ramp-in in TIME. If it still smacks: extend the tail's time (not
distance) or use `power5.out`.
- Phase 2 stays linear — easing it loses the burst contrast.
- Reveal new content DURING phase 2 — the burst masks its appearance.
- Same ratios vertical; scale distances proportionally, keep the time ratios.
---
## 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 |
| Z sign / axis | growing (push) | shrinking (pull) | X / Y | X, per-word |
| Travel/scale | 1→1.2, then 0.75→1 | 1→0.8, then 1.25→1 | ±230px | ±230px |
| Peak blur | 10px text / 1820 full-frame | 10px text / 1820 full-frame | 810px optional | none |
| Eases | power3.in / expo.out | power3.in / expo.out | power4.in / power4.out | power4.in / power4.out |
| Feel | progressing through | arriving at | carried sideways | a wave across the seam |
## Anti-Patterns
| Don't | Instead |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Two texts visible during a zoom-through | Hard cut at blur peak, one text at a time |
| 20px blur on text-scale subjects | 10px text; 1820px only full-frame |
| Inverse-zoom exit → grow-from-small entry (or push → oversized retraction) | Match the scale-velocity SIGN; verify at cut±0.1s |
| Incoming comp's own scale-up intro under a Z-seam wrapper tween | Arrive composed; stage entrances after the seam settles or match the sign |
| Mismatched blur/opacity at the swap | Identical values at the cut frame |
| Gentle entry easing (`power2.out`) | Mirror the exit: `power4.out` / `expo.out` |
| Full off-screen exits/entries | Partial travel (~12%) + early fade |
| `.inOut` eases on either side of a cut | Mirrored `power4.in` / `power4.out` |
| Lone element fading long before its cut | Fade ends ~0.02s before the cut, or word-cascade |
| Equal gaps across a waterfall cascade | Shrink gaps ×0.84 per word |
| Zoom-through on body text | Headlines and short phrases only |
| Scene cuts without cut-the-curve | It is the default boundary |
| Consecutive boundaries in opposing directions | One current; reserved vectors spent on meaning |
| Unpainted `#root` behind a mid-window cut | Opaque stage ground |
| Queued entries (each waits for the previous to settle) | Overlap ±12 frames — the cascade is a wave, not a queue |
| Same offset/duration for every cascade element | Vary by weight: anchors travel further, punctuation snaps |
| Gradual opacity fade on a §6 arrival | Binary 0→1 via `tl.set` — fading fights the snap (seam cuts fade; arrivals don't) |
| Single ease for a group slide (`power4.inOut`, `slow()`) | The §7 three-phase chain |
| Nudge tail shorter than 3× the ramp-in | Extend the tail's TIME, not its distance |
## Code
All GSAP templates — worker-authored versions, registry `gsap_template`s, the combined
cut-the-curve + zoom, waterfall DOM/CSS/JS, rack-focus — live in
`examples/gsap-implementation.md`.
@@ -0,0 +1,324 @@
# Cut the Curve — GSAP code templates
Canonical implementations for every variant. Parameters and rules live in `../SKILL.md`;
copy code from here. Worker-authored versions tween in-scene elements; registry
`gsap_template`s are injector-stamped onto the two clip wrappers (`__OLD__` / `__NEW__`
/ `__T__` / `__DUR__` tokens — see `seam-craft` for the token table).
## 1. Zoom-Through (forward)
### Worker version (within-scene wrapper swap)
```js
var EXIT_START = /* when readable text starts leaving */;
var CUT = EXIT_START + 0.2;
// Phase 1: Exit — scale/blur accelerate, opacity fades linearly (separate tween)
tl.to(".text-a-wrapper", {
scale: 1.2,
filter: "blur(10px)", // text-scale: 10px
duration: 0.2,
ease: "power3.in",
overwrite: "auto"
}, EXIT_START);
tl.to(".text-a-wrapper", { opacity: 0.15, duration: 0.2, ease: "none" }, EXIT_START);
// Phase 2: Hard cut — matched properties
tl.set(".text-a-wrapper", { opacity: 0 }, CUT);
tl.set(".text-b-wrapper", { opacity: 0.15, scale: 0.75, filter: "blur(10px)" }, CUT);
// Phase 3: Entry — fast initial velocity, long settle
tl.to(".text-b-wrapper", {
scale: 1, filter: "blur(0px)", opacity: 1,
duration: 0.5, ease: "expo.out"
}, CUT);
```
### Registry gsap_template
```js
tl.to(
__OLD__,
{ scale: 2.5, opacity: 0, filter: "blur(8px)", duration: __DUR__, ease: "power3.in" },
__T__,
);
tl.fromTo(
__NEW__,
{ scale: 0.5, opacity: 0, filter: "blur(8px)" },
{ scale: 1, opacity: 1, filter: "blur(0px)", duration: __DUR__, ease: "power3.out" },
__T__,
);
```
## 2. Inverse Zoom-Through
### Registry gsap_template
```js
tl.set(__NEW__, { opacity: 0 }, __T__);
tl.to(
__OLD__,
{ scale: 0.8, filter: "blur(10px)", duration: __DUR__ * 0.3, ease: "power3.in" },
__T__,
);
tl.to(__OLD__, { opacity: 0.15, duration: __DUR__ * 0.3, ease: "none" }, __T__);
tl.set(__OLD__, { opacity: 0 }, __T__ + __DUR__ * 0.3);
tl.fromTo(
__NEW__,
{ opacity: 0.15, scale: 1.25, filter: "blur(10px)" },
{
opacity: 1,
scale: 1,
filter: "blur(0px)",
duration: __DUR__ * 0.7,
ease: "expo.out",
immediateRender: false,
},
__T__ + __DUR__ * 0.3,
);
```
Worker version: same phases as zoom-through with the scale values flipped
(exit `1 → 0.8`, cut-in at `1.25`, entry `1.25 → 1`).
## 3. Cut the Curve
### Worker version (scene layers)
```js
var CUT_TIME = /* scene transition point */;
// Scene A: hero accelerates leftward (partial travel ~12% of frame)
tl.to(".scene-a-layer", { opacity: 0, duration: 0.33, ease: "power2.in" }, CUT_TIME - 0.33);
tl.to(".hero-a-wrapper", {
x: -230, filter: "blur(8px)",
duration: 0.33, ease: "power4.in", // mirrored half of power4.inOut
overwrite: "auto"
}, CUT_TIME - 0.33);
// Hard cut
tl.set(".scene-b-layer", { opacity: 1 }, CUT_TIME);
tl.set(".hero-b-wrapper", { x: 230, filter: "blur(8px)" }, CUT_TIME);
// Scene B: hero decelerates leftward
tl.to(".hero-b-wrapper", {
x: 0, filter: "blur(0px)",
duration: 0.33, ease: "power4.out" // matched velocity at the cut
}, CUT_TIME);
```
### Registry gsap_template
`__DX__` = `-1920` (LEFT) / `1920` (RIGHT); `__DY__` = `-1080` (UP) / `1080` (DOWN).
The `* 0.12` / `* 0.21` factors yield the ~12% partial travel.
```js
// horizontal
tl.set(__NEW__, { opacity: 0 }, __T__);
tl.to(__OLD__, { x: __DX__ * 0.12, duration: __DUR__ * 0.5, ease: "power4.in" }, __T__);
tl.to(__OLD__, { opacity: 0, duration: __DUR__ * 0.47, ease: "power2.in" }, __T__ + __DUR__ * 0.03);
tl.fromTo(
__NEW__,
{ x: __DXIN__ * 0.12, opacity: 0.35 },
{ x: 0, opacity: 1, duration: __DUR__ * 0.5, ease: "power4.out", immediateRender: false },
__T__ + __DUR__ * 0.5,
);
// vertical
tl.set(__NEW__, { opacity: 0 }, __T__);
tl.to(__OLD__, { y: __DY__ * 0.21, duration: __DUR__ * 0.5, ease: "power4.in" }, __T__);
tl.to(__OLD__, { opacity: 0, duration: __DUR__ * 0.47, ease: "power2.in" }, __T__ + __DUR__ * 0.03);
tl.fromTo(
__NEW__,
{ y: __DYIN__ * 0.21, opacity: 0.35 },
{ y: 0, opacity: 1, duration: __DUR__ * 0.5, ease: "power4.out", immediateRender: false },
__T__ + __DUR__ * 0.5,
);
```
### Combined cut-the-curve + zoom
The scale component obeys the Z sign rule: both sides SHRINK (exit `1 → 0.92`, entry
`1.08 → 1`) — a consistent mild pull layered on the lateral cut. Never pair a shrinking
exit with a grow-from-small entry here.
```js
// Scene A: hero slides left + mild pull + blur
tl.to(
".hero-a-wrapper",
{
x: -230,
scale: 0.92,
filter: "blur(8px)",
duration: 0.33,
ease: "power4.in",
},
CUT_TIME - 0.33,
);
// Cut + Scene B: continues leftward, arrives slightly oversized and retracts
tl.set(".hero-b-wrapper", { x: 230, scale: 1.08, filter: "blur(8px)" }, CUT_TIME);
tl.to(
".hero-b-wrapper",
{
x: 0,
scale: 1,
filter: "blur(0px)",
duration: 0.42,
ease: "power4.out",
},
CUT_TIME,
);
```
## 4. Waterfall Cut
### DOM + CSS
```html
<div class="beat" id="b1"><div class="line">And until now</div></div>
<div class="beat" id="b2">
<div class="line">
<span class="w">that</span> <span class="w">changes</span> <span class="w">today.</span>
</div>
</div>
```
```css
.beat {
position: absolute;
inset: 0;
display: grid;
place-items: center;
will-change: transform, opacity;
}
.w {
display: inline-block;
will-change: transform, opacity;
}
```
### Timeline
```js
// Pre-set at build time — immediateRender:false alone leaves un-started words visible.
gsap.set([...w1, ...w2], { x: 230, opacity: 0 });
function wordExit(words, C) {
// C = cut time on the comp timeline
let s = C - 0.32; // exits START before the cut
words.forEach((el) => {
tl.to(el, { x: -230, duration: 0.34, ease: "power4.in" }, s);
tl.to(el, { opacity: 0, duration: 0.18, ease: "power1.in" }, s); // fade ends ~25-30% into travel
s += 0.022; // reading-order stagger
});
}
function wordEnter(words, C) {
let off = 0,
gap = 0.05;
words.forEach((el) => {
tl.fromTo(
el,
{ x: 230, opacity: 0.35 }, // ignites MID-PATH, already moving
{ x: 0, opacity: 1, duration: 0.3, ease: "power4.out", immediateRender: false },
C + off,
);
off += gap;
gap *= 0.84; // shrinking gaps — the cascade accelerates
});
}
const C = 2.3;
wordExit(w1, C);
tl.set(b1, { opacity: 0 }, C); // hard layer swap AT the cut
tl.set(b2, { opacity: 1 }, C);
wordEnter(w2, C);
```
Whole-line first beat: exit as one element — `x: -230, 0.34s, power4.in` with the
opacity fade running nearly the whole ramp (ends ~0.02s before the cut).
## 5. Rack-Focus Blur-Cut
For a Z dolly, drop the x-offset and use scale `0.92 →` / `→ 1.08`.
```js
var T = /* transition start */;
// Phase 1: outgoing pans + blurs, held fully opaque
tl.to("#scene-out", { x: -80, scale: 1.06, filter: "blur(12px)", duration: 0.3, ease: "power2.in" }, T);
// Phase 2: hard cut at peak blur
tl.set("#scene-out", { opacity: 0 }, T + 0.3);
tl.fromTo("#scene-in",
{ opacity: 1, x: 80, scale: 1.06, filter: "blur(12px)" },
{ x: 0, scale: 1, filter: "blur(0px)", duration: 0.35, ease: "power2.out" },
T + 0.3);
```
## Tuning ranges
### Zoom-Through / Inverse Zoom
| Parameter | Default | Range |
| -------------- | ------------------------------ | --------- |
| Exit scale | 1.2 (fwd) / 0.8 (inv) | ±0.1 |
| Entry scale | 0.75 (fwd) / 1.25 (inv) | ±0.1 |
| Blur at cut | 10px text / 1820px full-frame | — |
| Opacity at cut | 0.15 | 0.10.2 |
| Exit duration | 0.2s | 0.150.3s |
| Entry duration | 0.5s | 0.40.6s |
### Cut the Curve
| Parameter | Default | Range |
| -------------- | ------------------ | --------- |
| Travel | 230px (~12% frame) | 150300px |
| Blur at cut | 8px | 610px |
| Exit duration | 0.33s | 0.20.4s |
| Entry duration | 0.330.42s | ≥ exit |
## 6. Waterfall Entry
Each element: `tl.set` (instant reveal + offset) then `tl.to` (whip to rest).
`nextStart = prevStart + prevDuration (overlapFrames × F)`; +overlap = cascade,
overlap = deliberate gap. CSS: elements start `opacity: 0; display: inline-block`.
```js
var F = 1 / 60;
var t0 = 0.1;
// anchor (heaviest): biggest travel, longest settle
tl.set("#el-1", { opacity: 1, y: 80 }, t0);
tl.to("#el-1", { y: 0, duration: 0.18, ease: "power4.out" }, t0);
// normal word: 2 frames after the anchor finishes
var t1 = t0 + 0.18 + 2 * F;
tl.set("#el-2", { opacity: 1, y: 45 }, t1);
tl.to("#el-2", { y: 0, duration: 0.15, ease: "power4.out" }, t1);
// light word: 1 frame BEFORE the previous finishes (overlap)
var t2 = t1 + 0.15 - F;
tl.set("#el-3", { opacity: 1, y: 40 }, t2);
tl.to("#el-3", { y: 0, duration: 0.14, ease: "power4.out" }, t2);
// split final-word fragments: tightest overlap, extra travel (lighter)
var t3 = t2 + 0.14 - F;
tl.set("#frag-a", { opacity: 1, y: 70 }, t3);
tl.to("#frag-a", { y: 0, duration: 0.16, ease: "power4.out" }, t3);
var t4 = t3 + 0.14 - F;
tl.set("#frag-b", { opacity: 1, y: 70 }, t4);
tl.to("#frag-b", { y: 0, duration: 0.15, ease: "power4.out" }, t4);
// punctuation: lightest, fastest
var t5 = t4 + 0.13 - 2 * F;
tl.set("#dot", { opacity: 1, y: 48 }, t5);
tl.to("#dot", { y: 0, duration: 0.12, ease: "power4.out" }, t5);
```
## 7. Nudge Curve
Reference values for a 270px leftward slide (0.57s total). Scale distances
proportionally for other travels; preserve the TIME ratios; tail ≥3× ramp-in.
```js
var t = /* start after content settles */;
tl.to(".text-row", { x: -30, duration: 0.12, ease: "power3.in" }, t); // ramp-in: 11% dist / 21% time
tl.to(".text-row", { x: -210, duration: 0.10, ease: "none" }, t + 0.12); // burst: 67% dist / 18% time
tl.to(".text-row", { x: -270, duration: 0.35, ease: "power4.out" }, t + 0.22); // tail: 22% dist / 61% time
// vertical: same ratios on y. 150px variant: -15 / -115 / -150 at the same times.
```
+173
View File
@@ -0,0 +1,173 @@
---
name: motion-doctrine
description: "GATEWAY — load FIRST before composing any HyperFrames animation or video. The high-level motion law that makes a multi-scene video feel like ONE continuous camera move instead of a stack of independently-animated slides. Covers the vector law (how you exit determines how you enter, incl. the Z scale-sign rule), the film's current, carrier elements, causal motion, the Seam Gate (build-gate enforcement), the ban on idle wobble (motion must PERFORM, not breathe), stillness-before-climax, and the sustained-motion routes. Routes to the low-level technique skills (cut-the-curve — the full catalog incl. waterfall entry + nudge curve, oversized-cursor, seam-craft). These rules SUPERSEDE generic / upstream motion guidance. [continuity, direction, vector, momentum, seam, transition, ease, performance, idle-motion, narrative-motion, film-grammar]"
---
# Motion Doctrine (Gateway)
Read this before composing any animation. It decides WHAT happens at every seam and how
every scene performs; the technique skills implement it. These rules supersede generic /
upstream motion guidance. The failure this prevents: scenes authored in isolation — the
eye's momentum dies at every cut, and scenes wobble in place between entry and exit.
## Route map
| Decision (this skill) | Implementation skill |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Seam transition choice + parameters + code | `cut-the-curve` §15 (the catalog) |
| Text / element entry cascades | `cut-the-curve` §6 (waterfall entry) |
| In-scene group repositioning (no cut) | `cut-the-curve` §7 (nudge curve) |
| Cursor-led action / scene kickoff / morph ignition | `oversized-cursor` |
| Seam render mechanics / white-flash guard | `seam-craft` |
| Product-launch / explainer / caption work | overlays `text-beat-economics`, `brand-faithful`, `captions-overlay` on top of the upstream skill |
Authoring order: **vector ledger (`ledger.json`) → STAMP the master seams from it
(`scripts/seam-stamp.mjs --ledger ledger.json --write index.html`) → sustained-motion
route per phase → carriers and causes → build comps → VERIFY (`scripts/seam-gate.mjs`).**
Hand-author only Tier-A morphs/match-cuts; stamped seams pass the gate by construction.
---
# Part 1 — The Seam Law
## The Vector Law
> How Scene A exits determines how Scene B enters: same axis, same direction, matched
> speed, cut mid-motion on both sides.
1. **Axis** — x stays x, y stays y, Z stays Z. Never trade axes across a cut.
2. **Direction** — never mirror. On Z, direction = the SIGN of scale change: growing =
push (camera forward), shrinking = pull (camera back). A receding exit answered by a
grow-from-small entry is a mirrored vector — the most common violation, because
grow-from-small is the default element entrance.
3. **Speed** — entry initial velocity ≈ exit final velocity, via mirrored eases (exit
`power4.in` + entry `power4.out`, same distance and duration; the incoming side picks
up ≥50% through the notional path). Mechanics in `cut-the-curve`.
4. **Phase** — the cut lands mid-motion on BOTH sides. Settling to rest before the cut,
or starting from rest after it, is a dead beat.
## The Current
Every film picks ONE dominant direction (house default: LEFT). Every ordinary seam uses
it. Other vectors are RESERVED — spending one means something:
| Vector | Meaning |
| ------------------------- | --------------------------------------------------------------- |
| The current (LEFT) | "next beat" — neutral forward progress |
| Upward | elevation — a conclusion or reveal rises above what came before |
| Z forward (zoom-through) | pushing deeper into the same thought |
| Z backward (inverse zoom) | ARRIVAL — something bigger lands |
| Scale-burst (explode out) | leaving a world — a surface blasts past camera |
- Never run consecutive seams in opposing directions — ping-pong reads as an error.
- A direction change needs a visible cause (click / bounce / impact) or a chapter boundary.
## The Vector Ledger
Write it before authoring any master timeline — as **`ledger.json` at the project root**
(schema: `references/seam-gate.md`). One row per seam: cut time, exit and entry vectors
(axis + signed direction; Z rows carry the scale sign), selectors, technique. Exit and
entry must match; if a row mismatches, fix the plan, not the easing. The verifier checks
row consistency statically before any runtime sampling.
## Carriers
The eye follows objects, not abstractions. The strongest seams hand a concrete carrier
across the cut at matched position AND velocity: a cursor mid-path, a container that
shrinks/docks into the next layout, a mark that flies into its exact slot, the word group
of a waterfall cut. With no natural carrier, the scene heroes carry it (partial travel +
early fade, entry mid-flight). Never a crossfade — it has no carrier at all.
## Causal Motion
Chain motion so each move is visibly launched by the last: click → squash → release
spring → flight → impact → recoil → reveal.
- Effects start ON the causing frame — same timeline position, never "shortly after."
- Reactions scale with implied mass: big elements rebound slower, small ones snap.
- A force is a license to change direction; an uncaused flip is a ping-pong.
## The Seam Gate (build gate — run the verifier, exit 0 or the seam is not done)
```bash
node <SKILL_DIR>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html # generate
node <SKILL_DIR>/scripts/seam-gate.mjs verify --ledger ledger.json --project . # verify
```
The script (usage + ledger schema: `references/seam-gate.md`) numerically enforces, per
seam: ledger-row consistency, exit still moving at the cut, entry mid-flight (never from
rest), measured direction = ledger direction, entry/exit speed match (WARN), **zero
overlap** (one side visible per frame — the cut is not a dissolve), the **Z sign** rule
(d(scale)/dt same sign both sides; the incoming scene's own entrances are scanned for
sign-fighting), and carrier rect continuity with ancestor scale included. Use
`seam-gate.mjs probe --t <cut>` to find each seam's true carrier selectors when authoring
the ledger.
Rules the script cannot check — still yours:
1. **Edits re-open the seam.** Any change to a scene's first/last ~1s (including
re-timing to new VO) invalidates that boundary's audit — re-run the verifier.
2. **Audio is the clock.** Re-time scenes to the VO's real word timestamps; never rush a
read to fit a slot. A VO regen re-opens its seams.
3. **Clip-gating gotcha** (the usual cause of a zero-overlap FAIL): a clip whose
`data-start` precedes its entry tween is un-hidden at its initial opacity — set
initial `autoAlpha: 0` AND `data-start` = the cut time, never earlier.
---
# Part 2 — Performance (the scene keeps performing)
## No idle wobble
Idle sine loops (breathe, float, drift, glow pulse) are BANNED as sustained motion — they
read as "the video is waiting." A scene that finishes entering with seconds left is a
planning bug: add story, not wobble. Every phase between entry and exit is owned by one
of these routes (name the route in the plan):
| Route | What it is |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Staged reveals** | Hold content back; pay it off on narration beats — the frame keeps gaining information (default for ≥2 content groups) |
| **Camera with intent** | A mapped scale+pan path: establish wide → travel → arrive on the subject |
| **Sequenced UI life** | The product behaves over time: progress advances, highlights step, counts tick |
| **Animated sequences** | Elements act out a beat: a card files into a stack, an item gets dragged, a result assembles |
| **Cursor-led action** | An oversized cursor walks the eye to a trigger; its CLICK ignites the next beat (`oversized-cursor`) |
Test: pause at any second — something meaningful must be mid-flight (a reveal landing,
the camera traveling, the UI doing what the narration says).
## Stillness before climax
Schedule a **0.30.75s pause** between the major action and its result — the dramatic
comma. A scene that jumps straight from action to result loses it.
## Timing intents
- Single entry ≤ ~800ms; longer buildup = multi-element stagger, not one slow element.
- Exit ≈ 75% of entry. Exception: cut-the-curve inverts this (entry ~127% of exit).
- Total stagger ≤ 500ms; with 8+ elements, tighten per-item delay or stagger the first few.
- Forbidden eases: `bounce.out` / `elastic.out`. Entry overshoot `back.out(1.41.7)` is fine.
- Similar elements share one ease+duration intent — never a unique pair per element.
## Transition vocabulary
Use only 23 inter-scene transitions per film and repeat them; the default boundary is
**cut-the-curve in the current's direction**. Hand-written shared-element morphs
(`intent: morph`) don't count against the budget.
---
## Anti-Patterns
| Don't | Instead |
| -------------------------------------------------------------------------- | -------------------------------------------------- |
| Author each scene's entrance in isolation | Write the vector ledger first |
| Crossfade between scenes | Cut-the-curve in the current's direction |
| Exit completes, THEN the scene changes | Cut mid-motion on both sides |
| Entry starts from rest after a cut | Enter ≥50% through the notional path |
| Inverse-zoom exit → grow-from-small entry (or push → oversized retraction) | Match the scale-velocity sign (Seam Gate 7) |
| Incoming scene's own pop-in intro under a Z-seam handoff | Hold its opening frame composed, or match the sign |
| Idle wobble / breathe / float to fill time | Assign a sustained-motion route; or add story |
| Direction flip without a cause | Spend a force, or keep the current |
| Reserved vectors used as variety | Default to the current; spend them on meaning |
| Reaction a few frames after its cause | Same-frame ignition |
| Action jumps straight to result | Schedule stillness-before-climax (0.30.75s) |
@@ -0,0 +1,93 @@
# seam-stamp.mjs + seam-gate.mjs — usage + ledger schema
Generate-and-verify pair for the Seam Gate. Zero npm deps (node ≥ 22 + a local Chrome;
the gate finds `~/.cache/puppeteer` chrome-headless-shell or system Chrome automatically).
```bash
# STAMP: write the master seam block (base sets + all wrapper tweens) from the ledger.
# Replaces the // <seams:auto> … // </seams:auto> block (inserts after the
# window.__timelines["main"] registration if markers are absent). Stamped seams pass
# the gate by construction. match-cut/morph rows get visibility sets only — the
# carrier handoff stays hand-authored.
node <SKILL_DIR>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html
```
Per-seam stamp options in the ledger: `exit.dur` / `entry.dur` (durations),
`entry.travel` (xPercent/yPercent offset, default 10 — use 8 for a soft entry),
`blur` (Z seams, default 18px full-frame / set 10 for text-scale).
```bash
# verify every seam in the ledger (exit 0 = gate passed)
node <SKILL_DIR>/scripts/seam-gate.mjs verify --ledger ledger.json --project <project-dir>
# reuse a RUNNING preview server (restart it after comp edits — stale bundle!)
node <SKILL_DIR>/scripts/seam-gate.mjs verify --ledger ledger.json --url http://localhost:5244
# discover movers around a cut time (use to author/fix ledger rows)
node <SKILL_DIR>/scripts/seam-gate.mjs probe --t 44.8 --project <project-dir>
```
`--project` spawns a fresh preview server with `HYPERFRAME_RUNTIME_URL` unset and kills
it after — preferred. `--json` for machine output. `--fps 30` default.
## ledger.json
Lives at the project root. One row per seam; this is the vector ledger as data.
```json
{
"fps": 30,
"seams": [
{
"id": "hook→claim",
"cut": 4.2,
"technique": "cut-the-curve LEFT",
"exit": { "selector": "#el-hook", "axis": "x", "dir": -1 },
"entry": { "selector": "#el-claim", "axis": "x", "dir": -1 }
},
{
"id": "claim→payoff",
"cut": 10.2,
"technique": "inverse zoom-through",
"exit": { "selector": "#el-claim", "axis": "z", "dir": -1 },
"entry": { "selector": "#el-payoff", "axis": "z", "dir": -1, "scanRoot": "#el-payoff" }
},
{
"id": "ui→player (match cut)",
"cut": 60.6,
"type": "match-cut",
"carrier": { "out": "#resting-card", "in": "#product-video" }
}
]
}
```
- `cut` — seconds on the master clock, the frame the incoming side ignites.
- `type``"cut"` (default; full vector checks), `"match-cut"` / `"morph"`
(carrier-continuity + overlap only; motion may start AT the boundary).
- `axis``"x"`, `"y"`, or `"z"` (z = scale). `dir` — sign of motion:
x 1 = leftward, y 1 = upward, z +1 = push (growing), z 1 = pull (shrinking).
- `selector` — the element that CARRIES the seam motion. Use the wrapper when the
master timeline moves the wrapper; use the in-comp hero (id or `[data-hf-id=…]`)
when the seam motion is authored inside the sub-comp. `probe` tells you which.
- `entry.scanRoot` (z seams) — subtree scanned for sign-fighting internal entrances;
defaults to the entry selector.
- `carrier` — optional on `"cut"` rows; required on match-cut/morph. `out`/`in` rects
must match at cut±1 frame (12px center / 5% size tolerance, ancestors included).
## What each check enforces (Seam Gate rule ↔ report row)
| Report row | Rule |
| ------------------------------------ | -------------------------------------------------------------- |
| `ledger` | exit/entry vectors match in the PLAN (axis + dir) |
| `exit-moving` / `entry-moving` | rule 1/3 — no settled exits, no from-rest entries |
| `exit-direction` / `entry-direction` | rule 3 — measured sign matches the ledger |
| `speed-match` (WARN) | law §3 — entry velocity ≈ exit velocity |
| `zero-overlap` | rule 6 — one side visible per frame, never both |
| `z-sign-scan` | rule 7 — incoming scene's own entrances don't fight the Z sign |
| `carrier-*` | rules 3/4 — carrier rect continuity, ancestor scale included |
Velocities are measured on `getBoundingClientRect` (center for x/y, width-ratio for z),
so ancestor wrapper transforms are automatically included.
@@ -0,0 +1,595 @@
#!/usr/bin/env node
// seam-gate.mjs — numeric Seam Gate verifier for HyperFrames films (motion-doctrine).
// Zero npm dependencies: drives chrome-headless-shell over raw CDP (node >= 22).
//
// verify node seam-gate.mjs verify --ledger ledger.json --project <dir> [--json]
// node seam-gate.mjs verify --ledger ledger.json --url http://localhost:5244
// probe node seam-gate.mjs probe --t 44.8 --project <dir> # list movers around a cut
//
// --project spawns a FRESH preview server (avoids the stale-bundle cache) with
// HYPERFRAME_RUNTIME_URL unset. --url reuses a running server: restart it after
// comp edits or you verify a stale build.
// Ledger schema: see references/seam-gate.md next to this skill.
import { spawn } from "node:child_process";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
// ---------- args ----------
const argv = process.argv.slice(2);
const mode = argv[0];
function flag(name, dflt) {
const i = argv.indexOf("--" + name);
return i >= 0 ? argv[i + 1] : dflt;
}
const has = (name) => argv.includes("--" + name);
if (!["verify", "probe"].includes(mode)) {
console.error(
"usage: seam-gate.mjs verify --ledger ledger.json (--project <dir> | --url <preview-url>) [--json]",
);
console.error(
" seam-gate.mjs probe --t <seconds> (--project <dir> | --url <preview-url>) [--window 0.1]",
);
process.exit(2);
}
const FPS = Number(flag("fps", 30));
const DT = 1 / FPS;
const VIS = 0.04; // cumulative opacity below this = invisible
const EPS_XY = 15; // px/s — slower than this = "static"
const EPS_Z = 0.04; // effective-scale units/s
const SPEED_RATIO = 3; // entry/exit velocity ratio beyond this = WARN
const CARRIER_POS_TOL = 12; // px center offset
const CARRIER_SIZE_TOL = 0.05;
const cleanup = [];
process.on("exit", () =>
cleanup.forEach((fn) => {
try {
fn();
} catch {}
}),
);
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(130));
// ---------- preview server ----------
async function httpOk(url) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(2000) });
return r.ok;
} catch {
return false;
}
}
async function ensureServer() {
let base = flag("url", null);
if (!base) {
const project = flag("project", null);
if (!project) throw new Error("need --url or --project");
const port = 5380 + Math.floor(Math.random() * 20);
const env = { ...process.env };
delete env.HYPERFRAME_RUNTIME_URL; // wrong value fails silently as 200 HTML
const cmd = flag("server-cmd", `npx --yes hyperframes preview --no-open --port ${port}`);
const child = spawn("sh", ["-c", cmd.replace(/\{port\}/g, String(port))], {
cwd: project,
env,
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
cleanup.push(() => {
try {
process.kill(-child.pid, "SIGTERM");
} catch {}
});
base = `http://localhost:${port}`;
const deadline = Date.now() + 120_000;
while (Date.now() < deadline) {
if (await httpOk(base + "/api/projects")) break;
if (child.exitCode !== null) throw new Error("preview server exited early");
await new Promise((r) => setTimeout(r, 500));
}
if (!(await httpOk(base + "/api/projects")))
throw new Error("preview server never became ready");
}
base = base.replace(/\/$/, "");
let compUrl = flag("comp-url", null);
if (!compUrl) {
const r = await fetch(base + "/api/projects");
const j = await r.json();
const id = j?.projects?.[0]?.id;
if (!id) throw new Error("could not resolve project id from /api/projects");
compUrl = `${base}/api/projects/${id}/preview/comp/index.html`;
}
return compUrl;
}
// ---------- chrome ----------
function findChrome() {
if (process.env.CHROME_PATH) return { bin: process.env.CHROME_PATH, headlessFlag: true };
const cache = join(homedir(), ".cache", "puppeteer");
for (const kind of ["chrome-headless-shell", "chrome"]) {
const root = join(cache, kind);
if (!existsSync(root)) continue;
const versions = readdirSync(root).sort().reverse();
for (const v of versions) {
const vdir = join(root, v);
for (const plat of readdirSync(vdir)) {
const bin =
kind === "chrome-headless-shell"
? join(vdir, plat, "chrome-headless-shell")
: join(
vdir,
plat,
"Google Chrome for Testing.app",
"Contents",
"MacOS",
"Google Chrome for Testing",
);
if (existsSync(bin)) return { bin, headlessFlag: kind !== "chrome-headless-shell" };
}
}
}
const sys = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
if (existsSync(sys)) return { bin: sys, headlessFlag: true };
throw new Error("no Chrome found (set CHROME_PATH)");
}
async function launchChrome() {
const { bin, headlessFlag } = findChrome();
const args = [
"--remote-debugging-port=0",
"--no-first-run",
"--no-default-browser-check",
"--mute-audio",
"--hide-scrollbars",
"--disable-extensions",
"--window-size=1920,1080",
"about:blank",
];
if (headlessFlag) args.unshift("--headless=new");
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], detached: true });
cleanup.push(() => {
try {
process.kill(-child.pid, "SIGKILL");
} catch {}
});
const wsUrl = await new Promise((resolve, reject) => {
let buf = "";
const t = setTimeout(() => reject(new Error("chrome DevTools endpoint timeout")), 20_000);
child.stderr.on("data", (d) => {
buf += d;
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
if (m) {
clearTimeout(t);
resolve(m[1]);
}
});
child.on("exit", () => reject(new Error("chrome exited: " + buf.slice(-400))));
});
return wsUrl;
}
// ---------- minimal CDP client ----------
class CDP {
constructor(ws) {
this.ws = ws;
this.id = 0;
this.pending = new Map();
this.listeners = [];
}
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = () => rej(new Error("ws connect failed"));
});
const c = new CDP(ws);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id !== undefined && c.pending.has(msg.id)) {
const { res, rej } = c.pending.get(msg.id);
c.pending.delete(msg.id);
if (msg.error) rej(new Error(msg.error.message));
else res(msg.result);
} else if (msg.method) {
c.listeners.forEach((l) => l(msg));
}
};
return c;
}
send(method, params = {}, sessionId, timeoutMs = 30_000) {
const id = ++this.id;
const payload = { id, method, params };
if (sessionId) payload.sessionId = sessionId;
this.ws.send(JSON.stringify(payload));
return new Promise((res, rej) => {
this.pending.set(id, { res, rej });
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
rej(new Error(method + " timeout"));
}
}, timeoutMs);
});
}
waitEvent(method, sessionId, timeoutMs = 30_000) {
return new Promise((res, rej) => {
const t = setTimeout(() => rej(new Error("waiting " + method + " timeout")), timeoutMs);
const l = (msg) => {
if (msg.method === method && (!sessionId || msg.sessionId === sessionId)) {
clearTimeout(t);
this.listeners = this.listeners.filter((x) => x !== l);
res(msg.params);
}
};
this.listeners.push(l);
});
}
}
async function openPage(compUrl) {
const cdp = await CDP.connect(await launchChrome());
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
await cdp.send(
"Emulation.setDeviceMetricsOverride",
{ width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false },
sessionId,
);
const loaded = cdp.waitEvent("Page.loadEventFired", sessionId, 60_000);
await cdp.send("Page.navigate", { url: compUrl }, sessionId);
await loaded;
const evalJs = async (expr, awaitPromise = false) => {
const r = await cdp.send(
"Runtime.evaluate",
{ expression: expr, returnByValue: true, awaitPromise },
sessionId,
60_000,
);
if (r.exceptionDetails)
throw new Error(
"page error: " + (r.exceptionDetails.exception?.description || r.exceptionDetails.text),
);
return r.result.value;
};
// wait for the HF runtime player
const deadline = Date.now() + 45_000;
while (Date.now() < deadline) {
if (await evalJs("!!(window.__playerReady && window.__renderReady && window.__player)")) break;
await new Promise((r) => setTimeout(r, 300));
}
if (!(await evalJs("!!window.__player")))
throw new Error("HF runtime player never appeared — is this a preview comp URL?");
await evalJs("document.fonts.ready.then(()=>true)", true);
await evalJs(HARNESS);
return { evalJs };
}
// ---------- in-page harness ----------
const HARNESS = `window.__seamGate = {
seek(t){ __player.pause(); __player.seek(t); void document.body.offsetHeight; },
cumOp(el){
let op = 1, n = el;
while (n && n.nodeType === 1) {
const c = getComputedStyle(n);
if (c.display === "none" || c.visibility === "hidden") return 0;
op *= parseFloat(c.opacity || "1");
n = n.parentElement;
}
return op;
},
read(sel){
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
const lw = el.offsetWidth || r.width || 1;
const onscreen = r.right > 0 && r.bottom > 0 && r.left < 1920 && r.top < 1080;
return { cx: r.x + r.width/2, cy: r.y + r.height/2, w: r.width, h: r.height,
op: this.cumOp(el), es: r.width / lw, onscreen };
},
sample(t, sels){ this.seek(t); const o = {}; for (const s of sels) o[s] = this.read(s); return o; },
pathOf(el){
if (el.id) return "#" + CSS.escape(el.id);
const hf = el.getAttribute && el.getAttribute("data-hf-id");
if (hf) return '[data-hf-id="' + hf + '"]';
let p = [], n = el, depth = 0;
while (n && n.nodeType === 1 && depth < 5) {
if (n.id) { p.unshift("#" + CSS.escape(n.id)); break; }
const h2 = n.getAttribute("data-hf-id");
if (h2) { p.unshift('[data-hf-id="' + h2 + '"]'); break; }
const kids = n.parentElement ? [...n.parentElement.children] : [n];
p.unshift(n.tagName.toLowerCase() + ":nth-child(" + (kids.indexOf(n) + 1) + ")");
n = n.parentElement; depth++;
}
return p.join(">");
},
scan(t, rootSel, cap){
this.seek(t);
const root = document.querySelector(rootSel || "#root");
if (!root) return [];
const els = [root, ...root.querySelectorAll("*")].slice(0, cap || 900);
const out = [];
for (const el of els) {
if (/^(SCRIPT|STYLE|AUDIO|LINK|META)$/.test(el.tagName)) continue;
const r = el.getBoundingClientRect();
if (r.width < 32 && r.height < 32) continue;
const op = this.cumOp(el);
const lw = el.offsetWidth || r.width || 1;
out.push({ sel: this.pathOf(el), cx: r.x + r.width/2, cy: r.y + r.height/2,
w: r.width, h: r.height, op, es: r.width / lw });
}
return out;
}
};true`;
// ---------- measurement helpers ----------
const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0);
const visible = (m) => !!m && m.op > VIS && m.w * m.h > 16 && m.onscreen !== false;
function velocity(m1, m2, dt, axis) {
if (!m1 || !m2) return null;
if (axis === "x") return (m2.cx - m1.cx) / dt;
if (axis === "y") return (m2.cy - m1.cy) / dt;
return (m2.es - m1.es) / dt; // z
}
const eps = (axis) => (axis === "z" ? EPS_Z : EPS_XY);
const fmtV = (v, axis) =>
v === null ? "n/a" : axis === "z" ? v.toFixed(3) + " es/s" : v.toFixed(0) + " px/s";
// ---------- verify ----------
async function verify() {
const ledgerPath = flag("ledger", "ledger.json");
const ledger = JSON.parse(readFileSync(ledgerPath, "utf8"));
const fps = ledger.fps || FPS,
dt = 1 / fps;
const compUrl = await ensureServer();
const { evalJs } = await openPage(compUrl);
const results = [];
for (const seam of ledger.seams) {
const rows = [];
const add = (check, status, detail) => rows.push({ check, status, detail });
const cut = seam.cut;
const type = seam.type || "cut";
const tA1 = Math.max(0, cut - 0.1),
tA2 = Math.max(0, cut - dt);
const tB1 = cut + dt,
tB2 = cut + 0.1;
const sels = [
seam.exit?.selector,
seam.entry?.selector,
seam.carrier?.out,
seam.carrier?.in,
].filter(Boolean);
const S = {};
for (const t of [tA1, tA2, tB1, tB2])
S[t] = await evalJs(`__seamGate.sample(${t}, ${JSON.stringify([...new Set(sels)])})`);
if (type === "cut") {
const ex = seam.exit,
en = seam.entry;
// 0 — ledger row itself
if (ex.axis !== en.axis || ex.dir !== en.dir)
add(
"ledger",
"FAIL",
`exit ${ex.axis}${ex.dir > 0 ? "+" : "-"} vs entry ${en.axis}${en.dir > 0 ? "+" : "-"} — mirrored/mixed vector in the PLAN`,
);
else add("ledger", "PASS", `${ex.axis}${ex.dir > 0 ? "+" : "-"} both sides`);
for (const [side, cfg, m1, m2, t1, t2] of [
["exit", ex, S[tA1][ex.selector], S[tA2][ex.selector], tA1, tA2],
["entry", en, S[tB1][en.selector], S[tB2][en.selector], tB1, tB2],
]) {
if (!m1 || !m2) {
add(side, "FAIL", `selector ${cfg.selector} not found`);
continue;
}
const v = velocity(m1, m2, t2 - t1, cfg.axis);
const moving = Math.abs(v) >= eps(cfg.axis);
const vizOk = side === "exit" ? visible(m1) : visible(m2);
if (!vizOk)
add(
side + "-visible",
"FAIL",
`${cfg.selector} not visible in its window (op ${(side === "exit" ? m1 : m2)?.op?.toFixed(2)})`,
);
if (!moving)
add(
side + "-moving",
"FAIL",
`${cfg.selector} static at the cut (${fmtV(v, cfg.axis)}) — ${side === "exit" ? "exit settled before the boundary" : "entry starts from rest"}`,
);
else if (sgn(v) !== cfg.dir)
add(
side + "-direction",
"FAIL",
`${cfg.selector} moving ${fmtV(v, cfg.axis)} — opposite of ledger dir ${cfg.dir > 0 ? "+" : "-"}${cfg.axis === "z" ? " (mirrored zoom)" : ""}`,
);
else
add(
side + "-vector",
"PASS",
`${fmtV(v, cfg.axis)} ${cfg.axis}${cfg.dir > 0 ? "+" : "-"}`,
);
if (side === "exit") seam.__vExit = v;
else seam.__vEntry = v;
}
// speed match
if (seam.__vExit != null && seam.__vEntry != null && Math.abs(seam.__vExit) > 0) {
const ratio = Math.abs(seam.__vEntry) / Math.abs(seam.__vExit);
if (ratio > SPEED_RATIO || ratio < 1 / SPEED_RATIO)
add("speed-match", "WARN", `entry/exit velocity ratio ${ratio.toFixed(2)} (want ~1)`);
else add("speed-match", "PASS", `ratio ${ratio.toFixed(2)}`);
}
// zero overlap
const enPre = S[tA2][en.selector],
exPost = S[tB1][ex.selector];
if (visible(enPre))
add(
"zero-overlap",
"FAIL",
`incoming ${en.selector} already visible at cut-1f (op ${enPre.op.toFixed(2)}) while outgoing still on screen — reads as a dissolve`,
);
else if (visible(exPost))
add(
"zero-overlap",
"FAIL",
`outgoing ${ex.selector} still visible at cut+1f (op ${exPost.op.toFixed(2)})`,
);
else add("zero-overlap", "PASS", "one side visible per frame");
// Z-sign scan: the incoming scene's OWN entrances must not fight the seam's Z sign
if (en.axis === "z") {
const scanRoot = en.scanRoot || en.selector;
const s1 = await evalJs(`__seamGate.scan(${tB1}, ${JSON.stringify(scanRoot)})`);
const s2 = await evalJs(`__seamGate.scan(${tB2}, ${JSON.stringify(scanRoot)})`);
const m1 = new Map(s1.map((e) => [e.sel, e]));
const offenders = [];
for (const e2 of s2) {
const e1 = m1.get(e2.sel);
if (!e1 || e2.op <= 0.1) continue;
const vs = (e2.es - e1.es) / (tB2 - tB1);
if (Math.abs(vs) >= EPS_Z && sgn(vs) !== en.dir)
offenders.push(`${e2.sel} (${vs.toFixed(3)} es/s)`);
}
if (offenders.length)
add(
"z-sign-scan",
"FAIL",
`elements scaling AGAINST the seam's Z sign in the entry window: ${offenders.slice(0, 5).join(", ")}${offenders.length > 5 ? ` +${offenders.length - 5} more` : ""}`,
);
else add("z-sign-scan", "PASS", "no sign-fighting entrances");
}
}
// carrier continuity (any seam type that declares one; the whole check for match-cut/morph)
if (seam.carrier) {
const out = S[tA2][seam.carrier.out],
inn = S[tB1][seam.carrier.in];
if (!out || !inn) add("carrier", "FAIL", "carrier selector not found");
else {
const dx = Math.abs(out.cx - inn.cx),
dy = Math.abs(out.cy - inn.cy);
const ds = Math.abs(out.w - inn.w) / Math.max(out.w, 1);
if (dx > CARRIER_POS_TOL || dy > CARRIER_POS_TOL)
add(
"carrier-position",
"FAIL",
`center off by ${dx.toFixed(0)},${dy.toFixed(0)}px across the cut`,
);
else if (ds > CARRIER_SIZE_TOL)
add(
"carrier-size",
"FAIL",
`size differs ${(ds * 100).toFixed(1)}% across the cut (ancestor scale?)`,
);
else
add(
"carrier",
"PASS",
`Δpos ${dx.toFixed(1)},${dy.toFixed(1)}px Δsize ${(ds * 100).toFixed(1)}%`,
);
}
}
if (type !== "cut" && !seam.carrier)
add("carrier", "WARN", `type "${type}" without a carrier — nothing to verify`);
results.push({ id: seam.id, cut, type, rows });
}
return results;
}
// ---------- probe ----------
async function probe() {
const t = Number(flag("t"));
if (!Number.isFinite(t)) throw new Error("probe needs --t <seconds>");
const win = Number(flag("window", 0.1));
const compUrl = await ensureServer();
const { evalJs } = await openPage(compUrl);
const dt = DT;
const scans = {};
for (const tt of [t - win, t - dt, t + dt, t + win])
scans[tt] = await evalJs(`__seamGate.scan(${Math.max(0, tt)}, "#root")`);
const join = (a, b) => {
const m = new Map(a.map((e) => [e.sel, e]));
return b.map((e2) => ({ e1: m.get(e2.sel), e2 })).filter((p) => p.e1);
};
const movers = (a, b, span) =>
join(a, b)
.map(({ e1, e2 }) => ({
sel: e2.sel,
vx: (e2.cx - e1.cx) / span,
vy: (e2.cy - e1.cy) / span,
vs: (e2.es - e1.es) / span,
op1: e1.op,
op2: e2.op,
w: e2.w,
h: e2.h,
}))
.filter(
(m) =>
(m.op1 > VIS || m.op2 > VIS) &&
(Math.abs(m.vx) > EPS_XY ||
Math.abs(m.vy) > EPS_XY ||
Math.abs(m.vs) > EPS_Z ||
Math.abs(m.op2 - m.op1) > 0.1),
)
.sort(
(x, y) =>
Math.abs(y.vx) +
Math.abs(y.vy) +
Math.abs(y.vs) * 800 -
(Math.abs(x.vx) + Math.abs(x.vy) + Math.abs(x.vs) * 800),
)
.slice(0, 14);
const fmt = (m) =>
` ${m.sel.padEnd(44)} vx ${m.vx.toFixed(0).padStart(6)} vy ${m.vy.toFixed(0).padStart(6)} vscale ${m.vs.toFixed(3).padStart(7)} op ${m.op1.toFixed(2)}${m.op2.toFixed(2)} (${m.w.toFixed(0)}×${m.h.toFixed(0)})`;
console.log(`\nPROBE @ ${t}s (window ±${win}s, 1f = ${dt.toFixed(3)}s)`);
console.log(`\n— OUTGOING side (${(t - win).toFixed(2)}${(t - dt).toFixed(2)}) — movers:`);
movers(scans[t - win], scans[t - dt], win - dt).forEach((m) => console.log(fmt(m)));
console.log(`\n— INCOMING side (${(t + dt).toFixed(2)}${(t + win).toFixed(2)}) — movers:`);
movers(scans[t + dt], scans[t + win], win - dt).forEach((m) => console.log(fmt(m)));
console.log(
`\nUse these selectors + signs to write the ledger row (x-: left, y-: up, scale+: push, scale-: pull).`,
);
}
// ---------- main ----------
try {
if (mode === "probe") {
await probe();
} else {
const results = await verify();
if (has("json")) {
console.log(JSON.stringify(results, null, 2));
} else {
let fails = 0,
warns = 0;
for (const r of results) {
const bad = r.rows.filter((x) => x.status === "FAIL").length;
fails += bad;
warns += r.rows.filter((x) => x.status === "WARN").length;
console.log(
`\n${r.id} (cut @${r.cut}s, ${r.type}) ${bad ? "✗ " + bad + " FAIL" : "✓"}`,
);
for (const row of r.rows)
console.log(` ${row.status.padEnd(4)} ${row.check.padEnd(16)} ${row.detail}`);
}
console.log(
`\n${fails ? "SEAM GATE: FAILED" : "SEAM GATE: PASSED"}${fails} fail, ${warns} warn across ${results.length} seams`,
);
}
process.exit(results.some((r) => r.rows.some((x) => x.status === "FAIL")) ? 1 : 0);
}
process.exit(0);
} catch (e) {
console.error("seam-gate error:", e.message);
process.exit(2);
}
@@ -0,0 +1,143 @@
#!/usr/bin/env node
// seam-stamp.mjs — generate master-timeline seam code FROM ledger.json (motion-doctrine).
// The generation half of the Seam Gate: stamped seams pass seam-gate.mjs by construction.
//
// node seam-stamp.mjs --ledger ledger.json # print the seam block
// node seam-stamp.mjs --ledger ledger.json --write index.html
//
// --write replaces the block between "// <seams:auto>" and "// </seams:auto>" markers
// (adds them before the final pad tween if absent). Tier-A morphs / match-cuts get
// visibility sets only — author the carrier handoff by hand.
//
// Per-seam ledger options (all optional):
// exit.dur / entry.dur — override durations (defaults below)
// entry.travel — xPercent/yPercent entry offset (default 10; "soft" look = 8)
// blur — Z-seam blur px (default 18 full-frame; use 10 for text-scale)
import { readFileSync, writeFileSync } from "node:fs";
const argv = process.argv.slice(2);
const flag = (n, d) => {
const i = argv.indexOf("--" + n);
return i >= 0 ? argv[i + 1] : d;
};
const ledger = JSON.parse(readFileSync(flag("ledger", "ledger.json"), "utf8"));
const round = (n) => +n.toFixed(3);
const lines = [];
const emit = (s) => lines.push(" " + s);
// ---------- scene inventory (order of appearance) + base states ----------
const scenes = [];
const zEntries = new Map(); // selector -> {scale, blur} preset for Z arrivals
for (const seam of ledger.seams) {
for (const sel of [seam.exit?.selector, seam.entry?.selector]) {
if (sel && !scenes.includes(sel)) scenes.push(sel);
}
if (seam.entry?.axis === "z") {
const blur = seam.blur ?? 18;
zEntries.set(
seam.entry.selector,
seam.entry.dir === -1
? { scale: 1.25, blur } // pull: arrives oversized
: { scale: 0.78, blur },
); // push: arrives small, growing
}
}
emit(`// <seams:auto> — generated by seam-stamp.mjs from ledger.json; do not hand-edit.`);
emit(
`// Regenerate: node <motion-doctrine>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html`,
);
if (scenes.length) {
const first = scenes[0];
const rest = scenes.slice(1).filter((s) => !zEntries.has(s));
emit(
`gsap.set("${first}", { autoAlpha: 1, xPercent: 0, yPercent: 0, scale: 1, filter: "blur(0px)", transformOrigin: "50% 50%" });`,
);
if (rest.length)
emit(
`gsap.set([${rest.map((s) => `"${s}"`).join(",")}], { autoAlpha: 0, xPercent: 0, yPercent: 0, scale: 1, filter: "blur(0px)", transformOrigin: "50% 50%" });`,
);
for (const [sel, p] of zEntries)
emit(
`gsap.set("${sel}", { autoAlpha: 0, scale: ${p.scale}, filter: "blur(${p.blur}px)", xPercent: 0, yPercent: 0, transformOrigin: "50% 50%" });`,
);
}
emit(``);
// ---------- per-seam stamping ----------
for (const seam of ledger.seams) {
const cut = seam.cut,
type = seam.type || "cut";
emit(`// SEAM — ${seam.id} : ${seam.technique || type} (cut @${cut})`);
if (type !== "cut") {
if (seam.exit?.selector) emit(`tl.set("${seam.exit.selector}", { autoAlpha: 0 }, ${cut});`);
if (seam.entry?.selector) emit(`tl.set("${seam.entry.selector}", { autoAlpha: 1 }, ${cut});`);
emit(
`// ${type}: carrier handoff is Tier-A — author it by hand and keep the carrier row in ledger.json`,
);
emit(``);
continue;
}
const ex = seam.exit,
en = seam.entry;
if (ex.axis !== en.axis || ex.dir !== en.dir)
throw new Error(
`ledger row "${seam.id}" mismatched (${ex.axis}${ex.dir} vs ${en.axis}${en.dir}) — fix the PLAN, not the stamp`,
);
if (ex.axis === "z") {
const blur = seam.blur ?? 18;
const exDur = ex.dur ?? 0.21,
enDur = en.dur ?? 0.5;
const exScale = ex.dir === -1 ? 0.8 : 1.18;
const enFrom = ex.dir === -1 ? 1.25 : 0.78;
emit(
`tl.to("${ex.selector}", { scale: ${exScale}, filter: "blur(${blur}px)", duration: ${exDur}, ease: "power3.in" }, ${round(cut - exDur)});`,
);
emit(
`tl.to("${ex.selector}", { autoAlpha: 0, duration: ${exDur}, ease: "none" }, ${round(cut - exDur)});`,
);
emit(`tl.set("${ex.selector}", { autoAlpha: 0 }, ${cut});`);
emit(
`tl.fromTo("${en.selector}", { autoAlpha: 0.15, scale: ${enFrom}, filter: "blur(${blur}px)" }, { autoAlpha: 1, scale: 1.0, filter: "blur(0px)", duration: ${enDur}, ease: "expo.out", immediateRender: false }, ${cut});`,
);
} else {
const prop = ex.axis === "x" ? "xPercent" : "yPercent";
const exDur = ex.dur ?? 0.34,
enDur = en.dur ?? 0.42;
const travel = en.travel ?? 10;
emit(
`tl.to("${ex.selector}", { ${prop}: ${12 * ex.dir}, autoAlpha: 0, duration: ${exDur}, ease: "power3.in" }, ${round(cut - exDur)});`,
);
emit(`tl.set("${ex.selector}", { autoAlpha: 0 }, ${cut});`);
emit(
`tl.fromTo("${en.selector}", { ${prop}: ${-travel * en.dir}, autoAlpha: 0.35 }, { ${prop}: 0, autoAlpha: 1, duration: ${enDur}, ease: "power4.out", immediateRender: false }, ${cut});`,
);
}
emit(``);
}
emit(`// </seams:auto>`);
const block = lines.join("\n");
const target = flag("write", null);
if (!target) {
console.log(block);
} else {
let html = readFileSync(target, "utf8");
const re = /[ \t]*\/\/ <seams:auto>[\s\S]*?\/\/ <\/seams:auto>/;
if (re.test(html)) {
html = html.replace(re, block);
} else {
// insert after the master timeline registration line
const anchor = /(window\.__timelines\["main"\]\s*=\s*tl;\s*\n)/;
if (!anchor.test(html))
throw new Error('no <seams:auto> markers and no window.__timelines["main"] anchor found');
html = html.replace(anchor, `$1\n${block}\n`);
}
writeFileSync(target, html);
console.log(`stamped ${ledger.seams.length} seams into ${target}`);
}
+137
View File
@@ -0,0 +1,137 @@
---
name: oversized-cursor
description: House-style oversized macOS cursor technique for HyperFrames launch videos. Load whenever a scene involves cursors or a pointer-led action, when kicking off a UI scene, when igniting a morph/transition/typing run with a click, or when a scene reads as static, dead, or stale and needs a cheap high-yield source of motion to carry the viewer's eye and segment them out of the stale state. Covers cursor size/look (incl. brand-motif cursors), the off-screen entry law, tip-targeting and the click tap, click-ignites-the-next-beat, and exit / cross-scene handoff.
---
# Oversized Cursor — the eye-carrier
A deliberately oversized macOS-style pointer that travels the frame as a _visible
protagonist_: it enters from off-screen, walks the viewer's eye to the next point of
interest, clicks to cause the next thing that happens, and leaves. Production-proven
across multiple launch films.
**Why it exists.** Big cursor movement is one of the cheapest high-yield motion sources
in a launch video: one element, transform-only tweens, and it (1) brings the eye across
the screen on scenes that would otherwise read as dead, (2) gives causal ignition to
morphs/transitions ("the click did that"), and (3) segments the eye out of a stale
state when kicking off a new scene or a complex animation sequence. Bigger is better —
an actual-size cursor disappears at video scale.
## Size & look (house convention)
- **Full-frame scenes: `7cqw`** (≈134px at 1920). In-mock / small-frame variants:
`4.65.5cqw`. Never smaller.
- One SVG arrow geometry everywhere. Two proven fills — white body + black stroke, or
black body (`#1c1c1c`) + white stroke (1.4px). Pick per scene contrast, keep it
constant per film.
- **Brand-motif cursors (the power play).** The macOS arrow is the DEFAULT, not a
mandate. When the subject brand has a recognizable cursor identity — a collaborative
design tool's colored multiplayer arrow with a name tag (Figma-style), a creative
suite's precision crosshair, a distinctive product pointer — use THAT cursor instead:
instantly legible brand language for anyone who knows the product. Same laws apply
unchanged (oversized scale, physical entry/exit, tip-targeting, click-ignition), and
a name-tag variant travels as one rigid unit (tag trailing the arrow). Reach for it
only when the motif is genuinely referenceable; a cursor nobody recognizes is just a
weird arrow — default back to macOS.
- `filter: drop-shadow(0 4px 6px rgba(0,0,0,.3))`, `pointer-events: none`,
`z-index` above all scene content, `will-change: transform`.
```css
#root .cursor {
position: absolute;
left: 48%;
top: 115%; /* off-screen below — the resting pose IS off-screen */
width: 7cqw;
height: 7cqw;
z-index: 20;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
pointer-events: none;
will-change: transform;
}
```
## Entry law — physical, never revealed
The cursor **always enters from off-screen** (canonical: from below, `top:115120%`)
and travels to its first target in one decelerating glide. It must _feel like it
entered the room_. Never opacity-fade it in at a resting position, never mask-reveal
it — that reads as a glitch (a real, repeatedly observed failure mode).
- Default path: **straight up the y-axis** to the target — no fragmented diagonals.
A diagonal is fine when it IS the story (entering toward an off-axis target), but it
is one continuous vector either way.
- `duration: 0.40.92s`, `ease: power3.out`, `immediateRender: false` on the fromTo.
```js
tl.fromTo(
cursor,
{ left: "48.6%", top: "115%" },
{ left: "48.6%", top: "55%", duration: 0.85, ease: "power3.out", immediateRender: false },
0.25,
);
```
## Tip-targeting & the click tap
The hot-spot is the arrow TIP, not the box center. Land the **tip** on the target's
center, and pivot all press scaling on the tip: `transformOrigin: '21% 14%'` (for the
house arrow path in a 24-unit viewBox).
Click = asymmetric compress/expand (1:2 ratio reads as a real tap):
```js
tl.to(cursor, { scale: 0.84, duration: 0.1, ease: "power2.in", transformOrigin: "21% 14%" }, t);
tl.to(
cursor,
{ scale: 1, duration: 0.22, ease: "power2.out", transformOrigin: "21% 14%" },
t + 0.1,
);
```
**The target's reaction is a separate, parallel tween** (button: `scale: 0.94` + press
color/shadow, starting at the same `t`). Cursor-only taps (e.g. focusing a text input)
get NO target reaction. Pair with `cursor-click-ripple` / `press-release-spring` for
the target side.
## The click IGNITES the next beat
Never let a morph, typing run, window transform, or scene-defining animation simply
_start_. Park the cursor on the trigger and let the click cause it, same-frame:
- click ▸ menu/submenu cascade, toggle flip
- click ▸ typing kickoff into an input
- click ▸ composer morph-down / window shrink
- click ▸ logo ignition / flight launch
- click ▸ play-state flip + UI-life wake in a product mock
During long beats it doesn't own (typing, narration), the cursor **drifts aside**
(0.50.9s, `power2.out`) — never sits frozen on top of the action, never wobbles idly.
## Exit law & cross-scene handoff
Two sanctioned exits — both physical, **never an opacity fade in place**:
1. **Leave the frame**: accelerate off the nearest edge with `power2.in`
(`left:'118%'`, `left:'-12%'`, or `top:'116%'`), 0.50.7s.
2. **Cut-the-curve handoff**: in the final ~0.3s before a hard cut, the cursor starts
accelerating (`power2.in`) toward the NEXT scene's first click point, covering the
first ~1/3 of that path; the next composition `gsap.set`s the cursor at the
handoff pose and continues with `power2.out` at matched velocity. The cursor itself
becomes the carrier element that stitches the seam:
```js
// scene A, last 0.3s — start the journey:
tl.to(cursor, { left: "40.7%", top: "63.7%", duration: 0.3, ease: "power2.in" }, CUT - 0.3);
// scene B, t=0 — finish it at matched velocity:
gsap.set(cursorB, { left: "40.7%", top: "63.7%" });
tl.to(cursorB, { left: "22%", top: "45%", duration: 0.6, ease: "power2.out" }, 0);
```
## Checklist
- [ ] ≥ 7cqw full-frame (4.65.5cqw inside a mock) — when unsure, bigger
- [ ] enters from off-screen on one continuous vector (no fade/mask reveal)
- [ ] tip lands on the target center; press pivots on `transformOrigin: '21% 14%'`
- [ ] every click causes something, same-frame
- [ ] drifts aside during beats it doesn't own; zero idle wobble
- [ ] exits physically (off-frame or cut-the-curve handoff) — no fade-in-place
+68
View File
@@ -0,0 +1,68 @@
---
name: seam-craft
description: Render-correctness doctrine for scene-to-scene seams in HyperFrames launch videos — the prerequisites that make transitions composite correctly on the master timeline. Load when assembling the master timeline / index.html, when a white flash appears at a cut or crossfade seam (especially on dark films), when reasoning about why a transition opacity dip shows through, or when verifying the render-side mechanics of how overlapping scene wrappers blend. Covers the opaque stage-ground (#root background) white-flash guard and how the injector overlaps wrappers, holds final frames, ping-pongs tracks, and stamps lint-clean template code onto the master timeline. Does NOT contain the per-transition catalog — see the transition registry for individual transition entries.
---
# Seam Craft — render prerequisites for scene-to-scene transitions
This is the **render-correctness doctrine** for PLV scene-to-scene seams: the
prerequisites and master-timeline mechanics that make any transition composite
correctly, independent of which specific transition is chosen. The per-transition
catalog (crossfade, push-slide, zoom-through, cut-the-curve, …) lives in the
transition registry — this page is the doctrine that sits underneath all of them.
The transitions this doctrine governs are **Tier-B-ready**: pure transform / opacity /
filter on the two scene **clip wrappers** (`#el-<sid>`), no injected overlay DOM, no
per-scene cooperation. Overlay families (staggered blocks, blinds, light leak, grid
dissolve, page burn) and shader transitions are deferred to later phases.
## Stage ground prerequisite (white-flash guard)
Several templates open a window where the two wrappers' summed opacity < 1 (the
cut-the-curve mid-window cut, zoom-through's 0.15 floor, plain crossfade's
power-curve dip). Whatever is BEHIND the wrappers shows through during that
window. If the assembled `index.html` `#root` has no opaque background, the
renderer composites the dip over its default **white** page → a white flash at
every seam, glaring on dark films (observed on two Spotify runs before the fix).
**The assembler must paint the stage:** `#root { background:
var(--canvas-deep, var(--canvas, #000)) }` — `assemble-index.mjs` now emits this;
any other consumer of these templates owns the same guarantee.
## How the injector applies a transition
At a `break` boundary between scene _i_ (`from`) and scene _i+1_ (`to`), the
injector:
1. Extends `#el-<from>` wrapper `data-duration` by `duration_s` (holds its final
frame — verified: `core/src/runtime/init.ts:1393-1410` external-slot branch).
2. Pulls `#el-<to>` wrapper `data-start` earlier by `duration_s` (creates the
overlap window).
3. Reassigns **all** clip `data-track-index` as a 0/1 ping-pong so the two
overlapping wrappers never share a track (same-track overlap is illegal —
`core/src/lint/rules/composition.ts`). Higher track composites on top.
4. Stamps the `gsap_template` into `window.__timelines["main"]` at `T = overlap-start`.
Verified by prototype render (2026-05-31): the master-timeline wrapper tween is
seeked and rendered (no double-seek with the sub-comp's own paused timeline —
the runtime drives them independently), the extended wrapper holds scene _i_'s
final frame, and the higher-track incoming wrapper composites over + blends with
the outgoing one.
## Template placeholders
The injector substitutes these tokens in each `gsap_template` line:
| Token | Meaning |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `__OLD__` | `"#el-<from>"` — outgoing clip wrapper selector (quoted) |
| `__NEW__` | `"#el-<to>"` — incoming clip wrapper selector (quoted) |
| `__T__` | overlap-start time in seconds (master clock) |
| `__DUR__` | `duration_s` for this boundary |
| `__DX__` | horizontal travel for directional types: `-1920` (LEFT) / `1920` (RIGHT) |
| `__DY__` | vertical travel: `-1080` (UP) / `1080` (DOWN) |
| `__ORIGIN_OUT__` / `__ORIGIN_IN__` | transformOrigin pair for `squeeze` |
`filter` / `scaleX` / `transformOrigin` are lint-clean on the master timeline
(verified: `core/src/lint/rules/gsap.ts` has no per-property whitelist and scopes
its checks to `data-composition-id` ranges; the x/y/scale/rotation/opacity
whitelist is a _scene-worker_ prompt rule only — it does not bind index.html).