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,168 @@
|
||||
# Aesthetic Principles for Cinematic Captions
|
||||
|
||||
The 18 rules that separate "designed" caption work from "preset-generated" caption work. This is the single most important reference in the skill — every plan.json and every Standard-mode HTML should be checked against these before committing.
|
||||
|
||||
The whole competitive thesis: every AI caption tool in 2026 (Veed, Submagic, Opus Clip, Captions.ai, CapCut) is a **preset picker**. They hand the user a box of crayons. This skill is a **director** — it exercises judgment. Beat them on taste, not feature count.
|
||||
|
||||
---
|
||||
|
||||
## The 18 rules
|
||||
|
||||
### 1. The subject always wins
|
||||
|
||||
Captions exist to serve the face, not compete with it. If the caption draws the eye before the speaker, the caption has failed. **Before shipping any plan.json, ask: "does the caption or the subject read first?"** If caption wins, shrink, dim, or move it.
|
||||
|
||||
### 2. Occlude, don't hover
|
||||
|
||||
World-class caption design puts text _into_ the scene. Letters that pass behind a shoulder, mic, or head feel diegetic. Letters that float uniformly above the lower-third feel like PowerPoint. Use the matte pipeline — that's the moat over every competitor.
|
||||
|
||||
### 3. Contrast is a hierarchy problem, not a brightness problem
|
||||
|
||||
A white box behind text is a failure of taste. Priority order:
|
||||
|
||||
1. `mix-blend-mode: overlay` or `screen` picking up scene luminance
|
||||
2. 2–3px dark stroke + soft drop shadow
|
||||
3. Narrow semi-opaque gradient bar (NOT a solid box)
|
||||
4. Dim the background plate by 10–15% locally
|
||||
5. (LAST RESORT) A hard white pill box — banned on cinematic directions
|
||||
|
||||
### 4. Kill the constant lower-third
|
||||
|
||||
Fixed-bottom captions are monotone. Caption zone shifts with shot:
|
||||
|
||||
- Tight close-up → upper sidebar or crown
|
||||
- Mid-shot → embedded on back wall / foam / whiteboard
|
||||
- Wide → classic lower-third offset to non-subject side
|
||||
|
||||
### 5. One family, two weights maximum
|
||||
|
||||
Hierarchy lives in **weight** (e.g., 500 → 800), not in **font**. Mixing Montserrat + script + serif in one clip is the #1 amateur tell. Ship Inter, SF Pro, Söhne, GT America, Aktiv Grotesk, or Neue Haas Grotesk with ≥5 weights and compose with weight + size.
|
||||
|
||||
### 6. Tracking tightens as size grows
|
||||
|
||||
Display-size (>40pt) wants negative tracking (-10 to -30 units, or `-0.015em` to `-0.035em`). Body-size (14–20pt equivalent) wants positive tracking (+5 to +15, or `+0.005em` to `+0.015em`). Apple SF Pro's optical-size model is the reference. Submagic defaults do the opposite — they look cheap because of it.
|
||||
|
||||
### 7. Cap height ≈ 3.5–5% of frame height for body captions
|
||||
|
||||
- 1080p vertical (1920 tall): body 65–95px, emph/hook 130–170px
|
||||
- 1080p landscape (1080 tall): body 40–55px, emph 70–100px
|
||||
- Hormozi-size (~7–9% of frame) is **hook** size, not body size. Reserve for punchlines.
|
||||
|
||||
### 8. Never italicize for emphasis in video text
|
||||
|
||||
Italics are a **print** convention for flow inside a paragraph. On 24fps motion they read as "tilted" not "stressed". Emphasize with weight (extrabold), color (single accent), or size (1.3–1.6×). Italics allowed only for:
|
||||
|
||||
- Literal quotation of written material
|
||||
- Foreign word
|
||||
- Thought vs spoken dichotomy
|
||||
|
||||
### 9. Color discipline: one hue + neutrals
|
||||
|
||||
Pick **one** saturated accent per video for keyword highlights. Hormozi's yellow+green+red works for him because his content is already loud. Cinematic = single accent + white/bone/charcoal. Default palette:
|
||||
|
||||
- Warm white `#F5EFE6` on dark
|
||||
- Graphite `#1A1A1A` on light
|
||||
- Accent chosen from scene sampling
|
||||
|
||||
### 10. Animate transform only, never the letter itself
|
||||
|
||||
`letter-spacing`, `filter:blur`, `font-weight` animations cause inline-block reflow → line-jumps. Animate `translateY`, `scale`, `opacity`, `clip-path`. This is locked from the embedded-captions debugging.
|
||||
|
||||
### 11. Minimum 0.4s per word visible
|
||||
|
||||
BBC reading speed is 160–180 wpm (0.33–0.38s/word). Cinematic feel wants more breathing room — 200–220ms stagger per word, hold full phrase 0.4–0.6s before exit. Entries under 150ms read as frantic.
|
||||
|
||||
### 12. Stagger is the primary expressive axis
|
||||
|
||||
Same phrase, different stagger, totally different feel:
|
||||
|
||||
| Stagger | Feel |
|
||||
| ------- | -------------------------------- |
|
||||
| 40ms | machine-gun, urgent, TikTok-hook |
|
||||
| 80ms | conversational, default |
|
||||
| 150ms | deliberate, documentary |
|
||||
| 250ms+ | poetic, ceremonial |
|
||||
|
||||
Pick from content tone, not default to one value.
|
||||
|
||||
### 13. Emphasis escalates _within_ a phrase, not _between_ phrases
|
||||
|
||||
Every word bolded = no emphasis. Structure:
|
||||
|
||||
- 70% plain body
|
||||
- 20% slight lift (color OR weight, not both)
|
||||
- 8% full emphasis (bigger, brighter, held longer)
|
||||
- 2% climax (biggest, held 1.5s, breath before + after)
|
||||
|
||||
### 14. Break rhythm once per 30s
|
||||
|
||||
Every-word-same-way = eye adapts, stops registering motion. Plant a rhythm-break every ~30s:
|
||||
|
||||
- Phrase enters from opposite direction
|
||||
- A single word at 2× size
|
||||
- A beat of pure silence with no caption
|
||||
- A color shift
|
||||
|
||||
This is what separates Submagic-preset work from something designed.
|
||||
|
||||
### 15. Caption what adds, cut what restates
|
||||
|
||||
Transcribe everything. **Display** 70–85%. Remove:
|
||||
|
||||
- Filler ("um", "like", "you know", "I mean")
|
||||
- Self-corrections ("I think... I mean actually...")
|
||||
- Obvious visual echoes ("as you can see here" while pointing)
|
||||
|
||||
Editorial judgment — no existing AI caption tool does this. It's pure upside.
|
||||
|
||||
### 16. Segment on breath, not on duration
|
||||
|
||||
Chunk at natural pauses ≥ 250ms. A caption spanning a breath-break feels wrong. Whisper word-level timestamps make this trivial.
|
||||
|
||||
### 17. Safe zones per platform, always
|
||||
|
||||
- 9:16 TikTok/IG/Shorts: caption zone `y ∈ [12%, 78%]` (bottom 22% is UI)
|
||||
- 16:9 broadcast: title-safe = center 80%
|
||||
- TV export: 5% margin on all sides
|
||||
|
||||
Bake into the layout solver. Never eyeball.
|
||||
|
||||
### 18. Letterbox/pillarbox is caption real estate
|
||||
|
||||
Black bars on 9:16 from 16:9 source? Those bars are the caption home. 2.35:1 cinematic frame on 16:9 export? Serif quotation in the letterbox reads as documentary.
|
||||
|
||||
---
|
||||
|
||||
## Rhetorical judgment rules (editorial)
|
||||
|
||||
These are the "what to caption" decisions. No existing tool exercises them.
|
||||
|
||||
- **Filler suppression** — off/light/strong. Default: light for documentary, strong for vlog.
|
||||
- **Self-correction folding** — display only the final version.
|
||||
- **Breath-group segmentation** — split on silences >250ms, never mid-phrase.
|
||||
- **Semantic emphasis** — LLM-pass per phrase: "which 1–2 words carry the meaning?" Highlight those. Don't default to stressed syllables or loudest words.
|
||||
- **Silence honor** — if speaker pauses 1.5s+ for rhetorical effect, don't back-fill with lingering prior caption. Let silence breathe.
|
||||
- **Quote-sensing** — "he said, quote…" or air quotes → italic (one of the allowed italic exceptions).
|
||||
- **No `[laughs]` / `[sighs]`** — those are accessibility captions. For aesthetic captions, they pollute the frame.
|
||||
|
||||
---
|
||||
|
||||
## Self-critique checklist (run before rendering)
|
||||
|
||||
The agent's own pre-render pass. Flag violations:
|
||||
|
||||
- [ ] Does the caption or subject read first? (Rule #1)
|
||||
- [ ] Any hard white pill boxes? (Rule #3 — banned on cinematic)
|
||||
- [ ] Still lower-third for a close-up? (Rule #4)
|
||||
- [ ] More than 2 weights or more than 1 font family? (Rule #5)
|
||||
- [ ] Italic used for emphasis? (Rule #8 — banned)
|
||||
- [ ] More than 1 saturated accent color? (Rule #9)
|
||||
- [ ] Letter-spacing or filter:blur animating? (Rule #10 — banned)
|
||||
- [ ] Stagger same for documentary and vlog? (Rule #12)
|
||||
- [ ] Every group emphasized? (Rule #13)
|
||||
- [ ] 30s+ with no rhythm-break? (Rule #14)
|
||||
- [ ] Displaying every filler word? (Rule #15)
|
||||
- [ ] Caption crossing breath-break? (Rule #16)
|
||||
- [ ] Caption in platform UI zone? (Rule #17)
|
||||
|
||||
Any "yes" to a violation question → regenerate the affected segment.
|
||||
@@ -0,0 +1,188 @@
|
||||
# Anti-Patterns
|
||||
|
||||
You default to these. Stop.
|
||||
|
||||
Each entry: the bad habit, what it produces, what to do instead. Written in the voice of someone who has watched an agent do this 10 times — because we have.
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### You default to center-aligned crown.
|
||||
|
||||
`.crown-plane { left: 0; right: 0; text-align: center }` is the template default, and you'll leave it on every video because it worked once. On a subject that sits right-of-center (Jobs in a 16:9 frame), the body eats the middle 60% of the word and "THE BEATLES" becomes "THE \_\_\_ S".
|
||||
|
||||
**Before using the default crown**, run the three conditions in [layout-heuristics.md § Crown placement](layout-heuristics.md):
|
||||
|
||||
1. Subject centered within 10% of frame center
|
||||
2. Clean zones ≥ 15% on each side
|
||||
3. Crown width > subject width + 400px
|
||||
|
||||
If any one fails, **move the crown to the larger clean zone** with a narrower container and smaller font. Don't keep centering a word that's about to be 60% occluded.
|
||||
|
||||
### You compute text leftmost as `plane_left + padding`.
|
||||
|
||||
That's correct for left-aligned. Wrong for the templates you're using, which are **right-aligned** on the main column and **center-aligned** on crowns. The real leftmost depends on the word width.
|
||||
|
||||
| Alignment | leftmost_x |
|
||||
| --------- | -------------------------------------------------- |
|
||||
| Left | `plane_left + padding_left` |
|
||||
| Right | `plane_right − padding_right − longest_line_width` |
|
||||
| Center | `plane_center − longest_line_width / 2` |
|
||||
|
||||
Compute it against the **longest wrapped line**, not the whole phrase. "four very talented guys" wraps to 3 lines — the widest is "talented" (~8 chars), not 23.
|
||||
|
||||
### You treat `plane_left` as the text's leftmost edge.
|
||||
|
||||
It isn't. With `left: 180px` and a right-aligned 468px word, the text starts 104px **before** `plane_left` relative to the plane, but actually lands somewhere inside the plane box. The plane box just sets the coordinate space — the text positions inside it based on alignment. Check the compiled output, not the plane attribute.
|
||||
|
||||
### You center text on the frame when the subject is off-center.
|
||||
|
||||
Look at the subject's body center, not the frame center. Jobs sits at x=1100 in a 1920 frame. The **scene's** center of gravity is 1100, not 960. Center-aligning text to 960 is center-aligning to the empty left third, not to anything meaningful.
|
||||
|
||||
### You copy-paste position values from memory-wall to a new video.
|
||||
|
||||
`top: 40, right: 30, width: 720, rotateY: -13` worked for a 1280×720 frame with an acoustic foam wall on the right. It will not work on a 1920×1080 frame with a bookshelf backdrop. Run the 6-item checklist in [scene-types.md](scene-types.md) for the new video before reusing any numbers.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### You use template default font sizes regardless of column width.
|
||||
|
||||
Template defaults (66/78/92/140) are tuned for a ~560px column. When you bump the plane to 700px+ and don't touch the fonts, the captions feel underweight — small text swimming in negative space.
|
||||
|
||||
Use the [Font-size × column-width matrix](typography-presets.md). For a 700px column, that's 78/108/128/220 — a 30-40% bump across the board. Re-check pillarbox safety after bumping (bigger right-aligned text extends further left).
|
||||
|
||||
### You pick `intro` style for every first caption.
|
||||
|
||||
Intro = italic, smaller, contemplative. Fine for filler discourse markers ("You know,", "So,", "Well,"). **Not** for the first line when it's actually the thesis ("I've had this kind of upbringing"). Read the words semantically, not positionally.
|
||||
|
||||
### You skip emphasis entirely because "every caption looks clean."
|
||||
|
||||
A story without an emph or crown is typographically flat — viewers can't feel the crescendo. Reserve at least ONE emph per video for the line that lands. Skip it only for truly monotone content (policy statements, warnings).
|
||||
|
||||
### You give every group its own style.
|
||||
|
||||
Style is supposed to signal _hierarchy_. Using `intro`/`phrase`/`emph`/`dream`/`crown` all in one 15-second video means none of them signal anything. Pick 2-3 styles max for a short clip.
|
||||
|
||||
### You only use the 5 preset class names.
|
||||
|
||||
`intro / phrase / emph / dream / crown` are scaffolding, not a closed set. The canonical `memory-wall.html` uses `cap-1 / cap-2 / cap-3 / cap-4` — **position-indexed** with bespoke typography per position. That's how it achieves the 3-line right-aligned cascade on the climax. You can't express "this cap has a hanging indent at position 2" with `"style": "dream"`.
|
||||
|
||||
The `"style"` field in plan.json accepts **any string** — it becomes `class="cap-<string>"`. Define the class in `custom_css`:
|
||||
|
||||
```json
|
||||
"custom_css": ".cap-1 { font-size: 78px; ... } .cap-2 { padding-right: 44px; }",
|
||||
"groups": [{"id": "cg-0", "style": "1", ...}]
|
||||
```
|
||||
|
||||
When the scene needs per-position bespoke typography, do this. Don't force-fit into `intro / phrase / emph`.
|
||||
|
||||
### You regenerate from plan.json when a canonical example already has the answer.
|
||||
|
||||
When scene framing matches `references/example-renders/memory-wall.html` or `champion.html`, clone that HTML into `<project>/index.html` and only swap the GROUPS array. Don't re-derive the design from presets — you'll lose the specific per-position typography that took many iterations to validate. See [bespoke-vs-presets.md § The clone-and-tweak workflow](bespoke-vs-presets.md).
|
||||
|
||||
---
|
||||
|
||||
## Blending
|
||||
|
||||
### You default to `mix-blend-mode: overlay`.
|
||||
|
||||
Overlay works on **mid-tone** backgrounds. On dark bookshelves (<60 luminance), overlay makes white text render as black. On sunny backgrounds (>180 luminance), overlay blows out. Check the actual luminance of the caption region in a sampled frame. Pick:
|
||||
|
||||
- mid-tone surface (60-180) → `overlay`
|
||||
- dark surface (<60) → `screen`
|
||||
- bright surface (>180) → `normal` with opaque text
|
||||
|
||||
### You animate `letter-spacing` on the word entry.
|
||||
|
||||
You saw a "typewriter breath" effect somewhere and added `letterSpacing: "0.04em"` to the `fromTo` "from" state. Result: inline-block reflow every frame → the whole `.cap` line box recomputes → captions visibly jump between rows. See the "Some → line 2" bug from memory-wall.
|
||||
|
||||
**Animate only opacity + transform.** If you want a breath effect, use `scale` or `y`, not letter-spacing.
|
||||
|
||||
---
|
||||
|
||||
## Animation
|
||||
|
||||
### You fade both the group container AND each word.
|
||||
|
||||
Default feels safer: fade the container in, then fade each word in. But container.opacity × word.opacity produces a non-linear curve — at progress 40% the combined opacity is 16%, not 40%. Visible result: captions seem to "pop" into view around halfway.
|
||||
|
||||
Set container opacity to 1 at entrance via `tl.set`, then fade only the words. Single-layer opacity = linear perceived fade.
|
||||
|
||||
### You stack captions in a flex column.
|
||||
|
||||
Flex-direction: column with multiple `.cap` children looks tidy in source. At runtime, hidden captions (`visibility: hidden`) still reserve flex space. The newly-entering caption shows up at the _bottom_ of the column instead of the designed position — landing in the gesture zone, getting clipped.
|
||||
|
||||
Use `position: absolute` for each `.cap` inside the plane so hidden ones don't occupy layout. Both shipped templates do this.
|
||||
|
||||
### You start the timeline at t=0.
|
||||
|
||||
Caption at exactly t=0 feels like it was there before the video started. Offset 0.1-0.3s (hyperframes motion-principles.md agrees). Same for the very last caption — let it exit before the video fades.
|
||||
|
||||
---
|
||||
|
||||
## Scene admission
|
||||
|
||||
### You trust that "looks like one speaker" = "is one speaker throughout."
|
||||
|
||||
TV archive clips cut to B-roll mid-sentence. Interview clips insert cutaways to the interviewer. You didn't check frames beyond the first one, so you rendered captions across a shot transition. Result: captions designed for Subject A are placed relative to Subject B's position (or empty frame) for half the render.
|
||||
|
||||
Sample frames at 20%, 50%, 80% BEFORE planning. If the scene changes, trim to the largest single-subject segment.
|
||||
|
||||
### You ignore baked-in captions / pillarbox / watermarks.
|
||||
|
||||
You saw black bars on the sides and didn't computing the safe-zone. Your captions cross the pillarbox. Or: the source already has burned-in subtitles and you added more — two caption systems fighting for attention.
|
||||
|
||||
Run the **letterbox probe** first. If the source has existing captions, refuse with: "source already captioned, adding more would conflict."
|
||||
|
||||
### You ship Whisper's transcript without checking timings against the beat.
|
||||
|
||||
Transcription is Whisper (via `transcribe.cjs`, no API key) — good word timings, but not infallible: a word can land with a near-zero duration or a timestamp a beat off. This skill is verbatim + on-beat, so `check-timing.cjs --strict` (80ms tolerance) is the gate, not a suggestion — fix drift in `plan.json` before rendering, and never pack two transcript words into one timed entry (the second inherits the first's timestamp and fires early).
|
||||
|
||||
---
|
||||
|
||||
## Matting
|
||||
|
||||
### You enable CoreML for the matting ONNX.
|
||||
|
||||
It's the Apple way, obviously faster. No. CoreML partitions the ONNX graph across providers. The mixed-precision boundary produced (observed with the previous RVM engine) alpha=30 inside the subject's face while background correctly reads 0. Captions shine through face. Pin the CPU execution provider only (`onnxruntime-node`). Our `matte.cjs` already does this; don't "optimize" it by re-adding CoreML.
|
||||
|
||||
### You pick a matte model by "general vs human."
|
||||
|
||||
DECISION FLIPPED 2026-06-12 after a 5-model × 6-scene A/B with caption renders: the matte's job here is CAPTION LAYERING, not prop fidelity. `u2net_human_seg` (via hyperframes `remove-background`) usually excludes thin offset furniture (mic boom arms) from the matte — words stop being sliced by booms, which beat PP-MattingV2's prop-preserving behavior on real caption videos. It is NOT surgical: large salient objects near the subject (telescope rigs) can still leak in — always sample frames_fg/. Known cost: HELD products can drop out intermittently (captions pass in front) — route product-demo climaxes away from held objects. `isnet-general-use` lost outright (backlit-hair collapse). birefnet-portrait (MIT) beat everything semantically (keeps held items AND drops furniture) but is 928 MB / ~7 s-per-frame CPU — a future quality tier, not the default.
|
||||
|
||||
---
|
||||
|
||||
## Grouping
|
||||
|
||||
### You caption every word.
|
||||
|
||||
Transcripts are verbose — "you know, um, I, I mean, you know" is 5 words from a single beat. Captioning all of them clutters the screen and breaks reading rhythm.
|
||||
|
||||
Editorially drop filler. Use the rules in [caption-grouping.md](caption-grouping.md): merge short fragments, cut repeated discourse markers, keep the meaning, trim the noise. You are writing typography to support speech, not a court transcript.
|
||||
|
||||
### You group by fixed word-count.
|
||||
|
||||
"3 words per caption, always" makes every caption look the same and fights the natural cadence of speech. Break on sentence boundaries, 250ms+ pauses, and semantic units instead. Caption sizes will naturally vary from 2 words to 5 — that's correct.
|
||||
|
||||
---
|
||||
|
||||
## The meta anti-pattern
|
||||
|
||||
### You read one reference doc and skip the rest.
|
||||
|
||||
You'll read this file alone and feel covered. These anti-patterns reference concepts defined in `layout-heuristics.md`, `typography-presets.md`, `scene-types.md`. If you haven't read those, the fix advice here won't make sense.
|
||||
|
||||
**Order of reading for a new video**:
|
||||
|
||||
1. SKILL.md (decision gate + pipeline + pre-flight probes)
|
||||
2. bespoke-vs-presets.md (**first check if a canonical example fits — clone if so**)
|
||||
3. scene-types.md (template selection — all 4 wall conditions)
|
||||
4. layout-heuristics.md (positions, sides, crown, font scale, pillarbox formula)
|
||||
5. typography-presets.md (font-size × column-width table, starting points)
|
||||
6. caption-grouping.md (word → group)
|
||||
7. **This file last** (to catch yourself before committing to plan.json)
|
||||
|
||||
If you're pressed for time, still read this one — it flags the failures you're about to make.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Bespoke design vs. presets — when to override, when to clone
|
||||
|
||||
The 5 preset styles (`intro / phrase / emph / dream / crown`) and the 3 templates (`wall-embed`, `corner-column-crown`, `portrait-header`) are **scaffolds**, not rules. The best renders we've shipped all override presets for specific groups because **typography is a per-scene decision**, not a general rule.
|
||||
|
||||
If you only use presets, your render will look generic. If you only copy existing renders, your skill won't adapt. The right workflow is:
|
||||
|
||||
1. **Decide the shape first** (template choice, plane position, blend mode).
|
||||
2. **Check if a canonical example is close enough** → clone and tweak words + timings.
|
||||
3. **Otherwise start from presets** → override per-group via `custom_css`.
|
||||
|
||||
---
|
||||
|
||||
## Canonical example renders
|
||||
|
||||
Full HTML for two validated renders is in `references/example-renders/`:
|
||||
|
||||
| File | Scene | What makes it work |
|
||||
| ------------------ | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `memory-wall.html` | Introspective monologue, right-side foam wall, mid-tone | Right-aligned cascade, per-group bespoke sizes (`cap-1` 78 italic / `cap-2` 66 italic + right hanging-indent / `cap-3` 72 upright / `cap-4` 90 uppercase). `mix-blend-mode: screen` for the dark-ish foam. |
|
||||
| `champion.html` | Podcast interview, cluttered bookshelf, 1920×1080 | Upper-left column + center-stage crown. Tuned preset class sizes (`cap-intro` 52 / `cap-phrase` 60 / `cap-emph` 70 / `cap-crown` 140). `screen` blend reads the shelves through text. |
|
||||
|
||||
**When a new scene matches one of these closely** (similar framing, similar subject-center, similar backdrop type): clone the HTML and only replace the GROUPS array + word timings. Don't re-derive the design from presets — you'll lose the specific choices that took many iterations to validate.
|
||||
|
||||
---
|
||||
|
||||
## When presets are wrong
|
||||
|
||||
You'll reach for presets like `"style": "emph"` when what the scene really needs is:
|
||||
|
||||
### "This cap is at position N and deserves its own treatment"
|
||||
|
||||
`memory-wall.html` uses `cap-1 / cap-2 / cap-3 / cap-4` — **position-indexed**, not role-indexed. Each one is a bespoke design for a specific phrase at a specific point in the arc:
|
||||
|
||||
- cap-1 (soft opener, 4 words): 78px italic 600 — feels like a whisper
|
||||
- cap-2 (dreamy modifier, 3 words): 66px italic 500 + `padding-right: 44px` — hanging indent creates ragged right-edge stagger
|
||||
- cap-3 (turn, 2 words): 72px upright 700 — the syntactic pivot, no italic
|
||||
- cap-4 (climax, 4 words): 90px uppercase 900 — three lines cascade right-aligned
|
||||
|
||||
`phrase/emph/intro` can't express "this cap has a hanging indent" or "this cap is the syntactic pivot". When that matters, invent your own class names:
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "wall-embed",
|
||||
"custom_css": "
|
||||
.cap-1 { font-size: 78px; font-weight: 600; font-style: italic;
|
||||
letter-spacing: -0.01em; }
|
||||
.cap-2 { font-size: 66px; font-weight: 500; font-style: italic;
|
||||
padding-right: 44px; }
|
||||
.cap-3 { font-size: 72px; font-weight: 700; letter-spacing: -0.015em; }
|
||||
.cap-4 { font-size: 90px; font-weight: 900; letter-spacing: -0.03em;
|
||||
text-transform: uppercase; line-height: 1.0; }
|
||||
",
|
||||
"groups": [
|
||||
{ "id": "cg-0", "style": "1", "words": [...] },
|
||||
{ "id": "cg-1", "style": "2", "words": [...] },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `"style": "1"` field becomes `class="cap-1"` on the element — any string works, no validation.
|
||||
|
||||
### "The template's blend doesn't suit this backdrop" → pick a different template, do NOT override
|
||||
|
||||
**Cinematic mode does not override colour or blend.** A template's `mix-blend-mode` + fill are **locked DNA** — `make-composition.cjs` ignores `plan.cap_color` / `blend_mode` / `text_shadow` / `text_filter`. Selecting a template commits to its look; the only agent-authored things are layout (planes/positions) and per-group typography.
|
||||
|
||||
So use the caption-region luminance to **choose** a template that already fits — never to recolour one:
|
||||
|
||||
| Region luminance | What fits | Why |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| < 60 (dark / low-key) | a cream + `screen` template (`cinematic-cream`, `memory-wall`, `champion`, `portrait-header`) | light text glows, picks up the scene |
|
||||
| 60–180 (mid-tone) | a cream + `screen` template still reads (add a scrim via Standard if marginal) | text picks up texture |
|
||||
| > 180 (bright: window, pale wall) | **none of the cream/`screen` Cinematic templates — they wash out** | → use **Standard mode** (opaque rail, set per the chosen template) instead |
|
||||
|
||||
If the scene is bright and the cream/`screen` look washes out, that's the signal to switch to **Standard mode** (which sets opaque colour in the HTML), not to recolour a Cinematic template into something it isn't.
|
||||
|
||||
### "Hanging indent / outdent / letter-width tweak"
|
||||
|
||||
These are per-group affordances you'll occasionally need. Express via `custom_css`:
|
||||
|
||||
```css
|
||||
#cg-2 {
|
||||
padding-right: 44px;
|
||||
} /* right-aligned: shrinks right edge, creating left outdent */
|
||||
#cg-2 {
|
||||
padding-left: 44px;
|
||||
} /* left-aligned: offset right start */
|
||||
.cap-emph .w:first-child {
|
||||
font-size: 110%;
|
||||
} /* oversize first word only */
|
||||
```
|
||||
|
||||
The `#cg-N` selector always works because `make-composition.cjs` writes `<div id="cg-N" ...>` for every group.
|
||||
|
||||
### "Caps should accumulate (flex stack) instead of swap"
|
||||
|
||||
All three templates default to `position: absolute` on `.cap` inside their plane — caps stack at one spot and only the active one shows (single-caption swap). This is correct for `portrait-header` and `corner-column-crown`, where each caption replaces the last.
|
||||
|
||||
`memory-wall` uses **flex column accumulation** — captions pile up like a poem. The template doesn't default to this, so override via custom_css:
|
||||
|
||||
```css
|
||||
.wall-plane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end; /* or flex-start for left-aligned */
|
||||
text-align: right;
|
||||
gap: 14px;
|
||||
}
|
||||
.wall-plane .cap {
|
||||
position: static; /* un-do template's absolute */
|
||||
top: auto;
|
||||
right: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
With this + staggered `in` / `out` times, cap-0 fades in at t=0.2, cap-1 at t=2.55 (below cap-0 in flex order), cap-2 at t=4.90 (replaces both as they fade out together at t=4.85) — this is how the memory-wall poem pages work.
|
||||
|
||||
### "Font size doesn't match the scene"
|
||||
|
||||
Template preset sizes are tuned for a specific column width + frame size. Don't fight them — override:
|
||||
|
||||
```json
|
||||
"custom_css": ".cap-intro { font-size: 52px; } .cap-phrase { font-size: 60px; }"
|
||||
```
|
||||
|
||||
Then check [typography-presets.md § Font-size scales with column width](typography-presets.md) for what to aim at given your plane's actual dimensions.
|
||||
|
||||
---
|
||||
|
||||
## The clone-and-tweak workflow
|
||||
|
||||
For a new video that's clearly similar to an existing canonical example:
|
||||
|
||||
```bash
|
||||
# 1. Scaffold the project
|
||||
hyperframes init <project> --non-interactive --video <video.mp4>
|
||||
|
||||
# 2. Matte + transcribe
|
||||
node scripts/matte.cjs <project>
|
||||
node scripts/transcribe.cjs <project>
|
||||
|
||||
# 3. Copy the canonical HTML instead of writing plan.json
|
||||
cp references/example-renders/memory-wall.html <project>/index.html
|
||||
|
||||
# 4. Replace GROUPS array with the new transcript's grouping (hand-edit index.html)
|
||||
|
||||
# 5. Render directly (skip make-composition.cjs since we're not using plan.json)
|
||||
bash scripts/render-and-composite.sh <project>
|
||||
```
|
||||
|
||||
This skips the preset-based plan.json entirely. Use when:
|
||||
|
||||
- Subject framing, shot composition, and backdrop type are similar to the example
|
||||
- You just need to swap the words and timings
|
||||
- The bespoke typography from the example is what you want
|
||||
|
||||
**Don't clone when**:
|
||||
|
||||
- Subject position differs significantly (e.g. centered vs off-center)
|
||||
- Scene luminance / blend-mode needs are different
|
||||
- You want to experiment with new typography
|
||||
|
||||
In those cases, start with plan.json + custom_css and iterate.
|
||||
|
||||
---
|
||||
|
||||
## Rendering history
|
||||
|
||||
`render-and-composite.sh` now snapshots `index.html` + `plan.json` into `<project>/history/` with a timestamp before every render. If the user says "the previous one was better", diff against the latest snapshot:
|
||||
|
||||
```bash
|
||||
ls <project>/history/
|
||||
diff <project>/history/index-20260422-203947.html <project>/index.html
|
||||
```
|
||||
|
||||
This lets you recover a design you iterated away from, without re-reading the agent transcript.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Caption Grouping
|
||||
|
||||
How to turn the Whisper word-level transcript into the `groups[]` array of plan.json.
|
||||
|
||||
## Goal
|
||||
|
||||
**Each group is one visual phrase**: enters, reveals word-by-word, exits. Rule of thumb: 1 group ≈ 1 comma-to-comma clause or 1 breath of speech.
|
||||
|
||||
## Input
|
||||
|
||||
`transcript.json` from `transcribe.cjs`:
|
||||
|
||||
```json
|
||||
{
|
||||
"words": [
|
||||
{ "text": "Some", "start": 0.24, "end": 0.44, "type": "word" },
|
||||
{ "text": " ", "start": 0.44, "end": 0.48, "type": "spacing" },
|
||||
{ "text": "memories", "start": 0.48, "end": 0.82, "type": "word" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Drop `type: "spacing"` entries; you only need words.
|
||||
|
||||
## Break boundaries
|
||||
|
||||
Cut a new group at ANY of:
|
||||
|
||||
1. **Pause ≥ 500ms** (gap between word.end[i] and word.start[i+1]) — speaker took a breath.
|
||||
2. **Sentence terminator** — word ends with `.`, `?`, `!`, or an em-dash-like pause.
|
||||
3. **Strong comma** — `,` followed by pause ≥ 250ms.
|
||||
4. **Discourse reset** — words like "but", "so", "and then", "you know" starting a clause often merit their own or new group.
|
||||
5. **Group reaches 6 words OR 2.5 seconds** — whichever first. Long groups feel like subtitles, not embedded typography.
|
||||
|
||||
Hard constraints:
|
||||
|
||||
- Minimum 2 words per group (1-word exceptions: interjections like "Wait." or the crown line).
|
||||
- Minimum 0.5s on screen. If a group is less, merge into neighbor.
|
||||
- No overlapping groups — at most one visible at a time.
|
||||
|
||||
## Timing the group
|
||||
|
||||
For a group with words `w[0]..w[n-1]`:
|
||||
|
||||
- `in` = `w[0].start - 0.08` (enter slightly before first word)
|
||||
- `out` = `min(next_group.in - 0.05, w[n-1].end + 0.6)` (linger ~0.6s after last word, but don't collide with next)
|
||||
|
||||
The last group extends to the video end if needed.
|
||||
|
||||
## Style & tone (cross-reference)
|
||||
|
||||
See `typography-presets.md` for how to pick `style` and `tone` per group. Work left-to-right through the groups and:
|
||||
|
||||
1. First group: default `intro` + `soft`.
|
||||
2. Watch for emphasis signals (ALL CAPS in transcript is rare but possible; more often it's semantic — superlatives, proper nouns).
|
||||
3. Escalate tone into `present` once the monologue shifts from setup to statement.
|
||||
4. Reserve `crown` for at most ONE group, typically the final line.
|
||||
|
||||
## Editorial surgery is allowed
|
||||
|
||||
You do NOT have to caption every word. It's fine to:
|
||||
|
||||
- **Drop filler** like extra "you know"s, "um"s, "I mean"s if they bloat the visual pace.
|
||||
- **Condense** a 6-word run into 4 by cutting function words, as long as the meaning and timing remain truthful.
|
||||
- **Skip the whole thing** during obvious silence or non-speech (laugh, music interlude).
|
||||
|
||||
Editorial rule: you are writing typography to support the speech, not a court transcript. Keep meaning, trim noise.
|
||||
|
||||
## Example (champion)
|
||||
|
||||
Transcript: "You know, for me I've had this kind of upbringing, had the great foundation and, you know, I've achieved incredible things. I was dreaming of becoming number one in the world and becoming a Wimbledon champion"
|
||||
|
||||
Groups after editorial pass:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "cg-0",
|
||||
"style": "intro",
|
||||
"tone": "soft",
|
||||
"words": ["You", "know", "for", "me"],
|
||||
"in": 0.1,
|
||||
"out": 1.45
|
||||
},
|
||||
{
|
||||
"id": "cg-1",
|
||||
"style": "phrase",
|
||||
"tone": "soft",
|
||||
"words": ["I've", "had", "this", "kind", "of", "upbringing"],
|
||||
"in": 1.4,
|
||||
"out": 3.35
|
||||
},
|
||||
{
|
||||
"id": "cg-2",
|
||||
"style": "phrase",
|
||||
"tone": "soft",
|
||||
"words": ["the", "great", "foundation"],
|
||||
"in": 3.5,
|
||||
"out": 5.35
|
||||
},
|
||||
{
|
||||
"id": "cg-3",
|
||||
"style": "emph",
|
||||
"tone": "present",
|
||||
"words": ["I've", "achieved", "incredible", "things"],
|
||||
"in": 6.05,
|
||||
"out": 8.3
|
||||
},
|
||||
{
|
||||
"id": "cg-4",
|
||||
"style": "dream",
|
||||
"tone": "present",
|
||||
"words": ["dreaming", "of", "becoming", "number", "one"],
|
||||
"in": 8.5,
|
||||
"out": 10.4
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Plus the crown:
|
||||
|
||||
```json
|
||||
{ "id": "cg-crown", "style": "crown", "words": ["Wimbledon", "Champion"], "in": 10.8, "out": 12.08 }
|
||||
```
|
||||
|
||||
Notice "had" was dropped from cg-2 ("had the great foundation" → "the great foundation"), "I was" was dropped from cg-4, and "a" was dropped from crown — all for visual cadence.
|
||||
|
||||
## word.start/end inside groups
|
||||
|
||||
Pass through the original timestamps from the transcript. Don't retime individual words — only the group `in`/`out`. The word-level karaoke reveal inside each group uses the original w.start.
|
||||
@@ -0,0 +1,522 @@
|
||||
# Composition craft — EMBED track only
|
||||
|
||||
This is the deep "how to lay a caption INTO the scene" manual — it governs the **embed**
|
||||
track only (captions composited behind the subject). The default **rail** track (standard
|
||||
lower-third subtitle, where most text lives) has its own, much simpler spec → [rail.md](rail.md).
|
||||
Read this before authoring any **promoted** phrase, in either authoring mode (template
|
||||
Cinematic `plan.json` or Standard `index.html`).
|
||||
|
||||
Topics: phrase grouping, planes & clean-zone anchoring, zone coherence, climax pop,
|
||||
readability, edge breathing, the occlusion 3-step judgement, accumulation, and persistence.
|
||||
|
||||
> **Two-track model (SKILL.md § Caption model).** Render states are `drop` / `rail` / `embed`.
|
||||
> The `bg / fg / hybrid` axis used throughout the text below is **superseded**: read **"bg" as
|
||||
> embed** (behind subject / in-scene), **"fg" as rail or promote-out** (in front), and "hybrid"
|
||||
> as simply rail + embed coexisting. Everything here applies to the **embed** track — don't
|
||||
> apply this craft to the rail.
|
||||
|
||||
(Granular sub-topics also have their own files — see SKILL.md § Shared knowledge.)
|
||||
|
||||
---
|
||||
|
||||
### Caption layer: BG vs FG (aesthetic axis)
|
||||
|
||||
Separately from template choice, decide whether captions are **embedded** (behind subject, matte occludes — default "embed" cinematic feel) or **announced** (in front of subject, no occlusion — poster / lower-third feel):
|
||||
|
||||
- **bg** — classic embed. Captions sit behind the matte, subject's body partially occludes letters. **Default for any cap that fits the frame.** Partial occlusion is the cinematic feature — small narrator caps with face nibbling 20-30% of letters reads as embedded texture; climax words with face crossing 30-40% reads as Vogue masthead. The eating is what sells the embed. Don't shrink to avoid it.
|
||||
- **fg** — captions float on top, subject can't occlude. **Fallback for when bg cannot work.** Two scenarios:
|
||||
- **Climax fg**: the climax word is so big at impactful size that subject + frame width together leave no place for bg to render at all (subject fills frame AND climax phrase is long; bg would be eaten past readability into broken).
|
||||
- **All-fg fallback**: rare, only when the entire scene has no usable bg zone for any caption.
|
||||
- **hybrid** (per-group `layer:` field) — when one cap (usually the climax) hits the "no place to fit bg" wall but the others (smaller body caps) still work fine in bg. **Body bg + climax fg** is the canonical hybrid pattern.
|
||||
|
||||
**Rule of thumb:**
|
||||
|
||||
- Small narrator/emphasis captions: bg almost always. Subject eating a few letter-edges = embed feature.
|
||||
- Climax: try bg first, sized to maximally cross subject. If that size won't fit frame width, AND no clean zone elsewhere, use fg.
|
||||
- Don't invert the hybrid: body fg + climax bg is the WRONG direction (sacrifices the easy bg embed wins on body, while the climax that needed protection from over-occlusion gets sent into the fire).
|
||||
|
||||
**Hybrid worked example — Startup_Host (subject fills frame).**
|
||||
|
||||
Subject fills the frame top-to-bottom (head_top y=126 / 10% from top). For body captions there's no large clean zone, but small italic narrator caps in upper corners get eaten ~25% by subject — that's the embed showcase. For the climax "SUCCESSFUL STARTUP" at maximally cinematic size, bg would be eaten 40-50%+ → past cinematic into broken. Climax goes fg.
|
||||
|
||||
```json
|
||||
{
|
||||
"caption_layer": "bg",
|
||||
"groups": [
|
||||
{
|
||||
"id": "cg-0",
|
||||
"plane": "body",
|
||||
"layer": "bg",
|
||||
"css": "font-size: calc(0.055 * var(--h)); font-style: italic; ..."
|
||||
}, // narrator bg — face eats edges, embed texture
|
||||
{
|
||||
"id": "cg-1",
|
||||
"plane": "body",
|
||||
"layer": "bg",
|
||||
"css": "font-size: calc(0.085 * var(--h)); font-weight: 800; text-transform: uppercase; ..."
|
||||
}, // emphasis bg — Vogue masthead
|
||||
{
|
||||
"id": "cg-5",
|
||||
"plane": "crown",
|
||||
"layer": "fg",
|
||||
"css": "font-size: calc(0.09 * var(--h)); font-weight: 900; text-transform: uppercase; ..."
|
||||
}
|
||||
// ↑ climax fg: subject too dense for bg to land readably; fg fallback. Sized to fit frame width fully (single line).
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Result: body bg captures embed cinematic across the subject (eaten edges = texture not bug), climax fg sits on top fully visible because there was nowhere else for it to go. make-composition.cjs emits both `index.html` (bg groups) and `index_fg.html` (fg groups); render-and-composite.sh runs two parallel Chromium passes and ffmpeg screen-blends fg over bg+matte.
|
||||
|
||||
Set via `caption_layer: "bg" | "fg"` plan-level (default for groups without explicit `layer:`), and `layer: "bg" | "fg"` per-group to override for hybrid.
|
||||
|
||||
**Decision rule — bg is default, fg is fallback, hybrid is for mixed semantic content.**
|
||||
|
||||
For BODY captions (narrator + emphasis):
|
||||
|
||||
1. **Find the actual clean zone.** Sample frames at 20/50/80% and mentally trace where the subject is (head, shoulders, gesturing hands). The clean zone is what's left. Top band above the head, bottom band below the shoulders, or off-center side column.
|
||||
2. **Anchor the plane in that zone.** Body caps fit comfortably with NO subject overlap → `layer: "bg"`.
|
||||
3. **If body cap stack inherently overlaps subject heavily (>40% per check-occlusion peak)** even at smallest reasonable fonts → body `layer: "fg"`. Subject fills frame too much for embed.
|
||||
|
||||
For CLIMAX caption (the payoff):
|
||||
|
||||
1. **Default: `layer: "bg"`, sized to maximally cross subject for embed effect.** The face/body eating part of the climax word IS the visual win. Don't shrink to avoid occlusion. Use the largest size that fits frame WIDTH (sub-pixel readable letters fully on-screen) — vertical position should land it across the subject's hair / forehead / face.
|
||||
2. **`layer: "fg"` for climax** ONLY when frame width physically can't hold a font big enough to feel climactic AND there's no compositional way to break the word across multiple lines that bg embed could carry.
|
||||
3. **Hybrid (body bg + climax fg)** is the canonical pattern for subject-fills-frame scenes. Body small caps stay bg — subject eats letter edges = embed texture (cinematic, not a bug). Climax goes fg ONLY because at impactful size + subject filling frame there's no place where bg climax could land readably (would be 50%+ eaten = past cinematic into broken). The opposite direction (body fg + climax bg) sacrifices the easy bg embed wins on small caps and forces the climax into a slot it can't survive — never do that.
|
||||
|
||||
**Editorial split — the spatial form of body bg + climax fg.** When the hybrid above is laid out with **bg in the upper half and fg in the lower-third**, you get a magazine-cover composition for free — no separate template needed. The rule:
|
||||
|
||||
- **Body caps (BG)** — placed in the upper half (top: 6-30%), at the head/hair zone. They get partially occluded by hair/forehead = vogue-masthead embed texture.
|
||||
- **Climax cap (FG)** — placed in lower-third (top: 70-85%), in front of subject's chest/below. It's fully clear, fully readable, dominates the bottom band.
|
||||
|
||||
The viewer's eye reads top-to-bottom: first the partially-veiled body line ("decorative, atmospheric"), then the clean climax statement ("the point"). Same Z-axis layering as Vogue / Apple Keynote (decorative top behind subject + clear bottom in front), but driven by cinematic-cream's existing per-cap `layer:` field — no new template, no separate masthead track.
|
||||
|
||||
Two timing modes inside this layout:
|
||||
|
||||
- **Sequential cross-fade (default)** — body fades out as climax fades in over 0.3-0.5s. The brief overlap window IS the editorial dual-layer moment. Standard cinematic-cream behavior.
|
||||
- **Sustained overlap** — extend body's `out` to coincide with or extend past climax's `in` so both are visible together for 1-2s. Use sparingly: works when body line is short (≤3 words) and acts as a "kicker" above the climax. Long body phrases sustained alongside a fg climax = visual noise.
|
||||
|
||||
**The plane-conflict rule for sustained overlap (debugged in Startup_Host).** If you extend a body bg cap's `out` into a window that contains another cap with `layer: "fg"` IN THE SAME PLANE, the two will visually overlap in the final composite — bg pass renders the extended bg cap, fg pass renders the fg cap, both at identical plane position, screen-blended on top of each other. Result: muddy double-text at the same spot.
|
||||
|
||||
Before extending any body bg's `out`:
|
||||
|
||||
1. Check what other caps occupy the body plane during the extended window
|
||||
2. If ANY of them is `layer: "fg"`, the extension causes plane overlap → don't do it
|
||||
3. Workaround: either (a) skip sustained overlap and use sequential cross-fade only, or (b) author the body kicker on a DIFFERENT plane / absolute position so the two don't share a plane
|
||||
|
||||
Concrete example: Startup_Host has cg-3 "diving deep" (body bg, plane=body, 3.1–4.15s) and cg-4 "into what it really takes to build a" (body fg fallback, plane=body, 4.15–6.42s). Both occupy `plane=body`. Extending cg-3 to overlap with the climax cg-5 (6.42–8.06s) ALSO drags cg-3 across cg-4's window where cg-3 (bg) and cg-4 (fg) collide on the body plane. Sequential editorial-split (climax in lower-third, body bg already faded out) was the only safe option for this case. AI_Insights had no fg in body plane between kicker and climax, so sustained overlap worked there.
|
||||
|
||||
When NOT to use editorial split:
|
||||
|
||||
- Body and climax are both information-carrying full sentences → forces viewer to read both at once, fails. Editorial split needs the body line to be _atmospheric / setup_ (italic mood), climax to be _the payoff_ (bold uppercase).
|
||||
- Subject takes the full vertical column (no top clean zone, no bottom clean zone) → there's no upper or lower band to anchor either layer. Fall back to all-fg with crown.
|
||||
- Climax is a long phrase that wraps to 3+ lines in lower-third → eats too much vertical real estate, leaves no room for the body bg above. Either shrink climax or split into more groups.
|
||||
|
||||
This pattern was historically what we called "hybrid" but the spatial framing — upper bg behind / lower fg in front — is what makes it read as editorial design rather than just two unrelated caption tracks. Keep this in mind when authoring per-cap `layer:` for hybrid scenes.
|
||||
|
||||
Common mistakes (debugged in real cases):
|
||||
|
||||
- Defaulting to "right column" because memory-wall did it → narrow 400px column forces long phrases to wrap 4-5 lines, pushing the stack DOWN into the subject's body. Fix: use top-band full-width instead, wider wrap fits fewer lines.
|
||||
- **Flipping climax to fg because "viewer needs to read it" → kills cinematic embed.** Climax is supposed to feel half-eaten by subject; that's the magazine masthead energy. Don't fix what isn't broken.
|
||||
- All-fg whole plan when subject fills frame → safe but sterile. Hybrid (body bg + climax fg) is almost always better — small body caps eaten = embed texture, climax fully visible since it had no readable bg slot.
|
||||
- Sizing caps before choosing the plane → typography is a function of plane geometry, not the other way round.
|
||||
- **Putting LONG multi-line phrases on bg in subject-fills-frame scenes.** "Subject eats letter edges = embed texture" only holds for short caps (1-3 words / 1 line). A 6-8 word italic phrase wraps 3-4 lines, and when subject's head is high in frame, **whole lines** of the phrase land deep in the body/face zone — entire words obliterated, not edge-textured. That's not embed, it's broken. **For long body phrases on subject-dense scenes: switch THAT specific cap to `layer: "fg"` even if other body caps stay bg.** Hybrid lets you mix per-cap. Sample the matte at the cap's time window: if head_top y is climbing and the cap's stack height extends into y > head_top, that cap needs fg.
|
||||
- **Subject doesn't move uniformly across the clip — sample matte per BLOCK time window, not just once per case.** Startup_Host's head_top swings y=224→96 across 8 seconds (128px / 10% of frame). A block plan that worked at t=2 (head low, plenty of space) breaks at t=5 (head high, no space left). Per-block matte sampling > one-shot per case.
|
||||
|
||||
### Step 0 for ANY plan — transcript role annotation (mandatory)
|
||||
|
||||
Before writing a single caption group, do a **pre-plan agentic pass** over the transcript. Read every word and grade it. This is judgment, not a lookup — context decides (e.g. "like" is content in "like this", filler in "like, um, yeah"). No hardcoded word lists; agent decides per token.
|
||||
|
||||
**The grade is the SELECTION INPUT — it picks the render state** (`drop` / `rail` / `embed`); it is not itself a typography class (see SKILL.md § Caption model):
|
||||
|
||||
| Grade | What it is | → render state |
|
||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **drop** | Interjections/hesitation (um, uh, er, ah), self-corrections ("I think— I mean actually…" keep only final), pure stutters ("the the the") collapse to one | **drop** (not shown) |
|
||||
| **normal** | Ordinary spoken content — connective glue + the content words that carry the idea | **rail** (dense conversational glue may be trimmed for readability; content/structure words stay) |
|
||||
| **emphasis** | The 1–2 punch words in a phrase | **rail** + `emphasis` highlight (inline — stays on the rail) |
|
||||
| **peak** | The payoff of a beat/section — the line you'd say out loud paraphrasing it | **promote to embed** (apply the craft below); the single biggest of the whole piece = `apex` |
|
||||
|
||||
A **peak** is per-beat, **not** per-clip: a short clip usually has one (its climax); a long/multi-section explainer has ~one per section. **Embed is scarce + spaced** — ≤1 per sentence/beat, never two adjacent or co-visible, ≥ a beat apart, at most one `apex`. (The old "1 climax / 2–4 emphasis / 4–10 body / 6–12 narrator / 2–8 drop per 10–20s clip" was calibrated for short single-beat clips; it generalises to "one peak per beat, spaced — everything else rides the rail.")
|
||||
|
||||
**This step replaces any filler-word list.** If you find yourself reaching for a regex, you're doing it wrong — go back and judge per-token.
|
||||
|
||||
Then build the plan from the annotated words:
|
||||
|
||||
- drops → do not appear in any word entry
|
||||
- narrator → either fold into adjacent body groups OR become tiny italic subordinate groups if rhythm demands
|
||||
- body/emphasis/climax → shape the group structure, with typography matching role
|
||||
|
||||
### The compositional unit is a PHRASE, not a chunk (read this twice)
|
||||
|
||||
A **caption group** is a semantic phrase — typically 2–8 words bound by intonation and meaning. It is NOT a mechanical "2–4 word chunk". A group has ONE typography (one font-size, one weight, one case, one position) matching its role; the "every-word" feel comes from **per-word GSAP stagger INSIDE the group**, not from splitting the phrase into many tiny groups.
|
||||
|
||||
Canonical reference — memory-wall, 12 spoken words, **4 groups**:
|
||||
|
||||
- cg-0 `"Some memories feel soft"` (4 words, italic 600, right-top)
|
||||
- cg-1 `"like old film"` (3 words, italic 500, right-mid, smaller)
|
||||
- cg-2 `"but suddenly"` (2 words, upright 700 pivot, right-center)
|
||||
- cg-3 `"EVERYTHING IS SHARP AGAIN"` (4 words, uppercase 900 climax, spanning width)
|
||||
|
||||
Every word is shown. Groups **cascade and accumulate** — cg-0 stays on screen while cg-1 enters below; by the climax, all 4 are visible simultaneously as a poem. Per-word stagger (~80ms) animates each word into its group in sequence.
|
||||
|
||||
If you find yourself producing 15+ groups for a 10s clip, stop — you're treating verbatim as mechanical chunking. Go back, re-read the transcript, identify 4–7 phrases, and make them groups.
|
||||
|
||||
**Don't split idioms, collocations, or bound phrases across groups.** "Not even kidding", "at the end of the day", "as a matter of fact", "whole lot of", "more or less", "a little bit", "kind of", "sort of", proper nouns ("Blackmagic Pocket 4K"), brand phrases, and other fixed expressions are semantic units. The climax of "I'm not even kidding" is the whole idiom, not just "KIDDING". The climax of the memory-wall canonical is "EVERYTHING IS SHARP AGAIN" as one group, not just "AGAIN". Splitting these across group boundaries breaks meaning AND typography (you can't render "not even kidding" with "not even" italic-small and "kidding" uppercase-huge — it reads as two different statements).
|
||||
|
||||
### Captions are always verbatim
|
||||
|
||||
Every word the speaker says gets captioned. The skill no longer ships a "highlight" mode — curated/edited transcripts produced fewer beats but lost the rhythm of the spoken sentence and ended up feeling like generic subtitle tracks. Verbatim with strong typographic hierarchy (small italic narrator → emphasis upright → climax massive uppercase) gives the cinematic feel without dropping content.
|
||||
|
||||
The only words you remove from the transcript:
|
||||
|
||||
1. **Pure phonetic interjections** (um, uh, er, ah, hmm) — not words, not captioned.
|
||||
2. **Exact immediate stutters** ("the the the" → keep one; "is is is is" → keep one, aligned with the phrase that follows). The viewer should see one "the", not three.
|
||||
3. **Self-correction backtracks** ("I think— I mean actually…" → keep only "I mean actually").
|
||||
|
||||
**All content words AND structure words stay** (articles, conjunctions, prepositions, auxiliaries, pronouns). Dropping a structure word like "in some" or "and I've" or "out of it" is a bug, not a stylistic choice.
|
||||
|
||||
### Planes — shared parent containers for spatial coherence (RECOMMENDED)
|
||||
|
||||
The memory-wall and champion canonicals don't set `top:` on each caption. They have a **parent container** (`wall-plane` / `left-plane` / `crown-plane`) that owns the spatial anchor, and caps are children that stack automatically (flex column with gap) or replace (absolute at same anchor).
|
||||
|
||||
Your plan.json can opt into this by declaring `planes` and assigning each group to one:
|
||||
|
||||
```json
|
||||
{
|
||||
"planes": {
|
||||
"body": {
|
||||
"css": "top: 4%; right: 4%; width: 720px; display: flex; flex-direction: column; align-items: flex-end; gap: 14px;"
|
||||
},
|
||||
"crown": {
|
||||
"css": "top: 32%; left: 0; right: 0; text-align: center;"
|
||||
}
|
||||
},
|
||||
"groups": [
|
||||
{ "id": "cg-0", "plane": "body", "css": "font-size: calc(0.08*var(--h)); font-weight: 600; font-style: italic;", ... },
|
||||
{ "id": "cg-1", "plane": "body", "css": "font-size: calc(0.10*var(--h)); font-weight: 800;", ... },
|
||||
{ "id": "cg-2", "plane": "crown", "css": "font-size: calc(0.20*var(--h)); font-weight: 900; text-transform: uppercase;", ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The plane CSS owns positioning (top/left/right + display:flex/grid/block). Per-group CSS owns **typography only** (font-size, weight, style, case, letter-spacing, line-height). **No top/left/right/position on a group when it's inside a plane** — the parent decides.
|
||||
|
||||
**Typography is agent-authored per group, NOT looked up from a preset.** The `cinematic-cream` template locks only the DNA (Inter family, motion curves, `mix-blend-mode`, `cap_color` palette, text-shadow/filter derived from luminance). Everything else — `font-size`, `font-weight`, `font-style` (italic vs upright), `text-transform` (uppercase vs mixed), `letter-spacing`, `line-height` — is written by the agent, per group, in `plan.json`'s `css` field. No "cap-1 must be italic 600, cap-2 must be uppercase 900" rule. Read the scene + transcript, pick typography that matches the semantic role (narrator/body/emphasis/climax) and the visual weight the frame can carry. See [references/typographic-moves.md](typographic-moves.md) for _recommended_ combinations — treat as palette, not schema.
|
||||
|
||||
When to use planes:
|
||||
|
||||
- **Multi-phrase sentence in one home zone** — use a flex column plane. All body caps inside accumulate/stack automatically, no collision math. This is the memory-wall pattern.
|
||||
- **Body zone + climax zone** — two planes: body (flex stack) + crown (center). Body caps live in body-plane; climax pops in crown-plane. Cross-plane transition = earned break.
|
||||
- **Any clip with ≥3 body phrases** — single-plane flex stacking beats hand-placing each group's top% every time.
|
||||
|
||||
### Picking the plane anchor — find the ACTUAL clean zone in THIS scene
|
||||
|
||||
**Do not default to "right column" because memory-wall did.** Memory-wall worked because its subject sat left-center with a clean wall on the right. Different scenes have different clean zones:
|
||||
|
||||
- **Subject centered (most selfies, vlogs)** → top band above the head is usually cleanest (clear sky / ceiling / wall). Right or left columns get eaten by shoulders or hands.
|
||||
- **Subject left (interview with right-column backdrop)** → right column cascade (memory-wall classic).
|
||||
- **Subject right (reverse interview)** → left column cascade.
|
||||
- **Two-shot or busy background** → may need to use a corner, or sit the caption on the ONE surface that's consistently clean (a solid jacket, a whiteboard, a ceiling).
|
||||
|
||||
Before writing plane CSS, look at a frame sample. Mentally trace the subject's bounding box and hand-gesture reach. The plane anchors into whatever's LEFT — negative space where the cascade won't be eaten by body or busy backdrop.
|
||||
|
||||
Small-size italic narrator text is especially vulnerable — it disappears into mottled backgrounds or body occlusion. Keep smallest caps in the cleanest part of the zone, not at the edges where subject extends.
|
||||
|
||||
When NOT to use planes (the remaining free-mode use cases):
|
||||
|
||||
- **1–2 isolated groups** with deliberately different positions — free mode is simpler.
|
||||
- **Specific clip-spanning motion** where a group needs to drift/move independently.
|
||||
|
||||
Free mode (no `planes` declared) is still supported — each group's CSS owns its own top/left/right. Use when you genuinely need bespoke per-group placement.
|
||||
|
||||
Wrong: "primary slot for 9:16 is top:12%". That's a lookup; it produces generic output.
|
||||
Right: look at THIS scene, find where captions will read cleanly, respect the speaker's framing, and pick a composition pattern that serves the content's narrative arc.
|
||||
|
||||
Example patterns an agent can pick (not exhaustive, not ranked):
|
||||
|
||||
- **Right cascade accumulating** (memory-wall): all groups right-aligned, stepping down in top%, they stay — final frame shows a poem. Works when subject is centered-left and there's clean right-column space.
|
||||
- **Center stack climax** (Sunset): italic openers at top, upright pivot mid, uppercase climax takes the full top as the final beat, earlier groups fade out before climax. Works when subject is centered and scene has clean sky + clean below-shoulders.
|
||||
- **Left-right split** (memory-wall variant): narrator voice on one side italic, statement voice on the other side upright, climax takes center. Works when subject has off-center clean zones.
|
||||
- **Single-line subtitle rail**: legit choice when scene is dense and no clean zones exist (busy background, subject fills frame). This is the "accessibility" fallback, not the default.
|
||||
|
||||
Whichever pattern is picked, the agent's job is to **justify the choice**: "Subject is on the left, clean gradient sky on the right — I'll cascade right-aligned so captions have clean backdrop throughout." Write that reasoning as a `_notes` field in plan.json so the intent is inspectable.
|
||||
|
||||
Anti-patterns to NEVER ship:
|
||||
|
||||
- Alternating top↔bottom per chunk — reads as chaos (one sentence's words appearing in different vertical zones).
|
||||
- Locking every group to the same `top` value — reads as subtitle track, kills cinematic feel.
|
||||
- Placing body/narrator groups directly over the face when clean space exists — the Vogue-embed effect is for the CLIMAX word only, not default behavior.
|
||||
- **Dropping transcript words to fit caps in fewer blocks.** NEVER. Verbatim is verbatim. If 4 caps don't fit in 1 block, split into 2 sub-blocks of 2 caps each — keep all words. The only legitimate drops are SKILL.md's filler list (um/uh/exact-stutters/self-corrections), not "we ran out of room."
|
||||
- **Mechanically aligning all caps in a plane to share `out` time.** This makes everyone stay on screen longer, _increasing_ density. Wrong fix for the no-jump problem. Correct fix: split natural-handoff caps into separate blocks where each block's caps share `out` (same exit moment, no jump), and `next_block.in ≥ prev_block.out` (clean handoff between blocks, no overlap).
|
||||
|
||||
**Block boundary three laws** (must hold for every plane in every plan):
|
||||
|
||||
1. Within a block, all caps share `out` — they fade together, no per-cap exit jitter.
|
||||
2. Between adjacent blocks in the same plane, `next_block.in == prev_block.out` (or `next.in > prev.out` with a small gap). Caps from prev_block must FULLY exit before next_block's caps mount; otherwise the prev cap's exit at its `out` time triggers a flex reflow that yanks next_block's already-mounted caps upward = jump.
|
||||
3. `prev_block.out ≤ next_block.first_word.start` so next_block's first word still animates from its real spoken time (the in-time only constrains when the container mounts; word animations fire at their per-word `start`).
|
||||
|
||||
A common mistake: setting `next_block.in` slightly EARLIER than `prev_block.out` to give the new block a head-start fade-in, while prev_block still has a different (later) `out`. The 50-300ms overlap looks invisible in the plan but produces a visible jump on screen because two caps with different `out` coexist in the same flex stack.
|
||||
|
||||
- **Vertical gap between body bottom and crown top.** Looks disconnected ("The pace is" at top:3% with "UNPRECEDENTED" at top:20% creates 144px void). Bond the setup phrase + climax word in the SAME crown plane via flex-stack — the Street_Talk "and being / CONSISTENT" pattern.
|
||||
|
||||
### Zone coherence — one sentence, one home zone
|
||||
|
||||
**Never switch spatial zones mid-sentence.** A full semantic unit (sentence or clause) should live in ONE home zone (e.g. right column, or top band). The viewer's eye follows the phrase group. Ping-ponging between top/bottom/left/right for consecutive phrases of the SAME sentence reads as chaos — the reader has to re-find the caption every beat.
|
||||
|
||||
Zone changes are earned at:
|
||||
|
||||
- **Sentence boundaries** (period, long pause, topic shift) — allowed to move to a new home zone, often paired with a tonal shift (italic body → uppercase emphasis)
|
||||
- **The single climax** — the one break from the home zone, always allowed, often takes center-full-width
|
||||
|
||||
Heuristic: if you find cg-N and cg-N+1 are in different zones (top vs bottom, left vs right) AND they're parts of the same sentence (no period/long pause between them), you've violated zone coherence. Fix by picking one home zone for the whole sentence.
|
||||
|
||||
### The climax has to POP — not just "be a bit bigger"
|
||||
|
||||
A climax caption that's the same size as body emphasis is a failed climax. The viewer's eye needs a clear visual break: THIS is the payoff.
|
||||
|
||||
**Size the embedded climax to the FRAME, not to the rail (two-track rule).** The climax is a hero — its size is anchored to frame height: **`font-size: calc(0.16–0.22 * var(--h))`** (default ~0.18–0.20h; go bigger for more impact — it should dominate the frame, only width/readability caps it). It is **NOT** a multiple of the rail: the rail is a small standard subtitle (~0.045h), so "≥1.8× the rail" would yield a tiny, weak climax. Express rail, embed-body and embed-climax all as `var(--h)` fractions so they scale across resolutions — **never hardcode px**. The ratios below are a **floor / hierarchy check** (the climax must clear body-emphasis by ≥1.8× and the smallest tier by ≥2.5×), not the climax's sizing basis. Width is the real ceiling: shrink only if the word can't fit the frame at a legible size (see "Readability is a hard constraint").
|
||||
|
||||
Contrast floor — the climax must at least clear these:
|
||||
|
||||
| Ratio | Feels like |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **1.0-1.3× body emphasis** | Flat — the climax is just another beat. Fix it. |
|
||||
| **1.5-1.8× body emphasis** | Noticeable but not explosive. OK for subtle payoffs, weak for punchlines. |
|
||||
| **≥1.8× body emphasis** | Pops. Viewer's eye snaps to it. Target for "punchline" / "reveal" / "reaction" climaxes. |
|
||||
| **≥2.5× body italic** | Compare against the SMALLEST body tier too — the multi-tier jump is what creates hierarchy. |
|
||||
|
||||
A quick audit script — before shipping, dump every plan's max-body vs max-crown font-size ratio, and flag anything <1.8×:
|
||||
|
||||
```js
|
||||
const bodyMax = Math.max(...plan.groups.filter((g) => g.plane === "body").map(fsize));
|
||||
const crownMax = Math.max(...plan.groups.filter((g) => g.plane === "crown").map(fsize));
|
||||
const ratio = crownMax / bodyMax; // aim for ≥1.8
|
||||
```
|
||||
|
||||
**Split pattern — when one word alone should carry the climax.** If the climax phrase is 2+ words ("and being consistent", "not even kidding") and one of them is the actual payoff, split into setup + payoff BUT keep them spatially bonded:
|
||||
|
||||
- Setup words → small italic narrator (soft, trailing off) on line 1
|
||||
- Payoff word → massive uppercase on line 2, same plane, flex-stacked directly below
|
||||
- **Both groups live in the SAME (crown) plane**, flex-column, gap 4-8px. Not two different planes — otherwise "and being" sits in the top band and "CONSISTENT" sits mid-frame and they read as two unrelated captions.
|
||||
|
||||
Crown plane CSS for split climax:
|
||||
|
||||
```
|
||||
"crown": "top: 26%; left: 0; right: 0; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px;"
|
||||
```
|
||||
|
||||
Street_Talk canonical example: original was `"AND BEING CONSISTENT"` all in crown at 0.085h (same as body emphasis, ratio 1.0×). Rewrite: crown plane becomes a flex-column; `"and being"` → 0.055h italic at top of column (line 1); `"CONSISTENT"` → 0.11h 900 uppercase stacked below (line 2). The payoff POPs because of size contrast within a bonded 2-line composition, AND it crosses the subject's head since the crown column is anchored at ~26-30% top.
|
||||
|
||||
Timing: both groups share `out` (at clip end or block boundary) so neither fades alone — they hold together as one composition. Setup enters first (soft fade-in), payoff enters later when the word is spoken (snappier present scale-in).
|
||||
|
||||
**Readability is a hard constraint — bleed can't eat the word.** Your first move when a climax word overflows is NOT "accept the bleed, it's cinematic" — it's "shrink to the largest size that fits." Compute the max font size that fits the frame:
|
||||
|
||||
```
|
||||
max_font_px = frame_width / (chars × char_ratio × letter_spacing_mult)
|
||||
```
|
||||
|
||||
For "CONSISTENT" (10 chars), uppercase 900 (char_ratio 0.62), letter-spacing -0.05em (mult 0.95), 720px frame: `max = 720/(10*0.62*0.95) = 122px ≈ 0.095h`. Use 0.09h for a small safety buffer. The word fully occupies the frame edge-to-edge — that's already maximum visual impact without losing any letter.
|
||||
|
||||
Cinematic bleed is only OK when **a few pixels of 1-2 outer letters** get mild cropping from scale-in animation or sub-pixel rounding — that reads as "bigger than frame." Any bleed that forces the reader to mentally complete missing letters (>15-20px per side on a 10-char word) is a BUG, not style. "ONSISTE" with 80px each side clipped is NOT a valid climax — it's broken.
|
||||
|
||||
Hard rules:
|
||||
|
||||
- **Readable first.** Every letter should be fully on-screen, with at most a few-px crop on the first/last letter.
|
||||
- **If max legible size doesn't meet ratio target**, raise impact via _other_ signals (weight 900, all-caps, bg head-cross, multi-line composition with setup word above), not via illegible bleed.
|
||||
- **Never bleed body/narrator captions.** Only the crown/climax is allowed any edge crop at all.
|
||||
- `OCCLUSION_SKIP=1` is an escape hatch for iterating; the shipped plan should pass `check-occlusion-v2.py --strict` (pixel-perfect via headless Chromium DOM).
|
||||
|
||||
### Edge breathing + climax-crosses-head (bg showcase)
|
||||
|
||||
Two placement tweaks that separate "looks like a caption sitting in the scene" from "looks like a pasted subtitle":
|
||||
|
||||
- **Non-climax caps need edge breathing room.** Body-plane groups should NOT hug `left: 0; right: 0` flush to the frame. Add `left: 3-6%; right: 3-6%` (or `right: 30-40px` for pixel-anchored planes). The breathing sells the compositional choice — flush edges read as "default subtitle track", indented edges read as "considered composition". Top edge the same: `top: 2-6%` not `top: 0`.
|
||||
|
||||
- **The climax SHOULD cross part of the subject's head — deliberately.** This is what makes bg-layer captions feel cinematic instead of just "text floating above the speaker". The effect is strongest when 20-40px of the word's last line dips behind the hair or forehead — viewer reads the word clearly AND sees it being occluded at the edge. Not "covered by the face" (unreadable), just "grazed by the hair" (legible + cinematic).
|
||||
|
||||
How to place the crown with intent:
|
||||
1. Sample the matte PNG at climax time: `f_{frame_idx:04d}.png` where `frame_idx = round(climax_time * fps)`.
|
||||
2. Scan rows from top until `alpha > 128` — that y is the subject's topmost pixel (usually hair).
|
||||
3. Position crown so the _bottom of the last line_ lands 30-80px below that y. For a 2-line crown at 0.085h on 1290h frame, line heights ~104px each; total height ~200px. If subject top is at y=445, set `crown.top` such that `top + height = 445 + 50 ≈ 495` → `top = 295px ≈ 23%`.
|
||||
4. Verify with checker — the HERO/crown peak occlusion target is ~30–55% (the embed effect; the head crossing the middle). Below ~15% it reads as a floating label (the hero-weak advisory fires); above ~65% the word is eaten (the gate FAILs). Small narrator caps: ~20–30% texture.
|
||||
|
||||
Practical rule of thumb: **crown peak occlusion 3-10%** is the sweet spot. The checker's number tells you directly — not a subjective "it looks good" judgment.
|
||||
|
||||
**Crown defaults are scene-specific, not template-wide.** Don't set `crown.top: 6%` because "that's what the previous case used." If the subject's head_top is at y=380 (29% of frame) and your crown is at top:6% with 100px height, the crown bottom lands at y=200 — 180px ABOVE the head, completely floating in sky, zero embed effect. Always sample the matte once per case and compute `crown.top` from `head_top - crown_height + (30 to 80)` so the crown's bottom edge dips into hair. This was a real bug across 6 cases until enforced.
|
||||
|
||||
FG-layer crowns skip this entirely (they render on top, no occlusion to showcase) — FG is announcement, not embed.
|
||||
|
||||
### Pre-render occlusion + frame-overflow gate
|
||||
|
||||
**`check-occlusion.cjs` (canonical, pixel-perfect)** is the only occlusion checker in `scripts/`. It auto-runs `measure-layout.cjs` (headless Chromium via Puppeteer) which loads the compiled `index.html`, seeks the GSAP timeline to 4 sample times per group (15/40/65/90% through window), and queries `getBoundingClientRect()` on every `.cap` and child `.w` span. Output is `_layout.json` with the actual rendered pixel coordinates of every word at every sample. Then it computes per-word, per-cap occlusion against the real subject-matte alpha (read via `sharp`):
|
||||
|
||||
- **Per-word peak occlusion** — the load-bearing metric: a 5-word phrase where 3 words are fully eaten averages the same as 8 words half-eaten, but only one of those is shippable, so per-WORD peak is what gates. Per-word peak ≥65% = `obliterated`, ≥35% = `warn`. A `WARN` band hit on a single climax word is OK (cinematic edge crop); two adjacent words obliterated is not.
|
||||
- **Per-cap aggregate** — avg + peak occlusion across all samples. Cap fails at peak ≥50%.
|
||||
- **fg-layer caps are auto-skipped** (they render above the matte; no occlusion possible).
|
||||
|
||||
**Why measure the real DOM, not estimate a bbox.** An earlier heuristic estimated each word's box from `char_ratio × font × text_length` and was wrong in both directions. Startup_Host case: it reported `cg-1 "podcast." OK at 9%/12%` because it guessed y=134 (above the subject's head), but the rendered DOM was at y=209 (mid-head) → actually `FAIL 54%/60%`. Same case, it reported `cg-3 "diving deep" FAIL at 76%/77%` while the real measurement was `WARN 36%/40%` (the box is wider than the head — most pixels clear, only descenders dip into the silhouette). Estimation is retired; `check-occlusion.cjs` measures the rendered pixels.
|
||||
|
||||
**Frame-edge overflow** — the checker also flags when a measured word bbox extends past the canvas (text the ffmpeg crop would clip), reported as `cg-N [overflow] "word" off-frame: left Xpx, right Ypx (@Ts)`. Because cropped body text is always wrong, a clear glyph clip (>8px past an edge) **fails `--strict`**; a sub-glyph graze (≤8px — the first/last-letter crop the climax is allowed) is a non-blocking `[overflow-warn]`.
|
||||
|
||||
**The checker reports facts, the agent applies judgment.** A FAIL is NOT an automatic "must re-layout" signal — it's evidence to interpret. Two competing goals coexist:
|
||||
|
||||
1. **Readability** — every sentence must remain comprehensible to the viewer
|
||||
2. **Cinematic embed** — partial occlusion is the desired vogue-masthead effect; zero occlusion looks like a pasted subtitle
|
||||
|
||||
When the checker reports a per-word obliteration (≥65%), apply this **3-step decision rule** before changing anything:
|
||||
|
||||
**Step 1 — POS check.** Is the obliterated word a function word (the / a / an / of / in / on / at / to / I / it / is / are / was / be / that / this / which / and / or / but)? Function words are grammatically optional in most contexts — a 100%-eaten "the" or "I" or "of" usually does NOT break the sentence. Content words (nouns, verbs, adjectives, climax words) carry meaning — they MUST stay legible.
|
||||
|
||||
**Step 2 — sentence parse without the word.** Mentally delete the obliterated word from the sentence. Does the remainder still convey the meaning?
|
||||
|
||||
- "like this are why I travel" minus "I" → "like this are why travel" — first-person obvious from context, **OK**
|
||||
- "take it all in" minus "all" → "take it in" — complete English idiom on its own, **OK**
|
||||
- "a whole lot of information" minus "of" → "a whole lot information" — slightly broken but recoverable, **OK**
|
||||
- "diving deep into startups" minus "deep" → "diving into startups" — works fine, **OK**
|
||||
- "diving deep into startups" minus "diving" → "deep into startups" — loses the verb, sentence breaks, **NOT OK**
|
||||
|
||||
**Step 3 — cinematic check.** A single content word at 30-65% partial occlusion is the _intended_ embed look (vogue-masthead "subject grazes the text"). Don't "fix" it. Only escalate to re-layout when:
|
||||
|
||||
- a content word is ≥80% gone (not just partially eaten — almost fully missing)
|
||||
- two or more adjacent words are ≥50% obliterated (sequential damage compounds; "X X X really takes" with first three eaten is unrecoverable)
|
||||
- the obliterated word IS the climax / key noun the viewer is supposed to read (e.g. the subject of a sentence, a brand name, a product feature)
|
||||
- key letters of a climax word are eaten (e.g. "STARTUP" with the S gone reads as "TARTUP" — the partial-letter pattern matters more than the whole-word percentage)
|
||||
|
||||
**Real examples from the 13-case batch** — all four caps below got a per-word obliteration FAIL, all four were correctly judged OK by this rule:
|
||||
|
||||
| case | obliterated word @% | sentence | rule applied |
|
||||
| ------------------ | ------------------- | ------------------------------------- | -------------------------------------- |
|
||||
| Sunset_Stroll cg-1 | "I" 100% | "like this are why I travel" | function word + parses without it → OK |
|
||||
| Sunset_Stroll cg-3 | "all" 75% | "take it all in" | "take it in" is a complete idiom → OK |
|
||||
| yt_gtds cg-6 | "of" 69% | "a whole lot of information" | function word + recoverable → OK |
|
||||
| AI_Insights cg-2 | "that" 66% | "that don't replace human creativity" | relative pronoun, optional → OK |
|
||||
|
||||
**When the rule says re-layout** — what to change:
|
||||
|
||||
- per-word obliterated AND content word AND breaks readability → switch THAT specific cap to `layer: "fg"` (per-cap override, not whole-plan), OR shrink/reposition, OR split the cap into a different block where the subject isn't crossing it.
|
||||
- per-cap fail (peak ≥50%) when the whole stack is in the head zone → lift y or shrink the block.
|
||||
- frame-edge overflow → shrink font OR tighten `letter-spacing` OR split the phrase. Cropped text is ALWAYS wrong, no aesthetic excuse.
|
||||
|
||||
**Override** (for iterating before shipping): `OCCLUSION_SKIP=1 bash render-and-composite.sh <project>` — don't ship past this without applying the 3-step rule and either fixing or consciously accepting each FAIL.
|
||||
|
||||
### Accumulation capacity — split into blocks when the plane can't hold more
|
||||
|
||||
Each scene's clean zone has a finite capacity for simultaneously-readable caps. **The agent must judge this before writing the plan**, not rely on CSS `bounded height` / `overflow: hidden` — those cause visual bugs (caps overflow off-screen, are clipped, or content is centered outside the plane bounds).
|
||||
|
||||
Rule of thumb (capacity = max caps visible SIMULTANEOUSLY in one block):
|
||||
|
||||
- **9:16 portrait, narrow right-column plane** (width ~380-420px) → max **2 caps** per block. Long phrases wrap to 2-3 lines; a 3rd cap starts spilling into the subject.
|
||||
- **16:9 landscape, subject centered** (e.g. selfie vlog) → max **2 caps** per block. Even if the frame is wider, a centered subject leaves narrow columns on both sides and hands reach far.
|
||||
- **16:9 landscape, subject on one side** (e.g. interview with clean opposite side — the memory-wall canonical scenario) → max **3-4 caps** per block. This is where accumulation shines.
|
||||
- **Top-band full-width plane** (either orientation) → max 1-2 caps before overflow into face zone.
|
||||
|
||||
Capacity is about the specific scene — same orientation can have very different room depending on subject position and background complexity.
|
||||
|
||||
When your phrase count exceeds the capacity, split into multiple accumulation blocks at **sentence or clause boundaries**. Example memory-wall canonical on a 12-word clip:
|
||||
|
||||
- Block 1 (0–4.85s): `"Some memories feel soft"` + `"like old film"` — 2 caps accumulate, fade together at 4.85s
|
||||
- Block 2 (4.90–8.04s): `"but suddenly"` + `"EVERYTHING IS SHARP AGAIN"` — 2 caps accumulate on a cleared plane
|
||||
|
||||
All caps in one block share the same `out` time (sentence boundary). Next block's `in` > previous block's `out` so the plane clears before the new accumulation starts. This gives each cap ~3-5s of read time, avoids overcrowding, and matches memory-wall's cinematic rhythm.
|
||||
|
||||
**Why the shared `out` is load-bearing.** The template's exit path is `tl.set(sel, { opacity: 0, display: "none" }, g.out)` — at `out`, each cap releases its flex space so the NEXT block's caps can take the top slot from the plane's origin (no permanent downward drift). If caps inside the same block had staggered `out` times, the plane would reflow mid-block (cap 1 vanishes, caps 2-3 jump up, cap 4 arrives… visible jitter). With block-synchronized `out`, the reflow happens ONCE at the boundary — invisible to the viewer because the block fades as a unit. **Never stagger `out` times within an accumulation block.** If you need individual fade-outs, you're not accumulating — switch to stagger-fade pattern (one group at a time, no flex plane needed).
|
||||
|
||||
**Anti-pattern: bounded `height` + `justify-content: center` on planes.** Do NOT write `height: 70%; justify-content: center` on a flex-column plane hoping to vertically center the caps. When the block's total cap height exceeds the bounded height, flex centering pushes content OUTSIDE the plane bounds on BOTH top and bottom — the topmost cap ends up above the frame (off-screen, unreadable). Instead:
|
||||
|
||||
- Let the plane grow with its content — set `top:` (origin anchor) and let caps stack down naturally.
|
||||
- Use `gap:` between caps; don't try to "center the stack".
|
||||
- Trust the block-capacity rule above to keep the stack within the clean zone — don't paper over overflow with `overflow: hidden` (it clips the very caps you wanted the viewer to read).
|
||||
|
||||
### Accumulation — vertical spacing, not single-line
|
||||
|
||||
Accumulation works with multi-line phrases too — memory-wall canonical's groups are multi-word, right-aligned cascade in 16:9 landscape, and several wrap to 2 lines. They don't collide because **each group's `top` is placed below the previous group's bottom extent with a margin**.
|
||||
|
||||
Math you must do before shipping accumulate:
|
||||
|
||||
For each group, estimate its rendered height:
|
||||
|
||||
- `line_count ≈ ceil(chars × font_px × 0.72 / usable_width_px)` (0.72 = Inter 900 uppercase; use 0.60 for italic/regular)
|
||||
- `group_height_pct ≈ line_count × font_size_pct × line_height × 100`
|
||||
|
||||
Then for each pair (cg-N, cg-N+1) you want to coexist:
|
||||
|
||||
- `cg-(N+1).top ≥ cg-N.top + cg-N_height_pct + 2%` (margin)
|
||||
|
||||
If the math doesn't fit in the available clean zone (top-clear + bottom-clear zones minus face/body bbox), you have two options:
|
||||
|
||||
- **Shrink fonts or merge phrases** so they fit.
|
||||
- **Switch persistence to stagger-fade** (groups don't coexist, no spacing constraint).
|
||||
|
||||
9:16 portrait specifically: clean zone is typically top 0–32% + bottom 72–90%, split by the subject. That's ~50% of frame height usable. At 15% per 2-line group + 2% margin, you can fit ~3 accumulating groups in top zone. A 4th must either (a) be smaller to fit, (b) go to the bottom zone (zone change allowed at sentence boundary), or (c) replace an earlier one as climax.
|
||||
|
||||
Landscape 16:9 has ~70–80% usable vertical — more room for 4–5 accumulating groups, which is why memory-wall canonical works cleanly there. Don't give up accumulation on portrait; just do the arithmetic first.
|
||||
|
||||
### Persistence — how long each group stays (agent decides per case)
|
||||
|
||||
The group's `out` time is not a default, not a rule, not a formula. It's an aesthetic choice. The same 5-group plan renders very differently depending on whether groups accumulate, stagger, crossfade, or roll — and each is right for different content.
|
||||
|
||||
Patterns to pick from:
|
||||
|
||||
| Pattern | What happens | Good for | Bad for |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| **Accumulate-through-climax** (memory-wall) | All groups stay on screen until climax arrives, then all fade together | Poetic builds; emotional crescendo; ≤5 groups where each word matters | 8+ groups (screen crowds); punchy comedic beats (breaks the laugh) |
|
||||
| **Stagger-fade** (Sunset-style) | Each group fades ~0.8–1.5s after its words end; next enters into clean space | Independent punchy beats; clip where each phrase is its own moment | Long poetic builds that want the stanzas to coexist |
|
||||
| **Crossfade-handoff** | Current group fades during next's entrance (~200ms overlap) | Dense verbatim (6+ groups in short clip); conversational flow | Short sparse clips — feels like subtitle track |
|
||||
| **Rolling window** | Keep N=2–3 visible; earliest fades when N+1 enters | Longer transcripts (15s+) where full accumulation would crowd, but some layering is still cinematic | Clips <8s (rolling isn't perceptible) |
|
||||
|
||||
Decision axes:
|
||||
|
||||
- **Narrative arc** — crescendo/revelation → accumulate; list of independent points → stagger; dense conversation → crossfade
|
||||
- **Group count** — ≤4: any pattern works. 5–7: prefer stagger or rolling. 8+: crossfade is usually forced.
|
||||
- **Scene density** — busy background eats stale captions; accumulate only works when backdrop is clean enough that older captions stay legible.
|
||||
- **Clip length** — ≥12s erodes accumulation (viewer loses connection to earliest caption); prefer stagger or rolling for long clips.
|
||||
|
||||
Write the reasoning in `_notes` in plan.json so the decision is inspectable and the agent's intent survives into review. e.g.:
|
||||
|
||||
```json
|
||||
"_notes": "Morning_Jog: 5 groups in 8s. Narrative is crescendo (Okay→honestly→changed my life→KIDDING), and background is clean (trees, sky). Accumulate-through-climax — all 4 earlier groups stay visible so KIDDING lands on top of a built-up poem. Cascading right with varied top%/alignment so the stack reads as composition, not a subtitle column."
|
||||
```
|
||||
|
||||
This is where per-case judgment earns its keep. The skill gives you a palette of patterns; the pick is yours.
|
||||
|
||||
### Spatial coherence — the main slot rule
|
||||
|
||||
**Every plan has ONE primary slot.** Most groups land there. Secondary slots exist only for intentional rhythm-breaks (once every ~30s, never per-chunk alternation). The old autogen rotation that threw each chunk to a different corner violated this and read as chaos.
|
||||
|
||||
Choose a primary slot based on scene:
|
||||
|
||||
- **9:16 portrait, subject mid-frame** → primary slot is `top: 4–20%` (sky / clean zone above the head). Secondary: `top: 72–78%` (below shoulders, above UI). Never: bottom 22% (UI/hand zone per [rule 17](aesthetic-principles.md)).
|
||||
- **16:9 landscape, subject left** → primary slot is the right column (`left: 50–55%`, right-aligned or centered in the column). Secondary: below subject.
|
||||
- **16:9 landscape, subject centered** → primary slot is `top: 10–20%` OR `top: 70–80%`. Pick one and stick.
|
||||
|
||||
Within the primary slot, vary **size and weight** to create hierarchy (narrator small, body mid, emphasis large, climax huge). That's the rhythm inside a coherent position. Secondary slots are for **break-rhythm moments**, not for adjacent chunks just to "avoid collision".
|
||||
|
||||
Collision validator still runs — but now the agent should be hitting it only in exceptional cases (intentional layering), not because every chunk is in a different place.
|
||||
|
||||
**Verbatim recipe** — every word is shown, but it is **not** a flat TikTok subtitle rail. Use the cinematic DNA: per-group typography variation, position variation, size modulation. Italic openers, upright pivots, uppercase climaxes, spatial cascade, per-word soft/present tones — all of it.
|
||||
|
||||
Chunking: walk the transcript and split into groups of **1–4 words** at pause boundaries (>0.25s gap = hard cut; within a phrase, chunk by emotional beat — short 1–2 word chunks for emphasis words, longer 3–4 word chunks for connective tissue).
|
||||
|
||||
Typography per chunk — treat each chunk as a mini-beat. The sizes below are a **starting palette, not rules.** The agent picks the actual `font-size` per group based on the scene's clean-zone width, the phrase's character count, and the desired visual weight relative to neighboring groups. Shift by ±30% whenever the scene demands (narrow portrait column → shrink; wide landscape hero → enlarge). Pick from [typographic-moves.md](typographic-moves.md):
|
||||
|
||||
- **Filler / connective** ("you know", "like", "so I was", pronouns, conjunctions) → italic 500–600, small (~0.05–0.08h starting point), softer tone, subordinate position. These read as narrator voice, not statement.
|
||||
- **Emphasis / content words** (verbs, nouns carrying meaning) → upright 600–800, mid (~0.08–0.12h starting point), centered or cascaded position, present tone.
|
||||
- **Climax / hook words** (the payoff word of the sentence) → uppercase 800–900, large (~0.14–0.22h starting point), full-frame or near-frame-width, present tone with scale pop.
|
||||
|
||||
**Size is always relative, never absolute.** What matters is the _contrast ratio_ between a group and its neighbors inside the same plane — climax should be ~2× the emphasis, emphasis ~1.3–1.5× the narrator. An agent that writes `0.08h / 0.08h / 0.08h` for three caps in a plane has failed regardless of which bucket it picked from. The hierarchy is the point.
|
||||
|
||||
Spatial composition: use the full frame as a canvas. Filler chunks tuck to corners or sit small at subordinate positions; emphasis chunks claim the middle; climax chunks own the frame. **Don't lock everything to `bottom: 12%`** — that's the boring-subtitle trap.
|
||||
|
||||
Time windows: either spatially-separate overlapping groups (cascade, poem-accumulation) or hand-off so only one chunk shows at a time. Handoff at the same slot reads jittery (too many cuts in a short window) — prefer spatial cascade where adjacent chunks coexist at different positions, or accumulation blocks bounded by sentences.
|
||||
|
||||
Layer: default `"bg"` for any chunk whose position has clean scene around it (sky, wall, the area above/below the subject) — that's the embed effect. Use `"fg"` only when the chunk's position would be ≥80% occluded by the body (e.g. chyron-style chunks at bottom in 9:16 portrait where the torso fills that zone).
|
||||
|
||||
**Climax chunks across the subject — readability first.** The Vogue-masthead effect (big uppercase crossing the body) is the most cinematic move, but only if the word is still readable. Use `bg` across the subject when **all** of:
|
||||
|
||||
1. The caption is multi-line (≥2 lines), so even if one line is mostly eaten, the others stay clean and the viewer can read the phrase. Single-line captions should never sit directly over the face/body — either reposition above/below the subject, or use `fg`.
|
||||
2. The subject's bounding box covers ≤40% of the caption's total pixel area. For 9:16 portrait with a typical head-and-shoulders subject (~30% vertical × ~50% horizontal at mid-frame), a caption at `top: 16%` spanning 4 lines × 0.14h meets this — the face eats one line, the other three read clean. A caption at `top: 40%` sitting directly on the face does not.
|
||||
3. Each individual word has visible letter endpoints on at least one side — a word that's split mid-letter with only interior strokes showing is unreadable even if "25% of total pixels" are covered.
|
||||
|
||||
If any of these fail, either **reposition** the BG caption to clean scene (above the head / below the shoulders / into an off-center corner), or **fall back to `fg`**. FG for climax is the right call when the scene has no clean zone big enough — don't force the embed aesthetic at the cost of legibility.
|
||||
|
||||
Rule of thumb: if you flattened your plan to just the climax chunks, the result should still read as a satisfying tweet-length headline. If it doesn't — you've under-varied your typography and the climax doesn't pop hard enough.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Direction catalog
|
||||
|
||||
10 distinct aesthetic directions the skill can express. Each is a complete look: font, motion, color, placement, rhetorical defaults. The agent picks by **content tone × shot type × platform**, not preset name.
|
||||
|
||||
Current shipped templates (`modes/cinematic/*`) cover about 30% of this catalog. The rest are future ship-ready aesthetics.
|
||||
|
||||
Classification matrix — pick direction by intersection:
|
||||
|
||||
| Tone ↓ / Platform → | 9:16 portrait | 16:9 landscape | 1:1 square |
|
||||
| ------------------- | ---------------------------------------- | ------------------------ | ------------------------ |
|
||||
| **Documentary** | documentary-dignified (vertical variant) | documentary-dignified | broadcast-dignified |
|
||||
| **Conversational** | portrait-header + default | memory-wall OR champion | broadcast-dignified |
|
||||
| **Energetic** | high-energy-vlog | high-energy-vlog-wide | high-energy-vlog-sq |
|
||||
| **Poetic** | lyrical-poem-on-wall (portrait) | lyrical-poem-on-wall | chapter-card |
|
||||
| **Keynote/tech** | — (9:16 not ideal) | tech-keynote-confident | tech-keynote-confident |
|
||||
| **Investigative** | — | investigative-typewriter | investigative-typewriter |
|
||||
| **Music video** | k-pop-lyric | k-pop-lyric | k-pop-lyric |
|
||||
|
||||
---
|
||||
|
||||
## Direction specs
|
||||
|
||||
### 1. documentary-dignified _(Errol Morris / PBS Frontline)_ — Standard-mode direction (no prebuilt Cinematic template)
|
||||
|
||||
- **Family**: Söhne Mono OR GT Sectra for name cards; Inter for body
|
||||
- **Weights**: 500 body, 700 name card
|
||||
- **Color**: bone `#F5EFE6` on charcoal, OR charcoal `#1A1A1A` on bone. No accent.
|
||||
- **Motion**: burn-in (no animation). Held 0.5s past last word.
|
||||
- **Placement**: bottom-left block, 10% margin, locked position for whole interview.
|
||||
- **Rules**: 2-line max. Speaker name card once at first utterance + after each cut. No emphasis styling — gravitas IS the style.
|
||||
- **Rhetorical**: aggressive filler suppression. Every word is chosen.
|
||||
|
||||
### 2. cinematic-noir _(Kyle Cooper / Se7en)_
|
||||
|
||||
- **Family**: Distressed serif (Hoefler Text) + tight mono for detail
|
||||
- **Weights**: 700 mostly; thin italic ONLY for whispers
|
||||
- **Color**: off-white `#E8E0D0` with deep red `#8B0000` single-word accent for menace
|
||||
- **Motion**: typewriter with 10% chance of 1px jitter on entry; scratched etch for chapter beats
|
||||
- **Placement**: off-axis — avoid dead-center, slightly high-left or low-right
|
||||
- **Rules**: no emoji, no rounded corners, everything hand-feeling
|
||||
|
||||
### 3. tech-keynote-confident _(Apple / Jony Ive ad)_
|
||||
|
||||
- **Family**: SF Pro Display OR Inter, nothing else
|
||||
- **Weights**: 800 headline keyword, 400 context
|
||||
- **Color**: pure white on any bg; `mix-blend-mode: difference` fallback for bright scenes
|
||||
- **Motion**: swipe-reveal from bottom via clip-path (400ms); exit fades 400ms
|
||||
- **Placement**: dead center, large. NOT lower-third.
|
||||
- **Rules**: one caption on screen at a time. 2–5 words per caption. Long held silences between. Feels like slides.
|
||||
|
||||
### 4. lyrical-poem-on-wall _(extends current memory-wall template)_
|
||||
|
||||
- **Family**: Serif with personality — EB Garamond / GT Sectra / Caslon Italic
|
||||
- **Weights**: 400–500 only. **This direction rejects bold.**
|
||||
- **Color**: picks up wall via `mix-blend-mode: overlay`, opacity 0.85, no stroke
|
||||
- **Motion**: etch in 600ms, hold 1.5–3s, etch out
|
||||
- **Placement**: always on a surface in the scene (back wall, foam, whiteboard). Uses matte pipeline.
|
||||
- **Rules**: captions are **fewer** than spoken text — 40–60%. Intentional silences. No other tool does this; strong differentiation.
|
||||
|
||||
### 5. high-energy-vlog _(controlled Hormozi)_
|
||||
|
||||
- **Family**: Inter ExtraBold, uppercase
|
||||
- **Weights**: 900 always
|
||||
- **Color**: white body + ONE accent (`#FFD600` or `#00E676`) + narrow 2px dark stroke
|
||||
- **Motion**: word-pop with elastic, 60ms stagger; active word scales 1.15× on speech onset
|
||||
- **Placement**: center-frame, ~35% from top
|
||||
- **Rules**: max 3 words per frame. Hard break at breath pauses. Accent color on ~15% of words. **One full silence every 10s** (what Hormozi imitators miss).
|
||||
|
||||
### 6. chapter-card _(Wes Anderson / Grand Budapest)_
|
||||
|
||||
- **Family**: Futura Bold (modern) / Archer (warm) / Bodoni (formal)
|
||||
- **Weights**: 700 chapter title, 400 attribution
|
||||
- **Color**: pastel hue sampled from scene
|
||||
- **Motion**: full-frame static card between interview segments
|
||||
- **Placement**: centered card for chapters; lower-left name card during speech
|
||||
- **Rules**: chapter cards get their own beat (0.8s silence before, 0.4s after). Feels authored.
|
||||
|
||||
### 7. investigative-typewriter _(Frontline / 60 Minutes / Morris Interrotron)_
|
||||
|
||||
- **Family**: IBM Plex Mono OR JetBrains Mono
|
||||
- **Weights**: 500
|
||||
- **Color**: white with 3px shadow, no stroke
|
||||
- **Motion**: typewriter char-by-char 25ms/char; cursor blink 600ms after last char
|
||||
- **Placement**: bottom band with 40% opacity gradient up — **NOT a hard box**
|
||||
- **Rules**: one line only, two-line max. Every caption ends with 1-frame cursor-hold before next enters.
|
||||
|
||||
### 8. k-pop-lyric _(music video typographic flex)_
|
||||
|
||||
- **Family**: oversized display serif (Playfair Display / GT Super) + tiny mono detail line
|
||||
- **Weights**: display 900, mono 400
|
||||
- **Color**: deep saturated single hue per song/segment
|
||||
- **Motion**: cascade entries alternating sides; words cross subject with mix-blend
|
||||
- **Placement**: intentionally LARGE — sometimes bigger than the face
|
||||
- **Rules**: emotional/musical moments only. Never informational content. **Breaks rule #1 (subject wins) on purpose** — that's the whole point.
|
||||
|
||||
### 9. broadcast-dignified _(BBC / NYT documentary standard)_
|
||||
|
||||
- **Family**: Helvetica Now / Neue Haas Grotesk
|
||||
- **Weights**: 500 body, 700 name
|
||||
- **Color**: white on 40% black gradient pill (not hard box, not plain shadow — gradient is the signature)
|
||||
- **Motion**: 200ms fade-up, 150ms fade-down
|
||||
- **Placement**: bottom-center, 10% margin, title-safe locked
|
||||
- **Rules**: strict BBC — 160–180wpm, 32–34 chars/line, 2 lines max, 1.5s min gap. "Export to broadcast" mode. Boring on purpose. Never wrong.
|
||||
|
||||
### 10. conference-lower-third-killer _(B2B but not lame)_
|
||||
|
||||
- **Family**: GT America / Aktiv Grotesk
|
||||
- **Weights**: dual-weight name card — role 400, name 700, same size
|
||||
- **Color**: scene-sampled accent on name, white on role
|
||||
- **Motion**: name card swipes in from left 400ms on first appearance only; subsequent static
|
||||
- **Placement**: lower-left card + active-word highlight inline in dialogue above
|
||||
- **Rules**: mode for podcast clips, founder interviews, keynote pulls. Sophisticated without being arty.
|
||||
|
||||
---
|
||||
|
||||
## Which to ship next
|
||||
|
||||
Current templates: `memory-wall` (roughly direction #4), `champion` (roughly a mix of #5 and #9), `portrait-header` (default vertical).
|
||||
|
||||
Priority ship order based on competitive gap:
|
||||
|
||||
1. **documentary-dignified** — no one else does this well; Errol Morris aesthetic is an unserved niche
|
||||
2. **lyrical-poem-on-wall** (as distinct template) — the one that most uses our matte-embed moat
|
||||
3. **high-energy-vlog** — matches but out-designs Hormozi/Submagic presets
|
||||
4. **tech-keynote-confident** — big for product launch videos
|
||||
5. **investigative-typewriter** — niche but highly distinctive
|
||||
|
||||
---
|
||||
|
||||
## How the agent picks
|
||||
|
||||
1. Classify the clip: `tone × shot × platform` (see matrix above)
|
||||
2. Look up direction in matrix
|
||||
3. Read that direction's spec in this file
|
||||
4. Translate spec into plan.json parameters OR clone the canonical HTML from `examples/`
|
||||
5. Tune layout per scene (agent judgment, see `aesthetic-principles.md §1–18`)
|
||||
|
||||
If no direction fits cleanly → **Standard mode**. Don't fake-fit an ill-matched direction.
|
||||
@@ -0,0 +1,371 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#a-roll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/*
|
||||
This scene has no flat wall — subject is center with a busy
|
||||
bookshelf backdrop. So instead of a wall-plane, we place a
|
||||
HEADER band across the top of the frame with subtle perspective
|
||||
(slight rotateX so it lies on the bookshelf rather than floats).
|
||||
Screen-blend picks up the shelf's darker patches.
|
||||
*/
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
perspective: 2400px;
|
||||
perspective-origin: 25% 50%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/*
|
||||
Left-column layout: subject fills x=550-1200, so the bookshelf
|
||||
at x=0-550 is the clean zone. Captions right-align there, with
|
||||
a mild perspective that makes them lie on the shelf.
|
||||
*/
|
||||
.left-plane {
|
||||
position: absolute;
|
||||
/* Upper-left region. Hands gesture widely below y≈460, so we
|
||||
keep the plane above that line. Head sits to the right of
|
||||
x≈600, so x=40-720 here is the clean bookshelf zone. */
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 720px;
|
||||
height: 420px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY(4deg);
|
||||
transform-origin: left center;
|
||||
padding: 24px 36px;
|
||||
}
|
||||
|
||||
/* Each cap stacks at the same absolute position within the plane
|
||||
so hidden captions don't reserve flex space. */
|
||||
.left-plane .cap {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 36px;
|
||||
max-width: 648px; /* plane 720 - padding 36×2 */
|
||||
}
|
||||
|
||||
/* A single "crown" caption placed center-stage for the payoff
|
||||
("A WIMBLEDON CHAMPION") — deliberately crosses the head to
|
||||
deliver the emphasis via cinematic occlusion. */
|
||||
.crown-plane {
|
||||
position: absolute;
|
||||
top: 420px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cap {
|
||||
display: block;
|
||||
position: relative;
|
||||
line-height: 1.02;
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
color: #fff5df;
|
||||
text-shadow:
|
||||
0 0 18px rgba(255, 220, 170, 0.55),
|
||||
0 3px 8px rgba(0, 0, 0, 0.85);
|
||||
filter: brightness(1.12) contrast(1.08);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.cap .w {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
/*
|
||||
Story beats sized to emphasize meaning:
|
||||
- intros / fillers = smaller italic
|
||||
- key statement phrases = bigger upright
|
||||
- emphasis phrases (incredible things) = largest bold
|
||||
- crown (Wimbledon Champion) = huge, center-stage, behind subject
|
||||
*/
|
||||
.cap-intro {
|
||||
font-size: 52px;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.cap-phrase {
|
||||
font-size: 60px;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.cap-emph {
|
||||
font-size: 70px;
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.cap-dream {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.cap-crown {
|
||||
font-size: 140px;
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
letter-spacing: -0.035em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="12.08"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<video
|
||||
id="a-roll"
|
||||
class="clip"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="12.08"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<div id="stage">
|
||||
<!-- Left column: G1-G5 live here, on the clean bookshelf -->
|
||||
<div class="left-plane">
|
||||
<div id="cg-0" class="cap cap-intro">
|
||||
<span class="w" data-i="0">You</span>
|
||||
<span class="w" data-i="1">know,</span>
|
||||
<span class="w" data-i="2">for</span>
|
||||
<span class="w" data-i="3">me</span>
|
||||
</div>
|
||||
<div id="cg-1" class="cap cap-phrase">
|
||||
<span class="w" data-i="0">I've</span>
|
||||
<span class="w" data-i="1">had</span>
|
||||
<span class="w" data-i="2">this</span>
|
||||
<span class="w" data-i="3">kind</span>
|
||||
<span class="w" data-i="4">of</span>
|
||||
<span class="w" data-i="5">upbringing</span>
|
||||
</div>
|
||||
<div id="cg-2" class="cap cap-phrase">
|
||||
<span class="w" data-i="0">the</span>
|
||||
<span class="w" data-i="1">great</span>
|
||||
<span class="w" data-i="2">foundation</span>
|
||||
</div>
|
||||
<div id="cg-3" class="cap cap-emph">
|
||||
<span class="w" data-i="0">I've</span>
|
||||
<span class="w" data-i="1">achieved</span>
|
||||
<span class="w" data-i="2">incredible</span>
|
||||
<span class="w" data-i="3">things</span>
|
||||
</div>
|
||||
<div id="cg-4" class="cap cap-dream">
|
||||
<span class="w" data-i="0">dreaming</span>
|
||||
<span class="w" data-i="1">of</span>
|
||||
<span class="w" data-i="2">becoming</span>
|
||||
<span class="w" data-i="3">number</span>
|
||||
<span class="w" data-i="4">one</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Center-stage crown: the payoff. Sits where the subject is,
|
||||
matte layering makes it *behind* him — the occlusion is the moment. -->
|
||||
<div class="crown-plane">
|
||||
<div id="cg-5" class="cap cap-crown">
|
||||
<span class="w" data-i="0">Wimbledon</span>
|
||||
<span class="w" data-i="1">Champion</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="12.08"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = 12.08;
|
||||
|
||||
/* Word-level transcript — ElevenLabs scribe_v2 */
|
||||
const GROUPS = [
|
||||
{
|
||||
id: "cg-0",
|
||||
in: 0.1,
|
||||
out: 1.45,
|
||||
tone: "soft",
|
||||
words: [
|
||||
{ text: "You", start: 0.18, end: 0.24 },
|
||||
{ text: "know", start: 0.28, end: 0.36 },
|
||||
{ text: "for", start: 0.46, end: 0.62 },
|
||||
{ text: "me", start: 0.66, end: 0.86 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cg-1",
|
||||
in: 1.4,
|
||||
out: 3.35,
|
||||
tone: "soft",
|
||||
words: [
|
||||
{ text: "I've", start: 1.5, end: 1.68 },
|
||||
{ text: "had", start: 1.76, end: 1.94 },
|
||||
{ text: "this", start: 1.98, end: 2.14 },
|
||||
{ text: "kind", start: 2.2, end: 2.34 },
|
||||
{ text: "of", start: 2.36, end: 2.44 },
|
||||
{ text: "upbringing", start: 2.48, end: 3.06 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cg-2",
|
||||
in: 3.5,
|
||||
out: 5.35,
|
||||
tone: "soft",
|
||||
words: [
|
||||
{ text: "the", start: 3.56, end: 3.64 },
|
||||
{ text: "great", start: 3.7, end: 3.9 },
|
||||
{ text: "foundation", start: 3.96, end: 4.6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cg-3",
|
||||
in: 6.05,
|
||||
out: 8.3,
|
||||
tone: "present",
|
||||
words: [
|
||||
{ text: "I've", start: 6.36, end: 6.5 },
|
||||
{ text: "achieved", start: 6.62, end: 7.08 },
|
||||
{ text: "incredible", start: 7.38, end: 7.86 },
|
||||
{ text: "things", start: 7.88, end: 8.16 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cg-4",
|
||||
in: 8.5,
|
||||
out: 10.4,
|
||||
tone: "present",
|
||||
words: [
|
||||
{ text: "dreaming", start: 8.54, end: 8.84 },
|
||||
{ text: "of", start: 8.88, end: 9.0 },
|
||||
{ text: "becoming", start: 9.04, end: 9.4 },
|
||||
{ text: "number", start: 9.48, end: 9.64 },
|
||||
{ text: "one", start: 9.72, end: 9.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cg-5",
|
||||
in: 10.8,
|
||||
out: 12.08,
|
||||
tone: "present",
|
||||
words: [
|
||||
{ text: "Wimbledon", start: 10.92, end: 11.28 },
|
||||
{ text: "Champion", start: 11.38, end: 11.84 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
GROUPS.forEach(function (g) {
|
||||
var sel = "#" + g.id;
|
||||
var words = g.words.map(function (_, i) {
|
||||
return sel + " .w[data-i='" + i + "']";
|
||||
});
|
||||
var isSoft = g.tone === "soft";
|
||||
|
||||
/* Container visible just before entrance; single-layer fade is on words. */
|
||||
tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
g.words.forEach(function (w, i) {
|
||||
if (isSoft) {
|
||||
tl.fromTo(
|
||||
words[i],
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.42, ease: "power2.out", overwrite: "auto" },
|
||||
w.start,
|
||||
);
|
||||
} else {
|
||||
tl.fromTo(
|
||||
words[i],
|
||||
{ opacity: 0, y: 6, scale: 1.04 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1.0,
|
||||
duration: 0.22,
|
||||
ease: "power3.out",
|
||||
transformOrigin: "50% 50%",
|
||||
overwrite: "auto",
|
||||
},
|
||||
w.start,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* Group exit — each group fades out before the next enters. */
|
||||
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);
|
||||
});
|
||||
|
||||
/* Self-lint */
|
||||
GROUPS.forEach(function (g) {
|
||||
var el = document.getElementById(g.id);
|
||||
if (!el) return;
|
||||
tl.seek(g.out + 0.01);
|
||||
var cs = window.getComputedStyle(el);
|
||||
if (cs.opacity !== "0" && cs.visibility !== "hidden") {
|
||||
console.warn("[caption-lint] " + g.id + " visible at t=" + (g.out + 0.01).toFixed(2));
|
||||
}
|
||||
});
|
||||
tl.seek(0);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,334 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280, height=720" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#a-roll,
|
||||
#fg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Foreground matte sits above the captions so the person & mic occlude
|
||||
the text — classic cinematic lower-third layering. */
|
||||
#fg-overlay {
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Caption stage — perspective container so the text plane lies on the
|
||||
acoustic-foam wall in the upper-right of the frame. Small jitter is
|
||||
applied to the stage by GSAP to fake handheld motion tracking.
|
||||
*/
|
||||
#stage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
perspective: 1400px;
|
||||
perspective-origin: 30% 40%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.wall-plane {
|
||||
position: absolute;
|
||||
/* Right-aligned cluster, but widened and pushed slightly left
|
||||
so emphasis words (EVERYTHING, SHARP AGAIN) reach past the
|
||||
subject's silhouette. Short words still hug the wall. */
|
||||
top: 90px;
|
||||
right: 30px;
|
||||
width: 720px;
|
||||
height: 520px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY(-13deg) rotateX(1deg);
|
||||
transform-origin: right center;
|
||||
padding: 24px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cap {
|
||||
display: block;
|
||||
position: relative;
|
||||
line-height: 1.05;
|
||||
opacity: 0;
|
||||
/* The "embed in scene" move: overlay blend picks up the wall's
|
||||
luminance and color so the text feels lit BY the scene. */
|
||||
mix-blend-mode: screen;
|
||||
color: #fff4dc;
|
||||
text-shadow:
|
||||
0 0 12px rgba(255, 210, 150, 0.45),
|
||||
0 2px 5px rgba(0, 0, 0, 0.7);
|
||||
filter: brightness(1.1) contrast(1.05);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.cap .w {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
|
||||
/*
|
||||
Unified cinematic typography — no mode switch between soft & sharp.
|
||||
Elegant serif-inflected sans, italic for the dreamy first half,
|
||||
upright for the second half. One look, one feeling, progressing.
|
||||
*/
|
||||
.cap-1 {
|
||||
font-size: 78px;
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cap-2 {
|
||||
font-size: 66px;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
padding-right: 44px; /* hanging indent on right for right-aligned flow */
|
||||
}
|
||||
.cap-3 {
|
||||
font-size: 72px;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.cap-4 {
|
||||
font-size: 90px;
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
letter-spacing: -0.03em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="8.04"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
>
|
||||
<!-- Layer 0: background plate (full video) -->
|
||||
<video
|
||||
id="a-roll"
|
||||
class="clip"
|
||||
src="source.mp4"
|
||||
muted
|
||||
playsinline
|
||||
data-duration="8.04"
|
||||
data-track-index="0"
|
||||
style="z-index: 1"
|
||||
></video>
|
||||
|
||||
<!-- Layer 2: caption stage (between background and foreground matte) -->
|
||||
<div id="stage">
|
||||
<div class="wall-plane">
|
||||
<div id="cg-0" class="cap cap-1">
|
||||
<span class="w" data-i="0">Some</span>
|
||||
<span class="w" data-i="1">memories</span>
|
||||
<span class="w" data-i="2">feel</span>
|
||||
<span class="w" data-i="3">soft</span>
|
||||
</div>
|
||||
<div id="cg-1" class="cap cap-2">
|
||||
<span class="w" data-i="0">like</span>
|
||||
<span class="w" data-i="1">old</span>
|
||||
<span class="w" data-i="2">film</span>
|
||||
</div>
|
||||
<div id="cg-2" class="cap cap-3">
|
||||
<span class="w" data-i="0">but</span>
|
||||
<span class="w" data-i="1">suddenly</span>
|
||||
</div>
|
||||
<div id="cg-3" class="cap cap-4">
|
||||
<span class="w" data-i="0">everything</span>
|
||||
<span class="w" data-i="1">is</span>
|
||||
<span class="w" data-i="2">sharp</span>
|
||||
<span class="w" data-i="3">again</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Foreground occlusion (person + mic) is applied in post via ffmpeg
|
||||
overlay. Browser-side alpha webm is unreliable across encoders. -->
|
||||
|
||||
<!-- Audio -->
|
||||
<audio
|
||||
id="a-roll-audio"
|
||||
src="source.mp4"
|
||||
data-start="0"
|
||||
data-duration="8.04"
|
||||
data-track-index="3"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DUR = 8.04;
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Word-level transcript — ElevenLabs timestamps.
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
const GROUPS = [
|
||||
{
|
||||
id: "cg-0",
|
||||
words: [
|
||||
{ text: "Some", start: 0.24, end: 0.44 },
|
||||
{ text: "memories", start: 0.48, end: 0.82 },
|
||||
{ text: "feel", start: 0.9, end: 1.14 },
|
||||
{ text: "soft", start: 1.2, end: 1.64 },
|
||||
],
|
||||
in: 0.2,
|
||||
out: 4.85,
|
||||
tone: "soft",
|
||||
},
|
||||
{
|
||||
id: "cg-1",
|
||||
words: [
|
||||
{ text: "like", start: 2.66, end: 2.78 },
|
||||
{ text: "old", start: 2.86, end: 3.02 },
|
||||
{ text: "film", start: 3.14, end: 3.46 },
|
||||
],
|
||||
in: 2.55,
|
||||
out: 4.85,
|
||||
tone: "soft",
|
||||
},
|
||||
{
|
||||
id: "cg-2",
|
||||
words: [
|
||||
{ text: "but", start: 5.02, end: 5.16 },
|
||||
{ text: "suddenly", start: 5.22, end: 5.76 },
|
||||
],
|
||||
in: 4.9,
|
||||
out: 8.04,
|
||||
tone: "present",
|
||||
},
|
||||
{
|
||||
id: "cg-3",
|
||||
words: [
|
||||
{ text: "everything", start: 6.8, end: 7.14 },
|
||||
{ text: "is", start: 7.16, end: 7.28 },
|
||||
{ text: "sharp", start: 7.3, end: 7.52 },
|
||||
{ text: "again", start: 7.56, end: 7.92 },
|
||||
],
|
||||
in: 6.7,
|
||||
out: 8.04,
|
||||
tone: "present",
|
||||
},
|
||||
];
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Each caption group: cinematic word-by-word reveal. No flashes, no
|
||||
grain — purely text animation. Soft groups drift up gently, later
|
||||
groups resolve firmly into place to match the sentence turn.
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
GROUPS.forEach(function (g) {
|
||||
var sel = "#" + g.id;
|
||||
var words = g.words.map(function (_, i) {
|
||||
return sel + " .w[data-i='" + i + "']";
|
||||
});
|
||||
|
||||
var isSoft = g.tone === "soft";
|
||||
|
||||
/* Container stays fully visible — each word animates its own fade.
|
||||
This avoids a double-fade (container × word) that produced a
|
||||
non-linear pop in the first ~100ms of the first caption. */
|
||||
tl.set(sel, { opacity: 1, y: 0 }, Math.max(0, g.in - 0.01));
|
||||
|
||||
/* Per-word reveal — opacity + translate only. No letter-spacing
|
||||
or filter:blur here: both can perturb inline-block metrics
|
||||
mid-animation and cause the cap line-box to reflow, which
|
||||
makes words like "Some" visibly shift between rows when a
|
||||
sibling word enters. transform-only = no reflow. */
|
||||
g.words.forEach(function (w, i) {
|
||||
if (isSoft) {
|
||||
tl.fromTo(
|
||||
words[i],
|
||||
{ opacity: 0, y: 8 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.45,
|
||||
ease: "power2.out",
|
||||
overwrite: "auto",
|
||||
},
|
||||
w.start,
|
||||
);
|
||||
} else {
|
||||
tl.fromTo(
|
||||
words[i],
|
||||
{ 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* Group exit — soft groups linger and fade as the sentence turns,
|
||||
present groups hold to the end. Clean fades, no trickery. */
|
||||
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);
|
||||
});
|
||||
|
||||
/* Self-lint */
|
||||
GROUPS.forEach(function (g) {
|
||||
var el = document.getElementById(g.id);
|
||||
if (!el) return;
|
||||
tl.seek(g.out + 0.01);
|
||||
var cs = window.getComputedStyle(el);
|
||||
if (cs.opacity !== "0" && cs.visibility !== "hidden") {
|
||||
console.warn("[caption-lint] " + g.id + " visible at t=" + (g.out + 0.01).toFixed(2));
|
||||
}
|
||||
});
|
||||
tl.seek(0);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
# Failure Modes — learned the hard way
|
||||
|
||||
Things that broke during skill development. Check against this list before you ship.
|
||||
|
||||
## Matting
|
||||
|
||||
### CoreML execution provider corrupts face alpha
|
||||
|
||||
Running the matting ONNX through the CoreML EP partitions the graph across providers; the mixed-precision boundary produced alpha ≈ 30 (out of 255) inside the subject's face while the background correctly read 0 (observed with the previous RVM engine — don't re-try). Result: caption text visibly shines THROUGH the face.
|
||||
|
||||
**Fix**: Only ever use `providers=["CPUExecutionProvider"]` for the matting ONNX. Our script does this; don't "optimize" it by adding CoreML.
|
||||
|
||||
### Aspect distortion / sharp channel-stride make the alpha garbage
|
||||
|
||||
(historical — the PP-MattingV2 engine was replaced 2026-06-12 by hyperframes `remove-background`; kept because the lessons generalize) Two earned scars from the ONNX era: (1) fixed-size model exports — squashing a portrait clip into a landscape canvas distorts humans; contain-pad (aspect preserved, centered, alpha cropped back) instead. (2) sharp returns a **3-channel** buffer from `.raw()` after resizing a 1-channel raw input — reading it with a 1-channel stride silently produces a smeared, striped, displaced matte that can _look_ plausible. Always `toBuffer({resolveWithObject:true})` and stride by `info.channels` (other scripts in this skill still use sharp).
|
||||
|
||||
**Fix**: both handled inside `matte.cjs`; preserve them if you touch it.
|
||||
|
||||
### isnet-general-use / u2net_human_seg miss props
|
||||
|
||||
rembg's general/human models capture the person but drop handheld props (mic, notebook). If the caption crosses the mic, it will NOT be occluded by the mic — text floats in front of it unnaturally.
|
||||
|
||||
**Fix**: For scenes where props matter, either (a) accept that the human-matting model captures what it captures, or (b) add a manual mask ROI for the prop region. We haven't productized (b) yet.
|
||||
|
||||
## Layout
|
||||
|
||||
### Flex-stacked captions push hidden ones into gesture zone
|
||||
|
||||
First champion iteration put all cap elements inside a flex column with `display: block`. Hidden captions (visibility: hidden) STILL reserve flex space. Result: cg-4 "dreaming" got pushed to y=700, landing right on the hand gesture zone and getting clipped.
|
||||
|
||||
**Fix**: Inside the caption plane, each `.cap` is `position: absolute` at the same coords. Hidden ones don't occupy layout. Both templates in this skill do it this way.
|
||||
|
||||
### Header-overlay on close-crop shots
|
||||
|
||||
First champion iteration used a full-width header band across the TOP of the frame (`top: 30, left: 40, right: 40, height: 360, rotateX: -3deg, justify-content: flex-start, align-items: center`). Subject's head crosses the center, eating the middle of every caption. "know, for me" became a faint "now, for" with the letters sliced apart.
|
||||
|
||||
**Fix**: On close-crop podcast shots, don't use symmetric full-width bands. Use `corner-column-crown` with caption column in the corner opposite the head.
|
||||
|
||||
### Text right-align + align-items: flex-end + no max-width → flows off-screen
|
||||
|
||||
If you set `align-items: flex-end` on the plane and don't constrain width, each `.cap` shrinks to content width. Long words then extend left beyond the plane's "visible" box without wrapping, clipping into the subject.
|
||||
|
||||
**Fix**: Set `max-width: calc(100% - padding*2)` on each `.cap`. Templates do this.
|
||||
|
||||
## Animation
|
||||
|
||||
### letterSpacing animation causes inline-block reflow jumps
|
||||
|
||||
Had `letterSpacing: "0.04em"` as the "from" value in `tl.fromTo(word, {opacity:0, letterSpacing: ".04em"}, {letterSpacing:"0"}, w.start)`. GSAP snaps the "from" state at w.start. The width of the word then changes over the tween → inline-block reflow → the `.cap` line box recomputes → "Some" visibly jumped between rows as "memories" entered.
|
||||
|
||||
**Fix**: Animate only **transform** properties (opacity, y, scale). NEVER animate letter-spacing, font-size, or filter:blur on `.w` spans. Templates follow this.
|
||||
|
||||
### Double-fade (container × word) produces a non-linear pop
|
||||
|
||||
Fading both the group container AND each word independently multiplies: effective_opacity = container.opacity × word.opacity. Even with matched eases, the combined curve is nonlinear and feels like a snap-in around the 40% mark.
|
||||
|
||||
**Fix**: Keep container at fixed `opacity: 1` during the group's life (set via `tl.set` just before first word). Animate only per-word opacity. Templates do this.
|
||||
|
||||
## Blending
|
||||
|
||||
### mix-blend-mode: overlay vanishes into dark regions
|
||||
|
||||
`overlay(white_text, black_bg) ≈ black`. If part of the caption falls on a dark shadow / dark prop, the letters disappear. The first champion render had `SHARP` become `ARP` because "S" and "H" landed in the dark mic's shadow.
|
||||
|
||||
**Fix**: For scenes with significant dark regions under the caption, use `mix-blend-mode: screen` (which preserves white on dark). `wall-embed` defaults to overlay (for mid-tone walls); `corner-column-crown` defaults to screen (for dark bookshelves).
|
||||
|
||||
### Bright backgrounds wash out screen blend
|
||||
|
||||
Opposite problem: `screen(white, bright) ≈ white` regardless of the text — letters become invisible on bright backgrounds like sunlit windows or white walls.
|
||||
|
||||
**Fix**: If average luminance of the caption region exceeds 180/255, switch CSS to `mix-blend-mode: normal` with an opaque color. Sample the background pixels in the caption bbox across the clip to check.
|
||||
|
||||
## Render pipeline
|
||||
|
||||
### Frame-rate mismatch between bg render and matte PNGs
|
||||
|
||||
If hyperframes renders at 30fps default but the matte was extracted at 24fps source rate, ffmpeg overlay gets a temporal mismatch — alpha from frame N is overlaid on bg content of some fractional frame ≠ N. Result: person's current silhouette and matte silhouette are shifted by a few frames, so the occlusion lags or leads the body.
|
||||
|
||||
**Fix**: Always pass `--fps` to hyperframes render matching the source's native FPS (usually 24). `render-and-composite.sh` reads fps from plan.json and passes it through.
|
||||
|
||||
### WebM alpha (VP8/VP9) flaky in Chromium
|
||||
|
||||
We tried making foreground an alpha-channel WebM. Chromium's support for VP9 alpha is inconsistent; VP8 alpha needs specific metadata the default ffmpeg encode doesn't produce.
|
||||
|
||||
**Fix**: Don't try to put the matte inside hyperframes. Do the overlay in post with ffmpeg against a PNG sequence. `render-and-composite.sh` does this.
|
||||
|
||||
## Crown / centered text
|
||||
|
||||
### Centered crown eaten by body
|
||||
|
||||
Default `.crown-plane` in `corner-column-crown` is `left: 0; right: 0; text-align: center`. That mechanically centers the word at `frame_width/2`. If the subject sits right of center (Jobs at x=1100 in a 1920 frame) OR the word isn't wide enough to poke both clean zones, the body eats 50-70% of the crown and it reads as "THE \_\_\_ S".
|
||||
|
||||
**Fix**: Check subject center_x vs frame center before choosing crown strategy (see [layout-heuristics.md § Crown placement](layout-heuristics.md)). Either size the crown large enough to cross both clean zones, or shift it into the larger clean zone with a narrower width. Keep `text-align: center` within the (re-positioned) box, not the frame.
|
||||
|
||||
### Crown font too small for frame → half-swallowed
|
||||
|
||||
On landscape 1920×1080, a centered crown at 140px is only ~500px wide. If the subject is 700-900px wide, the crown vanishes behind them. Symptom: only "THE" and "S" visible for "THE BEATLES".
|
||||
|
||||
**Fix**: For full-frame centered crown, font must be big enough that `crown_width > subject_width + 400`. See font-size table in [typography-presets.md](typography-presets.md). Prefer 180-260px for landscape centered crowns.
|
||||
|
||||
### Wider caption column + default font size → underweight
|
||||
|
||||
Template defaults (66/78/92/140) assume a ~560px column. Expanding the plane to 700+ px without bumping fonts makes the text feel too small relative to the column.
|
||||
|
||||
**Fix**: Column width 600-760 → phrase ~108px, emph ~128px, crown ~220px centered. See table in [typography-presets.md](typography-presets.md).
|
||||
|
||||
## Scene admission
|
||||
|
||||
### Handheld / fast camera → matte alpha flickers
|
||||
|
||||
The matte is temporally smoothed (EMA in matte.cjs) but moderate motion only. Fast camera pans (vlog selfie) still produce frame-to-frame alpha jitter that shows as flickering caption edges.
|
||||
|
||||
**Fix**: SKILL.md decision gate lists handheld vlog as `⚠️` — degrade or refuse. Production version would add `vidstabdetect` compensation; v1 does not.
|
||||
|
||||
### Multi-person scenes → matte is ambiguous
|
||||
|
||||
The human-matting model segments "foreground" as a single alpha. With two people, it captures both but they're treated as one blob. Captions can't go "behind person A but in front of person B". If the scene has both speakers visible, the skill should split by shot boundaries first.
|
||||
|
||||
**Fix**: Either pre-split with PySceneDetect + per-shot planning, or refuse for `⚠️` multi-subject scenes.
|
||||
|
||||
### Undetected shot cut inside the clip
|
||||
|
||||
Some sources (interviews, broadcast clips) cut to B-roll/archive footage mid-clip. The matte + caption layout assumes the subject is the same person throughout — a hard cut mid-render creates garbage (caption placement relative to old subject applied to a totally different shot).
|
||||
|
||||
**Fix for v1**: Probe the clip at 3-5 timestamps before accepting it. If any frame doesn't contain the primary subject at the same position, trim the clip to end before the cut. `Steve Jobs 60 Minutes` cut to Beatles archival at t≈9s, was manually trimmed to 8.5s.
|
||||
|
||||
**Fix for future**: Run PySceneDetect as part of the admission gate; split into shots automatically.
|
||||
|
||||
### Pillarbox / letterbox content with baked-in graphics
|
||||
|
||||
TV archive clips often have pillarbox (black bars at sides) plus baked lower-third logos ("60 Overtime") and date labels ("2003"). These are part of the video file, can't be matted out, and shrink the usable frame area.
|
||||
|
||||
**Fix**: Detect black-bar margins in the first frame (scan rows/columns for all-black). Constrain caption safe-zone to `[left_margin+pad, right_margin-pad, top_margin+pad, bottom_margin-pad]`. For Jobs, safe content zone was x=280-1640 (not 0-1920). Layout positions must respect this.
|
||||
|
||||
### transcribe.cjs sees an existing transcript and skips
|
||||
|
||||
Transcription is Whisper now, via `transcribe.cjs` (it wraps `hyperframes transcribe` — no API key). `hyperframes init --video <mp4>` may itself auto-write a `transcript.json` in hyperframes' raw whisper shape (a flat word array, no top-level `language_code`). `transcribe.cjs` only treats a transcript as done when it's ALREADY in our normalized schema (`{ words: [...], language_code }`); otherwise it (re-)runs Whisper and normalizes the flat word list into `{ words:[{text,start,end,type:"word"}], language_code }`.
|
||||
|
||||
**Fix / expectation**: If you init via hyperframes first, expect `transcribe.cjs` to normalize that transcript into our schema. Don't hand-leave a half-normalized file (e.g. our `words` shape but no `language_code`) — that's the one state the skip-guard can misread.
|
||||
|
||||
### Transcript gaps > 3s with no speech
|
||||
|
||||
If the original audio has a long silence, leaving the caption plane empty feels wrong mid-video. Either (a) let the prior caption linger, (b) show a title-card crown during the silence, or (c) cut the silence out of the clip.
|
||||
|
||||
**Fix**: During caption-grouping, detect silences > 3s. The skill should prompt the user about this, not silently proceed.
|
||||
@@ -0,0 +1,238 @@
|
||||
# Layout Heuristics
|
||||
|
||||
How to pick `wall_position` / `crown_position` in plan.json.
|
||||
|
||||
## Step 1: Sample 3 frames
|
||||
|
||||
```bash
|
||||
ffmpeg -y -ss 0.5 -i source.mp4 -vframes 1 frame0.jpg
|
||||
ffmpeg -y -ss <mid> -i source.mp4 -vframes 1 frame1.jpg
|
||||
ffmpeg -y -ss <end-0.5> -i source.mp4 -vframes 1 frame2.jpg
|
||||
```
|
||||
|
||||
Read all three. Note: subject's head bbox, shoulder top line, and wherever hands move during gestures. Worst-case foreground envelope = union across all 3.
|
||||
|
||||
## Step 2: Find the clean zone
|
||||
|
||||
The caption plane should live in pixels that are **always background** across the clip. Never where the body lands.
|
||||
|
||||
Annotate approximate ranges:
|
||||
|
||||
- Head bbox: `head_x_min`, `head_x_max`, `head_y_min`, `head_y_max`
|
||||
- Hand gesture envelope (if any): usually below `y = shoulder_top ≈ head_y_max + 40`
|
||||
- Props (mic, cup): typically static, note bbox
|
||||
|
||||
**Clean zones**, in priority order:
|
||||
|
||||
1. **Corner farthest from head** (usually opposite the gaze direction)
|
||||
2. **Upper strip above head_y_min minus 30px margin**
|
||||
3. **Lower-third** if upper is occupied (last resort — breaks "embedded" aesthetic)
|
||||
|
||||
## Step 2.5: Which side of the subject?
|
||||
|
||||
Once you know where the subject and baked graphics are, decide which side (left or right of the subject) hosts the caption column. Order of precedence:
|
||||
|
||||
### 1. Hard constraints first — baked graphics
|
||||
|
||||
Logos, watermarks, date stamps, "60 Overtime"-style lower-thirds are already in the source. They occupy permanent zones you must avoid. Map them out from frame samples:
|
||||
|
||||
- Jobs 60 Minutes: "2003" at upper-left (x=280-460, y=30-90); "60 Overtime" at bottom-left (x=280-770, y=960-1080). Left side partially constrained; still usable above/below these.
|
||||
- TikTok re-uploads: username watermark that rotates corners every few seconds — hard to plan around, often a refusal reason.
|
||||
|
||||
If one side has a hard constraint that eats > 60% of that side's clean zone, default to the other side.
|
||||
|
||||
### 2. Subject body bias — pick the bigger clean zone
|
||||
|
||||
Compute clean-zone widths on both sides:
|
||||
|
||||
```
|
||||
left_clean_width = body_x_min − safe_left_margin
|
||||
right_clean_width = safe_right_margin − body_x_max
|
||||
```
|
||||
|
||||
Pick the side where clean zone is wider. If the difference is within 10% of frame width, the sides are ~equivalent → fall through to step 3.
|
||||
|
||||
Worked examples:
|
||||
|
||||
- **Champion** (Djokovic, 1920×1080): body x ≈ 550-1200. Left clean = 510, right clean = 720. Right is wider → but we put captions LEFT because of gaze (see step 3). The narrow difference made either workable.
|
||||
- **Jobs**: body x ≈ 700-1480 (right-of-center). Left clean = 420, right clean = 160. Left wins decisively. Captions went LEFT.
|
||||
- **Memory Wall**: surface location dictated the side (see note below about wall-embed).
|
||||
|
||||
### 3. Gaze direction — the "looking room" rule (tiebreaker and aesthetic)
|
||||
|
||||
Classic cinematic framing: leave more empty space on the side the subject is **looking toward**. This preserves their gaze path and feels uncluttered. **Captions go to the OPPOSITE side** (the side the subject is facing away from), so they don't steal looking room.
|
||||
|
||||
Quick check: sample 3 frames. Estimate the eye-line vector. Does it point more left or right?
|
||||
|
||||
- Looking screen-right → captions on LEFT
|
||||
- Looking screen-left → captions on RIGHT
|
||||
- Looking forward at camera → no preference from gaze, use step 2 only
|
||||
|
||||
The champion / Djokovic shot has him addressing camera slightly from the left — captions on LEFT actually read like text he's looking toward, which can feel like he's acknowledging them. That's fine here but is a flavor choice; generally prefer gaze-opposite.
|
||||
|
||||
### 4. Narrative emphasis (for optional crown)
|
||||
|
||||
If you're using a **center-stage crown**, it sits across the subject — no left/right choice for it. But the other captions (the column) still follow steps 1-3.
|
||||
|
||||
If you're using a **clean-zone crown** (shrunk, placed in one clean zone instead of crossing the body), put it in the **same side as the main column** for visual consistency. Don't split crown and column on opposite sides — it creates ping-pong.
|
||||
|
||||
### Special case — wall-embed
|
||||
|
||||
When the template is `wall-embed`, the side is **dictated by where the usable surface is**, not by body bias or gaze. Memory Wall's foam panel was on the right, so captions went right even though the subject was slightly left-of-center. Surface location wins because the whole effect depends on the text sitting ON that specific surface.
|
||||
|
||||
### Decision summary
|
||||
|
||||
```
|
||||
Side = f(baked graphics, body bias, gaze, surface)
|
||||
|
||||
priority:
|
||||
1. Hard constraints (baked logos) — never place here
|
||||
2. Wall-embed surface location — wins if using wall-embed
|
||||
3. Bigger clean zone — default for corner-column
|
||||
4. Gaze direction (looking room) — tiebreaker when both sides similar
|
||||
```
|
||||
|
||||
If the subject actively swings their gaze across the clip (turning head L→R), pick a side that works for both extremes, not just the most frequent. Or accept that looking room will briefly be violated — it's a 10-second video, nobody cares.
|
||||
|
||||
## Step 3: Pick template + position
|
||||
|
||||
### If scene has a flat back wall (acoustic foam, plaster, fabric backdrop)
|
||||
|
||||
→ `wall-embed.html`
|
||||
|
||||
```
|
||||
wall_position: {
|
||||
top: max(40, head_y_min - 40),
|
||||
right: 20-60 (hug outer edge),
|
||||
width: video_width * 0.35 – 0.50,
|
||||
height: shoulder_top - top,
|
||||
rotateY: -10 to -16 deg if wall angles away on the left,
|
||||
+10 to +16 deg if mirrored, 0 if wall is parallel,
|
||||
rotateX: 0-3 deg subtle downtilt only
|
||||
}
|
||||
```
|
||||
|
||||
`mix-blend-mode: overlay` in this template — works on mid-tone walls. If wall is near-black (luminance < 60), switch CSS to `screen`.
|
||||
|
||||
### If scene has a cluttered but dark backdrop (bookshelf, plants, set dressing)
|
||||
|
||||
→ `corner-column-crown.html`
|
||||
|
||||
```
|
||||
wall_position: {
|
||||
top: 40,
|
||||
left: 40, // anchor to clean corner opposite head
|
||||
width: video_width * 0.40,
|
||||
height: clamp(360, 520), // don't reach below shoulder top
|
||||
rotateY: 3-6 deg (subtle, not flashy)
|
||||
}
|
||||
crown_position: {
|
||||
top: video_height * 0.40 // center-ish; body will cut middle letters
|
||||
}
|
||||
```
|
||||
|
||||
`mix-blend-mode: screen` is correct here (bookshelf is dark).
|
||||
|
||||
### If subject fills >70% of frame
|
||||
|
||||
Template doesn't matter — there's nowhere clean. **Refuse**, suggest classic lower-third.
|
||||
|
||||
## Step 3.5: Crown placement (when using `corner-column-crown`)
|
||||
|
||||
**Default preference: center-stage crown.** A big, centered crown word (think "WIMBLEDON CHAMPION", "BEATLES", "SHARP AGAIN") that crosses the subject is the most powerful embed move — body occludes the middle letters, clean zones on both sides hold the outer letters, and the word reads as a title drop. **Use this whenever conditions allow.**
|
||||
|
||||
### Conditions where a centered crown works
|
||||
|
||||
Sample 3 frames, eyeball the subject's horizontal envelope across the clip. Let `body_x_min / body_x_max` = tightest horizontal bounds that contain the head+shoulders+arms in any frame.
|
||||
|
||||
A centered crown at font size F (where `crown_width ≈ F × 0.55 × char_count`) reads as dramatic IF:
|
||||
|
||||
1. **Subject is roughly centered**: `|body_center_x − frame_width/2| < frame_width × 0.10`. The body sits in the middle third.
|
||||
2. **Clean zones on both sides are non-trivial**: `body_x_min > frame_width × 0.15` AND `body_x_max < frame_width × 0.85`. There's ≥15% of frame width clean on each side.
|
||||
3. **The crown word is wide enough to poke out both sides**: `crown_width > body_width + 400px`. If the word ends before reaching the body's right edge, most letters just get swallowed — ugly.
|
||||
|
||||
If all three hold, centered crown is right. Go big — font sized so the word spans `0.8 × frame_width` or more. "BEATLES" at 140px (~500px wide) fails condition 3 on an 1920 frame with Jobs-sized body (780px wide). "WIMBLEDON CHAMPION" at 140px (~1700px) on the same frame passes easily.
|
||||
|
||||
### When center fails — move crown to the clean zone
|
||||
|
||||
If any of the three conditions fails, center-stage eats too much and the word becomes illegible ("THE BEATLES" → "THE" and a sliver of "S"). Two strategies:
|
||||
|
||||
**Option A — shrink crown to fit cleanly in the larger clean zone.** Pick the side opposite the subject's bias. Compute `clean_zone_width` on that side. Pick crown font such that the word wraps to 1-2 lines inside it. Tail letters can touch the subject edge for a hint of embed, but the body of the word lives on backdrop.
|
||||
|
||||
```
|
||||
// Jobs: subject center ≈ x=1100 (right of frame center 960). Left clean zone is larger.
|
||||
crown_plane: { left: 200, width: 560, text-align: center }
|
||||
font-size: 118px → "THE / BEATLES" wraps to 2 lines, fits in x=230-770
|
||||
leaving "S" tail at x~760 lightly touching Jobs's shoulder
|
||||
```
|
||||
|
||||
**Option B — drop the crown entirely, promote the word to `emph` in the main column.** Simpler when the phrase doesn't deserve dramatic center-stage treatment. Works fine for 2-word emph like "the Beatles" or "incredible things" — they become large bold text in the left column with body occluding their tails.
|
||||
|
||||
### Rules of thumb for choosing
|
||||
|
||||
| Subject occupies center X%+ of frame | Clean zones L/R | Crown approach |
|
||||
| ------------------------------------ | ------------------------ | ----------------------------------------------- |
|
||||
| < 50%, roughly centered | both ≥ 15% of width | Centered crown, go BIG (frame_w × 0.8+) |
|
||||
| 50-70%, slightly offset | one side ≥ 25% | Crown shifted to larger clean zone |
|
||||
| > 70%, fills most of frame | neither side wide enough | Drop crown, use `emph` in column |
|
||||
| Close-crop face, fills > 80% | essentially none | No crown. All captions in a header/footer strip |
|
||||
|
||||
## Step 4: Respect these invariants
|
||||
|
||||
1. **Never cover the eyes.** Eye bbox is sacred. Add 20px margin around it when checking caption bbox intersection.
|
||||
2. **Caption bbox must not exit frame.** Reserve 4% broadcast-safe margin on all edges.
|
||||
3. **Large emphasis words should have horizontal slack.** At `cap-emph` (92px), an 8-char word ≈ 580px. Plane needs ≥ 620px width OR rely on wrapping at 2 lines.
|
||||
4. **Crown lives at `top ≈ shoulder_top − crown_font_size/2`** so it sits across the upper body, not the face.
|
||||
5. **If aspect ratio is portrait (9:16), rotate layout**: wall-plane becomes a **top band** (full width, height ≈ 20%), crown goes below it if used at all.
|
||||
|
||||
## Step 5: Validate before render
|
||||
|
||||
Before calling `render-and-composite.sh`, sanity check:
|
||||
|
||||
- Did you place the plane in the opposite quadrant from the face? ✓
|
||||
- Does the plane exit the frame anywhere? If yes, shrink.
|
||||
- Is the font too big for the plane width? Calculate: longest word in words[] × 0.55 × font_size < plane_width - padding\*2.
|
||||
- For `corner-column-crown`, did you set `crown_position.top` so the crown actually crosses the body (not above the head, not below the torso)?
|
||||
- **Pillarbox / letterbox hard check.** If the source has black bars (see pre-flight probe in SKILL.md), compute `leftmost_text_x` using the correct formula for the text alignment:
|
||||
|
||||
| Alignment | Formula |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| Left-aligned | `plane_left + padding_left` |
|
||||
| Right-aligned (main column) | `plane_right − padding_right − longest_word_width` |
|
||||
| Center-aligned (crown) | `plane_center − longest_word_width / 2` |
|
||||
|
||||
Where `longest_word_width ≈ font_size × char_count × 0.55` (adjust 0.55 down to ~0.50 for italic, up to ~0.62 for uppercase bold). **All** of these must be ≥ `pillarbox_left_edge + 10–20px` (safety margin). Mirror for the right bar.
|
||||
|
||||
**Gotcha from past iteration**: setting `plane_left = 180` with a right-aligned column on a Jobs 60 Minutes clip (pillarbox at 280) looked "inside the plane" but the right-aligned text at font 108px produced `leftmost = plane_right − padding − 468 = 180 + 700 − 36 − 468 = 376` … wait, that IS past pillarbox. The actual bug was different: for a 3-line wrap ("four very / talented / guys") the widest line is "talented" at ~468px, not the whole phrase. Compute against the longest **single line** after wrap, not total phrase width.
|
||||
|
||||
**Re-check after font-size changes** — bumping font 84→104 shifts right-aligned text leftward by ~30px. If the budget was already tight, the bump blows through the pillarbox.
|
||||
|
||||
**Empirical target**: leftmost text at pillarbox + 10-20px looks "anchored" to the bar (visually grounded). Pushing it much deeper (50px+) makes captions feel floaty in the middle of the frame.
|
||||
|
||||
## Worked examples
|
||||
|
||||
### memory-wall (1280×720, acoustic foam wall)
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "wall-embed",
|
||||
"wall_position": {
|
||||
"top": 40,
|
||||
"right": 30,
|
||||
"width": 720,
|
||||
"height": 520,
|
||||
"rotateY": -13,
|
||||
"rotateX": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### champion (1920×1080, bookshelf backdrop)
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "corner-column-crown",
|
||||
"wall_position": { "top": 40, "left": 40, "width": 720, "height": 420, "rotateY": 4 },
|
||||
"crown_position": { "top": 440 }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# Motion Vocabulary — named word-entry moves
|
||||
|
||||
10 motion primitives the skill uses. Each has a specific timing + easing + situational fit. Agent picks by **content tone**, not default to one.
|
||||
|
||||
**Easing philosophy:** `cubic-bezier(.2,.7,.2,1)` is the "considered confident" default. `linear` is almost always wrong. Back-easing (elastic) is for playful content only — never documentary.
|
||||
|
||||
**Exit rules:** Default exit is the same tween reversed at 60% duration. Never exit with a different motion than entry unless doing a rhetorical pivot. Never fade out during speech — only during the gap between phrases.
|
||||
|
||||
---
|
||||
|
||||
## The 10 moves
|
||||
|
||||
### 1. typewriter
|
||||
|
||||
Char-by-char reveal, 25–35ms/char, linear easing. Documentary / data-heavy / tense / interrogative.
|
||||
|
||||
```js
|
||||
// GSAP
|
||||
words.forEach((w, i) => {
|
||||
const chars = w.textContent.split("");
|
||||
w.textContent = "";
|
||||
chars.forEach((c, j) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = c;
|
||||
span.style.opacity = 0;
|
||||
w.appendChild(span);
|
||||
tl.to(span, { opacity: 1, duration: 0 }, wordStart + j * 0.028);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. word-fade-up (DEFAULT)
|
||||
|
||||
Per-word, 80ms stagger, `opacity 0→1 + translateY 8→0`, `cubic-bezier(.2,.7,.2,1)` (ease-out). Default for conversational content.
|
||||
|
||||
```js
|
||||
tl.fromTo(
|
||||
word,
|
||||
{ opacity: 0, y: 8 },
|
||||
{ opacity: 1, y: 0, duration: 0.45, ease: "power2.out" },
|
||||
wordStart,
|
||||
);
|
||||
```
|
||||
|
||||
### 3. word-pop
|
||||
|
||||
Per-word, 60ms stagger, `scale 0.85→1 + opacity 0→1`, elastic-light `cubic-bezier(.34,1.56,.64,1)`. Energetic / YouTube-vlog / high-tempo. **Never for documentary.**
|
||||
|
||||
### 4. swipe-reveal
|
||||
|
||||
Whole phrase at once, 400ms, `clip-path inset 0 100% 0 0 → 0 0 0 0`, expo `cubic-bezier(.77,0,.175,1)`. Keynote / tech product / announcement.
|
||||
|
||||
```css
|
||||
.cap {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
/* animate to inset(0 0 0 0) */
|
||||
```
|
||||
|
||||
### 5. drop-and-settle
|
||||
|
||||
Per-word, 120ms stagger, `y: -30→0` with back-bounce `cubic-bezier(.68,-.55,.27,1.55)`. Playful but controlled. **Not for documentary.**
|
||||
|
||||
### 6. etch
|
||||
|
||||
Whole word at once, 600ms, opacity ramp `0→1` with slight blur→sharp transition. Ease-in-out. Poetic / lyrical / "quote on wall" direction.
|
||||
|
||||
### 7. crosshair
|
||||
|
||||
Per-word, 200ms total: two 1px lines converge on word center (linear 150ms), then word snaps in (expo-out 50ms). Thriller / investigative doc.
|
||||
|
||||
### 8. breathing-hold
|
||||
|
||||
**Static but alive.** No entry animation. Once visible, micro-scale loop `1.00↔1.008` over 2s, sine ease. Used on climax lines held 1.5s+.
|
||||
|
||||
### 9. cascade
|
||||
|
||||
Each word from alternating direction: L, R, L, R. `translateX: ±40→0`, ease-out 300ms. **Rhythm-break move** — use sparingly, once per 30s max.
|
||||
|
||||
### 10. burn-in
|
||||
|
||||
**Zero animation.** Sudden 1-frame opacity 1. No fade, no transform. Interview-doc style. Use for every 6–8th phrase in a documentary direction for variety (Standard mode; see direction-catalog §1).
|
||||
|
||||
---
|
||||
|
||||
## Tone → timing lookup table
|
||||
|
||||
| Tone | Stagger | Hold | Entry move |
|
||||
| -------------- | ------------------------- | ----------- | ---------------------------------- |
|
||||
| documentary | 150ms | 600ms | burn-in (70%) + word-fade-up (30%) |
|
||||
| conversational | 80ms | 400ms | word-fade-up |
|
||||
| energetic | 50ms | 300ms | word-pop |
|
||||
| poetic | 250ms | 1200ms | etch |
|
||||
| broadcast | 0 (burn-in) | BBC timings | burn-in |
|
||||
| investigative | 25ms/char | 500ms | typewriter |
|
||||
| keynote | 0 per-word (phrase swipe) | 600ms | swipe-reveal |
|
||||
|
||||
Agent picks tone → this table → GSAP parameters. Data, not guesswork.
|
||||
|
||||
---
|
||||
|
||||
## Per-word vs per-phrase animation axis
|
||||
|
||||
Orthogonal to the 10 moves above.
|
||||
|
||||
- **Per-word animation** (default): each word enters at its `w.start` timestamp. Tight sync with speech.
|
||||
- **Per-phrase animation**: whole group enters/exits at `g.in`/`g.out`. Words appear together. Used for:
|
||||
- swipe-reveal moves
|
||||
- burn-in (no animation, but the "reveal" is a group event)
|
||||
- title cards / chapter breaks
|
||||
|
||||
Rule: per-word for content that tracks speech; per-phrase for moments that step outside the speech flow.
|
||||
|
||||
---
|
||||
|
||||
## Stagger breakdown by context
|
||||
|
||||
Fine-tune stagger within a tone:
|
||||
|
||||
- **Documentary dialog**: 150ms (measured)
|
||||
- **Documentary punchline**: 250ms (weighted)
|
||||
- **Vlog opener**: 50ms (fast hook)
|
||||
- **Vlog narration**: 70ms (conversational but snappy)
|
||||
- **Poem / lyric**: 250–400ms per word, with 500–1000ms gaps between phrases
|
||||
- **Keynote hero**: per-phrase only (stagger = N/A)
|
||||
- **Interview name card**: 0 stagger (static)
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (banned, from anti-patterns.md)
|
||||
|
||||
- Animating `letter-spacing` → reflow
|
||||
- Animating `filter:blur` → reflow + can wash out text
|
||||
- `Math.random()` / `Date.now()` in timeline → non-deterministic render
|
||||
- Entries under 100ms total → frantic, unreadable
|
||||
- Same motion for every group for 30s+ → eye adapts, stops registering
|
||||
|
||||
---
|
||||
|
||||
## When to break the vocabulary
|
||||
|
||||
The 10 moves are a comfortable coverage, not a ceiling. For Standard mode, invent new moves when the scene demands it:
|
||||
|
||||
- Kyle Cooper's scratched typewriter for noir
|
||||
- K-pop lyric's directional word-from-side
|
||||
- Film-title flash-cut burn-in with chromatic aberration
|
||||
|
||||
When inventing: stay deterministic, stay transform+opacity only, state the motion in terms of the 4 axes (entry, hold, exit, stagger).
|
||||
@@ -0,0 +1,68 @@
|
||||
# Rail track — standard lower-third subtitle
|
||||
|
||||
The **default** caption track and the workhorse: a clean, readable subtitle that sits **in
|
||||
front** of everything (never occluded by the matte) in the lower third. It carries **most**
|
||||
of the transcript. It is deliberately **plain** — the cinematic craft (planes, occlusion,
|
||||
Vogue-masthead, accumulation) lives in the _embed_ track only ([composition-craft.md](composition-craft.md)).
|
||||
|
||||
For talking-head / explainer / voiceover, the typical output is **the whole transcript on the rail**
|
||||
with only the climax(es) promoted to embed. Rail is not a fallback — it's the baseline.
|
||||
|
||||
> **Implementation note.** A dedicated rail renderer is the next build step. The rail is a
|
||||
> plain `fg` caption track and maps cleanly onto hyperframes' native caption pipeline
|
||||
> (`media-use` captions) — prefer reusing that over hand-rolling. Until wired, render
|
||||
> the rail as a simple `data-caption-layer="fg"` composition (no matte overlay for these caps).
|
||||
|
||||
## Position & safe area
|
||||
|
||||
- **Lower third, horizontally centered.** Landscape (16:9): baseline ~80–120px above the
|
||||
bottom edge. Portrait (9:16): lower-middle, ~ 600–700px from the bottom (clear of platform UI).
|
||||
- **Title-safe margins:** keep text within ~90% width / inside any letterbox-pillarbox bars
|
||||
(see the letterbox probe). Never flush to the frame edge.
|
||||
- **One caption group on screen at a time.** No accumulation, no cascade — that's embed-track behaviour.
|
||||
- It rides above the subject; the matte does **not** occlude it (rail = in front).
|
||||
|
||||
## Lines, length, timing
|
||||
|
||||
- **≤ 2 lines.** Broadcast target ~ 32–42 chars/line; break at a clause/phrase boundary, never
|
||||
mid-word, never leave a dangling 1-word line.
|
||||
- **Word-synced.** Each group's window envelops its words (`group.in ≤ first word.start`,
|
||||
`group.out ≥ last word.end`); each group ≥ 0.5s on screen; ~1.5s min gap discipline so it
|
||||
doesn't strobe. Word timings within 80ms of transcript (same gate as everywhere).
|
||||
- **Grouping** = short readable phrases (see [caption-grouping.md](caption-grouping.md)) — not the
|
||||
embed track's "phrase = composition" rule; here it's just legible subtitle chunking.
|
||||
|
||||
## Look (restrained on purpose)
|
||||
|
||||
- **Size: ~`calc(0.045 * var(--h))`** (≈48px @1080, ≈58px @1290) — readable, _not_ hero. The rail is a subtitle; it is deliberately much smaller than the embed body/climax. Express as a `var(--h)` fraction (never hardcode px) so it scales across resolutions. The embed climax is sized to the frame independently (see composition-craft § POP) — **never** size the climax as a multiple of this rail.
|
||||
- One clean sans (Inter / Helvetica Now / Neue Haas), weight 500–600; white (or near-white).
|
||||
- **Legibility without grading the video:** a tight text treatment local to the glyphs only —
|
||||
a soft dark drop-shadow, or a subtle rounded gradient pill / 30–40% scrim **sized to the text
|
||||
box** (not a full-frame bar, never a frame-wide grade). On luminance > 180 backgrounds, keep
|
||||
the scrim; never rely on bare light text.
|
||||
- Motion is minimal: 150–250ms fade-up in / fade-down out. No glitch, no scale-pop, no per-word
|
||||
choreography on the rail — that energy is reserved for embed.
|
||||
|
||||
## The `emphasis` flag (active-word highlight)
|
||||
|
||||
The only intensity the rail carries. When a word is graded `emphasis` (the 1–2 punch words in a
|
||||
phrase), give it an **inline** lift on the rail — accent colour and/or +weight, optionally a
|
||||
karaoke-style active-word pop (≤1.1× scale) as it's spoken. Keep it inline; it does **not**
|
||||
leave the rail. Anything that wants to leave the rail is an `embed`, not an emphasis.
|
||||
|
||||
## What the rail never does
|
||||
|
||||
- Never goes behind the subject (that's embed).
|
||||
- Never accumulates into a multi-line poem (that's embed).
|
||||
- Never crosses the face / uses occlusion as an effect.
|
||||
- Never grades or textures the underlying video.
|
||||
- Doesn't drop content to "fit fewer blocks" — split into more groups instead. The only drops
|
||||
are filler (um/uh, exact stutters, self-corrections); dense conversational `narrator` glue
|
||||
("you know", "sort of") may be trimmed for readability, content/structure words stay.
|
||||
|
||||
## Hand-off to embed
|
||||
|
||||
Rail + embed coexist in one render: the rail runs the whole clip; an embedded peak appears over
|
||||
it at its moment (the rail can briefly clear or dim under the embed if they'd collide). Decide
|
||||
which phrases promote via the role read in SKILL.md § Caption model; author the promoted ones
|
||||
with [composition-craft.md](composition-craft.md).
|
||||
@@ -0,0 +1,47 @@
|
||||
# The reference bar — what world-class looks like
|
||||
|
||||
Self-review without an external bar always passes. Before shipping, hold the render
|
||||
against these references — not "is anything broken" (the gates did that) but "would a
|
||||
designer at these houses sign it".
|
||||
|
||||
## Per register
|
||||
|
||||
**premium-warm / editorial (cream, editorial)** — the bar is a _Vogue cover_ and the
|
||||
A24 title card: type interacts with the subject (masthead behind the head), one or two
|
||||
sizes feel chosen not defaulted, the frame would work printed as a poster. Check: does
|
||||
the climax frame, screenshotted alone, look like a designed cover — or like a subtitle
|
||||
that grew?
|
||||
|
||||
**tech-premium (keynote, ink)** — the bar is an _Apple keynote slide_ over footage:
|
||||
opaque, weighty, dead-center confidence; negative space is doing the work; nothing
|
||||
decorating. Check: would this frame look at home in a WWDC sizzle?
|
||||
|
||||
**formal (documentary)** — the bar is _Errol Morris / PBS Frontline_: the type has the
|
||||
restraint of a court document; stillness reads as authority. Check: does ANY motion call
|
||||
attention to itself? If yes, cut it.
|
||||
|
||||
**loud** — the bar is a _k-pop lyric video / Nike spot_: type fights the subject and
|
||||
wins on purpose, color is saturated and scene-true, the beat hits WITH the word. Check:
|
||||
does the hero frame make you want to replay it?
|
||||
|
||||
## The five positive checks (run on the preview sheet, after the failure checks)
|
||||
|
||||
1. **Poster test** — screenshot the climax frame alone. Would you post it as a still?
|
||||
If it needs the motion to excuse the composition, the composition isn't done.
|
||||
2. **Timid test** — is any body caption apologizing (tiny, corner-parked, far from the
|
||||
silhouette)? Small is a choice; timid is a default. Hug the subject or commit to the
|
||||
margin as a designed column.
|
||||
3. **One-glance hierarchy** — at thumbnail size (shrink the sheet), can you still tell
|
||||
opener / body / hero apart? If the tiers blur together, the contrast ratios are off.
|
||||
4. **Scene handshake** — name three ways the type acknowledges THIS footage (accent
|
||||
sampled from the scene, occlusion crossing, shadow direction, depth blur, palette
|
||||
temperature). Fewer than two = it's a sticker, not an embed.
|
||||
5. **Dead-air audit** — scrub the no-caption stretches. Are they _composed rests_
|
||||
(subject carries the frame) or _gaps_ (the design just stopped)? Rests are earned by
|
||||
rhythm; gaps are bugs in pacing.
|
||||
|
||||
## How to use with fresh-eyes review
|
||||
|
||||
Give the sub-agent the preview sheet + the 5 failure checks (SKILL.md § Visual QA) +
|
||||
these 5 positive checks, and ask for verdicts on both lists. A render ships when it
|
||||
passes the failure checks AND at least 4/5 positive checks with specific evidence.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Scene Types — picking the right template
|
||||
|
||||
The three templates differ in one thing: **what surface (if any) the text physically sits on**. Pick by reading the scene, not by the genre label.
|
||||
|
||||
## wall-embed — "text is printed on a real surface"
|
||||
|
||||
Original Memory Wall render used this. Text uses perspective transform + `mix-blend-mode: overlay` to look like it was silk-screened onto a wall behind the subject. The illusion is strong when it works and completely broken when forced.
|
||||
|
||||
### Four conditions — ALL must hold
|
||||
|
||||
1. **A flat or near-flat surface exists somewhere in the frame, larger than the caption block.** Good: acoustic foam panel, painted wall, door, stone facade, large backdrop cloth, blackboard. Bad: bookshelf (3D depth + decorative items), mic shield (too small), window (transparent, content behind it shifts), brick wall with mortar patterns (competes).
|
||||
2. **The surface is at a non-zero angle to the camera.** Perspective is what sells "embedded". If the wall is perfectly parallel (0° rotation relative to camera), pasting rotateY:0 text onto it looks like a sticker, not embedded. Memory Wall's foam wall was tilted ~13° — perfect. A wall directly behind a centered frontal subject usually has no exploitable angle.
|
||||
3. **The surface is mid-luminance.** Overlay blend neutralizes mid-tones — text keeps contrast against a mid-grey wall. On near-black surfaces (luminance < 60), overlay makes text vanish → switch to `mix-blend-mode: screen`. On near-white surfaces (> 180), overlay also fails → switch to `mix-blend-mode: normal` with opaque color.
|
||||
4. **The surface has no competing graphical content.** Plain paint, uniform acoustic foam, blurred OOF background all work. Printed wallpaper, picture frames, text on signage, busy bookshelves all compete with the caption and ruin the embed illusion.
|
||||
|
||||
If **any** condition fails, **do not use wall-embed**. Pick another template.
|
||||
|
||||
### What memory-wall looked like
|
||||
|
||||
The acoustic foam wall to the right of the subject took ~40% of frame width, tilted ~13° toward the viewer, mid-grey color, uniform dimpled texture. Perfect. Text with `rotateY(-13deg)` and overlay blend sat on it like printed letters.
|
||||
|
||||
### Anti-examples (wall-embed would FAIL)
|
||||
|
||||
- **champion / demo1_avatarv** — bookshelf background with books, white vases, picture frames. Cluttered + 3D depth. Fails condition 1 and 4. Used corner-column-crown instead.
|
||||
- **Jobs 60 Minutes** — blurry potted plant background. No flat surface at all. Fails condition 1.
|
||||
- **Randy Pausch "Time is all we have"** — completely black backdrop. No perspective to exploit, no mid-tone surface. Fails 2 and 3. Used portrait-header.
|
||||
- **Any vlog / selfie with handheld camera** — surface moves frame-to-frame, can't lock perspective. Even if conditions 1-4 hold briefly, motion breaks the illusion.
|
||||
|
||||
## corner-column-crown — "text sits in a clean region next to the subject"
|
||||
|
||||
The default for most talking-head content. Works when wall-embed doesn't.
|
||||
|
||||
### Conditions
|
||||
|
||||
1. **Single subject, fixed camera.** Handheld is tolerable if drift is small.
|
||||
2. **At least one clean zone (≥ 15% of frame width) to the left or right of the subject** where captions can sit without continuous body occlusion.
|
||||
3. **Background can be cluttered or 3D** — we're not relying on a flat surface, just readable contrast. Screen blend mode picks up darker backgrounds (bookshelves, dark walls) naturally.
|
||||
4. **Landscape aspect** (16:9 or similar). For portrait, use `portrait-header`.
|
||||
|
||||
### When to add the crown vs when to skip it
|
||||
|
||||
- Add crown if there's a natural "title drop" line — the one phrase that lands the story (e.g. "WIMBLEDON CHAMPION", "BEATLES", "SHARP AGAIN"). Check [layout-heuristics.md § Crown placement](layout-heuristics.md) for the three geometric conditions.
|
||||
- Skip crown for factual / neutral monologues where no phrase carries the climax. Short videos (<10s) often don't need it.
|
||||
|
||||
## portrait-header — "subject fills center, text lives in top banner"
|
||||
|
||||
For 9:16 aspect where the subject inevitably fills the middle.
|
||||
|
||||
### Conditions
|
||||
|
||||
1. **Portrait aspect** (9:16, 1080×1920 etc.).
|
||||
2. **Subject occupies the vertical middle of frame**, leaving ~15-25% of the frame height clear at the top.
|
||||
3. **Optional crown at bottom** — only works if there's also clean space beneath the subject's waist, which is rare in close-crop portrait.
|
||||
|
||||
### What to watch
|
||||
|
||||
- Subject's head top often approaches y=40 — top banner captions at `top: 30px` will get partially occluded by hair. Accept the partial occlusion (it reads as embed) or bump the caption size down to clear the hair.
|
||||
- If the subject already has **baked-in captions** (like the burned-in subtitles test case with yellow subtitles burned in), the skill should refuse — two caption systems compete.
|
||||
|
||||
## Quick decision flow
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
├─ Aspect is 9:16 / portrait?
|
||||
│ ├─ YES → portrait-header
|
||||
│ └─ NO ──▼
|
||||
│
|
||||
├─ Does the scene have a flat uniform surface that passes
|
||||
│ ALL 4 wall-embed conditions?
|
||||
│ ├─ YES → wall-embed
|
||||
│ └─ NO ──▼
|
||||
│
|
||||
├─ Single subject + fixed camera + ≥15% clean zone on one side?
|
||||
│ ├─ YES → corner-column-crown
|
||||
│ └─ NO ──▼
|
||||
│
|
||||
└─ Refuse with reason:
|
||||
"No flat surface for embed, no clean caption zone for column.
|
||||
Suggest a classic lower-third instead."
|
||||
```
|
||||
|
||||
## Mode checklist (run this before committing to wall-embed)
|
||||
|
||||
Read 3 frames (start, mid, end). For each, honestly answer:
|
||||
|
||||
- [ ] Is there a single surface behind/beside the subject that occupies ≥ 30% of the frame?
|
||||
- [ ] Is that surface relatively flat (not curved, not 3D-stacked shelving)?
|
||||
- [ ] Does that surface angle at 5-20° to the camera (not perfectly parallel, not warped)?
|
||||
- [ ] Is the surface mid-luminance (not near-black like dark bookshelves, not near-white like a sunny window)?
|
||||
- [ ] Does the surface contain NO printed/decorative elements that compete with caption text?
|
||||
- [ ] Does the surface remain visible across all 3 frames (subject doesn't step in front of it)?
|
||||
|
||||
**Need all six YES to use wall-embed.** Even one NO → step down to corner-column-crown or portrait-header.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Test set — diverse scenes for template stability regression
|
||||
|
||||
Canonical test corpus for verifying templates don't regress when we add
|
||||
features. Pick cases that stress different parts of the pipeline — blend
|
||||
mode, matte quality, baked-in graphics, luminance extremes, motion.
|
||||
|
||||
## The test set
|
||||
|
||||
All in `~/Downloads/heygen_relevant_videos/` (720×1290, ~8s, 9:16). Add
|
||||
landscape + 1:1 as we grow.
|
||||
|
||||
| Case | Scene | Stress test |
|
||||
| --------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `Sunset_Stroll` | Outdoor selfie at sunset, palm trees, boardwalk | **Bright backdrop** → default `screen` blend fails; needs dark text + `normal` blend |
|
||||
| `Nature_Vibe` | Forest golden-hour + **baked timestamp** bottom-left + watermark top-right | Baked graphics avoidance; warm-mid luminance |
|
||||
| `Gym_Grind` | Dark gym + dark tank top + phone-mirror selfie | **Low luminance** → matte difficulty; screen blend natural fit |
|
||||
| `AI_Insights` | Podcast studio + neon "THE DIALOG" sign + window view | **Competing graphics in BG** (sign, city skyline); mid-tone |
|
||||
| `Kitchen_Buzz` | Warm kitchen, apron, smiling | Baseline friendly case |
|
||||
| `Tech_Trends` | Clean white studio + mic | **Max bright backdrop** — needs dark text or opaque BG |
|
||||
| `Street_Talk` | Walking city street, handheld | Handheld motion + bright outdoor |
|
||||
| `Outdoor_Hype` | Forest + **Instagram watermark** top + sun flare | Baked graphic + bright + motion |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
for case in Sunset_Stroll Nature_Vibe Gym_Grind AI_Insights; do
|
||||
bash scripts/render-and-composite.sh /tmp/ts-${case}
|
||||
done
|
||||
```
|
||||
|
||||
## Regression checklist
|
||||
|
||||
For every new template or significant change, re-run the test set and
|
||||
verify:
|
||||
|
||||
- [ ] Sunset_Stroll: captions legible on bright sunset (Cinematic cream/screen washes out → use **Standard mode**, opaque rail)
|
||||
- [ ] Nature_Vibe: captions don't collide with baked timestamp (bottom-left) or watermark
|
||||
- [ ] Gym_Grind: captions visible on dark gym despite dark subject
|
||||
- [ ] AI_Insights: captions don't fight with "THE DIALOG" neon sign
|
||||
- [ ] Tech_Trends: dark text + `normal` blend on pure white studio
|
||||
- [ ] Each case: 0.5s+ per caption, no wrap-into-subject-head-zone
|
||||
- [ ] Each case: FG layer for 9:16 portraits where subject fills frame
|
||||
|
||||
## Bugs we've fixed via this test set
|
||||
|
||||
- **2026-04-23**: Hardcoded `text-shadow: warm-glow + dark-drop` in all
|
||||
templates ignored `cap_color` — dark text got overwhelmed by bright
|
||||
halo on bright backgrounds. Fix: `TEXT_SHADOW` placeholder with
|
||||
luminance-adaptive default.
|
||||
- **2026-04-23**: Hardcoded `filter: brightness(1.12)` lightened dark
|
||||
text, shifting it toward neutral. Fix: `TEXT_FILTER` placeholder that
|
||||
drops brightness boost for dark colors.
|
||||
- **2026-04-23**: `BLEND_MODE` and `CAP_COLOR` hardcoded in new
|
||||
templates — plan.json overrides ignored. Fix: placeholders added to
|
||||
all 4 templates.
|
||||
|
||||
## Expansion plan
|
||||
|
||||
Still need to add:
|
||||
|
||||
- 1 × 16:9 landscape (from any heygen_relevant via letterboxing, or new source)
|
||||
- 1 × 1:1 square (if available)
|
||||
- 1 × multi-speaker (speaker diarization test)
|
||||
- 1 × foreign language (CJK rendering test)
|
||||
- 1 × shot-cut (PySceneDetect-required case)
|
||||
- 1 × product/B-roll (no talking head — refuse gate)
|
||||
@@ -0,0 +1,266 @@
|
||||
# Typographic moves — a palette, not a cage
|
||||
|
||||
`cinematic-cream` template locks DNA only (font-family, motion, color
|
||||
palette defaults). It does NOT enforce slot structure. The agent writes
|
||||
each caption group's typography from scratch via the plan.json `css`
|
||||
field.
|
||||
|
||||
This doc is a **palette** of tried combinations — pick what fits the
|
||||
scene, invent new ones, skip these if the content demands. These are
|
||||
recommendations, not rules.
|
||||
|
||||
## Size vs text length — DO THIS BEFORE PICKING SIZE
|
||||
|
||||
Bigger is NOT better if the word doesn't fit the frame width. Every time
|
||||
you pick a font-size, sanity-check against word length:
|
||||
|
||||
**Rough rule for Inter 900 uppercase at -0.03em tracking:**
|
||||
`max_font_px ≈ frame_width / (longest_word_chars × 0.72)`
|
||||
|
||||
The 0.72 multiplier is intentionally conservative — it targets the text
|
||||
filling ~90% of the frame width so there's a small side margin. If you
|
||||
want edge-to-edge, drop to 0.65, but you lose a bit of safety margin.
|
||||
|
||||
720px portrait:
|
||||
|
||||
- 3-char word (15%): max ~330px (a single big number like "15%")
|
||||
- 5-char word (TAKE): max ~200px
|
||||
- 7-char word (AUGMENT / QUANTUM / MATTERS): max ~143px
|
||||
- 10-char word (CREATIVITY): max ~100px
|
||||
- 13-char word (UNPRECEDENTED): max ~77px
|
||||
|
||||
1920px landscape:
|
||||
|
||||
- 7-char word: max ~380px
|
||||
- 10-char word: max ~267px
|
||||
|
||||
For italic/upright 500-700 it's slightly narrower — increase max by ~10%.
|
||||
|
||||
**If you want the word bigger than it fits:**
|
||||
|
||||
- Split into multiple words per group (natural wrap), OR
|
||||
- Add explicit `<br>` between syllables, OR
|
||||
- Drop letter-spacing to -0.05em, OR
|
||||
- Accept that the matte will cut part of it (Vogue-cover embed effect —
|
||||
only works when the cut area is exactly where the subject is)
|
||||
|
||||
Never let the text OVERFLOW the frame edge — that's not cinematic, that's
|
||||
just broken. Use the heuristic.
|
||||
|
||||
## Sizing discipline (frame-height-relative)
|
||||
|
||||
Portrait 9:16 (720×1290) and landscape 16:9 (1920×1080) have very
|
||||
different comfort ranges. Use `calc(<pct> * var(--h))` for responsive:
|
||||
|
||||
| Feel | % of frame height | Example calc |
|
||||
| ---------------------- | ----------------- | ----------------------- |
|
||||
| Whisper / label | 4-6% | `calc(0.05 * var(--h))` |
|
||||
| Soft opener | 6-9% | `calc(0.07 * var(--h))` |
|
||||
| Narrative body | 9-14% | `calc(0.11 * var(--h))` |
|
||||
| Emphasis | 14-22% | `calc(0.18 * var(--h))` |
|
||||
| Climax / monumental | 22-35% | `calc(0.28 * var(--h))` |
|
||||
| Title-bigger-than-face | 40%+ | `calc(0.48 * var(--h))` |
|
||||
|
||||
Use absolute px only when a specific scale needs to look identical
|
||||
across frame sizes (rare). Prefer `calc` + `var(--h)`.
|
||||
|
||||
## Named moves — pick per group
|
||||
|
||||
**Italic contemplative opener** (memory-wall cap-1 lineage):
|
||||
|
||||
```css
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: calc(0.075 * var(--h));
|
||||
```
|
||||
|
||||
**Italic continuation / dreamier continuation** (cap-2 lineage):
|
||||
|
||||
```css
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
font-size: calc(0.065 * var(--h));
|
||||
```
|
||||
|
||||
**Upright pivot / statement** (cap-3 lineage — "but suddenly"):
|
||||
|
||||
```css
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
font-size: calc(0.07 * var(--h));
|
||||
```
|
||||
|
||||
**Uppercase climax / monumental** (cap-4 lineage — "EVERYTHING IS SHARP AGAIN"):
|
||||
|
||||
```css
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.95;
|
||||
font-size: calc(0.18 * var(--h));
|
||||
```
|
||||
|
||||
**Chapter card / full-frame Adam Curtis manifesto**:
|
||||
|
||||
```css
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 0.9;
|
||||
font-size: calc(0.14 * var(--h));
|
||||
```
|
||||
|
||||
**News chyron / broadcast label** (small, locked):
|
||||
|
||||
```css
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
font-size: calc(0.038 * var(--h));
|
||||
```
|
||||
|
||||
**Tiny side note / caption-on-surface**:
|
||||
|
||||
```css
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-size: calc(0.045 * var(--h));
|
||||
```
|
||||
|
||||
**Vogue-masthead monumental**:
|
||||
|
||||
```css
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.85;
|
||||
font-size: calc(0.32 * var(--h));
|
||||
```
|
||||
|
||||
## Narrative arcs (suggestions, not required)
|
||||
|
||||
**Poem arc** — memory-wall canonical: italic opener → italic continuation
|
||||
→ upright pivot → uppercase climax. Four steps.
|
||||
|
||||
**Question-answer arc**: italic question small → uppercase answer big.
|
||||
Two steps.
|
||||
|
||||
**List-of-three arc**: three equal-weight narration segments, one
|
||||
climactic uppercase. Four steps.
|
||||
|
||||
**Single-statement arc**: one monumental uppercase held long. One step.
|
||||
|
||||
**Whisper-hook arc** (short-form): tiny italic hook → massive uppercase
|
||||
reveal. Two steps.
|
||||
|
||||
The **number** of groups comes from the **content**. A 25s Pausch talk
|
||||
has ~8 beats. A 7s TikTok hook has 2-3 beats. Don't force 4 or 5 or 7 —
|
||||
read the transcript and mark breath-groups.
|
||||
|
||||
## Position + layer vocabulary
|
||||
|
||||
**Position** is where the caption lives in frame. Use:
|
||||
|
||||
- `top / left / right / bottom` (percentages or px) for anchor
|
||||
- `transform: translateX/Y` for fine offsets
|
||||
- `text-align: center / left / right` for alignment
|
||||
|
||||
**Layer** per group:
|
||||
|
||||
- `bg` (default) — behind subject. Matte occludes. Cinematic embed. Works
|
||||
on clean zones (sky, walls) OR intentionally crosses subject for the
|
||||
Vogue-masthead effect.
|
||||
- `fg` — on top of subject. Announcement. Use for climax breakthroughs or
|
||||
when bg would be 100% occluded by body.
|
||||
|
||||
**Tone** per group:
|
||||
|
||||
- `soft` — gentle per-word opacity+y drift (0.45s power2.out). Use for
|
||||
introspective, dreamy, or narrative content.
|
||||
- `present` — snappy per-word opacity+y+scale pop (0.22s power3.out). Use
|
||||
for emphatic or declarative beats.
|
||||
|
||||
## Anti-patterns the DNA protects against
|
||||
|
||||
The DNA defaults prevent most ugly outcomes. But avoid in per-group CSS:
|
||||
|
||||
- Multiple different `font-family` within one video (DNA is one family)
|
||||
- Multiple saturated colors (one accent at most; DNA defaults cream or
|
||||
deep warm)
|
||||
- `letter-spacing` above ±0.05em unless there's a specific reason
|
||||
- `font-weight` 300 or below — too light for screen
|
||||
- `font-style: italic` on emphasis — italic is for lyricism, not stress
|
||||
|
||||
## When cinematic-cream is wrong
|
||||
|
||||
This DNA assumes mid-to-dark backgrounds (luminance 60-180). For:
|
||||
|
||||
- Bright white studios → cinematic-cream's cream + `screen` washes out, and the DNA is
|
||||
**locked** (you cannot recolour it) → use **Standard mode** (opaque rail) instead
|
||||
- Documentary formal → Standard mode (documentary-dignified direction; see references/direction-catalog.md §1)
|
||||
- Energetic vlog hooks → future `kinetic-vlog` DNA (not yet shipped)
|
||||
|
||||
## Example: memory-wall original, expressed in freeform
|
||||
|
||||
Same output as the canonical memory-wall.html, but using slot-free plan:
|
||||
|
||||
```json
|
||||
{
|
||||
"template": "cinematic-cream",
|
||||
"caption_layer": "bg",
|
||||
"groups": [
|
||||
{
|
||||
"id": "cg-0",
|
||||
"tone": "soft",
|
||||
"in": 0.2,
|
||||
"out": 4.85,
|
||||
"css": "top: 14%; right: 6%; left: auto; text-align: right; font-size: calc(0.09 * var(--h)); font-weight: 600; font-style: italic; letter-spacing: -0.01em;",
|
||||
"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",
|
||||
"tone": "soft",
|
||||
"in": 2.55,
|
||||
"out": 4.85,
|
||||
"css": "top: 26%; right: 10%; left: auto; text-align: right; font-size: calc(0.075 * var(--h)); font-weight: 500; font-style: italic;",
|
||||
"words": [
|
||||
{ "text": "like", "start": 2.66, "end": 2.78 },
|
||||
{ "text": "old", "start": 2.88, "end": 3.02 },
|
||||
{ "text": "film", "start": 3.16, "end": 3.46 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cg-2",
|
||||
"tone": "present",
|
||||
"in": 4.9,
|
||||
"out": 8.04,
|
||||
"css": "top: 18%; right: 6%; left: auto; text-align: right; font-size: calc(0.085 * var(--h)); font-weight: 700;",
|
||||
"words": [
|
||||
{ "text": "but", "start": 5.04, "end": 5.18 },
|
||||
{ "text": "suddenly", "start": 5.24, "end": 5.76 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cg-3",
|
||||
"tone": "present",
|
||||
"in": 6.7,
|
||||
"out": 8.04,
|
||||
"css": "top: 32%; right: 6%; left: auto; text-align: right; font-size: calc(0.11 * var(--h)); font-weight: 900; text-transform: uppercase; letter-spacing: -0.03em; line-height: 0.95;",
|
||||
"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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Same visual, no slot constraint. Each group owns its typography.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Typography Presets
|
||||
|
||||
Five named styles corresponding to the `cap-*` CSS classes. Pick one per caption group in `plan.json` based on the line's semantic role.
|
||||
|
||||
| style | CSS | Use when |
|
||||
| -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intro` | 66px italic 500 | First line, filler ("You know…", "So…"), contemplative openings. Low visual weight. |
|
||||
| `phrase` | 78px upright 600 | Main statement clauses. Default for most lines. |
|
||||
| `emph` | 92px upright 800 | Lines with emotional peak or key achievement ("I've achieved incredible things"). |
|
||||
| `dream` | 82px italic 700 | Aspirational / "was dreaming of…" style lines. Italic signals memory/imagination. |
|
||||
| `crown` | 140px upright 900 uppercase | **Only for the climax line.** Used in center-stage `crown-plane`. Maximum one per composition — ideally the last caption. |
|
||||
|
||||
## Tone field
|
||||
|
||||
Independent of style, each group has `tone`: `soft` or `present`.
|
||||
|
||||
- **soft** — gentle fade + 8px y-drift entrance, `power2.out` ease. Feels floaty, nostalgic. Use for memory, intro, dream.
|
||||
- **present** — snappy 6px y + 1.04 scale pop, `power3.out`, transformOrigin center. Feels assertive, in-the-moment. Use for emphasis and crown.
|
||||
|
||||
## Picking automatically
|
||||
|
||||
Scan each group's words:
|
||||
|
||||
1. Does it contain superlatives (incredible, best, only, never, always) or brand/proper nouns? → `emph` + `present`
|
||||
2. Does it start with discourse markers (you know, so, well, look) or is it short (≤3 words)? → `intro` + `soft`
|
||||
3. Does it reference dreams, hopes, memory, past tense verbs like "was dreaming"? → `dream` + `soft`
|
||||
4. Is it the final sentence-clinching line and feels like a title/headline? → `crown` + `present` (only one per video)
|
||||
5. Otherwise → `phrase` + (match the group's neighbors' tone; default `soft`)
|
||||
|
||||
## Font-size scales with column width
|
||||
|
||||
The `.cap-*` defaults in each template are tuned for a **~560px column** (the original champion composition). When the caption plane is wider than that, the fonts feel underweight — too much negative space around text. Scale up accordingly.
|
||||
|
||||
| Plane width | intro | phrase | emph | dream | crown (centered, full-frame) | crown (clean-zone only) |
|
||||
| ------------------- | ----- | ------ | ---- | ----- | ---------------------------- | ----------------------- |
|
||||
| 460-580 px (tight) | 66 | 78 | 92 | 82 | 140 | n/a |
|
||||
| 600-760 px (medium) | 78 | 108 | 128 | 100 | 220 | 118 |
|
||||
| 780+ px (wide) | 90 | 128 | 150 | 116 | 260 | 140 |
|
||||
|
||||
Notes:
|
||||
|
||||
- Crown sizes assume landscape (1920×1080). For portrait 1080×1920, divide all crown sizes by 1.5.
|
||||
- "full-frame crown" = centered, designed to span `0.8+ × frame_width` and cross the subject's body. Use only when [layout-heuristics.md § Crown placement](layout-heuristics.md) conditions pass.
|
||||
- "clean-zone only crown" = crown positioned inside one clean zone, smaller font, tail letters lightly touch subject. Fallback when centered crown would eat too much.
|
||||
|
||||
Also: if a plane's `rotateY` is non-trivial (say > 8°), effective visible width shrinks. Scale fonts up ~10% to compensate.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't use more than **one crown** per render. The whole point is that it's a singular payoff.
|
||||
- Don't use `emph` on more than ~30% of groups. Emphasis loses meaning when everything is emphasized.
|
||||
- Don't pick `intro` for the main content just because the words are short. "I won" (2 words) is `emph` or `crown`, not `intro`.
|
||||
- Don't mix `soft` and `present` alternately for no reason. Tone should follow the story arc: soft opening → present build → emph peak → (optional crown climax).
|
||||
|
||||
## When scene luminance affects color choice
|
||||
|
||||
The default color is warm bone-white (`#fff5df`). Adjust in the template CSS:
|
||||
|
||||
- Wall is mostly warm wood / tan → keep default, or shift cooler `#e8f0ff`
|
||||
- Wall is cool blue / tech-lab → push warmer `#fff0c0`
|
||||
- Wall has strong primary color → desaturate default, luminance first
|
||||
|
||||
These adjustments happen in the HTML template, not plan.json. If you repeatedly need different palettes, make new template variants.
|
||||
Reference in New Issue
Block a user