#!/usr/bin/env python3
"""brand-explainer — the shared deterministic engine.

One generic engine renders EVERY brand film; a film is DATA (a `.brand` pack)
plus an optional per-film `accents.py` hook. This replaces the old
copy-the-example-and-mutate model that lost all 11 fleet engines.

    pack   ($VCLAW_WORKSPACE/packs/<brand>.brand)  what changes per film
    engine (this file)                             what never changes
    hook   (projects/<slug>/accents.py)            bespoke moves + procedural mark

House style (the three properties that separate shipped films from rejected
cuts — see references/house-style.md):
  1. layered multi-tone GB illustrations, never flat single-ink clipart
  2. cards carry real mono text, typed on — an empty card says nothing
  3. composed negative space — quarter disc (WHITE wash), plus mark, dots

Motion contract: eased slide/stagger/settle only — no spring overshoot, no
camera moves, no shadows. Timeline: title + N feature beats + MY TAKE + a
MEET-<brand> outro with a real QR.

Inputs (env — the master runner exports these):
  BRAND_PACK     path to the .brand pack file          (required)
  BRAND_PROJECT  path to the project dir               (required)
Usage: python3 engine.py [16x9|9x16|1x1]

qc_spacing.py compatibility surface (do not rename): IVORY, BEATS, BEAT_DURS,
BEAT_FNS, QC_MINIMAL, Wd/Hd/CW/CH/SS, progress(), corner_mark(),
dot/rrect/pill/text_c/text_stagger.
"""
import importlib.util
import math
import os
import subprocess
import sys

from PIL import Image, ImageDraw, ImageFont

# ---------- pack loading (shared parser in packlib.py) ----------
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from packlib import parse_pack as _parse_pack, hex_rgb as _hex, word_at as _word_at, not_a_brand_film as _not_a_film  # noqa: E402


PACK_PATH = os.environ.get("BRAND_PACK", "")
PROJ = os.environ.get("BRAND_PROJECT", "")
if not PACK_PATH or not os.path.isfile(PACK_PATH):
    raise SystemExit("engine: set BRAND_PACK to a .brand pack file")
if not PROJ or not os.path.isdir(PROJ):
    raise SystemExit("engine: set BRAND_PROJECT to the project directory")
P = _parse_pack(PACK_PATH)
_why_not = _not_a_film(PACK_PATH, P)
if _why_not:
    # the lint SKIPS these; the engine must refuse them, or a skipped lint
    # would let a template or a vox pack reach a render
    raise SystemExit(f"engine: refusing {PACK_PATH}: {_why_not}")

BRAND = P.get("BRAND_NAME", "brand")
TITLE_WORD = P.get("TITLE_WORD", BRAND)
TAGLINE = P.get("TAGLINE", "")

# ---------- poster frame (frame 0 is the feed thumbnail) ----------
# LinkedIn, X and Slack all poster a video with frame 0. The title beat animates
# its lockup IN from zero, so frame 0 rendered as bare ground and the only frame
# most of a feed ever saw was a grey card — 12 of the first 14 films shipped that
# way, and check_frame0.py now refuses it. Set POSTER_LINE_1 (and optionally
# _LINE_2) and the title beat holds a COMPOSED frame from t=0, then lifts those
# lines away to leave the standard lockup. Absent the key, byte-identical.
POSTER_LINE_1 = P.get("POSTER_LINE_1", "")
POSTER_LINE_2 = P.get("POSTER_LINE_2", "")
POSTER_HOLD = float(P.get("POSTER_HOLD", "1.40"))   # frame 0..HOLD: perfectly still
POSTER_LIFT = float(P.get("POSTER_LIFT", "0.80"))   # then the lines rise away

# ---------- palette (exact scraped hexes — never invented) ----------
GROUND = _hex(P.get("COLOR_GROUND"))
INK = _hex(P.get("COLOR_INK"))
SECONDARY = _hex(P.get("COLOR_SECONDARY"))       # pale offset layer + accent cards
ACCENT = _hex(P.get("COLOR_ACCENT"))             # ONE point of emphasis per frame
WHITE = _hex(P.get("COLOR_CARD"), (0xFF, 0xFF, 0xFF))
BONE = _hex(P.get("COLOR_PANEL"), (0xEE, 0xEE, 0xEB))
IVORY = GROUND                                   # qc_spacing.py reads fm.IVORY

# background texture: none | dots
TEXTURE = P.get("TEXTURE", "none")
DOT_A = float(P.get("TEXTURE_DOT_ALPHA", "0.16"))
DOT_PITCH = float(P.get("TEXTURE_DOT_PITCH", "37"))
MARQUEE_WORD = P.get("MARQUEE_WORD", "")         # outro bg wordmark band; "" = off
MARQUEE_TRANSITIONS = P.get("MARQUEE_TRANSITIONS", "0") == "1"   # band sweeps beat boundaries

ASPECT = sys.argv[1] if len(sys.argv) > 1 else "16x9"
DIMS = {"16x9": (1920, 1080), "9x16": (1080, 1920), "1x1": (1080, 1080)}
Wd, Hd = DIMS[ASPECT]
PORT = ASPECT != "16x9"
SQK = 0.72 if ASPECT == "1x1" else 1.0
SS, FPS = 2, 30
CW, CH = Wd * SS, Hd * SS
CXd = Wd / 2

# ---------- timeline ----------
# VO is written to FIT these durations. Never grow a beat to fit prose.
TITLE_D = float(P.get("TITLE_D", "3.2"))
FEATURE_D = float(P.get("FEATURE_D", "6.0"))
MYTAKE_D = float(P.get("MYTAKE_D", "8.0"))
# MY TAKE is a PRESENTER'S verdict — a first-person header over a score the
# presenter awards. That is right for a review of someone else's product and
# wrong for a film the subject publishes about itself: a practice, a clinic or
# a vendor scoring itself 9.1 "OUT OF 10" is an unsubstantiated claim in its
# own advertisement, and there is no honest number to put there.
# MYTAKE="0" drops the beat. Default ON, so every existing pack is unchanged
# (proved byte-identical against the buzz fixture master).
MYTAKE_ON = str(P.get("MYTAKE", "1")).strip().lower() not in ("0", "false", "no", "off", "")
OUTRO_D = float(P.get("OUTRO_D", "8.0"))

# ---- reference-style opt-ins (2026-09-20) --------------------------------
# All three default OFF. A pack without these keys renders byte-identical to
# before — proved against the buzz fixture. Added to close the measured gaps
# against the Chatwoot "Captain" reference: tilted cards, chat bubbles instead
# of mono command cards, and a brushstroke underline on the poster line.
CARD_TILT = float(P.get("CARD_TILT", "0") or 0)        # degrees; cards rotate about their own centre
POSTER_UNDERLINE = P.get("POSTER_UNDERLINE", "").strip()  # a word in POSTER_LINE_2 to brushstroke (stripped, as the lint reads it)

# feature beats: BEAT_1.."BEAT_N" = "icon|icon_h|pill|card1|card2|caption|corner"
BEATS_SPEC = []
_i = 1
while f"BEAT_{_i}" in P:
    parts = [p.strip() for p in P[f"BEAT_{_i}"].split("|")]
    if len(parts) not in (7, 8):
        raise SystemExit(f"pack: BEAT_{_i} needs 7 (or 8 with icon_dx) |-separated fields, got {len(parts)}")
    icon_name, ih, pill_s, c1, c2, cap, corner = parts[:7]
    icon_dx = float(parts[7]) if len(parts) == 8 else 0.0
    if not c1 or not c2:
        raise SystemExit(f"pack: BEAT_{_i} has an empty card — cards carry the script")
    BEATS_SPEC.append((icon_name, float(ih), pill_s, c1, c2, cap, corner, icon_dx))
    _i += 1
if not BEATS_SPEC:
    raise SystemExit("pack: no BEAT_N rows — a film needs feature beats")

BEAT_DURS = [TITLE_D] + [FEATURE_D] * len(BEATS_SPEC) + ([MYTAKE_D] if MYTAKE_ON else [])
BEATS = len(BEAT_DURS)
BEAT_STARTS = []
_acc = 0.0
for _d in BEAT_DURS:
    BEAT_STARTS.append(_acc)
    _acc += _d
TOTAL = _acc + OUTRO_D

SLUG = os.path.basename(PROJ.rstrip("/"))
SUFFIX = "" if ASPECT == "16x9" else f"-{ASPECT}"
OUT = f"{PROJ}/final/videos/{SLUG}-video-only{SUFFIX}.mp4"

