# Deterministic mograph engine (reference implementation)

Frame-by-frame motion graphics rendered with PIL → piped straight into
ffmpeg/libx264. No AI video model touches the frames, so text is pixel-perfect,
brand hexes are exact, motion is eased code, and every re-render is
byte-reproducible and free. This is the **default lane for flat brand-token
motion graphics** (see SKILL.md); AI-video renders (Omni/Kling/Seedance) are
for organic/textured looks only.

Validated end-to-end on the Fable 5 explainer (2026-07-18): 54s master in
16:9 + 9:16 + 1:1 from one codebase, ~3 min per aspect on an M-series laptop.

## Files

- `fable_example.py` — the full working engine + the Fable beats as the
  reference implementation. **Start a new story by copying this file** and
  replacing the per-beat functions (`b1..b8`), the brand constants, and the
  asset paths. The engine core (easing, draw helpers, kinetic type, icon
  compositing, decor, wipes, progress dots, aspect system, render loop) is
  reusable as-is. Usage: `python3 <script>.py [16x9|9x16|1x1]`.
- `lib/stat_bar.py` — a percentage bar proven by geometry: the 100% track is
  drawn first with its own outline, the fill is exactly `pct` of it, the label
  is printed complete and static. The data-overlay rule: numbers on screen are
  never generated and never counted up.
- `gen_sfx_example.py` — procedural SFX synth (pop/tick/whoosh/thump/chime
  from sine/noise math). Author the event list to mirror the engine's
  animation times → one sample-accurate stereo WAV. No stock, no spend.
- `mix-narrated-example.sh` — final mix: per-beat VO (adelay at
  `intro + i*beat + 0.3s`) at 1.25, music at 0.22 ducked under VO
  (sidechaincompress ratio 12), SFX at 0.30 un-ducked, limiter.

## The workflow

1. **Brand tokens**: scrape real tokens from the brand's live CSS (playwright
   `browser_evaluate` on `getComputedStyle` custom props — WebFetch strips CSS).
   Real logo = real SVG/PNG composited, never AI-drawn.
2. **Icons**: ONE go-bananas `openai-gpt-image-2` generation — a 3x3 sheet of
   **LAYERED MULTI-TONE spot illustrations** (dark body + a pale secondary
   shape offset behind it + white interior detail + one small accent), in the
   Stripe/Linear marketing-icon register, "NO text/letters/numbers". Never
   flat single-ink pictograms — that reads as icon-pack clipart and was
   rejected in review (Buzz cut 2, 2026-07-23). Slice by flood-filling white
   from the CELL EDGES only (white interior detail survives), then snap pixels
   to the exact brand hexes + one mid steel tone. Full prompt + slicer:
   `skills/brand-explainer/references/icon-sheet-prompt.md` and
   `skills/brand-explainer/scripts/slice_icons.py`. Icons carry no
   text → zero hallucination risk.
3. **Beats**: one function per beat; compose icons + cards + pills + serif
   captions + kinetic type with the easing helpers. Smoke-test key frames as
   stills BEFORE rendering (cheap), then render, mix, QC.
4. **Aspects**: `16x9` designs at 1920x1080; `9x16`/`1x1` re-layout (stacked)
   via the `PORT` flag; `1x1` additionally shrinks elements via `SQK` because
   9:16 Y-fractions collide at 1080 tall. Check collisions per aspect —
   pills kissing cards and arrowheads hidden under cards are the usual bugs.

## Hard-won gotchas (do not relearn these)

- **PIL alpha does NOT blend on an RGBA canvas** — semi-transparent ink
  overwrites, and the alpha is silently dropped at RGB conversion, so "10%
  shadows" render solid black. Blending requires an **RGB** canvas +
  `ImageDraw.Draw(base, "RGBA")`; composite RGBA assets with
  `base.paste(im, pos, im)` (mask-paste), not `alpha_composite`.
- **Guard icon resizes against zero px** (`max(px(h), 2)`) — ease curves near
  t=0 produce sub-pixel sizes that crash PIL.
- **ffmpeg `sidechaincompress` ends at the SHORTER input** — `apad` the VO
  chain to the full duration or the tail music gets truncated.
