chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Cinematic mode (pure embed) — one engine, six DNAs
|
||||
|
||||
> Cinematic mode compiles **[../../dna/](../../dna/README.md)** through
|
||||
> **[engine.html](engine.html)** (`make-composition.cjs`). The old per-template HTML
|
||||
> shells are retired — `cinematic-cream` maps to `dna: "cream"` automatically; the other
|
||||
> archived templates (memory-wall / champion / portrait-header, in [\_archive/](_archive/))
|
||||
> remain as design references only.
|
||||
|
||||
Use this mode for pure-embed asks (no rail): brand film, hype, social reel, showcase.
|
||||
The **DNA** locks the visual language (type, palette scheme, blend, motion grammar, hero
|
||||
three-act); **safe-zones v2** parameterizes it to the scene (sampled accent, light-
|
||||
direction contact shadow, depth-match blur); **the agent decides layout only** (planes,
|
||||
blocks, per-line typography within the DNA).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. `bash scripts/prepare.sh <project>` → matte ∥ transcript ∥ envelope → safe-zones v2
|
||||
2. Pick a DNA ([../../dna/README.md](../../dna/README.md)): bright hero band → `ink`,
|
||||
else by register (cream / editorial / keynote / documentary / loud). Recommend, let
|
||||
the user pick.
|
||||
3. Author `<project>/cinematic.json` — `"dna": "<name>"` + thought-blocks (schema:
|
||||
`scripts/make-cinematic.cjs` header)
|
||||
4. `node scripts/make-cinematic.cjs <project>` → plan.json → engine-compiled index.html
|
||||
5. `node scripts/preview-frames.cjs <project>` → § Visual QA (failure checks + the 5
|
||||
positive checks in [../../references/reference-bar.md](../../references/reference-bar.md))
|
||||
6. `bash scripts/render-and-composite.sh <project>` → gates → final.mp4
|
||||
|
||||
## What the engine generates (never author these)
|
||||
|
||||
- word timings from the transcript; accumulate-within-block / page-flip-between-blocks
|
||||
- the hero hand-off + **three-act orchestration** (dim → RMS-coupled per-letter entrance
|
||||
→ breathe + glow), per the DNA's `hero` block
|
||||
- scene tokens: `--accent` (sampled), contact shadow, depth blur
|
||||
- reading order, re-slot from measured heights, hero size/collision post-pass
|
||||
|
||||
## What you DON'T do
|
||||
|
||||
- Override `.cap` color / blend / shadow / filter / motion curves — that's the DNA.
|
||||
Scene fights the look → pick a different DNA (bright → `ink`), never recolor.
|
||||
- Hand-position the hero into a clean margin (it belongs ON the subject, ~30–55%
|
||||
occluded — safe-zones `heroBands.best`).
|
||||
- Add full-frame grades/textures over the footage (hard rule: the video ships untouched).
|
||||
|
||||
## Adding a DNA
|
||||
|
||||
`dna/<name>.json` — copy one, change the voice (see [../../dna/README.md](../../dna/README.md)
|
||||
§ Adding). The engine consumes it with no code change. A DNA must be a distinct voice
|
||||
with a reason to exist, not a recolor.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Template: champion
|
||||
|
||||
**Look:** podcast / interview, upper-side column with intro→phrase→emph
|
||||
beats building toward a center-stage CROWN that crosses the subject's body.
|
||||
"Wimbledon Champion" energy.
|
||||
|
||||
**Visual identity (LOCKED):**
|
||||
|
||||
- 5 typography slots: intro / phrase / emph / dream / crown
|
||||
- `mix-blend-mode: screen` over warm bone color (#fff5df)
|
||||
- Deep text-shadow + brightness boost
|
||||
- Single-caption-swap layout (caps stack at one point in the column)
|
||||
- Center-stage crown crosses the subject for cinematic occlusion
|
||||
|
||||
## When to apply
|
||||
|
||||
✅ **Good fit:**
|
||||
|
||||
- Podcast / interview style with a single seated subject
|
||||
- Cluttered backdrop (bookshelf, props, dark) — screen blend reads through
|
||||
- Subject roughly centered with clear clean zones on one or both sides
|
||||
- Speech has a clear payoff line that can become a "crown" (1-3 short words)
|
||||
- 16:9 landscape, 1920×1080 ideal
|
||||
|
||||
❌ **Wrong fit:**
|
||||
|
||||
- Bright/sky/window backdrop (screen blend washes out)
|
||||
- Subject heavily off-center such that there's no clean column zone
|
||||
- Speech has no clear climax line
|
||||
- Portrait 9:16 → use `portrait-header` template instead
|
||||
|
||||
## Layout decisions agent makes
|
||||
|
||||
| Field | What | Example (1920×1080) |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `plane.top/left/width/height` | Where the side column sits. Pick the side opposite the subject's gaze (looking room) and hug the cleanest backdrop zone. | `{top: 40, left: 40, width: 720, height: 420}` |
|
||||
| `plane.rotateY` | Slight perspective into the wall. Positive for left column (recedes right), negative for right column. Keep small (3-7°). | `4` |
|
||||
| `crown_top` | Y position of the crown. Should fall on the subject's chest/neck so the body occludes the middle letters. | `420` |
|
||||
| `font_scale` | Multiplier on locked sizes. | `1.0` for 1920×1080 |
|
||||
| `crown_enabled` | Whether to render a crown. Skip if the speech has no clean payoff line, or if the subject is too far off-center for a center crown to land. | `true` |
|
||||
|
||||
### Crown placement rules
|
||||
|
||||
Before enabling the crown, verify all 3 conditions:
|
||||
|
||||
1. Subject body center within ±10% of frame horizontal center
|
||||
2. Clean zones ≥15% of frame width on each side of subject
|
||||
3. Crown text width (uppercase 140px × ~chars) > subject body width + 400px
|
||||
|
||||
If any fails: disable the crown OR use a smaller font + place it in the
|
||||
larger clean zone instead of center. See `references/layout-heuristics.md`.
|
||||
|
||||
## Slot assignment
|
||||
|
||||
| Slot | Use for |
|
||||
| ------ | ---------------------------------------------------------- |
|
||||
| intro | Filler / discourse markers ("you know,", "for me,", "so…") |
|
||||
| phrase | Main statement clauses |
|
||||
| emph | Key achievement / superlative line |
|
||||
| dream | Aspirational lines, past-tense reflection |
|
||||
| crown | The single climax line — center-stage payoff |
|
||||
|
||||
Aim for ≤3 slots per video (excluding crown). Don't use all 4 in a 12s clip.
|
||||
|
||||
## Plan.json shape
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "template",
|
||||
"template": "champion",
|
||||
"duration": 12.08, "fps": 24, "width": 1920, "height": 1080,
|
||||
"plane": {
|
||||
"top": 40, "left": 40, "width": 720, "height": 420,
|
||||
"rotateY": 4
|
||||
},
|
||||
"crown_top": 420,
|
||||
"font_scale": 1.0,
|
||||
"groups": [
|
||||
{ "id": "cg-0", "slot": "intro", "tone": "soft",
|
||||
"in": 0.10, "out": 1.45, "words": [...] },
|
||||
{ "id": "cg-1", "slot": "phrase", "tone": "soft",
|
||||
"in": 1.55, "out": 4.20, "words": [...] },
|
||||
{ "id": "cg-2", "slot": "emph", "tone": "present",
|
||||
"in": 6.50, "out": 8.30, "words": [...] }
|
||||
],
|
||||
"crown_group": {
|
||||
"id": "cg-crown", "tone": "present",
|
||||
"in": 10.85, "out": 12.08,
|
||||
"words": [
|
||||
{"text": "Wimbledon", "start": 10.94, "end": 11.28},
|
||||
{"text": "Champion", "start": 11.38, "end": 11.84}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`crown_group` always uses the crown slot — no need to specify `slot`.
|
||||
Set `crown_group: null` to disable.
|
||||
@@ -0,0 +1,188 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
TEMPLATE: champion
|
||||
STYLE LOCKED. LAYOUT OPEN.
|
||||
|
||||
Look: podcast / interview, upper-left column with intro→phrase→emph
|
||||
beats, big center-stage CROWN crossing the subject for the payoff.
|
||||
|
||||
Locked:
|
||||
- 5 typography slots: intro / phrase / emph / dream / crown
|
||||
- mix-blend-mode: screen, color #fff5df, deep text-shadow
|
||||
- Single-caption-swap layout (caps absolute-positioned in left-plane)
|
||||
- GSAP soft/present per-word reveal
|
||||
|
||||
Open via plan.json:
|
||||
- left-plane geometry + perspective
|
||||
- crown_plane top
|
||||
- --font-scale
|
||||
- GROUPS array + slot per group
|
||||
- whether crown is enabled (crown_group)
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
:root { --font-scale: {{FONT_SCALE}}; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
#a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
perspective: 2400px;
|
||||
perspective-origin: 25% 50%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* LAYOUT */
|
||||
.left-plane {
|
||||
position: absolute;
|
||||
top: {{PLANE_TOP}}px;
|
||||
left: {{PLANE_LEFT}}px;
|
||||
width: {{PLANE_WIDTH}}px;
|
||||
height: {{PLANE_HEIGHT}}px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY({{ROTATE_Y}}deg);
|
||||
transform-origin: left center;
|
||||
padding: 24px 36px;
|
||||
}
|
||||
.left-plane .cap {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 36px;
|
||||
max-width: calc({{PLANE_WIDTH}}px - 72px);
|
||||
}
|
||||
.crown-plane {
|
||||
position: absolute;
|
||||
top: {{CROWN_TOP}}px;
|
||||
left: {{CROWN_LEFT}}px;
|
||||
right: {{CROWN_RIGHT}}px;
|
||||
text-align: {{CROWN_ALIGN}};
|
||||
pointer-events: none;
|
||||
}
|
||||
.crown-plane .cap-crown {
|
||||
font-size: calc(140px * var(--font-scale) * {{CROWN_SCALE}});
|
||||
}
|
||||
|
||||
/* STYLE (locked) */
|
||||
.cap {
|
||||
display: block;
|
||||
position: relative;
|
||||
line-height: 1.02;
|
||||
opacity: 0;
|
||||
mix-blend-mode: {{BLEND_MODE}};
|
||||
color: {{CAP_COLOR}};
|
||||
text-shadow: {{TEXT_SHADOW}};
|
||||
filter: {{TEXT_FILTER}};
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }
|
||||
|
||||
.cap-intro { font-size: calc(52px * var(--font-scale));
|
||||
font-weight: 500; font-style: italic;
|
||||
letter-spacing: -0.005em; }
|
||||
.cap-phrase { font-size: calc(60px * var(--font-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em; }
|
||||
.cap-emph { font-size: calc(70px * var(--font-scale));
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em; }
|
||||
.cap-dream { font-size: calc(64px * var(--font-scale));
|
||||
font-weight: 700; font-style: italic;
|
||||
letter-spacing: -0.015em; }
|
||||
.cap-crown { font-size: calc(140px * var(--font-scale));
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.035em; text-transform: uppercase;
|
||||
line-height: 1.0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-width="{{WIDTH}}"
|
||||
data-height="{{HEIGHT}}"
|
||||
>
|
||||
<video
|
||||
id="a-roll"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<div id="stage">
|
||||
<div class="left-plane">{{GROUPS_HTML}}</div>
|
||||
<div class="crown-plane">{{CROWN_HTML}}</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = {{DURATION}};
|
||||
const GROUPS = {{GROUPS_JSON}};
|
||||
const CROWN = {{CROWN_JSON}};
|
||||
|
||||
function animateGroup(g) {
|
||||
var sel = "#" + g.id;
|
||||
var isSoft = g.tone === "soft";
|
||||
tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
g.words.forEach(function (w, i) {
|
||||
var wsel = sel + " .w[data-i='" + i + "']";
|
||||
if (isSoft) {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" },
|
||||
w.start);
|
||||
} else {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 6, scale: 1.04 },
|
||||
{ opacity: 1, y: 0, scale: 1.0, duration: 0.22,
|
||||
ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
|
||||
w.start);
|
||||
}
|
||||
});
|
||||
|
||||
if (g.out < DUR) {
|
||||
tl.to(sel, { opacity: 0, duration: 0.45, ease: "power2.in", overwrite: "auto" },
|
||||
g.out - 0.45);
|
||||
}
|
||||
tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
|
||||
}
|
||||
|
||||
GROUPS.forEach(animateGroup);
|
||||
if (CROWN) animateGroup(CROWN);
|
||||
|
||||
tl.seek(0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
# Template: memory-wall
|
||||
|
||||
**Look:** introspective monologue, italic poem stacking on a back wall,
|
||||
right-aligned cascade for the climax. Sam Harris / Pico Iyer territory.
|
||||
|
||||
**Visual identity (LOCKED — do not change):**
|
||||
|
||||
- 4-slot typography arc: italic 600 → italic 500 hanging-indent → upright
|
||||
700 pivot → uppercase 900 climax
|
||||
- `mix-blend-mode: screen` over warm-bone color (#fff4dc)
|
||||
- soft text-shadow + brightness boost
|
||||
- Per-word fade with subtle Y-drift (soft tone) or pop+scale (present tone)
|
||||
|
||||
## When to apply
|
||||
|
||||
✅ **Good fit:**
|
||||
|
||||
- Talking-head with a flat back wall (acoustic foam, plain plaster, soft
|
||||
bookshelf) on one side
|
||||
- Single subject, fixed or near-fixed camera
|
||||
- Voice-over feel: introspective, narrative, "I was thinking…"
|
||||
- Mid-tone or dark backdrop (luminance < 180)
|
||||
|
||||
❌ **Wrong fit:**
|
||||
|
||||
- Bright window/sky backdrop → screen blend blows out
|
||||
- Cluttered backdrop with text/graphics → conflicts with caption text
|
||||
- Energetic / vlog / TikTok style → wrong vibe
|
||||
- Multiple speakers / hard cuts → caption arc breaks
|
||||
|
||||
## What agent decides per scene
|
||||
|
||||
| Field | What | Example |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `plane.top/right/width/height` | Where the wall plane sits in frame. Plane should HUG the side of the wall, not float over the subject. | `{top: 90, right: 30, width: 720, height: 520}` for 1280×720 |
|
||||
| `plane.rotateY` | Match the wall's perspective angle. Negative for right-side wall (text recedes left), positive for left-side wall. | `-13` for a right-side wall at slight angle |
|
||||
| `plane.rotateX` | Tiny vertical tilt if wall is angled up/down. Usually 0-2 deg. | `1` |
|
||||
| `font_scale` | Multiplier on the locked cap-N base sizes. Tune for frame height. | `1.0` for 1280×720, `~1.5` for 1920×1080, `~0.7` for 720p portrait |
|
||||
| `groups[].slot` | Which of the 4 typography slots this group uses (1/2/3/4). Drives the arc. | See below |
|
||||
| `groups[].tone` | "soft" or "present" — drives motion curve | `"soft"` for opening lines, `"present"` for pivot+climax |
|
||||
|
||||
## Slot assignment rules
|
||||
|
||||
Aim for ONE arc across the video: `1 → 2 → 3 → 4`. Don't repeat slots if you
|
||||
can avoid it; don't skip slots without reason.
|
||||
|
||||
| Slot | Use for |
|
||||
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | The opener. The "I was thinking" line. Italic, soft entry. |
|
||||
| 2 | The dreamy continuation / modifier. Smaller italic, hanging-indent — feels like the second half of a sentence with some staggered drift. |
|
||||
| 3 | The pivot. Upright (no italic), the syntactic turn. ("but suddenly", "and then…") |
|
||||
| 4 | The climax. Big uppercase 900. The payoff line. Aim for ≤4 short words so the right-aligned cascade naturally wraps to 2-3 lines. |
|
||||
|
||||
If the script is longer than 4 groups, repeat slot 1 or 2 for additional
|
||||
soft lines, but reserve slot 4 for the SINGLE climax.
|
||||
|
||||
## Plan.json shape
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "template",
|
||||
"template": "memory-wall",
|
||||
"duration": 8.04,
|
||||
"fps": 24,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"plane": {
|
||||
"top": 90,
|
||||
"right": 30,
|
||||
"width": 720,
|
||||
"height": 520,
|
||||
"rotateY": -13,
|
||||
"rotateX": 1
|
||||
},
|
||||
"font_scale": 1.0,
|
||||
"groups": [
|
||||
{
|
||||
"id": "cg-0",
|
||||
"slot": 1,
|
||||
"tone": "soft",
|
||||
"in": 0.2,
|
||||
"out": 4.85,
|
||||
"words": [
|
||||
{ "text": "Some", "start": 0.24, "end": 0.44 },
|
||||
{ "text": "memories", "start": 0.48, "end": 0.82 },
|
||||
{ "text": "feel", "start": 0.92, "end": 1.14 },
|
||||
{ "text": "soft", "start": 1.2, "end": 1.64 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cg-1",
|
||||
"slot": 2,
|
||||
"tone": "soft",
|
||||
"in": 2.55,
|
||||
"out": 4.85,
|
||||
"words": [
|
||||
{ "text": "like", "start": 2.66, "end": 2.78 },
|
||||
{ "text": "old", "start": 2.86, "end": 3.02 },
|
||||
{ "text": "film", "start": 3.16, "end": 3.46 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cg-2",
|
||||
"slot": 3,
|
||||
"tone": "present",
|
||||
"in": 4.9,
|
||||
"out": 8.04,
|
||||
"words": [
|
||||
{ "text": "but", "start": 5.04, "end": 5.18 },
|
||||
{ "text": "suddenly", "start": 5.24, "end": 5.76 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cg-3",
|
||||
"slot": 4,
|
||||
"tone": "present",
|
||||
"in": 6.7,
|
||||
"out": 8.04,
|
||||
"words": [
|
||||
{ "text": "everything", "start": 6.82, "end": 7.14 },
|
||||
{ "text": "is", "start": 7.18, "end": 7.28 },
|
||||
{ "text": "sharp", "start": 7.32, "end": 7.52 },
|
||||
{ "text": "again", "start": 7.58, "end": 7.92 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Don't fight the template
|
||||
|
||||
If the user wants different typography, different blend, or different motion:
|
||||
**use Standard mode** (`../../standard/`). Don't try to override the locked CSS — the
|
||||
template's visual consistency is the whole point.
|
||||
@@ -0,0 +1,179 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
TEMPLATE: memory-wall
|
||||
STYLE LOCKED. LAYOUT OPEN.
|
||||
|
||||
Locked (do not edit — overriding breaks visual consistency):
|
||||
- cap-1/2/3/4 typography (weights, italic, letter-spacing, padding-right)
|
||||
- mix-blend-mode: screen
|
||||
- color, text-shadow, filter
|
||||
- GSAP motion: container fade-in via tl.set, per-word opacity+y reveal,
|
||||
group fade-out 0.45s before g.out
|
||||
|
||||
Open (agent fills via plan.json):
|
||||
- {{PLANE_TOP}} {{PLANE_RIGHT}} {{PLANE_WIDTH}} {{PLANE_HEIGHT}}
|
||||
- {{ROTATE_Y}} {{ROTATE_X}} (perspective angle on the wall)
|
||||
- --font-scale CSS var (multiplies all cap-N sizes — agent tunes for frame)
|
||||
- GROUPS array (transcript groups, slot assignment 1/2/3/4, in/out/words)
|
||||
|
||||
Style typography is keyed to "slots", semantic positions in the arc:
|
||||
cap-1: opener (italic, soft entry)
|
||||
cap-2: continuation (smaller italic, hanging indent — ragged poem feel)
|
||||
cap-3: pivot (upright, no italic, syntactic turn)
|
||||
cap-4: climax (UPPERCASE 900, the payoff line)
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
:root { --font-scale: {{FONT_SCALE}}; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#a-roll {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
perspective: 1400px;
|
||||
perspective-origin: 30% 40%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* LAYOUT (agent-tunable via plan.json) */
|
||||
.wall-plane {
|
||||
position: absolute;
|
||||
top: {{PLANE_TOP}}px;
|
||||
right: {{PLANE_RIGHT}}px;
|
||||
width: {{PLANE_WIDTH}}px;
|
||||
height: {{PLANE_HEIGHT}}px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY({{ROTATE_Y}}deg) rotateX({{ROTATE_X}}deg);
|
||||
transform-origin: right center;
|
||||
padding: 24px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* STYLE (locked) */
|
||||
.cap {
|
||||
display: block;
|
||||
position: relative;
|
||||
line-height: 1.05;
|
||||
opacity: 0;
|
||||
mix-blend-mode: {{BLEND_MODE}};
|
||||
color: {{CAP_COLOR}};
|
||||
text-shadow: {{TEXT_SHADOW}};
|
||||
filter: {{TEXT_FILTER}};
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }
|
||||
|
||||
.cap-1 { font-size: calc(78px * var(--font-scale));
|
||||
font-weight: 600; font-style: italic;
|
||||
letter-spacing: -0.01em; }
|
||||
.cap-2 { font-size: calc(66px * var(--font-scale));
|
||||
font-weight: 500; font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
padding-right: calc(44px * var(--font-scale)); }
|
||||
.cap-3 { font-size: calc(72px * var(--font-scale));
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em; }
|
||||
.cap-4 { font-size: calc(90px * var(--font-scale));
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-width="{{WIDTH}}"
|
||||
data-height="{{HEIGHT}}"
|
||||
>
|
||||
<video
|
||||
id="a-roll"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<div id="stage">
|
||||
<div class="wall-plane">{{GROUPS_HTML}}</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = {{DURATION}};
|
||||
const GROUPS = {{GROUPS_JSON}};
|
||||
|
||||
GROUPS.forEach(function (g) {
|
||||
var sel = "#" + g.id;
|
||||
var isSoft = g.tone === "soft";
|
||||
tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
g.words.forEach(function (w, i) {
|
||||
var wsel = sel + " .w[data-i='" + i + "']";
|
||||
if (isSoft) {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.45, ease: "power2.out", overwrite: "auto" },
|
||||
w.start);
|
||||
} else {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 6, scale: 1.04 },
|
||||
{ opacity: 1, y: 0, scale: 1.0, duration: 0.22,
|
||||
ease: "power3.out", overwrite: "auto", transformOrigin: "100% 50%" },
|
||||
w.start);
|
||||
}
|
||||
});
|
||||
|
||||
if (g.out < DUR) {
|
||||
tl.to(sel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" },
|
||||
g.out - 0.5);
|
||||
}
|
||||
tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
|
||||
});
|
||||
|
||||
tl.seek(0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
# Template: portrait-header
|
||||
|
||||
**Look:** 9:16 portrait talking-head with captions on a horizontal strip
|
||||
across the top of the frame, above the subject's head. Optional crown at
|
||||
the bottom for shots that include the subject's waist or below.
|
||||
|
||||
**Visual identity (LOCKED):**
|
||||
|
||||
- 5 typography slots: intro / phrase / emph / dream / crown (centered)
|
||||
- `mix-blend-mode: screen` over warm bone color (#fff5df)
|
||||
- Single-caption swap (caps stack at one position in the header strip)
|
||||
- Per-word fade with subtle Y-drift (soft) or pop+scale (present)
|
||||
|
||||
## When to apply
|
||||
|
||||
✅ **Good fit:**
|
||||
|
||||
- 9:16 portrait video (1080×1920 typical)
|
||||
- Single subject filling the center of the frame
|
||||
- Clean band visible above the head
|
||||
- Speech is relatively dense (many short phrases) — crown only for the
|
||||
one true climax
|
||||
|
||||
❌ **Wrong fit:**
|
||||
|
||||
- Subject's head crops to the very top of frame (no header band exists)
|
||||
- 16:9 landscape → use `champion` or `memory-wall` instead
|
||||
- Multiple speakers / cuts
|
||||
|
||||
## Layout decisions
|
||||
|
||||
| Field | What | Example (1080×1920) |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `header.top` | Y of the header strip. Should sit above the subject's head with ~30px breathing room. | `30` if hair top is around y=200 |
|
||||
| `header.height` | Height of the strip. Bigger for longer captions / 2-line wraps. | `280` |
|
||||
| `crown_top` | Y of the crown line (bottom of frame). Skip if no waist visible. | `1620` for a near-full-body shot |
|
||||
| `crown_enabled` | Whether to render a bottom crown. Skip if subject body fills the lower half completely (matte will block crown anyway). | `false` for tight head-and-shoulders |
|
||||
| `font_scale` | Scale multiplier on locked sizes. | `1.0` for 1080×1920 |
|
||||
|
||||
## Slot assignment
|
||||
|
||||
Same arc as champion: intro / phrase / emph / dream / crown.
|
||||
|
||||
For portrait-header specifically, header band is narrow (one strip), so:
|
||||
|
||||
- Most groups fit on 1-2 lines
|
||||
- Don't use crown if it'd be hidden by the body matte (Pausch-style portraits
|
||||
often need to skip crown entirely and promote the climax to `emph` in the
|
||||
header instead)
|
||||
|
||||
## Plan.json shape
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "template",
|
||||
"template": "portrait-header",
|
||||
"duration": 25.3, "fps": 24, "width": 1080, "height": 1920,
|
||||
"header": { "top": 30, "height": 280 },
|
||||
"crown_top": 1620,
|
||||
"font_scale": 1.0,
|
||||
"groups": [
|
||||
{ "id": "cg-0", "slot": "intro", "tone": "soft",
|
||||
"in": 0.10, "out": 3.20, "words": [...] },
|
||||
{ "id": "cg-1", "slot": "phrase", "tone": "soft",
|
||||
"in": 3.55, "out": 5.80, "words": [...] },
|
||||
{ "id": "cg-2", "slot": "emph", "tone": "present",
|
||||
"in": 17.85, "out": 22.10,
|
||||
"words": [
|
||||
{"text": "Time", "start": 17.94, "end": 18.26},
|
||||
{"text": "is", "start": 18.38, "end": 18.54},
|
||||
{"text": "all", "start": 18.66, "end": 18.80},
|
||||
{"text": "we", "start": 18.92, "end": 19.02},
|
||||
{"text": "have", "start": 19.12, "end": 19.38}
|
||||
]}
|
||||
],
|
||||
"crown_group": null
|
||||
}
|
||||
```
|
||||
|
||||
Set `crown_group: null` if you've checked that a crown would be blocked by
|
||||
the subject body or the shot composition.
|
||||
@@ -0,0 +1,170 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
TEMPLATE: portrait-header
|
||||
STYLE LOCKED. LAYOUT OPEN.
|
||||
|
||||
Look: 9:16 portrait talking-head, captions on a horizontal strip across
|
||||
the top above the subject. Optional crown at bottom if waist+ visible.
|
||||
|
||||
Locked: 5 typography slots (intro/phrase/emph/dream/crown), screen blend,
|
||||
single-caption swap (caps absolute-positioned in header strip).
|
||||
|
||||
Open via plan.json: header strip top/height, crown enable+top, font_scale.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
:root { --font-scale: {{FONT_SCALE}}; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
#a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* LAYOUT */
|
||||
.header-plane {
|
||||
position: absolute;
|
||||
top: {{HEADER_TOP}}px;
|
||||
left: 0; right: 0;
|
||||
height: {{HEADER_HEIGHT}}px;
|
||||
padding: 24px 40px;
|
||||
}
|
||||
.header-plane .cap {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 40px; right: 40px;
|
||||
max-width: calc(100% - 80px);
|
||||
text-align: center;
|
||||
}
|
||||
.crown-plane {
|
||||
position: absolute;
|
||||
top: {{CROWN_TOP}}px;
|
||||
left: 0; right: 0;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* STYLE (locked) */
|
||||
.cap {
|
||||
display: block;
|
||||
position: relative;
|
||||
line-height: 1.05;
|
||||
opacity: 0;
|
||||
mix-blend-mode: {{BLEND_MODE}};
|
||||
color: {{CAP_COLOR}};
|
||||
text-shadow: {{TEXT_SHADOW}};
|
||||
filter: {{TEXT_FILTER}};
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }
|
||||
|
||||
.cap-intro { font-size: calc(72px * var(--font-scale));
|
||||
font-weight: 500; font-style: italic;
|
||||
letter-spacing: -0.005em; line-height: 1.1; }
|
||||
.cap-phrase { font-size: calc(88px * var(--font-scale));
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em; line-height: 1.05; }
|
||||
.cap-emph { font-size: calc(104px * var(--font-scale));
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em; line-height: 1.0; }
|
||||
.cap-dream { font-size: calc(92px * var(--font-scale));
|
||||
font-weight: 700; font-style: italic;
|
||||
letter-spacing: -0.015em; line-height: 1.05; }
|
||||
.cap-crown { font-size: calc(130px * var(--font-scale));
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.035em; text-transform: uppercase;
|
||||
line-height: 1.0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-width="{{WIDTH}}"
|
||||
data-height="{{HEIGHT}}"
|
||||
>
|
||||
<video
|
||||
id="a-roll"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<div id="stage">
|
||||
<div class="header-plane">{{GROUPS_HTML}}</div>
|
||||
<div class="crown-plane">{{CROWN_HTML}}</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = {{DURATION}};
|
||||
const GROUPS = {{GROUPS_JSON}};
|
||||
const CROWN = {{CROWN_JSON}};
|
||||
|
||||
function animateGroup(g) {
|
||||
var sel = "#" + g.id;
|
||||
var isSoft = g.tone === "soft";
|
||||
tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
g.words.forEach(function (w, i) {
|
||||
var wsel = sel + " .w[data-i='" + i + "']";
|
||||
if (isSoft) {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" },
|
||||
w.start);
|
||||
} else {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 6, scale: 1.04 },
|
||||
{ opacity: 1, y: 0, scale: 1.0, duration: 0.22,
|
||||
ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
|
||||
w.start);
|
||||
}
|
||||
});
|
||||
|
||||
if (g.out < DUR) {
|
||||
tl.to(sel, { opacity: 0, duration: 0.45, ease: "power2.in", overwrite: "auto" },
|
||||
g.out - 0.45);
|
||||
}
|
||||
tl.set(sel, { opacity: 0, visibility: "hidden" }, g.out);
|
||||
}
|
||||
|
||||
GROUPS.forEach(animateGroup);
|
||||
if (CROWN) animateGroup(CROWN);
|
||||
|
||||
tl.seek(0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
# cinematic-cream — spec
|
||||
|
||||
The authoritative spec lives in [template.html](template.html)'s header comment (DNA-only:
|
||||
what's LOCKED vs OPEN, the LAYOUT/planes contract, READING ORDER + hand-off, hero sizing).
|
||||
Read that header before authoring plan.json. Quick contract:
|
||||
|
||||
- LOCKED: Inter, soft/present motion, warm-cream palette, screen blend, shadow logic.
|
||||
- OPEN per group (css field): size/weight/style/transform/spacing + position WITHIN a plane.
|
||||
- LAYOUT: run scripts/safe-zones.cjs first. Narration planes HUG the silhouette — use
|
||||
zones.hugLeft/hugRight (2-6% gap from the subject's edge); a plane parked in the far
|
||||
corner reads as disconnected floating text, not an embed. The ONE hero stays BIG
|
||||
(~0.22-0.34·h) centered ON the subject (heroAnchor / heroBands.best), target ~30-55%
|
||||
occlusion. fg is the LAST resort: only when heroBands.feasible=false (no band ≤62%).
|
||||
hero BIG (~0.22-0.34·h) centered ON the subject (heroAnchor), target ~30-55% occlusion.
|
||||
- plan.json: groups[] with per-word transcript timings (use scripts/fill-timings.cjs),
|
||||
fit via scripts/fit-fonts.cjs, compile via scripts/make-composition.cjs.
|
||||
@@ -0,0 +1,226 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
TEMPLATE: cinematic-cream
|
||||
DNA-ONLY template. No slot structure. No preset typography classes.
|
||||
The agent composes each caption group's typography per-scene via the
|
||||
plan.json `css` field.
|
||||
|
||||
What's LOCKED (DNA — do not override):
|
||||
- font-family: Inter (+ fallback)
|
||||
- Motion vocabulary: per-word soft/present reveal curves from the
|
||||
validated memory-wall timeline (tone: "soft" | "present")
|
||||
- Color palette defaults (cream on dark scenes, deep warm on light)
|
||||
- mix-blend-mode defaults, text-shadow / filter auto-picked from
|
||||
cap_color luminance (via make-composition.py)
|
||||
- Animation: 0.7s container fade-in (at g.in-0.01 via tl.set), per-word
|
||||
reveal at w.start, group fade-out 0.5s before g.out
|
||||
|
||||
What's OPEN per group (agent decides per scene via `css` field):
|
||||
- font-size, font-weight, font-style (italic or not), text-transform
|
||||
(uppercase or not), letter-spacing, line-height
|
||||
- Position: top / left / right / bottom / transform / text-align
|
||||
- Layer: bg (embed via matte) | fg (announcement on top)
|
||||
- Tone: soft | present (motion curve)
|
||||
- scale: optional shortcut for size multiplication
|
||||
|
||||
LAYOUT — FIRST run the safe-zone precompute, THEN compose into PLANES:
|
||||
- Run `node scripts/safe-zones.cjs <project>` and read `safe-zones.json`. From the
|
||||
matte it gives subject coverage%, the clearer side, the largest CLEAN rectangles
|
||||
(left/right/top), and a verdict:
|
||||
· "fg" → subject fills the frame; NO room to embed behind it. Use
|
||||
`caption_layer:"fg"` (screen-blend on top). Do NOT fight it with bg embeds —
|
||||
that's the 9:16 case that costs many failed occlusion rounds.
|
||||
· "embed"→ NARRATION planes go INSIDE the reported clean zones (prefer the clearer
|
||||
side / top). The HERO is the EXCEPTION — see the next bullet. Don't guess
|
||||
narration positions — use the zones safe-zones gives you.
|
||||
- NARRATION: define 2-4 named planes in plan.json `planes {}`, each inside a clean zone
|
||||
from safe-zones.json. Give each narration group a `plane`; groups STACK within it (a
|
||||
plane is a flex column → lines accumulate, aligned + coherent). Narration must be CLEAR.
|
||||
- THE HERO (the one promoted peak word) is the OPPOSITE of narration — BIG, and ON the
|
||||
subject. It is the scarce, earned PEAK; it must feel like it BURSTS, not a polite label:
|
||||
· BIG + dominant — **~0.22–0.34 × frame height**. Give it a WIDE plane (≈ centered,
|
||||
spanning the subject), NEVER a narrow margin column. (fit-fonts won't enlarge — if
|
||||
the hero's box is narrow it stays small; widen the box.)
|
||||
· CENTERED on the subject (use safe-zones `heroAnchor` = the subject's center),
|
||||
CROSSING it so the head/torso occludes the MIDDLE — **target ~30–55% occlusion**.
|
||||
That occlusion IS the effect. A hero shoved into a clean margin (~10% graze) is
|
||||
WRONG — it reads as a small floating word, not an embed behind the subject.
|
||||
· Exactly ONE hero per clip (scarcity = impact). Mark it `"hero": true` in the plan.
|
||||
- Do NOT hand-position every group with its own scattered absolute `top%` — that
|
||||
produces the unbalanced, occluded layouts the gate rejects (many re-tries). Planes
|
||||
first; per-group `css` only fine-tunes WITHIN a plane. (Position fields above apply
|
||||
per-group only when a group is NOT in a plane — the exception, not the default.)
|
||||
|
||||
READING ORDER — on-screen position follows SPOKEN order; a promoted word appears once:
|
||||
- For groups sharing a column/region, vertical (top→bottom) order MUST match spoken
|
||||
order. A promoted hero must NOT sit below words that come after it.
|
||||
(e.g. "judge us by the [ACTIONS] that we take" → ACTIONS reads BETWEEN "by the" and
|
||||
"that we take", NEVER below "that we take".)
|
||||
- The promoted word is lifted OUT of the narration (appears once, as the hero — never
|
||||
also in a narration line). For the words AFTER it, two options, in order:
|
||||
1. PREFERRED — flow them directly BELOW the hero (in reading order) or merge them
|
||||
into the hero's group, IF that area is clean (check safe-zones). One continuous
|
||||
read, no clearing.
|
||||
2. FALLBACK — if below/merge would land on the subject (occluded), do the Standard
|
||||
hand-off: the pre-hero narration CLEARS, then the post-hero words appear fresh
|
||||
(page-flip); the hero may hold across the flip.
|
||||
Prefer promoting a CLAUSE-FINAL word when you can — then nothing follows to place.
|
||||
|
||||
Philosophy:
|
||||
- Number of groups = content decides (3? 7? 12? whatever the speech
|
||||
warrants)
|
||||
- Each group's typography = scene decides (italic for soft moments,
|
||||
uppercase for punch, mixed case for narration — per group)
|
||||
- No more "cap-1 must be italic 600" rules. Those were slots.
|
||||
- The agent reads the scene + transcript and composes. The template
|
||||
only guarantees the DNA feels coherent (Inter family, same motion
|
||||
library, warm-cream or deep-warm palette, same shadow logic).
|
||||
|
||||
See `references/typographic-moves.md` for RECOMMENDED typography
|
||||
combinations (NOT rules) — italic 600 for openers, uppercase 900 for
|
||||
climaxes, etc. These are suggestions the agent can pick from or deviate.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
:root { --font-scale: {{FONT_SCALE}}; --h: {{HEIGHT}}px; --w: {{WIDTH}}px; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Inter", "Helvetica Neue", system-ui, sans-serif;
|
||||
}
|
||||
#a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* BASE — everything shared across groups */
|
||||
.cap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
mix-blend-mode: {{BLEND_MODE}};
|
||||
color: {{CAP_COLOR}};
|
||||
text-shadow: {{TEXT_SHADOW}};
|
||||
filter: {{TEXT_FILTER}};
|
||||
line-height: 1.0;
|
||||
will-change: transform, opacity;
|
||||
/* Free-mode defaults (when cap is a direct child of #stage):
|
||||
per-group `top:` positions the element's TOP edge from the
|
||||
frame top. Agent writes top/left/right per group. */
|
||||
top: 50%; left: 0; right: 0; text-align: center;
|
||||
}
|
||||
.cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }
|
||||
|
||||
/* Plane mode — when .cap is nested inside .plane, switch to flow
|
||||
layout so the plane's flex/grid can own the stacking. Agent writes
|
||||
typography only per group; positions are delegated to the plane.
|
||||
This is the memory-wall / champion architecture: a shared parent
|
||||
container handles spatial coherence, caps inside don't fight for
|
||||
top% values. */
|
||||
.plane { position: absolute; pointer-events: none; }
|
||||
.plane > .cap {
|
||||
position: relative;
|
||||
top: auto; left: auto; right: auto; bottom: auto;
|
||||
}
|
||||
|
||||
{{PLANES_CSS}}
|
||||
|
||||
/* Per-group typography (agent-authored). */
|
||||
{{CUSTOM_CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-width="{{WIDTH}}"
|
||||
data-height="{{HEIGHT}}"
|
||||
>
|
||||
<video
|
||||
id="a-roll"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<div id="stage">{{GROUPS_HTML}}</div>
|
||||
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = {{DURATION}};
|
||||
const GROUPS = {{GROUPS_JSON}};
|
||||
|
||||
/* Motion vocabulary — LOCKED.
|
||||
soft: gentle fade with 8px y-drift, 0.45s power2.out
|
||||
present: snappier pop with 6px y + 1.04 scale, 0.22s power3.out
|
||||
Both target per-WORD spans. Container fade-in via tl.set at g.in-0.01. */
|
||||
GROUPS.forEach(function (g) {
|
||||
var sel = "#" + g.id;
|
||||
var isSoft = g.tone === "soft";
|
||||
|
||||
// Enter: reveal opacity at g.in-0.01. The cap has been reserving flex
|
||||
// space all along (opacity: 0 but display: block from CSS), so no
|
||||
// layout reflow when it fades in — matches memory-wall canonical.
|
||||
tl.set(sel, { opacity: 1 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
g.words.forEach(function (w, i) {
|
||||
var wsel = sel + " .w[data-i='" + i + "']";
|
||||
if (isSoft) {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.45, ease: "power2.out", overwrite: "auto" },
|
||||
w.start);
|
||||
} else {
|
||||
tl.fromTo(wsel,
|
||||
{ opacity: 0, y: 6, scale: 1.04 },
|
||||
{ opacity: 1, y: 0, scale: 1.0, duration: 0.22,
|
||||
ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
|
||||
w.start);
|
||||
}
|
||||
});
|
||||
|
||||
if (g.out < DUR) {
|
||||
tl.to(sel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" },
|
||||
g.out - 0.5);
|
||||
}
|
||||
// Exit: display:none releases flex space so the next sentence-block's
|
||||
// caps can take the top slot. All caps in ONE block share the same
|
||||
// `out` so this fires synchronously for the block — one clean reflow
|
||||
// at the boundary, no per-cap jitter. Plan must share `out` within a
|
||||
// block; staggered per-cap exits are forbidden in plane mode.
|
||||
tl.set(sel, { opacity: 0, display: "none" }, g.out);
|
||||
});
|
||||
|
||||
tl.seek(0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,355 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
ENGINE — the one Cinematic-mode shell, parameterized by a DNA (dna/<name>.json).
|
||||
Replaces per-template HTML files: the DNA supplies type / palette / blend / motion
|
||||
grammar / hero orchestration; safe-zones v2 supplies scene tokens (accent sampling,
|
||||
contact-shadow direction, depth-match blur); the plan supplies layout + per-group
|
||||
typography. Compiled by make-composition.cjs.
|
||||
|
||||
Contract (unchanged from cinematic-cream — gates depend on it):
|
||||
#root[data-composition-id="main"] · #a-roll · #stage · #a-roll-audio
|
||||
.plane > .cap > .w spans · paused seek-safe GSAP on window.__timelines["main"]
|
||||
container fade via tl.set at g.in−0.01 · exit fade + display:none at g.out
|
||||
no Math.random / Date.now / CSS keyframes / repeat:-1
|
||||
|
||||
New in the engine:
|
||||
- MOTION grammar from the DNA: soft / present / impact tones (+ "burn" reveal)
|
||||
- HERO three-act orchestration:
|
||||
act 1 SETUP — co-visible groups dim 0.35s before the hero lands
|
||||
act 2 IMPACT — entrance (emergence | settle | slam | rise) with per-letter
|
||||
stagger; amplitude coupled to the spoken word's RMS loudness
|
||||
act 3 AFTERGLOW — slow breathe scale + glow layer (DNA-gated) until exit
|
||||
- depth-match blur on embed (bg) captions only; fg stays sharp
|
||||
- scene-sampled --accent custom property available to per-group CSS
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--font-scale: {{FONT_SCALE}};
|
||||
--h: {{HEIGHT}}px; --w: {{WIDTH}}px;
|
||||
--accent: {{ACCENT}};
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: '{{FONT_FAMILY}}', "Helvetica Neue", system-ui, sans-serif;
|
||||
}
|
||||
#a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: {{WIDTH}}px; height: {{HEIGHT}}px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* BASE — DNA tokens. bg (embed) captions get the depth-match filter; fg stays sharp. */
|
||||
.cap {
|
||||
display: block;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
mix-blend-mode: {{BLEND_MODE}};
|
||||
color: {{CAP_COLOR}};
|
||||
text-shadow: {{TEXT_SHADOW}};
|
||||
filter: {{TEXT_FILTER_BG}};
|
||||
line-height: 1.12; /* contains ink + internal leading — wrapped rows breathe (was 1.0: rows touched) */
|
||||
will-change: transform, opacity;
|
||||
top: 50%; left: 0; right: 0; text-align: center;
|
||||
}
|
||||
.cap[data-layer="fg"] { filter: {{TEXT_FILTER_FG}}; }
|
||||
.cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }
|
||||
.cap .w .l { display: inline-block; will-change: opacity, transform; }
|
||||
|
||||
/* hero glow — same glyphs duplicated UNDER the hero, blurred; opacity driven by GSAP.
|
||||
em-based blur scales with the hero's font size. Not part of plan.groups → gates skip it.
|
||||
MUST be out of flow (absolute) — as a flow sibling inside a plane it displaces the hero. */
|
||||
.hero-glow, .plane > .cap.hero-glow { position: absolute; left: 0; right: 0; top: 0; filter: blur(0.06em); }
|
||||
/* hero echoes — colored duplicates (RGB fringe / speed trails); position mirrors the
|
||||
hero via its #id rule, color comes from each echo's own rule */
|
||||
.hero-echo, .plane > .cap.hero-echo { position: absolute; left: 0; right: 0; top: 0; }
|
||||
|
||||
.plane { position: absolute; pointer-events: none; }
|
||||
.plane > .cap {
|
||||
position: relative;
|
||||
top: auto; left: auto; right: auto; bottom: auto;
|
||||
}
|
||||
|
||||
{{DNA_CSS}}
|
||||
|
||||
{{PLANES_CSS}}
|
||||
|
||||
/* Per-group typography (agent-authored). */
|
||||
{{CUSTOM_CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-width="{{WIDTH}}"
|
||||
data-height="{{HEIGHT}}"
|
||||
>
|
||||
<video
|
||||
id="a-roll"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<div id="stage">{{GROUPS_HTML}}</div>
|
||||
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="{{DURATION}}"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = {{DURATION}};
|
||||
const GROUPS = {{GROUPS_JSON}};
|
||||
const MOTION = {{MOTION_JSON}};
|
||||
const HEROES = {{HEROES_JSON}};
|
||||
const HERO_IDS = {};
|
||||
HEROES.forEach(function (h) { HERO_IDS[h.id] = true; });
|
||||
|
||||
/* ── word entrance: the DNA's motion grammar ─────────────────────────
|
||||
Each tone = {y, x, blur, scale, rot, dur, ease} or {reveal:"burn"}.
|
||||
rot alternates sign by word index (deterministic percussion); blur is a
|
||||
filter tween on the word span only (the container's depth-match filter
|
||||
is a separate element — no conflict, no reflow). */
|
||||
function wordReveal(wsel, m, at, idx) {
|
||||
if (m.reveal === "burn") { tl.set(wsel, { opacity: 1 }, at); return; }
|
||||
if (m.reveal === "flicker") {
|
||||
/* neon tube igniting — deterministic on/off pattern, phase-shifted by word
|
||||
index so a line of words doesn't strobe in unison */
|
||||
var o = (idx % 3) * 0.018;
|
||||
tl.set(wsel, { opacity: 1 }, at);
|
||||
tl.set(wsel, { opacity: 0.25 }, at + 0.042 + o);
|
||||
tl.set(wsel, { opacity: 1 }, at + 0.078 + o);
|
||||
tl.set(wsel, { opacity: 0.5 }, at + 0.112 + o);
|
||||
tl.set(wsel, { opacity: 1 }, at + 0.152 + o);
|
||||
return;
|
||||
}
|
||||
var from = { opacity: 0 }, to = { opacity: 1, duration: m.dur || 0.4,
|
||||
ease: m.ease || "power2.out", overwrite: "auto", transformOrigin: "50% 70%" };
|
||||
if (m.y) { from.y = m.y; to.y = 0; }
|
||||
if (m.x) { from.x = m.x; to.x = 0; }
|
||||
if (m.scale && m.scale !== 1) { from.scale = m.scale; to.scale = 1; }
|
||||
if (m.stretchX && m.stretchX !== 1) { from.scaleX = m.stretchX; to.scaleX = 1; }
|
||||
if (m.skewX) { from.skewX = m.skewX; to.skewX = 0; }
|
||||
if (m.rot) { from.rotation = ((idx % 2) ? 1 : -1) * m.rot; to.rotation = 0; }
|
||||
if (m.blur) { from.filter = "blur(" + m.blur + "px)"; to.filter = "blur(0px)"; }
|
||||
tl.fromTo(wsel, from, to, at);
|
||||
}
|
||||
|
||||
var EXIT = MOTION.exit || { dur: 0.5, ease: "power2.in", stagger: 0 };
|
||||
var LINE_REVEAL = MOTION.lineReveal || null;
|
||||
|
||||
/* exit cascade: lines of the same plane sharing one page-flip `out` can leave
|
||||
top-to-bottom (editorial). Slot layout = absolute positions, so staggered
|
||||
exits cause no reflow. Every exit still hard-kills at g.out. */
|
||||
var pageMates = {};
|
||||
GROUPS.forEach(function (g) {
|
||||
if (HERO_IDS[g.id]) return;
|
||||
var key = (g.plane || "free") + "@" + g.out;
|
||||
(pageMates[key] = pageMates[key] || []).push(g.id);
|
||||
});
|
||||
|
||||
GROUPS.forEach(function (g) {
|
||||
if (HERO_IDS[g.id]) return; // heroes are orchestrated below
|
||||
var sel = "#" + g.id;
|
||||
var m = MOTION[g.tone] || MOTION.soft || {};
|
||||
|
||||
tl.set(sel, { opacity: 1 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
/* line container reveal (keynote's surgical wipe) — clip-path, not transform.
|
||||
PHRASE mode: the whole line is set visible up front and the WIPE is the
|
||||
reveal (slide-title idiom) — wiping an empty container reads as nothing. */
|
||||
if (LINE_REVEAL && LINE_REVEAL.type === "wipe-x") {
|
||||
tl.set(sel + " .w", { opacity: 1 }, Math.max(0, g.in - 0.01));
|
||||
tl.fromTo(sel, { clipPath: "inset(0% 100% 0% 0%)" },
|
||||
{ clipPath: "inset(0% 0% 0% 0%)", duration: LINE_REVEAL.dur || 0.4,
|
||||
ease: LINE_REVEAL.ease || "expo.out", overwrite: "auto" },
|
||||
Math.max(0, g.in - 0.01));
|
||||
} else {
|
||||
g.words.forEach(function (w, i) {
|
||||
wordReveal(sel + " .w[data-i='" + i + "']", m, w.start, i);
|
||||
});
|
||||
}
|
||||
|
||||
/* exit: DNA grammar — direction + speed + optional per-line cascade */
|
||||
var mates = pageMates[(g.plane || "free") + "@" + g.out];
|
||||
var rank = mates.indexOf(g.id);
|
||||
var lead = (EXIT.stagger || 0) * (mates.length - 1 - rank);
|
||||
var exitAt = Math.max(g.in + 0.1, g.out - (EXIT.dur || 0.5) - lead);
|
||||
var to = { opacity: 0, duration: EXIT.dur || 0.5, ease: EXIT.ease || "power2.in", overwrite: "auto" };
|
||||
if (EXIT.y) to.y = EXIT.y;
|
||||
if (EXIT.x) to.x = EXIT.x;
|
||||
if (EXIT.scale && EXIT.scale !== 1) to.scale = EXIT.scale;
|
||||
if (g.out < DUR) tl.to(sel, to, exitAt);
|
||||
tl.set(sel, { opacity: 0, display: "none" }, g.out);
|
||||
});
|
||||
|
||||
/* ── HEROES: three-act orchestration, one per promoted beat ──────────── */
|
||||
HEROES.forEach(function (HERO) {
|
||||
var hsel = "#" + HERO.id;
|
||||
var gsel = "#" + HERO.id + "-glow";
|
||||
var hg = GROUPS.filter(function (g) { return g.id === HERO.id; })[0];
|
||||
var amp = HERO.amp || 1;
|
||||
|
||||
/* act 1 — SETUP: co-visible groups yield (dim) just before the hero lands.
|
||||
Minor heroes are emphasis-plus: no dim, no ripple, no breathe — the apex
|
||||
keeps those privileges (that's what makes it the apex). */
|
||||
if (!HERO.minor && HERO.dimOthers < 1) {
|
||||
GROUPS.forEach(function (g) {
|
||||
if (g.id === HERO.id) return;
|
||||
if (g.in < HERO.in && g.out > HERO.in) {
|
||||
tl.to("#" + g.id, { opacity: HERO.dimOthers, duration: 0.3, ease: "power2.out", overwrite: "auto" },
|
||||
Math.max(0, HERO.in - 0.35));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tl.set(hsel, { opacity: 1 }, Math.max(0, HERO.in - 0.01));
|
||||
|
||||
/* act 2 — IMPACT: container entrance (amplitude ∝ spoken loudness) */
|
||||
if (HERO.entrance === "settle") {
|
||||
tl.fromTo(hsel, { scale: 1 + 0.10 * amp },
|
||||
{ scale: 1, duration: 0.50, ease: "expo.out", transformOrigin: "50% 60%", overwrite: "auto" }, HERO.in);
|
||||
} else if (HERO.entrance === "slam") {
|
||||
/* percussion: ONE unit, violent snap — geometry locks in 0.16s; heat comes after */
|
||||
tl.fromTo(hsel, { scale: 1 + 0.22 * amp },
|
||||
{ scale: 1, duration: 0.16, ease: "back.out(2.2)", transformOrigin: "50% 55%", overwrite: "auto" }, HERO.in);
|
||||
} else if (HERO.entrance === "rise") {
|
||||
tl.fromTo(hsel, { y: 22 * amp },
|
||||
{ y: 0, duration: 0.60, ease: "power2.out", overwrite: "auto" }, HERO.in);
|
||||
} else if (HERO.entrance === "wipe-up") {
|
||||
/* surgical slide-title reveal — clip-path only (not a transform: can't
|
||||
collide with the afterglow scale channel), bottom-up behind the subject */
|
||||
tl.fromTo(hsel, { clipPath: "inset(100% 0% 0% 0%)" },
|
||||
{ clipPath: "inset(0% 0% 0% 0%)", duration: 0.45, ease: "expo.out", overwrite: "auto" }, HERO.in);
|
||||
} else if (HERO.entrance === "flicker-on") {
|
||||
/* neon sign powering up — deterministic strobe, then steady */
|
||||
tl.set(hsel, { opacity: 1 }, HERO.in);
|
||||
tl.set(hsel, { opacity: 0.15 }, HERO.in + 0.05);
|
||||
tl.set(hsel, { opacity: 1 }, HERO.in + 0.095);
|
||||
tl.set(hsel, { opacity: 0.4 }, HERO.in + 0.14);
|
||||
tl.set(hsel, { opacity: 1 }, HERO.in + 0.19);
|
||||
tl.fromTo(hsel, { scale: 1.03 }, { scale: 1, duration: 0.4, ease: "power2.out", overwrite: "auto", transformOrigin: "50% 55%" }, HERO.in + 0.19);
|
||||
} else if (HERO.entrance === "streak") {
|
||||
/* speed pass — slides in along its own motion blur (skew rides the velocity) */
|
||||
tl.fromTo(hsel, { x: -90 * amp, skewX: -12, opacity: 0 },
|
||||
{ x: 0, skewX: 0, opacity: 1, duration: 0.28, ease: "expo.out", overwrite: "auto", transformOrigin: "50% 80%" }, HERO.in);
|
||||
} else { /* emergence — grows out from behind the subject */
|
||||
tl.fromTo(hsel, { scale: Math.max(0.74, 1 - 0.18 * amp) },
|
||||
{ scale: 1, duration: 0.55, ease: "expo.out", transformOrigin: "50% 62%", overwrite: "auto" }, HERO.in);
|
||||
}
|
||||
|
||||
/* impact ripple (loud): the hero's landing bumps every co-visible caption —
|
||||
captions only, the footage itself NEVER moves */
|
||||
if (!HERO.minor && HERO.ripple > 0) {
|
||||
GROUPS.forEach(function (g) {
|
||||
if (g.id === HERO.id) return;
|
||||
if (g.in < HERO.in && g.out > HERO.in + 0.3) {
|
||||
tl.to("#" + g.id, { y: HERO.ripple, duration: 0.07, ease: "power2.out", overwrite: "auto" }, HERO.in + 0.04);
|
||||
tl.to("#" + g.id, { y: 0, duration: 0.20, ease: "power2.out", overwrite: "auto" }, HERO.in + 0.11);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* words / letters */
|
||||
(hg ? hg.words : []).forEach(function (w, i) {
|
||||
var wsel = hsel + " .w[data-i='" + i + "']";
|
||||
if (HERO.perLetter) {
|
||||
tl.set(wsel, { opacity: 1 }, w.start);
|
||||
var letters = document.querySelectorAll(wsel + " .l");
|
||||
for (var li = 0; li < letters.length; li++) {
|
||||
var lf = { opacity: 0, y: "0.16em", scale: 1.05 };
|
||||
var lt = { opacity: 1, y: 0, scale: 1, duration: 0.30, ease: "power4.out", overwrite: "auto" };
|
||||
if (HERO.letterBlur) { lf.filter = "blur(" + HERO.letterBlur + "px)"; lt.filter = "blur(0px)"; }
|
||||
tl.fromTo(letters[li], lf, lt, w.start + li * (HERO.letterStagger || 0.014));
|
||||
}
|
||||
} else {
|
||||
var m = MOTION.impact || MOTION.present || {};
|
||||
wordReveal(wsel, m, w.start, i);
|
||||
}
|
||||
});
|
||||
|
||||
/* glow layer (DNA-gated). Slam = impact THEN heat: glow waits for geometry lock. */
|
||||
if (HERO.glow > 0) {
|
||||
var glowAt = HERO.in + (HERO.entrance === "slam" ? 0.17 : 0.08);
|
||||
var glowPeak = Math.min(0.5, HERO.glow * amp) * (HERO.minor ? 0.5 : 1);
|
||||
tl.set(gsel + " .w", { opacity: 1 }, Math.max(0, HERO.in - 0.01));
|
||||
tl.fromTo(gsel, { opacity: 0 },
|
||||
{ opacity: glowPeak, duration: HERO.entrance === "slam" ? 0.3 : 0.5, ease: "power2.out", overwrite: "auto" },
|
||||
glowAt);
|
||||
tl.to(gsel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" }, HERO.out - 0.5);
|
||||
tl.set(gsel, { opacity: 0, display: "none" }, HERO.out);
|
||||
}
|
||||
|
||||
/* fx: ECHO layers (RGB-split fringe / speed trails) — colored duplicates that
|
||||
CONVERGE as the word lands, then rest at a small offset until exit */
|
||||
if (HERO.echoes && HERO.echoes.length) {
|
||||
HERO.echoes.forEach(function (ec, k) {
|
||||
var esel = "#" + HERO.id + "-echo-" + k;
|
||||
tl.set(esel + " .w", { opacity: 1 }, Math.max(0, HERO.in - 0.01));
|
||||
tl.fromTo(esel,
|
||||
{ x: (ec.dx || 0) * 4, y: (ec.dy || 0) * 4, opacity: 0 },
|
||||
{ x: ec.dx || 0, y: ec.dy || 0, opacity: ec.opacity != null ? ec.opacity : 0.5,
|
||||
duration: 0.22, ease: "power4.out", overwrite: "auto" }, HERO.in);
|
||||
tl.to(esel, { opacity: 0, duration: 0.4, ease: "power2.in", overwrite: "auto" }, HERO.out - 0.45);
|
||||
tl.set(esel, { opacity: 0, display: "none" }, HERO.out);
|
||||
});
|
||||
}
|
||||
|
||||
/* fx: SHEEN — one gradient sweep across the hero during the hold (chrome).
|
||||
Targets the .w spans (the gradient lives there — see wordCss). Starts as the
|
||||
entrance settles so the specular hit is visible early in the hold. */
|
||||
if (HERO.sheen && HERO.out - HERO.in > 0.9) {
|
||||
tl.fromTo(hsel + " .w", { backgroundPosition: "0% 50%" },
|
||||
{ backgroundPosition: "200% 50%", duration: Math.min(1.2, HERO.out - HERO.in - 0.5),
|
||||
ease: "power1.inOut", overwrite: "auto" }, HERO.in + 0.22);
|
||||
}
|
||||
|
||||
/* act 3 — AFTERGLOW: ONE slow scale tween (no loops — deterministic).
|
||||
loom (slow growth, loud) subsumes breathe — never two transform tweens
|
||||
on the same element in the same window. wipe-up entrances are clip-path,
|
||||
so the afterglow scale channel is always free by now. */
|
||||
var after = HERO.loom > 0 ? HERO.loom : HERO.breathe;
|
||||
if (!HERO.minor && after > 0 && HERO.out - HERO.in > 1.4) {
|
||||
tl.fromTo(hsel, { scale: 1 },
|
||||
{ scale: 1 + after, duration: HERO.out - HERO.in - 1.2, ease: HERO.loom > 0 ? "power1.inOut" : "sine.inOut", overwrite: "auto" },
|
||||
HERO.in + 0.7);
|
||||
}
|
||||
|
||||
/* exit */
|
||||
if (HERO.out < DUR) {
|
||||
tl.to(hsel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" }, HERO.out - 0.5);
|
||||
}
|
||||
tl.set(hsel, { opacity: 0, display: "none" }, HERO.out);
|
||||
});
|
||||
|
||||
tl.seek(0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: caption-template-anatomy
|
||||
description: The shared, reproducible scene engine every caption template is built on — a matted talking-head with a flowing verbatim foreground caption and a single climax word, driven by one paused GSAP timeline. Read it ONCE per session (it is identical for all 54 templates); each per-template file in templates/ only overrides the style tokens + the named climax entrance/exit.
|
||||
metadata:
|
||||
tags: caption, talking-head, matte, occlusion, verbatim, climax, gsap, hyperframes
|
||||
---
|
||||
|
||||
# Caption Template — Anatomy (the shared engine)
|
||||
|
||||
A **caption template** = one complete, reproducible HyperFrames scene:
|
||||
|
||||
> **the person (always in frame)** + a **flowing foreground caption** (verbatim, word-by-word, with appear **and** disappear) + a **climax word** (big, behind the speaker, with a designed entrance **and** exit) + a coherent **font / colour / motion** design.
|
||||
|
||||
Every file in `templates/` is the SAME engine described here with three things swapped: (1) the **style tokens** (font, fills, accent, optional gradient/stroke), (2) the named **climax entrance** + **exit** (see `_motion.md`), (3) the **copy** (flow lines + climax word) and **scene/person**. So read this once; each template file is short.
|
||||
|
||||
HyperFrames-native, so anyone can reproduce it:
|
||||
|
||||
- **One paused GSAP timeline per composition**, registered to `window.__timelines[data-composition-id]`.
|
||||
- All timing in **seconds**; `data-start` / `data-duration` carry the scene window.
|
||||
- **Deterministic + seek-safe** only — no `Math.random()`, no `Date.now()`, no infinite repeats, no un-seekable CSS animations. Every state is reachable by seeking the timeline to a time `t`.
|
||||
- GSAP transform aliases (`x`, `y`, `scale`, `rotation`); animate `opacity`, `filter`, `clipPath`, `textShadow`, `backgroundPosition`, `letterSpacing` — never layout props (width/top/left/margin).
|
||||
|
||||
For the composition contract see `hyperframes-core`; eases + the animated-property allowlist see `hyperframes-gsap`; caption grouping/positioning/exit guarantees see `hyperframes-captions`.
|
||||
|
||||
## 1 · Asset prep (two CLI calls)
|
||||
|
||||
```bash
|
||||
# 1) The person — transparent talking-head cutout over the scene (VP9 + alpha)
|
||||
bash scripts/prepare.sh <project> # matte ∥ transcribe ∥ safe-zones (THIS skill — not remove-background)
|
||||
# (a still works too: remove-background portrait.jpg -o person.png)
|
||||
|
||||
# 2) The verbatim word timings that drive the flowing caption
|
||||
npx hyperframes transcribe subject.mp4 --model small # → transcript.json
|
||||
# shape: [{ "id":"w0","text":"Hello","start":0.0,"end":0.5 }, …]
|
||||
```
|
||||
|
||||
The flow caption consumes that `transcript.json` directly (word `start`/`end` → reveal + active-word emphasis). The climax word is authored by hand (it is the headline beat, not part of the spoken transcript). In production the avatar/matte pipeline yields a pixel-perfect alpha for free — `remove-background` is the fallback for arbitrary footage.
|
||||
|
||||
## 2 · The matte sandwich (HTML)
|
||||
|
||||
Six layers, back-to-front. The person is layered ON TOP of the climax so they physically **occlude** it — this is what sells "behind the speaker" (blur/opacity alone reads as a flat overlay).
|
||||
|
||||
```html
|
||||
<div
|
||||
class="stage {STYLE} {PERSON}"
|
||||
id="cap-{id}"
|
||||
data-composition-id="cap-{id}"
|
||||
data-start="0"
|
||||
data-duration="{SCENE_DUR}"
|
||||
data-track-index="0"
|
||||
>
|
||||
<!-- z0 background plate (the original frame, full) -->
|
||||
<!-- supplied by .{PERSON} as background-image, or a <video> at z-index:0 -->
|
||||
|
||||
<!-- z1 CLIMAX — big, BEHIND the person, occluded -->
|
||||
<div class="climax"><span>{CLIMAX_WORD}</span></div>
|
||||
|
||||
<!-- z4 the person cutout (transparent webm/png), aligned to the plate -->
|
||||
<video class="cut" src="person.webm" muted playsinline></video>
|
||||
<!-- or: <img class="cut" src="person.png"> -->
|
||||
|
||||
<!-- z5 grade / vignette for depth + legibility -->
|
||||
<div class="grade"></div>
|
||||
|
||||
<!-- z6 FLOW — the verbatim caption, IN FRONT, lower third -->
|
||||
<div class="flow"></div>
|
||||
<!-- words injected from transcript.json -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## 3 · Base CSS (layout · z-order · sizing)
|
||||
|
||||
Caption size is in **`cqh`** (% of frame height) via a size container, so it honours broadcast spec at any resolution (~8% cap-height for a 16:9 word). The per-template file only sets the `--ff` / `--cfill` / `--cacc` tokens and any `.climax span` fill (gradient/stroke).
|
||||
|
||||
```css
|
||||
.stage {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
container-type: size;
|
||||
background-size: cover;
|
||||
background-position: center 12%;
|
||||
background-color: #0a0a0e;
|
||||
font-family: var(--ff);
|
||||
}
|
||||
.stage > * {
|
||||
position: absolute;
|
||||
}
|
||||
.cut {
|
||||
z-index: 4;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 12%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.grade {
|
||||
z-index: 5;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(130% 100% at 50% 26%, transparent 40%, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
|
||||
/* CLIMAX — big, behind person (z1), occluded. line-height ≥1.15 so clip-reveal
|
||||
entrances (inset(0)) never slice glyph tops; pad clips negative for script faces. */
|
||||
.climax {
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
top: 37%;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
line-height: 1.18;
|
||||
font-family: var(--ff);
|
||||
color: var(--cfill);
|
||||
font-weight: 900;
|
||||
font-size: 44cqh;
|
||||
text-transform: uppercase;
|
||||
text-shadow:
|
||||
0 2px 13px rgba(0, 0, 0, 0.6),
|
||||
0 0 48px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
.climax span {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
} /* GSAP reveals it */
|
||||
|
||||
/* FLOW — verbatim caption, in front (z6), lower third */
|
||||
.flow {
|
||||
z-index: 6;
|
||||
left: 50%;
|
||||
bottom: 9%;
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
line-height: 1.15;
|
||||
font-family: var(--ff);
|
||||
font-weight: 700;
|
||||
font-size: 7.5cqh;
|
||||
color: var(--cfill);
|
||||
}
|
||||
.flow .w {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
margin: 0 0.1em;
|
||||
color: var(--cfill);
|
||||
}
|
||||
.flow .w.act {
|
||||
color: var(--cacc);
|
||||
} /* the currently-spoken word */
|
||||
|
||||
/* tokens every template overrides: */
|
||||
.stage {
|
||||
--ff: "Inter";
|
||||
--cfill: #fff;
|
||||
--cacc: #10a37f;
|
||||
}
|
||||
```
|
||||
|
||||
**Legibility on busy/bright scenes:** a behind-the-person climax needs separation from the footage, not just a fill colour. For dark or gradient fills on lit scenes give the climax an outline — `-webkit-text-stroke:1px rgba(0,0,0,.5);paint-order:stroke fill` — a dark drop-shadow alone fails against highlights (e.g. a lamp). Gradient/clip fills must live on `.climax span` (the text node), never on the transformed `.climax` container, or the clip detaches and only the shadow shows.
|
||||
|
||||
## 4 · One paused GSAP timeline (the loop, made seek-safe)
|
||||
|
||||
The gallery used a `setInterval` loop; HyperFrames needs the same beats as **absolute-time tweens on one paused timeline**. The cycle is **FLOW line → (FLOW line) → CLIMAX in → hold → out**. Restraint is the rule: flow stays clean; the one big mood move happens only at the climax.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const stage = document.getElementById("cap-{id}");
|
||||
const flow = stage.querySelector(".flow");
|
||||
const climax = stage.querySelector(".climax span");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// --- FLOW: render words from transcript.json, reveal each at its start time ---
|
||||
// WORDS = grouped transcript lines: [{ words:[{text,start,end}], end }] in scene-local seconds.
|
||||
function renderLine(line) {
|
||||
flow.innerHTML = line.words
|
||||
.map((w, i) => `<span class="w" data-i="${i}">${w.text}</span>`)
|
||||
.join(" ");
|
||||
return [...flow.querySelectorAll(".w")];
|
||||
}
|
||||
WORDS.forEach((line) => {
|
||||
const spans = renderLine(line); // (one renderLine per active line; see captions skill for multi-line groups)
|
||||
spans.forEach((el, i) => {
|
||||
const w = line.words[i];
|
||||
tl.add(FLOW_IN(el), w.start); // FLOW_IN = the template's flow entrance (see _motion.md)
|
||||
tl.set(spans, { className: "w" }, w.start); // active-word sweep: only the spoken word gets .act
|
||||
tl.set(el, { className: "w act" }, w.start);
|
||||
});
|
||||
tl.add(FLOW_OUT(spans), line.end); // FLOW_OUT = flow exit
|
||||
tl.set(flow, { autoAlpha: 0 }, line.end + FOUT); // hard-hide the group so old text can't linger
|
||||
tl.set(flow, { autoAlpha: 1 }, line.end + FOUT + 0.001);
|
||||
});
|
||||
|
||||
// --- CLIMAX: entrance at the beat → hold ≥1s → exit (named recipes from _motion.md) ---
|
||||
const T = CLIMAX_AT; // beat time (after the flow lines)
|
||||
tl.add(CLIMAX_IN(climax), T); // e.g. SLAM / DEBLUR / INK-LOOM / CHROME-SWEEP …
|
||||
tl.add(CLIMAX_OUT(climax), T + CLIMAX_HOLD); // hold ≥1s, then exit. CLIMAX_OUT ends opacity:0 (hard exit)
|
||||
|
||||
window.__timelines["cap-{id}"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
`FLOW_IN` / `FLOW_OUT` / `CLIMAX_IN` / `CLIMAX_OUT` are the named recipes in **`_motion.md`** — each returns a GSAP tween/timeline so the per-template file just picks four names. A simpler equivalent for the flow active-word _glow_ (rather than discrete reveal) is the single-driver envelope in `hyperframes-animation/rules/asr-keyword-glow.md`.
|
||||
|
||||
## 5 · How to choose values
|
||||
|
||||
- **SCENE_DUR** — must equal `data-duration`. Typical 6–10 s for a looping demo card.
|
||||
- **WORDS grouping** — 2–4 words / line, ~380–520 ms per word (premium pacing is slower than Hormozi). Group from `transcript.json`; keep `end < next.start` (monotonic).
|
||||
- **CLIMAX_AT** — place the climax _after_ the flow lines clear, on the narration's emphasis beat.
|
||||
- **CLIMAX_HOLD** — **≥1 s** of settled dwell _after_ the entrance finishes (the climax is the headline beat). Entrances run 0.6–1.6 s, so e.g. hold = entranceDur + 1.0–1.6.
|
||||
- **FOUT** — flow exit ≈ 0.5 s. **Exit ≈ 75 % of entry** for every element (arrival deliberate, departure swift; see `_motion.md`).
|
||||
- **Climax size** — base 44 cqh; long words bleed off-frame (intended cinematic); 3-char words behind a centred subject need size + an outline so they peek.
|
||||
|
||||
## Critical constraints (HyperFrames)
|
||||
|
||||
- Timeline **paused**; registry key = `data-composition-id`.
|
||||
- **No CSS keyframe animation** on caption elements — all motion is GSAP tweens at absolute times (seek-safe).
|
||||
- No `Math.random` / `Date.now` / infinite repeats.
|
||||
- `display:inline-block` on every `.w` and the climax `span`.
|
||||
- **Hard-hide** each flow group at its end time; **CLIMAX_OUT ends at `opacity:0`** (or fully-clipped) so nothing lingers.
|
||||
- Gradient / `background-clip:text` / stroke fills go on `.climax span`, not the transformed `.climax`.
|
||||
- `.climax` `line-height ≥ 1.15`; pad clip-reveal entrances with negative insets for script/decorative fonts.
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `media-use` — `remove-background` (the matte) + `transcribe` (word timings).
|
||||
- `hyperframes-captions` — transcript consumption, grouping, positioning, exit guarantees, `fitTextFontSize`.
|
||||
- `hyperframes-animation/rules/asr-keyword-glow.md` — the verbatim active-word envelope.
|
||||
- `hyperframes-gsap` — single paused timeline, transform aliases, ease palette.
|
||||
- `_motion.md` (this folder) — the named flow/climax entrance + exit recipes.
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
name: caption-template-motion
|
||||
description: The named flow + climax entrance/exit recipes (GSAP, seek-safe) that the per-template files reference, plus the motion numerics (ease palette, exit=75% entry, climax dwell, restraint, mood→motion). Every template picks a FLOW_IN/OUT and a CLIMAX_IN/OUT from here — look up only the 2-3 recipes a template names (grep the `### name`), this is a catalog, not a read-through.
|
||||
metadata:
|
||||
tags: gsap, easing, entrance, exit, timing, restraint, mood-matching, captions
|
||||
---
|
||||
|
||||
# Caption Template — Motion Language
|
||||
|
||||
`_anatomy.md` wires four hooks onto one paused timeline: `FLOW_IN`, `FLOW_OUT`, `CLIMAX_IN`, `CLIMAX_OUT`. This file is the catalog each template picks from. Each recipe is a GSAP tween on the climax `span` (or a flow `.w`); placed at an absolute time it is fully **seek-safe**. Multi-stage looks use GSAP `keyframes:{}` (still seek-safe) — never CSS keyframes.
|
||||
|
||||
## Numerics (shared)
|
||||
|
||||
**Ease palette** (sanctioned — reads clean on video, stays distinct):
|
||||
|
||||
| Curve | GSAP | Use |
|
||||
| --------------- | --------------------------------------- | --------------------------------------------------- |
|
||||
| overshoot entry | `back.out(1.4–1.7)` | confident arrivals (premium/epic/creator) |
|
||||
| heavy/refined | `power3.out` | hero/secondary entrances, slides |
|
||||
| general | `power2.out` | fades, scales |
|
||||
| exit accel | `power2.in` | the _exit_ side of any move (departure accelerates) |
|
||||
| snappy | `expo.out` | warp/fly/sonic confident snaps |
|
||||
| breathing | `sine.inOut` | gentle wellness drift |
|
||||
| impact thud | `back.out(1.5–2)` or a quick `expo.out` | slam/stomp/stamp landings |
|
||||
| linear driver | `none` | the asr active-word envelope driver only |
|
||||
| digital stutter | `steps(n)` | glitch/vhs/blink/possess/cut |
|
||||
|
||||
**Forbidden:** `bounce.out`, `elastic.out` everywhere **except** the Playful cluster (explicit playfulness — KIDS drop-bounce, CANDY jelly). Elsewhere real objects decelerate; they don't bounce. Use low `back.out` for sanctioned overshoot.
|
||||
|
||||
**Durations** — flow word enter 0.30–0.50 s · climax entrance 0.6–1.6 s · **exit ≈ 75 % of entry** (arrival deliberate, departure swift) · stagger 50–150 ms / word.
|
||||
|
||||
**Restraint (the rule that makes it premium):** the FLOW stays clean — a tasteful reveal + the active-word accent, no effects. The big mood move + any scene effect (flash, shockwave, shake) happens **only at the CLIMAX**, then clears. Never sprinkle effects across the flow.
|
||||
|
||||
**Climax dwell ≥ 1 s** after the entrance finishes — the climax is the headline beat.
|
||||
|
||||
**Mood → motion** (pick an entrance whose physics match the theme):
|
||||
|
||||
| Mood | Entrances |
|
||||
| ------------------------------- | ---------------------------------------------------- |
|
||||
| premium | deblur · rise · expose · breathe · flip |
|
||||
| epic | slam · monument · fly · grandrise |
|
||||
| cyber | glitch · type · scan · boot |
|
||||
| horror | loom · possess · glimpse · seep (never bouncy/clean) |
|
||||
| luxury | tracking-expand · hairrise |
|
||||
| retro | vhs · stamp · blink · type |
|
||||
| neon | ignite · buzz |
|
||||
| hype | stomp · punch · slap |
|
||||
| playful | jelly · dropb · scrawl · popr |
|
||||
| creator | boxpop · slideup · flip · shimmer |
|
||||
| ultra (flash in the type) | volt · hyper · liquid · prism · shatter · extrude |
|
||||
| atelier (design) | editwipe · rise · block · vert · ink · weight |
|
||||
| impact (scene effect at climax) | nuke · meteor · sonic · seismic · judge |
|
||||
|
||||
---
|
||||
|
||||
## FLOW entrances / exits (per word `.w`)
|
||||
|
||||
The flow caption reveals word-by-word from the transcript. The active word gets `.act` (→ `--cacc`). Exit hard-hides the group.
|
||||
|
||||
- **fade-up** (premium/atelier default) — `fromTo(w,{opacity:0,y:14},{opacity:1,y:0,duration:.42,ease:'power3.out'})`
|
||||
- **pop** (epic/hype) — `fromTo(w,{opacity:0,scale:.5},{opacity:1,scale:1,duration:.34,ease:'back.out(1.6)'})`
|
||||
- **whip** (kinetic) — `fromTo(w,{opacity:0,x:-30,filter:'blur(10px)'},{opacity:1,x:0,filter:'blur(0)',duration:.32,ease:'expo.out'})`
|
||||
- **glitch** (cyber) — `fromTo(w,{opacity:0,clipPath:'inset(45% 0 45% 0)'},{opacity:1,clipPath:'inset(0)',duration:.4,ease:'steps(6)'})`
|
||||
- **type** (terminal/retro) — `fromTo(w,{opacity:0},{opacity:1,duration:.2,ease:'steps(3)'})` staggered as the cadence
|
||||
- **blur-in** (horror) — `fromTo(w,{opacity:0,filter:'blur(10px)',y:'.1em'},{opacity:1,filter:'blur(0)',y:0,duration:.6,ease:'power2.out'})`
|
||||
- **karaoke** (creator) — words start visible-dim; on `w.start` `set(w,{className:'w act'})` (active → `--cacc`), prior word back to spoken. The signature verbatim mechanic.
|
||||
- **flow exits** — fade-up-out `to{opacity:0,y:-10}` · horror smear `to{opacity:0,x:6,skewX:10,filter:'blur(5px)'}` · all ≈0.4–0.55 s.
|
||||
|
||||
---
|
||||
|
||||
## CLIMAX entrances
|
||||
|
||||
Each is a `gsap.fromTo(span, {from}, {to, duration, ease})`. Where a `keyframes:{}` is shown, pass it as the 2nd arg's `keyframes`.
|
||||
|
||||
### premium
|
||||
|
||||
- **deblur** — `{opacity:0,scale:.96,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.8,ease:'power3.out'}`
|
||||
- **rise** — `{opacity:0,yPercent:48}→{opacity:1,yPercent:0,duration:.9,ease:'power3.out'}`
|
||||
- **expose** — `{opacity:0,filter:'brightness(3.2) blur(5px)',scale:1.05}→{opacity:1,filter:'brightness(1) blur(0)',scale:1,duration:1,ease:'power2.out'}`
|
||||
- **breathe** — `{opacity:0,scale:1.09,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:1.2,ease:'sine.out'}`
|
||||
- **flip** — `{opacity:0,rotationX:93,transformPerspective:720}→{opacity:1,rotationX:0,duration:.9,ease:'power3.out'}`
|
||||
|
||||
### epic
|
||||
|
||||
- **slam** — `{opacity:0,scale:1.6,filter:'blur(12px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.7,ease:'back.out(1.6)'}` (lands with a thud; overshoot ~1.03 mid)
|
||||
- **monument** — `{opacity:0,scale:1.42,letterSpacing:'.12em'}→{opacity:1,scale:1,letterSpacing:'0',duration:1.3,ease:'power3.out'}`
|
||||
- **fly** — `{opacity:0,scale:.3,filter:'blur(14px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.8,ease:'expo.out'}`
|
||||
- **grandrise** — `{opacity:0,yPercent:66,scale:1.05}→{opacity:1,yPercent:0,scale:1,duration:1.15,ease:'power3.out'}`
|
||||
|
||||
### cyber
|
||||
|
||||
- **glitch** — `{opacity:0}→{opacity:1,duration:.6,ease:'steps(8)',keyframes:{clipPath:['inset(40% 0 40% 0)','inset(0)','inset(60% 0 10% 0)','inset(0)'],x:[-8,5,-2,0],textShadow:['4px 0 #ff003c,-4px 0 #00ffd1','none']}}`
|
||||
- **type** — `{opacity:1,clipPath:'inset(0 100% 0 0)'}→{clipPath:'inset(0 0 0 0)',duration:.85,ease:'steps(13)'}` (typewriter L→R)
|
||||
- **scan** — `{opacity:0,scaleY:.02,filter:'brightness(2.4)'}→{opacity:1,scaleY:1,filter:'brightness(1)',duration:.75,ease:'power3.out'}`
|
||||
- **boot** — `{opacity:0,scaleY:.1}→{opacity:1,scaleY:1,duration:1,ease:'steps(1)',keyframes:{opacity:[0,.6,.1,.85,.2,1]}}` (hologram power-up flicker)
|
||||
|
||||
### horror (never bouncy, never clean)
|
||||
|
||||
- **loom** — `{opacity:0,scale:.6,filter:'blur(17px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:1.6,ease:'power2.in',keyframes:{scale:[.6,.93,1.07,1]}}` (slowly approaches out of the dark, then a lurch closer)
|
||||
- **possess** — `{opacity:0}→{opacity:1,duration:1.15,ease:'steps(1)',keyframes:{x:[0,-5,6,-4,5,-2,0],skewX:[0,9,-10,7,-5,3,0],textShadow:['none','4px 0 #c00,-4px 0 #0aa','-6px 0 #c00,6px 0 #0aa','none']}}` (materialise then violent demonic shudder)
|
||||
- **glimpse** — `{opacity:0}→{opacity:1,duration:1.35,ease:'steps(1)',keyframes:{opacity:[0,1,0,0,1,0,0,1,.05,1,.15,1],x:[0,0,0,3,0,-2,0]}}` (subliminal failing-light flicker)
|
||||
- **seep** — `{opacity:0,clipPath:'inset(-15% -8% 100% -8%)',filter:'blur(4px)'}→{opacity:1,clipPath:'inset(-15% -8% -15% -8%)',filter:'blur(0)',duration:1.35,ease:'power1.inOut'}` (blood seeps down into the word; negative insets so script tops aren't clipped)
|
||||
|
||||
### luxury
|
||||
|
||||
- **tracking** — `{opacity:0,letterSpacing:'-.05em'}→{opacity:1,letterSpacing:'.28em',duration:1.4,ease:'power3.out'}` (the most "expensive" move)
|
||||
- **hairrise** — `{opacity:0,yPercent:32,letterSpacing:'.07em'}→{opacity:1,yPercent:0,letterSpacing:'0',duration:1.1,ease:'power3.out'}`
|
||||
|
||||
### retro
|
||||
|
||||
- **vhs** — `{opacity:0}→{opacity:1,duration:.8,ease:'steps(1)',keyframes:{x:[0,-7,6,-3,2,0],textShadow:['none','3px 0 #ff3b5c,-3px 0 #3aa0ff','none']}}` (tracking-lock jitter + chroma)
|
||||
- **stamp** — `{opacity:0,scale:1.7,rotation:-5}→{opacity:1,scale:1,rotation:0,duration:.7,ease:'back.out(2)'}` (printing-stamp thud)
|
||||
- **blink** — `{opacity:0}→{opacity:1,duration:.75,ease:'steps(1)',keyframes:{opacity:[0,0,1,0,1,0,1,1]}}` (8-bit blink-in)
|
||||
|
||||
### neon
|
||||
|
||||
- **ignite** — `{opacity:0}→{opacity:1,duration:1.3,ease:'steps(1)',keyframes:{opacity:[.2,1,.2,1,.3,1,1]}}` (tube strikes + buzzes alight; glow is a style `text-shadow`)
|
||||
- **buzz** — `{opacity:0}→{opacity:1,duration:1.2,ease:'steps(1)',keyframes:{opacity:[0,.7,.1,.85,.2,1,.5,1]}}` (slower warm-up rhythm)
|
||||
|
||||
### hype
|
||||
|
||||
- **stomp** — `{opacity:0,scale:1.5,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.9,ease:'back.out(1.6)'}`
|
||||
- **punch** — `{opacity:0,xPercent:-55,scale:1.1,filter:'blur(9px)'}→{opacity:1,xPercent:0,scale:1,filter:'blur(0)',duration:.6,ease:'expo.out'}` (whip from the side)
|
||||
- **slap** — `{opacity:0,rotation:12,scale:1.5,filter:'blur(7px)'}→{opacity:1,rotation:0,scale:1,filter:'blur(0)',duration:.7,ease:'back.out(2)'}` (sticker/tag slap)
|
||||
|
||||
### playful (bounce allowed here)
|
||||
|
||||
- **jelly** — `{opacity:0,scale:.5}→{opacity:1,scale:1,duration:.8,ease:'back.out(2.2)',keyframes:{scale:[.5,1.12,.92,1.04,1],skewX:[0,0,6,-4,0]}}`
|
||||
- **dropb** — `{opacity:0,yPercent:-85}→{opacity:1,yPercent:0,duration:.95,ease:'power2.out',keyframes:{yPercent:[-85,0,-22,0,-8,0]}}` (drop + multi-bounce). **❗opacity must reach 1 and stay** — declare it at the end of the keyframes.
|
||||
- **scrawl** — `{opacity:0,clipPath:'inset(-12% 100% -12% 0)',rotation:-3}→{opacity:1,clipPath:'inset(-12% 0 -12% 0)',rotation:0,duration:.8,ease:'power2.out'}` (handwritten write-on, for script faces)
|
||||
- **popr** — `{opacity:0,scale:.4,rotation:8}→{opacity:1,scale:1,rotation:0,duration:.6,ease:'back.out(2)'}`
|
||||
|
||||
### creator
|
||||
|
||||
- **boxpop** — `{opacity:0,scale:.5}→{opacity:1,scale:1,duration:.5,ease:'back.out(1.8)'}` (the active flow word's highlight box is a style)
|
||||
- **slideup** — `{opacity:0,yPercent:44}→{opacity:1,yPercent:0,duration:.55,ease:'power3.out'}`
|
||||
- **flip** — see premium
|
||||
- **shimmer** — `{opacity:0,scale:.7,filter:'brightness(2.2)'}→{opacity:1,scale:1,filter:'brightness(1)',duration:.75,ease:'power2.out'}`
|
||||
|
||||
### ultra (the flash lives in the type)
|
||||
|
||||
- **volt** — `{opacity:0}→{opacity:1,duration:1.1,ease:'steps(1)',keyframes:{opacity:[0,1,.12,1,.35,1],x:[0,0,0,2,-2,0]}}` then settle to glow `text-shadow:0 0 8px #fff,0 0 22px #43f4ff,0 0 48px #1e90ff` (electric strike)
|
||||
- **hyper** — `{opacity:0,scale:.05,filter:'blur(3px) brightness(3)'}→{opacity:1,scale:1,filter:'blur(0) brightness(1)',duration:.9,ease:'expo.out'}` (warp from depth)
|
||||
- **liquid** — `{opacity:0,scale:.9,yPercent:6,filter:'blur(10px)'}→{opacity:1,scale:1,yPercent:0,filter:'blur(0)',duration:1,ease:'power2.out'}` + the `.climax` _container_ carries SVG `filter:url(#liquid)`; in HF drive `feDisplacementMap@scale` from the timeline (don't use SMIL — not seek-safe)
|
||||
- **prism** — `{opacity:0,textShadow:'-38px 0 #ff003c,38px 0 #00ffd1',filter:'blur(5px)',scale:1.08}→{opacity:1,textShadow:'-3px 0 #ff003c,3px 0 #00ffd1',filter:'blur(0)',scale:1,duration:1,ease:'power3.out'}` (chromatic dispersion converge)
|
||||
- **shatter** — `{opacity:0,scale:1.9,rotation:-3,filter:'blur(14px)'}→{opacity:1,scale:1,rotation:0,filter:'blur(0)',duration:1,ease:'back.out(1.4)',keyframes:{scale:[1.9,.9,1.06,.98,1]}}` (+ a 1-frame white `text-shadow` flash on landing)
|
||||
- **extrude** — `{opacity:0,rotationY:42,scale:.9,transformPerspective:700}→{opacity:1,rotationY:-9,scale:1,duration:.9,ease:'power3.out'}` (3D turn; depth via stacked `text-shadow` in the style)
|
||||
|
||||
### atelier (design-forward)
|
||||
|
||||
- **editwipe** — `{opacity:1,clipPath:'inset(-15% 100% -15% 0)'}→{clipPath:'inset(-15% -4% -15% 0)',duration:.7,ease:'power4.inOut'}` (Swiss precise wipe; negative top/bottom so glyph tops aren't clipped)
|
||||
- **rise** — see premium (slower, `duration:.9`)
|
||||
- **block** — `{opacity:0,scaleY:0,transformOrigin:'bottom center'}→{opacity:1,scaleY:1,duration:.7,ease:'power3.out'}` (constructed/Bauhaus)
|
||||
- **vert** — `{opacity:0,clipPath:'inset(0 0 100% 0)'}→{opacity:1,clipPath:'inset(0 0 0 0)',duration:.9,ease:'power2.out'}` (vertical ink-drop; `writing-mode:vertical-rl` is a style, climax sits at `left:81%`)
|
||||
- **ink** — `{opacity:0,clipPath:'inset(-28% 100% -28% -4%)',filter:'blur(3px)'}→{opacity:1,clipPath:'inset(-28% -8% -28% -4%)',filter:'blur(0)',duration:.9,ease:'power2.out'}` (brush write-on; generous negative insets for the script face)
|
||||
- **weight** — `{opacity:0,fontVariationSettings:"'wght' 100",letterSpacing:'.18em'}→{opacity:1,fontVariationSettings:"'wght' 900",letterSpacing:'0',duration:1,ease:'power2.out'}` (variable-font morph; needs a variable face, e.g. Inter `wght@100..900`)
|
||||
|
||||
### impact (the ONE sanctioned scene effect, at the climax only)
|
||||
|
||||
Each pairs the type move with a scene element (`<div class="flash">` / `<div class="ring">`) and/or a stage shake. Keep it to the climax beat.
|
||||
|
||||
- **nuke** — type: `{opacity:0,scale:.3,filter:'brightness(8) blur(10px)'}→{opacity:1,scale:1,filter:'brightness(1) blur(0)',duration:1,ease:'expo.out'}` + white `.flash` `to{opacity:0}` over 0.3 s + brief stage `x` shake
|
||||
- **meteor** — `{opacity:0,yPercent:-45,scale:1.12,filter:'blur(12px) brightness(2.2)'}→{opacity:1,yPercent:0,scale:1,filter:'blur(0)',duration:1,ease:'power4.in'}` (accelerate DOWN) + on-land stage shake
|
||||
- **sonic** — `{opacity:0,scale:1.4,filter:'blur(8px)'}→{opacity:1,scale:1,filter:'blur(0)',duration:.9,ease:'expo.out'}` + a `.ring` scaling `0→3` opacity `1→0`
|
||||
- **seismic** — `{opacity:0,scale:1.1}→{opacity:1,scale:1,duration:.9,ease:'power3.out'}` + stage `x`/`y` quake `keyframes:[3,-3,2,-2,0]` decaying
|
||||
- **judge** — `{opacity:0,yPercent:-45,scale:1.12,filter:'blur(12px) brightness(2.2)'}→{opacity:1,yPercent:0,scale:1,filter:'blur(0)',duration:1,ease:'power3.out'}` (descends from above) + light-shaft `.flash`
|
||||
|
||||
---
|
||||
|
||||
## CLIMAX exits
|
||||
|
||||
Default is **fade** unless a template names another. **Every exit ends at `opacity:0`** (or fully-clipped) so nothing lingers — declare opacity:0 at the end even on transform/clip exits.
|
||||
|
||||
- **fade** — `to{opacity:0,scale:1.03,duration:.6,ease:'power2.in'}` (premium/epic/most defaults)
|
||||
- **rise-off** — `to{opacity:0,yPercent:-42,duration:.6}` · **sink** — `to{opacity:0,yPercent:16,scale:.97}` · **lift** — `to{opacity:0,yPercent:-26,letterSpacing:'.1em'}`
|
||||
- **expose-off** — `to{opacity:0,filter:'brightness(3.6) blur(4px)'}` · **breathe-off** — `to{opacity:0,scale:1.08,filter:'blur(7px)'}`
|
||||
- **flip-off** — `to{opacity:0,rotationX:-90}` · **fly-off** — `to{opacity:0,scale:2.5,filter:'blur(13px)'}`
|
||||
- **untype** — `to{clipPath:'inset(-15% 100% -15% 0)',opacity:0,ease:'steps(11)'}` (backspace) · **scan-collapse** — `to{opacity:0,scaleY:.02}` · **power-off** — `keyframes:{opacity:[1,.3,1,.1,.5,0]}` + scaleY collapse
|
||||
- **drag** (horror) — `to{opacity:0,xPercent:-40,skewX:-20,filter:'blur(11px)',duration:.55,ease:'power2.in'}` (dragged away)
|
||||
- **snap** (horror) — `keyframes:{x:[0,7,-6,0],skewX:[0,-13,0],scale:[1,1.1,1.16,1.32],opacity:[1,1,1,0],textShadow:['none','8px 0 #c00,-8px 0 #0aa','none','none']}` (violent jerk then gone)
|
||||
- **cut** (horror) — `keyframes:{opacity:[1,0,1,0,.8,0]},ease:'steps(1)'` (frame-drop) · **bleed** — `to{opacity:0,yPercent:50,scale:.97,filter:'blur(6px)'}` (drips down)
|
||||
- **knock** — `to{opacity:0,xPercent:55,filter:'blur(9px)'}` · **peel** — `to{opacity:0,rotation:14,xPercent:42,scale:.9}` · **spin-out** — `to{opacity:0,rotation:180,scale:.3}`
|
||||
- **hop** — `keyframes:{yPercent:[0,-26,120],opacity:[1,1,0]},scaleY:.7` · **popout** — `to{opacity:0,scale:.3,rotation:-10}` · **slidedown** — `to{opacity:0,yPercent:42}` · **scrawl-off** — `to{opacity:0,yPercent:-14,rotation:2}`
|
||||
- **vhs-out** — `keyframes:{x:[0,-9,8,0],opacity:[1,1,0]},scaleY:.2` (tracking-lost roll) · **blink-out** — `keyframes:{opacity:[1,0,1,0,1,0,0]},ease:'steps(1)'` · **power-down** — `keyframes:{opacity:[1,.2,.7,.1,0]},ease:'steps(2)'` · **lift2** — `to{opacity:0,scale:1.12,filter:'blur(3px)'}` (stamp lift) · **shim-out** — `to{opacity:0,scale:.8,filter:'brightness(1.9)'}`
|
||||
- **iris-close** — `to{opacity:0,clipPath:'circle(0% at 50% 46%)'}` (Bond) · **un-shine** — `to{opacity:0,clipPath:'inset(0 0 0 100%)',filter:'brightness(1.7)'}` · **sweep-off** — `to{opacity:1,clipPath:'inset(0 100% 0 0)'}`
|
||||
- ultra family exits: **glitch-out**, **neon-out**, **hyper-out** `to{opacity:0,scale:2.6,filter:'blur(13px)'}`, **liquid-out** `to{opacity:0,yPercent:40,scale:.96,filter:'blur(9px)'}`, **prism-out** (channels split apart), **shatter-out** `to{opacity:0,scale:1.45,filter:'blur(9px)',textShadow:'7px 0 #ff003c,-7px 0 #00ffd1'}`
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `hyperframes-gsap` — single paused timeline, transform aliases, ease list, the animated-property allowlist.
|
||||
- `_anatomy.md` — where these four hooks attach.
|
||||
- `hyperframes-animation/rules/asr-keyword-glow.md` — the verbatim active-word envelope for the flow.
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* build-fonts-css.cjs — bundle the template fonts into ONE self-contained
|
||||
* fonts.css with base64 data-URI @font-face (no sub-resources, no CDN, no
|
||||
* system-font dependency). This is what makes Standard/Cinematic renders
|
||||
* deterministic on ANY machine: hyperframes' renderer only auto-supplies its
|
||||
* ~18 CANONICAL_FONTS; every other family falls back to a generic font unless
|
||||
* the HTML ships a local @font-face. (hyperframes' own font linter says exactly
|
||||
* this: "Use local @font-face declarations with captured .woff2 files.")
|
||||
*
|
||||
* Source woff2 live in ./files/<slug>-latin-<weight>-normal.woff2 (latin subset
|
||||
* from @fontsource via jsdelivr). Re-run after adding/removing a font:
|
||||
* node build-fonts-css.cjs
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const FILES = path.join(__dirname, "files");
|
||||
const OUT = path.join(__dirname, "fonts.css");
|
||||
|
||||
// slug → exact CSS family name (must match what templates declare in font-family).
|
||||
const FAMILY = {
|
||||
anton: "Anton",
|
||||
audiowide: "Audiowide",
|
||||
inter: "Inter",
|
||||
"baloo-2": "Baloo 2",
|
||||
bangers: "Bangers",
|
||||
"bodoni-moda": "Bodoni Moda",
|
||||
caveat: "Caveat",
|
||||
cinzel: "Cinzel",
|
||||
"cormorant-garamond": "Cormorant Garamond",
|
||||
creepster: "Creepster",
|
||||
fredoka: "Fredoka",
|
||||
monoton: "Monoton",
|
||||
orbitron: "Orbitron",
|
||||
"permanent-marker": "Permanent Marker",
|
||||
"press-start-2p": "Press Start 2P",
|
||||
"saira-stencil-one": "Saira Stencil One",
|
||||
"shippori-mincho": "Shippori Mincho",
|
||||
rajdhani: "Rajdhani",
|
||||
"chakra-petch": "Chakra Petch",
|
||||
sora: "Sora",
|
||||
"space-grotesk": "Space Grotesk",
|
||||
"special-elite": "Special Elite",
|
||||
teko: "Teko",
|
||||
vt323: "VT323",
|
||||
};
|
||||
|
||||
const files = fs
|
||||
.readdirSync(FILES)
|
||||
.filter((f) => f.endsWith(".woff2"))
|
||||
.sort();
|
||||
const faces = [];
|
||||
let raw = 0;
|
||||
const seenFamilies = new Set();
|
||||
for (const f of files) {
|
||||
const m = f.match(/^(.*)-latin-(\d+)-(normal|italic)\.woff2$/);
|
||||
if (!m) {
|
||||
console.error(`[fonts] skip unrecognized filename: ${f}`);
|
||||
continue;
|
||||
}
|
||||
const slug = m[1],
|
||||
weight = m[2],
|
||||
style = m[3];
|
||||
const family = FAMILY[slug];
|
||||
if (!family) {
|
||||
console.error(`[fonts] no family mapping for slug "${slug}" (${f}) — add it to FAMILY`);
|
||||
process.exit(1);
|
||||
}
|
||||
const buf = fs.readFileSync(path.join(FILES, f));
|
||||
raw += buf.length;
|
||||
seenFamilies.add(family);
|
||||
faces.push(
|
||||
`@font-face {\n` +
|
||||
` font-family: '${family}';\n` +
|
||||
` font-style: ${style};\n` +
|
||||
` font-weight: ${weight};\n` +
|
||||
` font-display: block;\n` + // block (not swap): render text only once the real face is ready — measure-layout + capture see the true glyphs, never a fallback flash
|
||||
` src: url(data:font/woff2;base64,${buf.toString("base64")}) format('woff2');\n` +
|
||||
`}`,
|
||||
);
|
||||
}
|
||||
|
||||
const header =
|
||||
`/* AUTO-GENERATED by build-fonts-css.cjs — do not edit by hand.\n` +
|
||||
` ${faces.length} faces / ${seenFamilies.size} families, ${Math.round(raw / 1024)}KB raw woff2 (latin subset, base64-inlined).\n` +
|
||||
` Families: ${[...seenFamilies].sort().join(", ")}.\n` +
|
||||
` These are the template fonts NOT in hyperframes' auto-resolved set; inlining\n` +
|
||||
` them makes every render deterministic regardless of installed system fonts. */\n`;
|
||||
|
||||
fs.writeFileSync(OUT, header + "\n" + faces.join("\n\n") + "\n");
|
||||
const outKb = Math.round(fs.statSync(OUT).size / 1024);
|
||||
console.log(`[fonts] wrote ${OUT}`);
|
||||
console.log(
|
||||
`[fonts] ${faces.length} faces, ${seenFamilies.size} families, ${outKb}KB css (${Math.round(raw / 1024)}KB raw)`,
|
||||
);
|
||||
console.log(`[fonts] families: ${[...seenFamilies].sort().join(", ")}`);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user