# ---------- MY TAKE + MEET rows ----------
SCORE = float(P.get("MYTAKE_SCORE", "8.0"))
VERDICT = (P.get("MYTAKE_VERDICT_1", ""), P.get("MYTAKE_VERDICT_2", ""))
BEST_FOR = ("BEST FOR", P.get("MYTAKE_BEST_FOR", ""))
THE_CATCH = ("THE CATCH", P.get("MYTAKE_CATCH", ""))
MYTAKE_CAPTION = P.get("MYTAKE_CAPTION", "")

MEET_ROWS = []
for _k in ("MEET_ROW_1", "MEET_ROW_2", "MEET_ROW_3"):
    if _k in P:
        f = [x.strip() for x in P[_k].split("|")] + ["", "", ""]
        MEET_ROWS.append((f[0], f[1], f[2] or "white", f[3], f[4]))
MEET_SUBTITLE = P.get("MEET_SUBTITLE", "")
QR_URL_LABEL = P.get("QR_URL_LABEL", "")
OUTRO_CAPTION = P.get("OUTRO_CAPTION", "")

# ---------- fonts ----------
FONT_SANS = P.get("FONT_SANS", "/System/Library/Fonts/HelveticaNeue.ttc")
FONT_MONO = P.get("FONT_MONO", "/System/Library/Fonts/Menlo.ttc")
IDX = {"regular": int(P.get("FONT_IDX_REGULAR", "0")),
       "bold": int(P.get("FONT_IDX_BOLD", "1")),
       "medium": int(P.get("FONT_IDX_MEDIUM", "10")),
       "light": int(P.get("FONT_IDX_LIGHT", "7"))}


_FONT_FILES = {w: P.get(f"FONT_SANS_{w.upper()}", "") for w in ("regular", "bold", "medium", "light")}


def F(size, weight="regular"):
    # a dedicated per-weight file (e.g. CashSans-Bold.ttf) wins; else a .ttc face index
    path = _FONT_FILES.get(weight) or ""
    if path:
        if not os.path.isabs(path):
            path = os.path.join(PROJ, path)
        return ImageFont.truetype(path, int(size * SS))
    return ImageFont.truetype(FONT_SANS, int(size * SS), index=IDX[weight])


def mono(size=36):
    return ImageFont.truetype(FONT_MONO, int(size * SS))


f_pill = F(34, "medium")
f_label = F(37, "regular")


# ---------- easing (no overshoot: the style forbids bounce) ----------
def clamp01(x):
    return max(0.0, min(1.0, x))


def ease_out_cubic(t):
    t = clamp01(t)
    return 1 - (1 - t) ** 3


def ease_in_out(t):
    t = clamp01(t)
    return t * t * (3 - 2 * t)


def seg(t, a, b):
    if b <= a:
        return 1.0 if t >= b else 0.0
    return clamp01((t - a) / (b - a))


def X(f):
    return Wd * f


def Y(f):
    return Hd * f


def px(v):
    return int(round(v * SS))


def lerp(a, b, t):
    return a + (b - a) * t


# ---------- flat draw helpers (no shadows anywhere) ----------
def rrect(dr, x, y, w, h, r, fill, alpha=1.0, shadow=False):
    # shadow accepted for qc_spacing.py compatibility, always ignored — the
    # house style bans drop shadows outright.
    if alpha <= 0 or w <= 0 or h <= 0:
        return
    dr.rounded_rectangle([px(x), px(y), px(x + w), px(y + h)], px(min(r, w / 2, h / 2)),
                         fill=fill + (int(255 * alpha),))


def rrect_o(dr, x, y, w, h, r, color, alpha=1.0, width=2):
    if alpha <= 0 or w <= 0 or h <= 0:
        return
    dr.rounded_rectangle([px(x), px(y), px(x + w), px(y + h)], px(min(r, w / 2, h / 2)),
                         outline=color + (int(255 * alpha),), width=max(px(width), 1))


def dot(dr, cx, cy, r, color, alpha=1.0):
    if alpha <= 0 or r <= 0:
        return
    dr.ellipse([px(cx - r), px(cy - r), px(cx + r), px(cy + r)], fill=color + (int(255 * alpha),))


def ring(dr, cx, cy, r, alpha, color=None, width=3):
    if alpha <= 0 or r <= 0:
        return
    color = color or INK
    dr.ellipse([px(cx - r), px(cy - r), px(cx + r), px(cy + r)],
               outline=color + (int(255 * alpha),), width=max(px(width), 1))


def line(dr, x0, y0, x1, y1, color=None, width=1.5, alpha=1.0):
    if alpha <= 0:
        return
    color = color or INK
    dr.line([px(x0), px(y0), px(x1), px(y1)], fill=color + (int(255 * alpha),),
            width=max(px(width), 1))


def line_draw(dr, x0, y0, x1, y1, prog, color=None, width=1.5, alpha=1.0):
    if prog <= 0 or alpha <= 0:
        return
    line(dr, x0, y0, lerp(x0, x1, prog), lerp(y0, y1, prog), color, width, alpha)


def _alpha_layer(dr):
    """An RGBA scratch layer the size of the frame, plus the image to paste onto.

    WHY THIS EXISTS. PIL discards the alpha byte of an RGBA `fill` when the
    draw target is RGB — verified on Pillow 12.1.0 against the fleet's own
    TTFs: glyphs render at identical full ink for every alpha from 1.0 down to
    0.02, while rounded_rectangle at 0.5 correctly yields pixel 125. Shapes
    blended; text did not. So every `alpha` argument threaded through text_c
    and text_stagger was accepted, documented, and silently ignored, and NO
    text in any delivered film has ever faded — each element hard-cut the
    moment its ramp crossed the `alpha <= 0` guard.

    On an RGBA-mode draw the alpha byte IS honoured, so one scratch layer per
    call is enough even when every character carries a different alpha. Per
    character would mean a 3840x2160 RGBA allocation per glyph per frame.
    """
    target = getattr(dr, "_image", None)
    if target is None:
        raise RuntimeError(
            "engine: ImageDraw._image is gone (Pillow API change). Alpha text "
            "needs the backing image to composite onto. Fix it here — a silent "
            "fallback to dr.text() is how this bug survived fourteen films."
        )
    ov = Image.new("RGBA", target.size, (0, 0, 0, 0))
    return target, ov, ImageDraw.Draw(ov, "RGBA")


_tilt_scratch = None


def tilted(dr, cx, cy, angle, fn, box=(980, 420)):
    """Run fn(draw) onto a scratch layer, rotate it about (cx, cy), composite.

    WHY: the reference's cards sit rotated a few degrees and bleed off-frame,
    which is most of why it reads as a layout rather than a slide. The engine
    had no rotation at all. angle == 0 draws straight to `dr`, so every pack
    without CARD_TILT is byte-identical.

    PERFORMANCE. The obvious version — allocate a full-canvas RGBA and
    `rotate()` it — measured **3.2x slower per frame** (408 ms -> 1314 ms,
    13 min -> 43 min for a 65s film), because BICUBIC over 8.3M supersampled
    pixels twice a frame dwarfs the drawing. So the scratch layer is allocated
    ONCE and only the card's own region is cleared, rotated and pasted back.
    `box` must cover the drawn card; the margin covers what rotation sweeps
    outside it.
    """
    if not angle:
        fn(dr)
        return
    target = getattr(dr, "_image", None)
    if target is None:
        raise RuntimeError("engine: ImageDraw._image is gone — tilt needs the backing image")
    global _tilt_scratch
    if _tilt_scratch is None or _tilt_scratch.size != target.size:
        _tilt_scratch = Image.new("RGBA", target.size, (0, 0, 0, 0))
    ov = _tilt_scratch
    bw, bh = box
    # Margin = exactly what the rotated bbox sweeps outside the box, + slack.
    # A flat 30% margin made the crop 6.3M px of an 8.3M px canvas, which threw
    # away most of the point of cropping at all.
    _c, _s2 = abs(math.cos(math.radians(angle))), abs(math.sin(math.radians(angle)))
    mx = int(px((bw * _c + bh * _s2 - bw) / 2)) + 8
    my = int(px((bw * _s2 + bh * _c - bh) / 2)) + 8
    W, H = target.size
    x0 = max(0, int(px(cx - bw / 2)) - mx)
    y0 = max(0, int(px(cy - bh / 2)) - my)
    x1 = min(W, int(px(cx + bw / 2)) + mx)
    y1 = min(H, int(px(cy + bh / 2)) + my)
    if x1 <= x0 or y1 <= y0:
        return
    ov.paste((0, 0, 0, 0), (x0, y0, x1, y1))
    fn(ImageDraw.Draw(ov))
    reg = ov.crop((x0, y0, x1, y1)).rotate(
        angle, resample=Image.BICUBIC, center=(px(cx) - x0, px(cy) - y0))
    target.paste(reg, (x0, y0), reg)