- Supersample 2x and downscale LANCZOS for crisp edges; render via rawvideo
  pipe (`-f rawvideo -pix_fmt rgb24`), never intermediate PNGs.

## Cloning the template for a new story (validated: CCC Vol. 2, 2026-07-18)

Keep the 54s skeleton (2.6 intro / 8×6s beats / 3.4 outro) and the whole
audio chain transfers unchanged — same VO offsets, same SFX event grammar,
same mix script. The clone recipe:

1. Copy the engine → swap the project paths and the per-beat functions only.
2. New GB icon sheet in the same prompt style (one generation, slice, alpha).
3. VO via `vclaw video dialogue` (8 turns, ≤5.5s each fits a 6s beat at
   +0.3s offset).
4. Clone `gen_sfx` + the mix script with `sed 's|/old-project|/new-project|g'`
   — **no trailing slash in the pattern** (a trailing slash silently misses
   the `P=` assignment line and the mix reads the old project's video).
5. Smoke-test stills before rendering; per-beat collisions (pill kissing a
   card) are the usual clone bug.

Real photos/illustrations (e.g. official product-page assets) composite the
same way as icons — rounded-corner mask paste onto the ivory ground — and
carry brand authenticity AI re-drawing can't.

## Mandatory: automated spacing QC (`qc_spacing.py`)

Run `python3 qc_spacing.py <engine.py> [aspects…]` BEFORE every render.
Two detection layers, per beat x aspect at t=3.2/4.6:
1. **Pixel profile** — touching elements (gap < 1.6% H), interior dead
   bands (> 17% H on 16:9, 19% on 1:1, 22% on 9:16), top/bottom-empty
   (> 22% H, 20% on 9:16). Sub-6px splits merge (a card and its shadow).
2. **Element bboxes** — the harness monkey-patches the engine's draw
   helpers (pill/icon/text/photo/mark/dot/rrect) to record real geometry
   and flags partial overlaps (> 12% of the smaller box; stricter > 2%
   for pill-on-icon / text-on-dot classes). Catches what pixels can't:
   a bullet inside a word, a pill kissing an icon.

Iterate fix → re-QC until `QC CLEAN`, then render. The first full sweep
found ~180 latent issues across six shipped engines. Fix patterns that
recur: (a) captions drift — anchor 120px above the progress dots, closer
on portrait; (b) **icons with internal gaps** (a spark above a hook, a
baked-in dotted trail, a ruled border) read as separate blocks — back
them with a subtle OAT panel, or crop the asset to its solid body;
(c) decor quarter-discs and plus marks count as content — they kiss
pills at beat tops; (d) story-fades that drop an element to alpha 0
leave literal holes — fade to ~0.4 instead; (e) dashed trails with
gap > ~12px read as separate bands — use dash=40/gap=4.

## Multi-brand recipes (validated across 7 brands, 2026-07-18)

One constants block re-brands the whole engine — keep the token NAMES
(IVORY/SLATE/CLAY/OAT/KRAFT/WHITE), swap the VALUES. Light grounds
(Claude ivory, OpenAI white, Gemini white, Stripe #F6F9FC, Vercel #FAFAFA)
and dark grounds (GitHub #0D1117, Linear #08090A) both work:

- **Dark grounds invert the icon pipeline**: after white→alpha slicing,
  remap ink (max(r,g,b)<120) → the light text color and light fills
  (>195 all channels) → a dark panel tone. cmd_card text flips to dark
  ink on the now-light cards.
- **Card fills must register against the ground** for the QC pixel layer
  (≥ ~20 summed RGB delta). Pure white cards on a pale ground are
  invisible to QC and dead-band falsely; tint them (e.g. #EBF0F8 on
  #F6F9FC).
- **Decor discs near the threshold dither** — on dark grounds bump the
  quarter-disc alpha (90 → 150) or rows register intermittently.
- **QC v5 harness**: bottom chrome (progress dots + corner mark, rows ≥
  92% H) coalesces into one block; thin fragments (<16px tall) merge
  across gaps < 22px (glyph descenders, icon internals); same-unit
  splits < 10px always merge. Icon internal gaps that persist (bell
  clappers, floating chart dots, hub satellite rings) take a subtle
  backing panel — or crop the asset.
- **VO discipline**: probe every clip with ffprobe (reported durationMs
  lies); atempo ≤ ~1.2x is inaudible; re-record any line needing more.
  Batch TTS can stall mid-run — count the clips, then record the missing
  tail as a fresh batch (it writes index 0..n — protect existing files
  first, then rename).
- Logos: always the real SVG scraped from the site nav (currentColor →
  fill with the brand ink/light), rasterized via rsvg-convert. Never
  AI-drawn.

## Narrated brand films → use `skills/brand-explainer` (do not clone this dir)

The 60–70s narrated brand-film product (house-style beats + typed command
cards + MY TAKE + a QR outro + Rachel VO + a ducked bed) is productized as
`skills/brand-explainer`: ONE shared engine driven by a
`$VCLAW_WORKSPACE/packs/<brand>.brand` data pack, with a gated master runner.
The copy-this-example-and-mutate model below lost all 11 fleet engines — for a
brand film, author a pack instead. The examples in this directory remain the
reference for engine INTERNALS and for non-brand-film deterministic work.

Three rules the brand-explainer split hardened, which apply to ANY engine in
this lane:

- **The timeline has one source of truth.** SFX and mix scripts import
  `BEAT_STARTS`/`TOTAL` from the engine — never hardcode beat times in a
  second file.
- **VO is written to fit the beats, never the reverse.** ffprobe every TTS
  clip (reported durations lie — a "6.0s" clip was really 10.03s); `atempo`
  ≤1.2× passes, worse means re-record. See
  `skills/brand-explainer/references/vo-recipe.md` and `mix-recipe.md`
  (sidechain apad trap, `alimiter level=disabled`, mono-collapse trap).
- **Cards carry real mono text.** An empty card / grey blob communicates
  nothing; on a silent film the card text IS the script.

## v2 — cinematic engine (cold-open · set-piece · action-outro)

`v2_engine_example.py` (the approved Vercel Agent showcase) is the v2 reference.
Adds over v1: variable `BEAT_DURS` (cumsum starts), a moving `camera()` applied
post-compose by `apply_camera()` (crop-zoom: slow push per beat + a cut-punch at
each boundary + optional story-driven drift), `spring()` overshoot easing,
`countup()` rolling numbers, `typewriter()` with a caret, a narrative cold-open
(incident → set-piece → title reveal beats title-first), agenda chips on the
title beat, and a USEFUL outro: availability + the exact enable path (typewriter)
+ URL + a real scannable QR held static ~5s. `gen_sfx_v2.py <slug> [setpiece]`
scores the 60s timeline (setpiece ∈ timeline|pipeline|fanout tunes b3 hits).
`QC_MINIMAL = {beat indices}` exempts deliberately-sparse cinematic beats from
density checks (touching/overlap still enforced). Proven across 11 brand films.

Per-film add-ons (not in the base): `photo_card` (rounded-mask product imagery),
`word_pop` (scale-pop word) — paste from an existing engine when a beat needs them.

### Two contrast/visibility traps (found in review, 2026-07-19)

- **Empty agenda chips.** `pill()` only draws its label when `scale > 0.62`
  (was 0.8). Agenda chips settle around 0.78, so a 0.8 gate rendered the pill
  but no text — a row of blank grey buttons. Keep the gate ≤ chip settle-scale,
  or the chips read as empty.
- **White-on-white / same-tone cards on dark grounds.** The token NAMES are
  ground-relative: on a DARK-ground brand `SLATE` is the light text colour, so a
  card drawn with `bg=SLATE` is nearly white and any white text on it vanishes.
  For a "terminal" card that must work on both grounds, use `bg=WHITE`
  (the card-fill token: dark on dark grounds, light on light) with `fg=SLATE`
  (always the contrasting text colour). Never hardcode `fg=(255,255,255)` on a
  card whose bg flips tone by brand. Also swap ALL per-film copy when cloning a
  dark engine — literal `·` in strings won't match a `·` search/replace.
