# .brand pack schema + the accents contract

A pack is a bash-sourceable `KEY="value"` file at
`$VCLAW_WORKSPACE/packs/<brand>.brand` (template: `_template.brand`; proven
example: `buzz.brand`). It is read by BOTH the bash runner (sourced) and the
python workers (`packlib.parse_pack`). Per-video overrides go in
`projects/<slug>/video.env`, sourced after the pack so the video wins.

## Keys

| Group | Keys | Notes |
|---|---|---|
| Identity | `BRAND_NAME TITLE_WORD TAGLINE BRAND_URL TOKENS_VERIFIED` | tokens gate blocks until `TOKENS_VERIFIED="1"` |
| Palette | `COLOR_GROUND COLOR_INK COLOR_SECONDARY COLOR_ACCENT COLOR_CARD COLOR_PANEL` | EXACT scraped hexes; `?` placeholders fail preflight |
| Texture | `TEXTURE (none\|dots) TEXTURE_DOT_ALPHA TEXTURE_DOT_PITCH MARQUEE_WORD MARQUEE_TRANSITIONS` | `MARQUEE_TRANSITIONS="1"` sweeps the wordmark band through each beat boundary; or a `texture()` hook. **`MARQUEE_WORD` is effectively REQUIRED, not decorative** — the outro calls `marquee()` unconditionally (engine.py:1126) and its band is what holds the frame while the rows and QR slide in. Empty leaves the outro under 3% ink for ~1.2s, at the call to action. Measured 4 Sep 2026 |
| Type | `FONT_SANS FONT_MONO FONT_IDX_*` + `FONT_SANS_REGULAR/_BOLD/_MEDIUM/_LIGHT` | per-weight files (project-relative ok) beat .ttc indices — use the REAL brand family (buzz.xyz serves Cash Sans woff2; fonttools converts to ttf) |
| Timeline | `TITLE_D FEATURE_D MYTAKE_D OUTRO_D` | defaults 3.2 / 6.0 / 8.0 / 8.0 |
| Poster frame | `POSTER_LINE_1 POSTER_LINE_2 POSTER_HOLD POSTER_LIFT` | frame 0 is the feed thumbnail (LinkedIn/X/Slack all poster with it). Set `POSTER_LINE_1` and the title beat holds a COMPOSED frame from t=0 for `POSTER_HOLD` (1.40), then lifts the lines over `POSTER_LIFT` (0.80), leaving the standard lockup. Absent the key → byte-identical to before. The lines carry the STORY, not the title — a thumbnail reading the brand name tells a scroller nothing. **No `accents.py` needed**; keep the real `TITLE_WORD`/`TAGLINE` |
| Beats | `BEAT_1..N` = `icon\|icon_h\|pill\|card1\|card2\|caption\|corner[\|icon_dx]` | empty card fails preflight. **pill** supports `Kicker~Headline[~subtitle]` (kicker lozenge over display type). A `^`-split headline (`Kicker~Line1 ^ *AccentLine* ^ Line3~subtitle`) makes it a STATEMENT beat: left text column (stacked lines, one in ACCENT via `*wrap*`, underline tick, subtitle), illustration + cards right — the thesis moment, use once per film. **cards** support `Title~tagline~feat · feat · feat` — a PRODUCT card (brand-face title, mono tagline, divider, staggered feature row); any `~` card switches the beat to the rich two-card layout with a connector node |
| Icons | `ICON_SHEET_NAMES` | 9 names, sheet reading order |
| MY TAKE | `MYTAKE MYTAKE_SCORE _VERDICT_1 _VERDICT_2 _BEST_FOR _CATCH _CAPTION` | see mytake-recipe. `MYTAKE="0"` DROPS the beat (and its VO slot: the count becomes beats+2) — for a film the subject publishes about ITSELF, where a first-person "MY TAKE" over a self-awarded score out of 10 is an unsubstantiated claim in its own advertisement. Default on; absent the key → byte-identical to before |
| Outro | `MEET_ROW_1..3` = `LABEL\|value\|tone[\|icon\|subtitle]` (icon = a sliced sheet icon in a tile; subtitle = quiet mono line), `MEET_SUBTITLE`, `QR_URL QR_URL_LABEL OUTRO_CAPTION` | QR is generated, real, scannable; the outro heading is a left lockup (mark · MEET kicker · name · subtitle) |
| VO | `VO_0..VO_(N+2)` | count MUST be beats+3; see vo-recipe |
| Audio | `VOICE_ID VOICE_BACKEND MUSIC_PROMPT MUSIC_BACKEND MUSIC_FILE` | Rachel default |

Values may contain `|` only as the field separator; `→ · —` are fine.

## Rules `lint_pack.py` enforces before anything renders

Each one shipped as a defect once and was caught only by the post-render design
review — the lint is the cheap half of that gate.

- **`icon_h` is a HEIGHT.** Width follows the glyph's aspect, so a wide
  illustration (a toggle at 2.45:1) at a normal height overshoots the column and
  collides with the cards. The engine clamps rendered width to `ICON_MAX_W`
  (derived from the card geometry, ~764); the lint tells you the pack is wrong
  rather than letting the clamp hide it. Rule of thumb: `icon_h ≈ 380 / aspect`.
- **`MEET_ROW` tone is a semantic slot — `blue|white|ink` only.** It selects a
  card STYLE (SECONDARY / WHITE / INK), never a brand colour. `green` used to
  raise `KeyError` from the engine mid-stills.