def _wrap_to(dr, s, font, max_w):
    """Greedy word wrap at a pixel width (design units, not supersampled)."""
    words = s.split()
    lines, cur = [], ""
    for w in words:
        trial = (cur + " " + w).strip()
        if dr.textlength(trial, font=font) / SS <= max_w or not cur:
            cur = trial
        else:
            lines.append(cur)
            cur = w
    if cur:
        lines.append(cur)
    return lines


def chat_card(dr, cx, cy, text, side, appear, fo=1.0, w=560):
    """A chat BUBBLE — a wrapped sentence in the brand face, not a mono command.

    The reference shows its product as a conversation; command cards read as a
    terminal. `side` is 'user' (INK, sits LEFT in the column) or 'bot'
    (SECONDARY, sits RIGHT) — the offset is what makes it read as a dialogue at
    a glance. One corner is squared off on the speaker's side, the chat-UI
    convention for a tail; a protruding nub reads as a rendering artifact
    (tried it, it did).
    """
    a = ease_out_cubic(appear)
    if a <= 0:
        return 0
    # The user bubble is INK, not BONE. BONE measures 1.18:1 against a
    # saturated ground — the lowest-contrast surface in the film, so only one
    # side of the "conversation" was a visible surface at all. The house's own
    # dark/secondary pairing is what every command card already uses, which
    # also keeps the dark-anchored rhythm consistent across bubble and command
    # beats. A pale surface works in the reference because the reference sits
    # on WHITE; on #FFDA32 it collapses.
    bg = INK if side == "user" else SECONDARY
    fg = WHITE if side == "user" else INK
    f = F(30, "regular")
    inner = w - 76
    lines = _wrap_to(dr, text, f, inner)
    lh = 40
    h = 46 + lh * len(lines)
    # offset the two speakers so the exchange reads as a dialogue
    off = -70 if side == "user" else 70
    x0, y0 = cx + off - w / 2, cy - h / 2
    r = 24
    rrect(dr, x0, y0, w, h, r, bg, a * fo)
    # square off the speaker-side bottom corner = the tail, without a nub
    if side == "user":
        rrect(dr, x0, y0 + h - r, r, r, 2, bg, a * fo)
    else:
        rrect(dr, x0 + w - r, y0 + h - r, r, r, 2, bg, a * fo)
    # the text block is CENTRED in the bubble, not hung from the top
    ty = cy - (lh * (len(lines) - 1)) / 2
    # Every line at the bubble's own alpha. A per-line stagger timed from the
    # BEAT clock was dead (finished before the bubble appeared at 0.55 s) and,
    # on a tilted layer, text below the bubble's alpha punched holes in it.
    for i, ln in enumerate(lines):
        text_l(dr, x0 + 38, ty + i * lh, ln, f, fg, a * fo)
    return h


def brushstroke(dr, cx, cy, w, prog, color=None, thick=13, alpha=1.0):
    """A tapered underline, revealed L->R by `prog` (0..1; 1.0 = complete).

    The reference's opening hook underlines one word with a brush mark. The
    first version stacked three centred rounded bars, widest at FULL thickness
    — which renders as a uniform slab (measured taper ratio 0.98). A brush mark
    is thin at both ends and full in the middle, so the thickness has to vary
    ALONG the stroke: one polygon with a sine profile.
    """
    if prog <= 0 or alpha <= 0 or w <= 0:
        return
    color = color or ACCENT
    p = ease_out_cubic(min(max(prog, 0.0), 1.0))
    ww = w * p
    if ww <= 1:
        return
    x0 = cx - w / 2
    N = 40
    top, bot = [], []
    for i in range(N + 1):
        f = i / N
        x = x0 + ww * f
        prof = 0.18 + 0.82 * math.sin(math.pi * f) ** 0.5   # thin ends, full middle
        hh = thick * prof / 2
        top.append((px(x), px(cy - hh)))
        bot.append((px(x), px(cy + hh)))
    dr.polygon(top + bot[::-1], fill=color + (int(255 * alpha),))


def text_c(dr, cx, cy, s, font, fill, alpha=1.0, tracking=0.0):
    """Centred text; tracking is design px and may be negative (brand-tight)."""
    if alpha <= 0 or not s:
        return
    widths = [dr.textlength(ch, font=font) for ch in s]
    tw = sum(widths) + px(tracking) * (len(s) - 1)
    x = px(cx) - tw / 2
    asc, desc = font.getmetrics()
    y = px(cy) - (asc + desc) / 2

    # ALPHA MUST BE COMPOSITED, NOT PASSED AS A FILL BYTE.
    #
    # This used to be `fill + (int(255*alpha),)` handed to dr.text(). PIL
    # DISCARDS that alpha byte when the draw target is RGB — verified on
    # Pillow 12.1.0 against the fleet's own TTFs: glyphs render at identical
    # full ink for every alpha from 1.0 down to 0.02, while rounded_rectangle
    # at 0.5 correctly yields pixel 125. So `alpha` was accepted, documented
    # and silently ignored at every one of the eleven call sites below, and
    # NO text in any delivered film has ever faded — each one hard-cut when
    # its ramp crossed the `alpha <= 0` guard.
    #
    # quarter() already had the right shape. _alpha_layer does the same for
    # text: draw into an RGBA scratch layer, composite it back.
    if alpha >= 0.999:
        for ch, w0 in zip(s, widths):
            dr.text((x, y), ch, font=font, fill=fill)
            x += w0 + px(tracking)
        return
    target, ov, d2 = _alpha_layer(dr)
    for ch, w0 in zip(s, widths):
        d2.text((x, y), ch, font=font, fill=fill + (int(255 * alpha),))
        x += w0 + px(tracking)
    target.paste(ov, (0, 0), ov)


def text_stagger(dr, cx, cy, s, font, fill, prog, per=0.05, dur=0.3, rise=14, tracking=0.0):
    """Per-character rise-and-settle. No overshoot."""
    if prog <= 0:
        return
    widths = [dr.textlength(ch, font=font) for ch in s]
    tw = sum(widths) + px(tracking) * (len(s) - 1)
    x = px(cx) - tw / 2
    asc, desc = font.getmetrics()
    y0 = px(cy) - (asc + desc) / 2
    # Per-character alpha, composited. Characters used to RISE correctly (a
    # position) but never FADE (an alpha), so a stagger read as glyphs sliding
    # in at full ink. One layer for the whole string — see _alpha_layer.
    target, ov, d2 = _alpha_layer(dr)
    for i, (ch, w0) in enumerate(zip(s, widths)):
        a = ease_out_cubic(seg(prog, i * per, i * per + dur))
        if a > 0 and ch != " ":
            d2.text((x, y0 + px(rise) * (1 - a)), ch, font=font,
                    fill=fill + (int(255 * a),))
        x += w0 + px(tracking)
    target.paste(ov, (0, 0), ov)


def typewriter(dr, cx, cy, s, font, fill, prog, cps=14.0, alpha=1.0, caret=True, T=0.0):
    """Reveal chars L->R, centred on the FULL string so it never shifts.
    Caret blinks steps(1)-style at 1s."""
    if prog <= 0 or alpha <= 0:
        return
    n = min(len(s), int(prog * cps))
    widths = [dr.textlength(ch, font=font) for ch in s]
    tw = sum(widths)
    x = px(cx) - tw / 2
    asc, desc = font.getmetrics()
    y = px(cy) - (asc + desc) / 2
    col = fill + (int(255 * alpha),)
    for ch, w0 in zip(s[:n], widths[:n]):
        dr.text((x, y), ch, font=font, fill=col)
        x += w0
    if caret and (n < len(s) or (T % 1.0) < 0.5):
        h = asc + desc
        dr.rectangle([x + px(3), y + h * 0.16, x + px(3) + px(3), y + h * 0.84], fill=col)


def text_l(dr, x, cy, s, font, fill, alpha=1.0, tracking=0.0):
    """Left-aligned text, vertically centred on cy."""
    if alpha <= 0 or not s:
        return
    col = fill + (int(255 * alpha),)
    asc, desc = font.getmetrics()
    xx = px(x)
    y = px(cy) - (asc + desc) / 2
    for ch in s:
        dr.text((xx, y), ch, font=font, fill=col)
        xx += dr.textlength(ch, font=font) + px(tracking)


def pill(dr, cx, cy, s, alpha=1.0, bg=None, fg=None, scale=1.0, font=None):
    """Full-round pill. Label gate 0.62 — a higher gate renders blank buttons."""
    if alpha <= 0 or scale <= 0:
        return
    bg = bg or WHITE
    fg = fg or INK
    font = font or f_pill
    tw = dr.textlength(s, font=font)
    w = (tw / SS + 58) * scale
    h = 62 * scale
    rrect(dr, cx - w / 2, cy - h / 2, w, h, h / 2, bg, alpha)
    if scale > 0.62:
        text_c(dr, cx, cy - 1, s, font, fg, alpha, tracking=-0.3)


