Subchapter 14.5
references/composition-craft.mdMarkdown55 KBView on GitHub
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 → . Read this before authoring any phrase, in either authoring mode (template Cinematic or Standard ).
Also bundled
Gitignoreplan.jsonindex.htmlTopics: 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. Thebg / fg / hybridaxis 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.)
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):
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:
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.
{
"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):
layer: "bg".layer: "fg". Subject fills frame too much for embed.For CLIMAX caption (the payoff):
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.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.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:
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:
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:
layer: "fg", the extension causes plane overlap → don’t do itConcrete 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:
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):
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.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:
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:
"Some memories feel soft" (4 words, italic 600, right-top)"like old film" (3 words, italic 500, right-mid, smaller)"but suddenly" (2 words, upright 700 pivot, right-center)"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).
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:
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.
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:
{
"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 for recommended combinations — treat as palette, not schema.
When to use planes:
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:
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):
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):
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:
top value — reads as subtitle track, kills cinematic feel.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):
out — they fade together, no per-cap exit jitter.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.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.
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:
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.
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×:
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.8Split 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:
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:
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).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:
f_{frame_idx:04d}.png where frame_idx = round(climax_time * fps).alpha > 128 — that y is the subject’s topmost pixel (usually hair).crown.top such that top + height = 445 + 50 ≈ 495 → top = 295px ≈ 23%.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.
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):
obliterated, ≥35% = warn. A WARN band hit on a single climax word is OK (cinematic edge crop); two adjacent words obliterated is not.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:
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?
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:
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:
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.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.
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):
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:
"Some memories feel soft" + "like old film" — 2 caps accumulate, fade together at 4.85s"but suddenly" + "EVERYTHING IS SHARP AGAIN" — 2 caps accumulate on a cleared planeAll 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:
top: (origin anchor) and let caps stack down naturally.gap: between caps; don’t try to “center the stack”.overflow: hidden (it clips the very caps you wanted the viewer to read).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 × 100Then 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:
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.
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:
Write the reasoning in _notes in plan.json so the decision is inspectable and the agent’s intent survives into review. e.g.:
"_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.
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:
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).left: 50–55%, right-aligned or centered in the column). Secondary: below subject.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:
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:
fg.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.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.