- **The QR label, the encoded URL and the SPOKEN outro URL must all agree.**
  A viewer hears one address, reads another and scans a third otherwise.
- **An initialism is cased one way per pack.** Uppercase in the outro and
  lowercase on a card reads as a typo in the same mono face. A consistently
  lowercase voice is fine — only inconsistency is a finding.
- **A statement subtitle must not restate its own caption**, and `MYTAKE_CATCH`
  must not restate a beat caption (advisory; `--strict` promotes it).

## THE ENGINE IS 16:9 ONLY — the other two aspects render off-canvas

`DIMS` offers `16x9`, `9x16` and `1x1`, and `engine.py <aspect>` runs happily for
all three. **Only 16:9 produces a usable film.** `LX = 470` and `RX = 1245`
(engine.py:585-586) are ABSOLUTE pixel constants sized for a 1920px canvas. At
`Wd = 1080` the right-hand card column spans x=900..1590 against a 1080px frame,
so **510px of every card, the outro's brand name and the QR label render past the
right edge**. Measured 4 Sep 2026 for any pack, the shipped `buzz` fixture
included — it is the engine, not your content:

| aspect | canvas W | card right edge | |
|---|---|---|---|
| 16x9 | 1920 | 1590 | OK |
| 1x1 | 1080 | 1590 | **off-canvas by 510px** |
| 9x16 | 1080 | 1590 | **off-canvas by 510px** |

**`qc_spacing.py` cannot catch this and passing it means nothing here.** Its own
docstring says it "analyzes the **vertical** layout" — touching elements, dead
bands, top/bottom imbalance. Horizontal overflow is invisible to it. So `1x1`
reports **QC CLEAN** while the cards are half off the frame, which is worse than
`9x16`, which at least fails the vertical QC loudly for unrelated reasons. A
clean `qc_spacing` run on a non-16:9 aspect is not evidence the layout works;
render a frame and look at it.

**For a vertical or square social deliverable today, pad the 16:9** into the taller
canvas with `COLOR_GROUND`, rather than rendering another aspect:

```bash
ffmpeg -i master.mp4 -vf "scale=1080:-2,pad=1080:1350:(ow-iw)/2:(oh-ih)/2:color=0xF2EEE6" \
  -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p -c:a copy master-4x5.mp4
```

Because the films are flat-ground by design, the pad is invisible — it reads as a
square/portrait film, not as letterboxing — and the reviewed picture is untouched
inside the frame. Making the aspects genuinely work means deriving `LX`/`RX` from
`Wd` AND rethinking the beat layout (an icon column beside a card column does not
fit 1080px wide; it wants to stack), which is engine work under the promotion rule.

## The accents contract (`projects/<slug>/accents.py`)

Optional. Every hook receives `E`, the engine module — all draw helpers
(`rrect dot ring line line_draw text_c text_stagger typewriter pill label
icon quarter plus decor cmd_card accent_panel`), easing (`seg ease_out_cubic ease_in_out
lerp`), geometry (`X Y px LX RX CXd Wd Hd`), palette (`GROUND INK SECONDARY
ACCENT WHITE BONE`), and timeline (`BEAT_STARTS BEAT_DURS TOTAL OUTRO_D`).

| Hook | When | Use for |
|---|---|---|
| `draw_mark(E, base, cx, cy, h, alpha, color, T)` | wherever the mark appears | a PROCEDURAL mark that can animate (Buzz's bee flaps on the brand's CSS keyframes). Without it the engine composites `assets/brand/mark.png` |
| `corner_mark(E, base, T)` | bottom-right chrome | override the default mark chrome |
| `texture(E, base, T)` | every frame | custom background texture |
| `accent_title(E, base, dr, t, dur, fo)` | title beat | replaces the default mark entrance |
| `accent_beat(E, base, dr, t, dur, bi, fo)` | after each feature beat (bi is 0-based) | the bespoke move that makes a beat specific: a travelling pulse, a strikethrough, a merge landing |
| `accent_outro(E, base, dr, t, g)` | outro | extra outro motion |
| `sfx_events(E, ev)` | gen_sfx | SFX cues matched to accent moves; `ev(t, kind, gain)`, kinds: pop tick click whoosh thump thud drop wash |

Design accents in the beat's LAST ~3s (the cards land in the first ~2s) at
`Y(0.735)` — the sub-row band the generic layout leaves clear — and back any
floating row with `E.accent_panel(dr, cx, half_w, alpha)`: unanchored fragments
read as clutter and thin glyphs split under QC.

**Promotion rule:** the moment a second film wants the same accent, move it
into the engine behind a pack key. Accents are for what is genuinely
one-film-specific.

## Loading the engine from your own script

The engine reads `sys.modules[__name__]` for the hook bridge, so any
importlib loader MUST register it first:

```python
spec = importlib.util.spec_from_file_location("_engine", ENGINE_PATH)
E = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = E          # required
spec.loader.exec_module(E)
```

`BRAND_PACK` and `BRAND_PROJECT` must be in the environment before loading.
- **A month on a card keeps its capital.** `weights: july 27` beside an outro row
  reading "July 27" is the same split the acronym rule catches. The KICKER is
  exempt — the engine uppercases it, so lowercase there renders correctly.
- **A beat has exactly 7 fields (8 with `icon_dx`).** A `~` typed where a `|`
  belongs silently turns a card into a subtitle and shifts every later field; the
  lint now names the beat instead of skipping it.