def label(dr, cx, cy, s, prog, color=None):
    # NOTE: this used to take `alpha=0.55`. It was never referenced in the body
    # and no call site ever passed it — all five use the positional form — so it
    # was a documented 55% opacity that has never once rendered. Removed rather
    # than honoured: making a default nobody chose suddenly apply would drop
    # every caption in the fleet to 55% in the same commit that fixed text_c,
    # and the two changes would be impossible to tell apart in review.
    color = color or INK
    text_stagger(dr, cx, cy, s, f_label, color, prog, per=0.010, dur=0.22, rise=8, tracking=-0.3)


# ---------- per-film accents hook ----------
# projects/<slug>/accents.py may export any of:
#   draw_mark(E, base, cx, cy, h, alpha=1.0, color=None, T=0.0)   the brand mark
#   corner_mark(E, base, T)                                       bottom-right chrome
#   texture(E, base, T)                                           bg texture override
#   accent_title(E, base, dr, t, dur, fo)                         extra title motion
#   accent_beat(E, base, dr, t, dur, bi, fo)                      extra per-beat motion
#   accent_outro(E, base, dr, t, g)                               extra outro motion
_accents = None
_ACC_PATH = os.path.join(PROJ, "accents.py")
if os.path.isfile(_ACC_PATH):
    _spec = importlib.util.spec_from_file_location("brand_accents", _ACC_PATH)
    _accents = importlib.util.module_from_spec(_spec)
    _spec.loader.exec_module(_accents)

_E = sys.modules.get(__name__)
if _E is None:
    raise SystemExit(
        "engine: loader must register the module in sys.modules BEFORE "
        "exec_module — sys.modules[spec.name] = module (see gen_sfx.py)")


def _hook(name):
    return getattr(_accents, name, None) if _accents else None


# ---------- brand mark ----------
_mark_img = None


def draw_mark(base, cx, cy, h_design, alpha=1.0, color=None, T=0.0):
    """The brand mark. accents.draw_mark wins (procedural — how a mark keeps
    its real motion, e.g. animated wings); else composite assets/brand/mark.png."""
    fn = _hook("draw_mark")
    if fn:
        return fn(_E, base, cx, cy, h_design, alpha=alpha, color=color, T=T)
    global _mark_img
    if alpha <= 0 or h_design <= 2:
        return
    if _mark_img is None:
        _mark_img = Image.open(f"{PROJ}/assets/brand/mark.png").convert("RGBA")
    h_pix = max(px(h_design), 2)
    w_pix = max(int(_mark_img.width * h_pix / _mark_img.height), 2)
    m = _mark_img.resize((w_pix, h_pix), Image.LANCZOS)
    if alpha < 1.0:
        m.putalpha(m.getchannel("A").point(lambda v: int(v * alpha)))
    base.paste(m, (int(px(cx) - w_pix / 2), int(px(cy) - h_pix / 2)), m)


# ---------- go-bananas spot icons (illustration GB draws, layout PIL draws) ----------
ICON_DIR = f"{PROJ}/assets/icons"
_icons = {}


def icon(base, name, cx, cy, h_design, alpha=1.0, rise=0.0):
    """Composite a sliced GB spot illustration (layered multi-tone — the house
    style). Icons carry no text -> nothing to garble."""
    if alpha <= 0 or h_design <= 2:
        return
    key = (name, int(h_design))
    if key not in _icons:
        im = Image.open(f"{ICON_DIR}/{name}.png").convert("RGBA")
        h_pix = max(px(h_design), 2)          # guard: ease curves near t=0 -> 0px crash
        w_pix = max(int(im.width * h_pix / im.height), 2)
        # icon_h is a HEIGHT: a wide glyph (a toggle, a scale) at a normal height
        # overshoots the column and collides with the cards. Clamp the WIDTH and
        # derive the height back — a no-op for every icon that already fits.
        max_w = px(ICON_MAX_W)
        if w_pix > max_w:
            w_pix = max_w
            h_pix = max(int(im.height * w_pix / im.width), 2)
        _icons[key] = im.resize((w_pix, h_pix), Image.LANCZOS)
    m = _icons[key]
    if alpha < 1.0:
        m = m.copy()
        m.putalpha(m.getchannel("A").point(lambda v: int(v * alpha)))
    base.paste(m, (int(px(cx) - m.width / 2), int(px(cy + rise) - m.height / 2)), m)


# ---------- composed negative space ----------
def quarter(base, corner, r, alpha, color=None):
    """A soft disc bled off one corner. WHITE wash by default — a brand tint
    over a saturated ground goes muddy."""
    if alpha <= 0:
        return
    color = color or WHITE
    ov = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
    d2 = ImageDraw.Draw(ov, "RGBA")
    cx, cy = {"tl": (0, 0), "tr": (Wd, 0), "bl": (0, Hd), "br": (Wd, Hd)}[corner]
    d2.ellipse([px(cx - r), px(cy - r), px(cx + r), px(cy + r)], fill=color + (int(46 * alpha),))
    base.paste(ov, (0, 0), ov)


def plus(dr, cx, cy, sz, alpha, color=None, width=4):
    if alpha <= 0:
        return
    color = color or INK
    col = color + (int(255 * alpha),)
    dr.line([px(cx - sz), px(cy), px(cx + sz), px(cy)], fill=col, width=px(width))
    dr.line([px(cx), px(cy - sz), px(cx), px(cy + sz)], fill=col, width=px(width))


def decor(base, dr, t, dur, corner="tr", seed=0, upper_left_clear=True):
    """Composed negative space: a quarter disc bled off a corner, a plus mark,
    scattered dots.

    The plus lands in the upper-left quadrant, which is empty on the workhorse
    feature beat (illustration at LX, cards at RX) but NOT on the two layouts
    that put a text lockup there. On the STATEMENT beat it wedged itself between
    the kicker lozenge and the headline's cap line, flush with the headline's
    left edge; on the outro it sat 36px left of the subtitle and vertically
    centred on it, reading as a list bullet. Pass upper_left_clear=False from
    those layouts and the plus is dropped — the disc and the two dots still
    compose the space.
    """
    da = ease_out_cubic(seg(t, 0.1, 0.9)) * (1.0 - seg(t, dur - 0.35, dur))
    quarter(base, corner, 360, da)
    pts = [(X(0.09) + (seed * 97) % 110, Y(0.19) + (seed * 61) % 90),
           (X(0.92) - (seed * 43) % 120, Y(0.83) - (seed * 77) % 140),
           (X(0.14) + (seed * 31) % 80, Y(0.86) - (seed * 53) % 70)]
    if upper_left_clear:
        plus(dr, pts[0][0], pts[0][1], 15, 0.45 * da)
    dot(dr, pts[1][0], pts[1][1], 9, ACCENT, 0.5 * da)
    dot(dr, pts[2][0], pts[2][1], 6, INK, 0.30 * da)


def accent_panel(dr, cx, half_w, alpha, y_frac=0.7385, h=100):
    """A quiet BONE panel behind the accent band — floating sub-rows read as
    unanchored fragments without one (and thin glyphs split under QC)."""
    if alpha <= 0:
        return
    rrect(dr, cx - half_w, Y(y_frac) - h / 2, half_w * 2, h, 24, BONE, 0.55 * alpha)


def cmd_card(dr, cx, cy, text, appear, type_from, t, tone="ink", fo=1.0, w=690, h=132):
    """A command card: solid fill + mono text typing on. The card text IS the
    script — an empty card says nothing."""
    a = ease_out_cubic(appear)
    if a <= 0:
        return
    bg = INK if tone == "ink" else SECONDARY
    fg = WHITE if tone == "ink" else INK
    rrect(dr, cx - w / 2, cy - h / 2, w, h, 22, bg, a * fo)
    typewriter(dr, cx, cy, text, mono(42), fg, max(t - type_from, 0), cps=26, alpha=a * fo, T=t)


def product_card(dr, cx, cy, spec, appear, type_from, t, tone="ink", fo=1.0, w=690, h=192):
    """A PRODUCT card: "Title~tagline~feat · feat · feat". Title in the brand
    face, mono tagline, a hairline divider, then a staggered feature row — the
    detail is STAGED, not dumped (a 6s beat gives viewers ~4 seconds)."""
    a = ease_out_cubic(appear)
    if a <= 0:
        return
    parts = [p.strip() for p in spec.split("~")]
    title = parts[0]
    tagline = parts[1] if len(parts) > 1 else ""
    feats = [f.strip() for f in (parts[2] if len(parts) > 2 else "").split("·") if f.strip()]
    bg = INK if tone == "ink" else SECONDARY
    fg = WHITE if tone == "ink" else INK
    rrect(dr, cx - w / 2, cy - h / 2, w, h, 24, bg, a * fo)
    lx0 = cx - w / 2 + 42
    text_l(dr, lx0, cy - 56, title, F(38, "medium"), fg, a * fo, tracking=-0.5)
    if tagline:
        ta = ease_out_cubic(seg(t, type_from, type_from + 0.45))
        text_l(dr, lx0, cy - 14, tagline, mono(22), fg, ta * 0.75 * fo)
    line(dr, lx0, cy + 16, cx + w / 2 - 42, cy + 16, fg, 0.75, a * 0.25 * fo)
    fx = lx0
    for i, f in enumerate(feats[:5]):
        fa = ease_out_cubic(seg(t, type_from + 0.55 + i * 0.22, type_from + 0.85 + i * 0.22))
        if fa > 0:
            dot(dr, fx + 5, cy + 52, 4.5, ACCENT if i == 0 else fg, fa * (0.9 if i == 0 else 0.5) * fo)
            text_l(dr, fx + 17, cy + 52, f, mono(19), fg, fa * 0.85 * fo)
        tmp = ImageDraw.Draw(Image.new("RGB", (8, 8)))
        fx += 17 + tmp.textlength(f, font=mono(19)) / SS + 34


# two-column layout: illustration left, mechanism right
LX = 470
RX = 1245
# widest an illustration may render before it crosses the frame edge or the card
# column (cards are CARD_W wide, centred at RX). icon() clamps to this; the pack
# lint reports it. Derived, not tuned: 2 * min(LX - edge, card_left - gutter - LX).
ICON_MAX_W = 2 * min(LX - 48, (RX - 690 / 2 - 48) - LX)

# ---------- background texture ----------
_CELL = 4
_TILE = px(DOT_PITCH * _CELL)
_PHASES = 24
_dot_tiles = []


def _build_dot_tiles():
    r = 1.25
    for ph in range(_PHASES):
        tile = Image.new("RGBA", (_TILE, _TILE), (0, 0, 0, 0))
        d = ImageDraw.Draw(tile, "RGBA")
        for row in range(_CELL):
            for col in range(_CELL):
                phase = ((col + row) % 4) / 4.0
                k = 0.5 + 0.5 * math.cos(2 * math.pi * (ph / _PHASES - phase))
                a = DOT_A * (0.45 + 0.55 * k)
                cx = (col + 0.5) * DOT_PITCH
                cy = (row + 0.5) * DOT_PITCH
                d.ellipse([px(cx - r), px(cy - r), px(cx + r), px(cy + r)],
                          fill=INK + (int(255 * a),))
        _dot_tiles.append(tile)


def dot_field(base, T, drift=10.0, alpha=1.0):
    if not _dot_tiles:
        _build_dot_tiles()
    tile = _dot_tiles[int(T * 6) % _PHASES]
    if alpha < 1.0:
        tile = tile.copy()
        tile.putalpha(tile.getchannel("A").point(lambda v: int(v * alpha)))
    off = int((T * drift * SS)) % _TILE
    y = -off
    while y < CH:
        x = -_TILE
        while x < CW:
            base.paste(tile, (x, y), tile)
            x += _TILE
        y += _TILE


def texture(base, T):
    fn = _hook("texture")
    if fn:
        return fn(_E, base, T)
    if TEXTURE == "dots":
        dot_field(base, T)


# ---------- outro background wordmark band ----------
_marquee = None


OUTRO_MARQUEE_GAP = 26         # design px of clear air between caption ink and band ink


def _outro_marquee_y_frac():
    """Slide the outro wordmark band below the closing caption's ink.

    The band is a 300pt word centred at Y(0.93) and deliberately cropped by the
    frame edge — but its ASCENDERS reach ~180px above the bottom, straight
    through the caption at Y(0.85). Measured across the fleet, EVERY film
    overlapped: gaps ran -26px to -69px, so on a `claude` band the `l` and `d`
    ran between the caption's words on the final frame, which holds for ~8s.
    Measure both inks and push the band down only as far as needed; a film with
    no OUTRO_CAPTION (or no MARQUEE_WORD) keeps Y(0.93) exactly.

    The beat-boundary wipe keeps the original position — it is a 0.15s pass
    behind content, never simultaneous with the caption.
    """
    base_frac = 0.93
    if not (OUTRO_CAPTION and MARQUEE_WORD):
        return base_frac
    try:
        cap_asc, cap_desc = f_label.getmetrics()
        cap_ink_bottom = (px(Y(0.85)) - (cap_asc + cap_desc) / 2
                          + f_label.getbbox(OUTRO_CAPTION)[3])
        mf = F(300 if not PORT else 190, "bold")
        m_asc, m_desc = mf.getmetrics()
        band_ink_top = mf.getbbox(MARQUEE_WORD)[1]        # within the band bitmap
        # marquee() draws at Y(frac)*SS - (asc+desc)/2, so the ink lands at
        # Y(frac)*SS - (asc+desc)/2 + band_ink_top. Solve for the gap.
        need = (cap_ink_bottom + px(OUTRO_MARQUEE_GAP) - band_ink_top
                + (m_asc + m_desc) / 2) / SS
        return max(base_frac, need / Hd)
    except Exception:
        return base_frac


def marquee(base, T, alpha=1.0, speed=90.0, y_frac=0.93):
    """A band of very large pale letterforms sliding left, looping seamlessly,
    cropped by the frame edges. Off when MARQUEE_WORD is empty."""
    global _marquee
    if alpha <= 0 or not MARQUEE_WORD:
        return
    if _marquee is None:
        f = F(300 if not PORT else 190, "bold")
        tmp = Image.new("RGBA", (10, 10))
        td = ImageDraw.Draw(tmp)
        word = MARQUEE_WORD + "   "
        w = int(td.textlength(word, font=f))
        asc, desc = f.getmetrics()
        band = Image.new("RGBA", (w, asc + desc), (0, 0, 0, 0))
        bd = ImageDraw.Draw(band)
        bd.text((0, 0), word, font=f, fill=SECONDARY + (255,))
        _marquee = band
    band = _marquee
    if alpha < 1.0:
        band = band.copy()
        band.putalpha(band.getchannel("A").point(lambda v: int(v * alpha)))
    off = int(T * speed * SS) % band.width
    y = int(Y(y_frac) * SS - band.height / 2)
    x = -off
    while x < CW:
        base.paste(band, (x, y), band)
        x += band.width


# ---------- shared chrome ----------
def progress(dr, bi, bt):
    spacing = 26
    x0 = CXd - spacing * (BEATS - 1) / 2
    y = Hd - 40
    for i in range(BEATS):
        if i < bi:
            dot(dr, x0 + i * spacing, y, 4.5, INK, 0.5)
        elif i == bi:
            dot(dr, x0 + i * spacing, y, 4.5 + 2 * ease_out_cubic(seg(bt, 0.0, 0.45)), INK, 0.8)
        else:
            dot(dr, x0 + i * spacing, y, 4.0, INK, 0.2)


def corner_mark(base, T=0.0):
    fn = _hook("corner_mark")
    if fn:
        return fn(_E, base, T)
    draw_mark(base, Wd - 62, Hd - 44, 30, 0.85, T=T)


def fadeo(t, dur):
    return 1.0 - seg(t, dur - 0.35, dur)


HEAD_PILL_H = 46               # the kicker lozenge's height
QC_TOUCH_FLOOR = 1080 * 0.016  # 17.28: qc_spacing calls anything tighter "touching"
HEAD_PILL_GAP = 19             # the gap to open up, but ONLY when a lift is needed


def _kicker_pill_y(yy, headline):
    """Top of the kicker lozenge so it never crowds the headline's ink.

    Same failure and same remedy as `_tagline_y`: the fixed offset (yy - 88) was
    tuned against one face, and a REAL brand face with taller caps closes the
    gap. Anthropic Sans at F(68) puts headline ink at y=130.2 against a pill
    bottom of 114.6 — a 15.7px gap, under qc_spacing's floor, failing five beats.

    THE LIFT IS CONDITIONAL, and that is the whole design. An unconditional
    target would move films that are already fine: cdnjs measures 17.4px, which
    clears the floor by 0.12px, so a blanket 20px target silently re-laid-out a
    film that had already shipped and been reviewed. Verified by frame hash, not
    assumed. So: if the gap already clears the floor, return the fixed position
    untouched and the film is byte-identical; only a gap that would actually
    FAIL QC gets lifted.
    """
    base = yy - 88
    if not headline:
        return base
    try:
        hf = F(68, "bold")
        h_asc, h_desc = hf.getmetrics()
        ink_top = (px(yy) - (h_asc + h_desc) / 2 + hf.getbbox(headline)[1]) / SS
        if ink_top - (base + HEAD_PILL_H) >= QC_TOUCH_FLOOR:
            return base                       # already clears — do not touch it
        return ink_top - HEAD_PILL_GAP - HEAD_PILL_H
    except Exception:
        return base


def head_pill(dr, s, t, y=None):
    """The beat HEADLINE stack — dominant display type in the brand face, never
    a chip (chips vanish on light grounds and waste the top band). The pill
    field supports "Kicker~Headline" or "Kicker~Headline~subtitle"."""
    parts = [p.strip() for p in s.split("~")]
    yy = y if y is not None else Y(0.145)
    if len(parts) >= 2:
        kicker, headline = parts[0], parts[1]
        sub = parts[2] if len(parts) > 2 and parts[2] else ""
        ka = ease_out_cubic(seg(t, 0.1, 0.5))
        # the kicker sits in a small lozenge — context chip OVER the display
        # headline (as ornament above a real heading, the chip works)
        kf = F(21, "medium")
        _tmp = ImageDraw.Draw(Image.new("RGB", (8, 8)))
        kw = (_tmp.textlength(kicker.upper(), font=kf) + px(4) * (len(kicker) - 1)) / SS
        py = _kicker_pill_y(yy, headline)
        rrect(dr, CXd - kw / 2 - 26, py, kw + 52, HEAD_PILL_H, 23, WHITE, ka * 0.95)
        # +22, not HEAD_PILL_H/2 (=23): the original was `yy - 66` against a pill
        # top of `yy - 88`, so the text sits 1px above the lozenge's centre.
        # Keeping the original offset is what makes this byte-identical.
        text_c(dr, CXd, py + 22, kicker.upper(), kf, INK, ka * 0.75, tracking=4)
        text_stagger(dr, CXd, yy, headline, F(68, "bold"), INK,
                     max(t - 0.25, 0), per=0.03, dur=0.3, rise=16, tracking=-2)
        if sub:
            sa = ease_out_cubic(seg(t, 0.75, 1.25))
            text_c(dr, CXd, yy + 62, sub, F(28, "regular"), INK, sa * 0.6, tracking=-0.3)
    else:
        text_stagger(dr, CXd, yy, parts[0], F(68, "bold"), INK,
                     max(t - 0.15, 0), per=0.03, dur=0.3, rise=16, tracking=-2)


# ---------- beats ----------
TITLE_LOCKUP_GAP = 30          # design px of clear air between word ink and tagline ink


def _tagline_y(dr):
    """Where the tagline sits so it never collides with the title word.

    The word is drawn at Y(0.66) and the tagline used to sit at a FIXED Y(0.79).
    A title word with a descender (p, g, y, q, j) carries ink far below its
    anchor, so that fixed y left only ~12px of air where a descender-free title
    got 45-61px — and a deep-descender face closed it entirely ("Opus 5" in Plus
    Jakarta Sans overlapped outright). Measure the word instead, and push the
    tagline down ONLY when Y(0.79) would not clear it. A no-op for every title
    whose ink already fits, so descender-free films render byte-identically.
    """
    base_y = Y(0.79)
    if not TITLE_WORD:
        return base_y
    try:
        wf = F(210, "bold")
        w_asc, w_desc = wf.getmetrics()
        # mirror text_stagger's anchoring: top edge = px(cy) - (asc+desc)/2
        word_top = px(Y(0.66)) - (w_asc + w_desc) / 2
        word_ink_bottom = word_top + wf.getbbox(TITLE_WORD)[3]
        l_asc, l_desc = f_label.getmetrics()
        l_ink_top = f_label.getbbox(TAGLINE)[1]
        # solve px(ty) so the tagline's ink starts TITLE_LOCKUP_GAP below the word's
        needed = (word_ink_bottom + px(TITLE_LOCKUP_GAP)
                  + (l_asc + l_desc) / 2 - l_ink_top)
        return max(base_y, needed / SS)
    except Exception:
        return base_y


def title_beat(base, dr, t, dur):
    """Title: the mark arrives, the title word lands, the tagline reads on."""
    fo = fadeo(t, dur)
    fn = _hook("accent_title")
    if fn:
        fn(_E, base, dr, t, dur, fo)
    elif POSTER_LINE_1:
        # static from frame 0 — an eased entrance would leave the poster markless
        draw_mark(base, CXd, Y(0.42), 250, fo, T=t)
    else:
        ma = ease_out_cubic(seg(t, 0.1, 0.8))
        draw_mark(base, CXd, Y(0.42) + 20 * (1 - ma), 250, ma * fo, T=t)
    # mark 0.42 / word 0.66 / tagline 0.79 form one lockup — no dead band
    #
    # POSTER MODE. With POSTER_LINE_1 set, the lockup is painted COMPLETE at t=0
    # and held still, so frame 0 is a finished poster rather than bare ground.
    # `prog=1.0` drives text_stagger/label to their settled state instead of
    # animating from zero; the word and tagline are still drawn HERE, once, by
    # the same calls as always — there is no second copy to keep in sync and
    # nothing to ghost against. The hook lines then lift away over POSTER_LIFT,
    # leaving exactly the lockup every other film ends its title beat on.
    poster = bool(POSTER_LINE_1) and not fn
    if fo > 0.05:
        text_stagger(dr, CXd, Y(0.66), TITLE_WORD, F(210, "bold"), INK,
                     1.0 if poster else max(t - 1.05, 0),
                     per=0.07, dur=0.32, rise=26, tracking=-8.0)
    if TAGLINE:
        label(dr, CXd, _tagline_y(dr), TAGLINE,
              fo if poster else ((t - 1.45) * fo if fo > 0.5 else 0))
    if poster:
        # ease_in_out so the exit reads as a settle, never a bounce (motion
        # contract). The lines carry the STORY, not the title — a thumbnail
        # reading "GPT-5.6" tells a scroller nothing they did not know.
        out = ease_in_out(seg(t, POSTER_HOLD, POSTER_HOLD + POSTER_LIFT))
        la = (1.0 - out) * fo
        rise = 26 * out
        text_c(dr, CXd, Y(0.155) - rise, POSTER_LINE_1,
               F(50, "bold"), INK, la, tracking=-0.6)
        text_c(dr, CXd, Y(0.232) - rise, POSTER_LINE_2,
               F(31, "medium"), ACCENT, la * 0.95, tracking=0.2)
        # The reference's opening hook underlines ONE word with a brushstroke.
        # Painted complete (see prog=1.0 below), not drawn on. Opt-in: POSTER_UNDERLINE names the word.
        _at = _word_at(POSTER_LINE_2, POSTER_UNDERLINE)
        if _at >= 0:
            _pf = F(31, "medium")
            _full = dr.textlength(POSTER_LINE_2, font=_pf) / SS
            _pre = dr.textlength(POSTER_LINE_2[:_at], font=_pf) / SS
            _wd = dr.textlength(POSTER_UNDERLINE, font=_pf) / SS
            _ux = CXd - _full / 2 + _pre + _wd / 2
            # prog=1.0: POSTER mode paints the lockup COMPLETE at t=0 and holds
            # it. An animated-on stroke was ABSENT from frame 0 (24 accent px at
            # t=0 vs 1,992 at t=1.2) — the feed thumbnail lost the opening hook.
            brushstroke(dr, _ux, Y(0.232) - rise + 30, _wd + 14,
                        1.0, ACCENT, 12, la)


def statement_layout(base, dr, t, dur, icon_name, ih, pill_s, c1, c2, fo):
    """The STATEMENT beat — the thesis moment. Left text column: kicker
    lozenge, a stacked multi-line headline with ONE line in accent
    (*wrapped*), an accent underline tick, a quiet subtitle. Right column:
    the illustration above the two cards. One per film, at the peak."""
    parts = [p.strip() for p in pill_s.split("~")]
    kicker = parts[0] if len(parts) > 1 else ""
    headline = parts[1] if len(parts) > 1 else parts[0]
    sub = parts[2] if len(parts) > 2 else ""
    lx = X(0.08)
    if kicker:
        ka = ease_out_cubic(seg(t, 0.1, 0.5))
        kf = F(21, "medium")
        _tmp = ImageDraw.Draw(Image.new("RGB", (8, 8)))
        kw = (_tmp.textlength(kicker.upper(), font=kf) + px(4) * (len(kicker) - 1)) / SS
        rrect(dr, lx, Y(0.16) - 23, kw + 52, 46, 23, WHITE, ka * 0.95)
        text_l(dr, lx + 26, Y(0.16), kicker.upper(), kf, INK, ka * 0.75, tracking=4)
    lines = [ln.strip() for ln in headline.split("^") if ln.strip()]
    y0 = Y(0.30)
    for i, ln in enumerate(lines):
        accent = ln.startswith("*") and ln.endswith("*")
        txt = ln.strip("*")
        la = ease_out_cubic(seg(t, 0.35 + i * 0.28, 0.85 + i * 0.28))
        if la > 0:
            asc, desc = F(76, "bold").getmetrics()
            xx = px(lx)
            col = (ACCENT if accent else INK)
            # Composited — see _alpha_layer. This is the film's thesis moment and
            # the largest type in the format, and it used to hard-cut in at full
            # ink because dr.text() discards a fill's alpha on an RGB target.
            # The whole line shares one alpha, so one layer covers it.
            _tgt, _ov, _d = _alpha_layer(dr)
            for ch in txt:
                _d.text((xx, px(y0 + i * Y(0.107)) + px(14) * (1 - la) - (asc + desc) / 2),
                        ch, font=F(76, "bold"), fill=col + (int(255 * la * fo),))
                xx += dr.textlength(ch, font=F(76, "bold")) + px(-2)
            _tgt.paste(_ov, (0, 0), _ov)
    ua = ease_out_cubic(seg(t, 0.5 + len(lines) * 0.28, 0.9 + len(lines) * 0.28))
    rrect(dr, lx + 4, Y(0.30 + len(lines) * 0.107) + 8, 92 * ua, 8, 4, ACCENT, ua * fo)
    if sub:
        sa = ease_out_cubic(seg(t, 0.8 + len(lines) * 0.28, 1.3 + len(lines) * 0.28))
        text_l(dr, lx + 4, Y(0.335 + len(lines) * 0.107) + 26, sub, F(28, "regular"),
               INK, sa * 0.65 * fo)
    ia = ease_out_cubic(seg(t, 0.15, 0.8))
    icon(base, icon_name, RX, Y(0.225), ih * 0.82 * (0.94 + 0.06 * ia), ia * fo,
         rise=18 * (1 - ia))
    cmd_card(dr, RX, Y(0.525), c1, seg(t, 0.9, 1.5), 1.1, t, "ink", fo)
    cmd_card(dr, RX, Y(0.72), c2, seg(t, 1.7, 2.3), 1.9, t, "blue", fo)


def feature_beat(base, dr, t, dur, bi, icon_dx=0):
    """The workhorse layout: illustration at LX, two typed command cards at RX,
    caption below — the shipped-fleet composition."""
    icon_name, ih, pill_s, c1, c2, cap, corner, spec_dx = BEATS_SPEC[bi]
    icon_dx = icon_dx or spec_dx
    fo = fadeo(t, dur)
    # "^" means the STATEMENT layout, whose headline lockup owns the left column
    decor(base, dr, t, dur, corner, seed=bi + 3, upper_left_clear="^" not in pill_s)
    if "^" in pill_s:
        statement_layout(base, dr, t, dur, icon_name, ih, pill_s, c1, c2, fo)
        label(dr, CXd, Y(0.875), cap, (t - 2.4) * fo if fo > 0.5 else 0)
        fn = _hook("accent_beat")
        if fn:
            fn(_E, base, dr, t, dur, bi, fo)
        return fo
    head_pill(dr, pill_s, t)
    ia = ease_out_cubic(seg(t, 0.1, 0.75))
    icon(base, icon_name, LX + icon_dx, Y(0.485), ih * 1.18 * (0.94 + 0.06 * ia), ia * fo,
         rise=22 * (1 - ia))
    rich = ("~" in c1) or ("~" in c2)
    if rich:
        y1, y2 = Y(0.355), Y(0.635)
        for spec, yy, tone, ap, tf in ((c1, y1, "ink", seg(t, 0.55, 1.15), 0.75),
                                       (c2, y2, "blue", seg(t, 1.6, 2.2), 1.8)):
            if "~" in spec:
                product_card(dr, RX, yy, spec, ap, tf, t, tone, fo)
            else:
                cmd_card(dr, RX, yy, spec, ap, tf, t, tone, fo)
        # the connector: the two products are one SYSTEM, not two cards
        h1 = 192 if "~" in c1 else 132
        h2 = 192 if "~" in c2 else 132
        top = y1 + h1 / 2 + 10
        bot = y2 - h2 / 2 - 10
        cp = ease_in_out(seg(t, 2.3, 2.9))
        line_draw(dr, RX, top, RX, bot, cp, INK, 2, 0.4 * fo)
        na = ease_out_cubic(seg(t, 2.8, 3.1))
        if na > 0:
            dot(dr, RX, (top + bot) / 2, 9, WHITE, na * fo)
            ring(dr, RX, (top + bot) / 2, 9, na * fo, ACCENT, 3)
    else:
        # A '>' or '<' prefix makes the card a CHAT BUBBLE instead of a command
        # card: '>' is the user, '<' is the product replying. Reference-style
        # product UI. No prefix = the original mono command card, unchanged.
        def _card(d, spec, yy, ap, tf, tone):
            if spec[:1] in (">", "<"):
                chat_card(d, RX, yy, spec[1:].strip(),
                          "user" if spec[0] == ">" else "bot", ap, fo)
            else:
                cmd_card(d, RX, yy, spec, ap, tf, t, tone, fo)
        tilted(dr, RX, Y(0.375), CARD_TILT,
               lambda d: _card(d, c1, Y(0.375), seg(t, 0.55, 1.15), 0.75, "ink"))
        # SAME sign, not mirrored: a mirrored pair diverges by 2x the angle and
        # the gutter between the cards swings 67->174px across its own width,
        # which reads as misaligned rather than hand-placed.
        tilted(dr, RX, Y(0.60), CARD_TILT,
               lambda d: _card(d, c2, Y(0.60), seg(t, 1.35, 1.95), 1.55, "blue"))
    label(dr, CXd, Y(0.815), cap, (t - 2.4) * fo if fo > 0.5 else 0)
    fn = _hook("accent_beat")
    if fn:
        fn(_E, base, dr, t, dur, bi, fo)
    return fo


def mytake_beat(base, dr, t, dur):
    """MY TAKE — the presenter's verdict: a score card, a two-line thesis with
    the second line in the accent, BEST FOR and THE CATCH."""
    fo = fadeo(t, dur)
    decor(base, dr, t, dur, "bl", seed=11)
    head_pill(dr, "MY TAKE", t, y=Y(0.155))
    ca = ease_out_cubic(seg(t, 0.15, 0.85))
    sx_, sy_ = LX - 40, Y(0.53)
    rrect(dr, sx_ - 205, sy_ - 205, 410, 410, 30, WHITE, ca * fo)
    n = SCORE * ease_out_cubic(seg(t, 0.45, 1.7))
    text_c(dr, sx_, sy_ - 42, f"{n:.1f}", F(150, "bold"), INK, ca * fo, tracking=-5)
    text_c(dr, sx_, sy_ + 62, "OUT OF 10", F(30, "medium"), INK, ca * 0.55 * fo, tracking=3)
    bw = 300
    rrect(dr, sx_ - bw / 2, sy_ + 128, bw, 16, 8, INK, ca * 0.14 * fo)
    rrect(dr, sx_ - bw / 2, sy_ + 128, bw * (SCORE / 10) * ease_out_cubic(seg(t, 0.9, 2.1)),
          16, 8, ACCENT, ca * fo)
    v1 = ease_out_cubic(seg(t, 0.7, 1.3))
    v2 = ease_out_cubic(seg(t, 1.0, 1.6))
    text_c(dr, RX, Y(0.295), VERDICT[0], F(62, "bold"), INK, v1 * fo, tracking=-2)
    text_c(dr, RX, Y(0.375), VERDICT[1], F(62, "bold"), ACCENT, v2 * fo, tracking=-2)
    for i, ((lab, val), tone, y) in enumerate(((BEST_FOR, "blue", Y(0.545)),
                                               (THE_CATCH, "ink", Y(0.70)))):
        a = ease_out_cubic(seg(t, 1.7 + i * 0.5, 2.3 + i * 0.5))
        if a <= 0:
            continue
        bg = SECONDARY if tone == "blue" else INK
        fg = INK if tone == "blue" else WHITE
        rrect(dr, RX - 345, y - 62, 690, 124, 22, bg, a * fo)
        text_c(dr, RX, y - 24, lab, F(24, "medium"), fg, a * 0.65 * fo, tracking=3)
        text_c(dr, RX, y + 16, val, F(34, "medium"), fg, a * fo, tracking=-0.5)
    if MYTAKE_CAPTION:
        label(dr, CXd, Y(0.845), MYTAKE_CAPTION, (t - 3.0) * fo if fo > 0.5 else 0)


def _make_feature_fn(bi):
    def fn(base, dr, t, dur):
        feature_beat(base, dr, t, dur, bi)
    fn.__name__ = f"b{bi + 2}"
    return fn


BEAT_FNS = ([title_beat] + [_make_feature_fn(i) for i in range(len(BEATS_SPEC))]
            + ([mytake_beat] if MYTAKE_ON else []))
QC_MINIMAL = {0}   # the title card is deliberately sparse


# ---------- outro ----------
_qr = None


def outro(base, dr, t):
    """MEET <BRAND> — labelled rows + a real scannable QR held static."""
    global _qr
    g = 1.0 - seg(t, OUTRO_D - 0.45, OUTRO_D)
    decor(base, dr, t, OUTRO_D, "tr", seed=17, upper_left_clear=False)
    ma = ease_out_cubic(seg(t, 0.05, 0.6))
    # left-aligned heading lockup: mark · MEET kicker · name · subtitle
    hx = X(0.115)
    draw_mark(base, hx + 40, Y(0.145), 80, ma * g, T=t + 4.4)
    tx0 = hx + 112
    if g > 0.05:
        ka = ease_out_cubic(seg(t, 0.15, 0.55))
        text_l(dr, tx0, Y(0.095), "MEET", F(26, "medium"), INK, ka * 0.65 * g, tracking=5)
        _f = F(88, "bold")
        xx = tx0
        _tmp = ImageDraw.Draw(Image.new("RGB", (8, 8)))
        # Composited per character — see _alpha_layer. The outro wordmark
        # staggered its RISE (a position, which worked) while every glyph stayed
        # at full ink (an alpha, which did not). One layer for the whole word.
        _tgt, _ov, _d = _alpha_layer(dr)
        # MEET_BRAND_DISPLAY overrides the uppercasing for a brand whose own
        # styling IS lowercase. `BRAND.upper()` is right for almost every brand,
        # but cdnjs is written lowercase everywhere it appears — including the
        # marquee in this same outro frame — so shouting CDNJS at 88px read as a
        # typo, which is exactly what pack-schema.md's casing rule calls a
        # finding ("uppercase in the outro and lowercase on a card"). The KICKER
        # exemption does not cover it: that applies to 21-26px tracked labels,
        # not the hero word. Absent key = BRAND.upper(), byte-identical.
        for i, ch in enumerate(P.get("MEET_BRAND_DISPLAY", "") or BRAND.upper()):
            a2 = ease_out_cubic(seg(max(t - 0.25, 0), i * 0.045, i * 0.045 + 0.3))
            if a2 > 0 and ch != " ":
                asc, desc = _f.getmetrics()
                _d.text((px(xx), px(Y(0.155)) + px(18) * (1 - a2) - (asc + desc) / 2),
                        ch, font=_f, fill=INK + (int(255 * a2 * g),))
            xx += _tmp.textlength(ch, font=_f) / SS + (-2)
        _tgt.paste(_ov, (0, 0), _ov)
        if MEET_SUBTITLE:
            sa = ease_out_cubic(seg(t, 0.6, 1.05))
            text_l(dr, tx0, Y(0.225), MEET_SUBTITLE, F(28, "regular"), INK, sa * 0.6 * g)
    lx = X(0.315)
    for i, (lab, val, tone, icon_name, sub) in enumerate(MEET_ROWS):
        a = ease_out_cubic(seg(t, 0.85 + i * 0.42, 1.45 + i * 0.42))
        if a <= 0:
            continue
        y = Y(0.415) + i * 150
        bg = {"blue": SECONDARY, "white": WHITE, "ink": INK}[tone]
        fg = WHITE if tone == "ink" else INK
        rrect(dr, lx - 340, y - 66, 680, 136, 24, bg, a * g)
        tx = lx - 340 + 36
        if icon_name:
            tile_bg = WHITE if tone != "white" else SECONDARY
            rrect(dr, tx, y - 44, 88, 88, 20, tile_bg, a * (0.9 if tone == "ink" else 0.55) * g)
            icon(base, icon_name, tx + 44, y, 58, a * g)
            tx += 118
        text_l(dr, tx, y - 34, lab, F(21, "medium"),
               ACCENT if tone == "ink" else fg, a * (0.9 if tone == "ink" else 0.6) * g, tracking=3)
        text_l(dr, tx, y + 2, val, F(32, "medium"), fg, a * g, tracking=-0.5)
        if sub:
            text_l(dr, tx, y + 38, sub, mono(18), fg, a * 0.6 * g)
    qa = ease_out_cubic(seg(t, 1.5, 2.1))
    if qa > 0:
        if _qr is None:
            _qr = Image.open(f"{PROJ}/assets/brand/qr.png").convert("RGBA")
        hq = px(300)
        m = _qr.resize((hq, hq), Image.NEAREST)
        if qa < 1.0:
            m.putalpha(m.getchannel("A").point(lambda v: int(v * qa)))
        qx, qy = X(0.795), Y(0.50)
        rrect(dr, qx - 178, qy - 178, 356, 356, 26, WHITE, qa * g)
        base.paste(m, (int(px(qx) - hq / 2), int(px(qy) - hq / 2)), m)
        text_c(dr, qx, Y(0.685), QR_URL_LABEL, F(38, "bold"), INK, qa * g, tracking=-1)
        line(dr, qx - 90, Y(0.718), qx - 14, Y(0.718), INK, 0.75, qa * 0.3 * g)
        dot(dr, qx, Y(0.718), 4, ACCENT, qa * 0.9 * g)
        line(dr, qx + 14, Y(0.718), qx + 90, Y(0.718), INK, 0.75, qa * 0.3 * g)
        text_c(dr, qx, Y(0.748), "scan to open it", F(25, "regular"), INK, qa * 0.55 * g)
    if OUTRO_CAPTION:
        label(dr, CXd, Y(0.85), OUTRO_CAPTION, (t - 2.6) * g if g > 0.5 else 0)
    fn = _hook("accent_outro")
    if fn:
        fn(_E, base, dr, t, g)


# ---------- render loop ----------
def compose_frame(T):
    """Compose ONE delivered frame at time T.

    The single source of truth for what a frame looks like. make_stills.py used
    to keep a hand-copied duplicate of this body, and it drifted: it called
    marquee() without y_frac, so the contact sheet the operator APPROVES at the
    stills gate showed the outro wordmark band at Y(0.93) — running through the
    closing caption — while the delivered film pushed it clear. The operator was
    signing off frames the render did not produce. Both callers now go through
    here so that class of drift cannot recur.
    """
    base = Image.new("RGB", (CW, CH), GROUND)
    in_outro = T >= TOTAL - OUTRO_D
    if in_outro:
        ot = T - (TOTAL - OUTRO_D)
        # texture settles to stillness; the wordmark band fades up
        texture(base, (TOTAL - OUTRO_D) + min(ot, 1.2) * (1 - ease_in_out(seg(ot, 0, 1.2))))
        marquee(base, ot, alpha=ease_out_cubic(seg(ot, 0.2, 1.0)) * 0.85,
                y_frac=_outro_marquee_y_frac())
    else:
        texture(base, T)
        # the signature band sweeps through each beat boundary (behind content)
        if MARQUEE_TRANSITIONS and MARQUEE_WORD:
            for tb in BEAT_STARTS[1:]:
                dt = abs(T - tb)
                if dt < 0.45:
                    marquee(base, T, alpha=(1 - dt / 0.45) * 0.55, speed=420)
                    break
    dr = ImageDraw.Draw(base, "RGBA")
    if not in_outro:
        bi = BEATS - 1
        for i, st in enumerate(BEAT_STARTS):
            if T < st + BEAT_DURS[i]:
                bi = i
                break
        bt = T - BEAT_STARTS[bi]
        BEAT_FNS[bi](base, dr, bt, BEAT_DURS[bi])
        progress(dr, bi, bt)
        corner_mark(base, T)
    else:
        outro(base, dr, T - (TOTAL - OUTRO_D))
    return base.resize((Wd, Hd), Image.LANCZOS)


def render():
    n_frames = int(TOTAL * FPS)
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    cmd = ["ffmpeg", "-y", "-v", "error",
           "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{Wd}x{Hd}", "-r", str(FPS), "-i", "-",
           "-c:v", "libx264", "-preset", "medium", "-crf", "17", "-pix_fmt", "yuv420p", OUT]
    proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
    for fi in range(n_frames):
        T = fi / FPS
        frame = compose_frame(T)
        proc.stdin.write(frame.tobytes())
        if fi % 300 == 0:
            print(f"[{ASPECT}] frame {fi}/{n_frames} ({T:.1f}s)", flush=True)
    proc.stdin.close()
    proc.wait()
    print(f"[{ASPECT}] video-only:", OUT, "exit", proc.returncode)
    if proc.returncode:
        sys.exit(proc.returncode)


if __name__ == "__main__":
    render()
