#!/usr/bin/env python3
"""Deterministic Claude-brand motion graphics — Fable explainer v3.
v3: kinetic typography (staggered reveals), beat wipes + progress through-line,
and aspect-aware layouts (16x9 / 9x16 / 1x1 from the same code).
Usage: python3 fable_mograph.py [16x9|9x16|1x1]
"""
import subprocess, math, sys
from PIL import Image, ImageDraw, ImageFont

# ---------- brand ----------
IVORY = (250, 249, 245)
SLATE = (20, 20, 19)
SLATE_M = (61, 61, 58)
SLATE_L = (94, 93, 89)
CLAY = (217, 119, 87)
OAT = (227, 218, 204)
OAT_D = (212, 199, 178)
KRAFT = (212, 162, 127)
WHITE = (255, 255, 255)

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"      # stacked layouts for 9x16 and 1x1
SQK = 0.72 if ASPECT == "1x1" else 1.0  # square shrinks elements to keep air
SS, FPS = 2, 30
CW, CH = Wd * SS, Hd * SS
CXd = Wd / 2

INTRO_D, BEAT_D, OUTRO_D = 2.6, 6.0, 3.4
BEATS = 8
TOTAL = INTRO_D + BEATS * BEAT_D + OUTRO_D  # 54.0

BRAND_DIR = "/Users/davendrapatel/videoclaw/projects/fable-explainer/assets/brand"
MARK = f"{BRAND_DIR}/anthropic-mark-slate.png"
ICON_DIR = f"{BRAND_DIR}/icons"
SUFFIX = "" if ASPECT == "16x9" else f"-{ASPECT}"
OUT = f"/Users/davendrapatel/videoclaw/projects/fable-explainer/final/videos/fable-video-only{SUFFIX}.mp4"

# ---------- fonts ----------
AB = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
GEO_I = "/System/Library/Fonts/Supplemental/Georgia Italic.ttf"
def F(path, size): return ImageFont.truetype(path, int(size * SS))
f_pill = F(AB, 30)
f_caption = F(GEO_I, 40 if not PORT else 36)

# ---------- easing ----------
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 ease_out_back(t):
    t = clamp01(t); c1, c3 = 1.70158, 2.70158
    return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2
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

# ---------- draw helpers ----------
def px(v): return int(round(v * SS))

def rrect(dr, x, y, w, h, r, fill, alpha=1.0, shadow=True):
    if alpha <= 0 or w <= 0 or h <= 0: return
    if shadow and alpha > 0.05:
        dr.rounded_rectangle([px(x + 3), px(y + 8), px(x + w + 3), px(y + h + 8)], px(r),
                             fill=(0, 0, 0, int(26 * alpha)))
    dr.rounded_rectangle([px(x), px(y), px(x + w), px(y + h)], px(r), fill=fill + (int(255 * alpha),))

def text_c(dr, cx, cy, s, font, fill, alpha=1.0, tracking=0):
    if alpha <= 0: return
    col = fill + (int(255 * alpha),)
    if tracking:
        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
        for ch, w0 in zip(s, widths):
            dr.text((x, y), ch, font=font, fill=col); x += w0 + px(tracking)
    else:
        tw = dr.textlength(s, font=font)
        asc, desc = font.getmetrics()
        dr.text((px(cx) - tw / 2, px(cy) - (asc + desc) / 2), s, font=font, fill=col)

def text_stagger(dr, cx, cy, s, font, fill, prog, per=0.06, dur=0.3, rise=16):
    """Kinetic reveal: characters fade+rise with a stagger. prog in seconds since start."""
    if prog <= 0: return
    widths = [dr.textlength(ch, font=font) for ch in s]
    tw = sum(widths)
    x = px(cx) - tw / 2
    asc, desc = font.getmetrics()
    y0 = px(cy) - (asc + desc) / 2
    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 != " ":
            dr.text((x, y0 + px(rise) * (1 - a)), ch, font=font, fill=fill + (int(255 * a),))
        x += w0

def word_pop(dr, cx, cy, s, size, fill, prog, dur=0.45):
    """One word scale-pops in (ease_out_back)."""
    a = seg(prog, 0, dur)
    if a <= 0: return
    f2 = ImageFont.truetype(AB, max(int(size * SS * (0.7 + 0.3 * ease_out_back(a))), 2))
    text_c(dr, cx, cy, s, f2, fill, ease_out_cubic(min(a * 1.6, 1)))

def pill(dr, cx, cy, s, alpha=1.0, bg=CLAY, fg=IVORY, scale=1.0):
    if alpha <= 0 or scale <= 0: return
    tw = dr.textlength(s, font=f_pill) + px(4) * (len(s) - 1)
    w = (tw / SS + 56) * scale; h = 58 * scale
    rrect(dr, cx - w / 2, cy - h / 2, w, h, h / 2, bg, alpha, shadow=False)
    if scale > 0.8:
        text_c(dr, cx, cy - 1, s, f_pill, fg, alpha, tracking=4)

def caption(dr, cx, cy, s, prog):
    """Typewriter-stagger serif caption; prog = seconds since reveal start."""
    text_stagger(dr, cx, cy, s, f_caption, SLATE_M, prog, per=0.014, dur=0.22, rise=8)

def arrow(dr, x0, y0, x1, y1, t, color=SLATE, alpha=1.0, width=7):
    if t <= 0 or alpha <= 0: return
    col = color + (int(255 * alpha),)
    ex, ey = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
    dr.line([px(x0), px(y0), px(ex), px(ey)], fill=col, width=px(width))
    if t > 0.25:
        ang = math.atan2(y1 - y0, x1 - x0); hl = 26
        for da in (0.45, -0.45):
            hx, hy = ex - hl * math.cos(ang + da), ey - hl * math.sin(ang + da)
            dr.line([px(ex), px(ey), px(hx), px(hy)], fill=col, width=px(width))

def dashed(dr, x0, y0, x1, y1, alpha, color=SLATE_L, width=4, dash=20, gap=14):
    if alpha <= 0: return
    col = color + (int(255 * alpha),)
    L = math.hypot(x1 - x0, y1 - y0)
    if L <= 0: return
    ux, uy = (x1 - x0) / L, (y1 - y0) / L
    d = 0.0
    while d < L:
        e = min(d + dash, L)
        dr.line([px(x0 + ux * d), px(y0 + uy * d), px(x0 + ux * e), px(y0 + uy * e)], fill=col, width=px(width))
        d += dash + gap

def ring(dr, cx, cy, r, alpha, color=CLAY, width=8):
    if alpha <= 0: return
    dr.ellipse([px(cx - r), px(cy - r), px(cx + r), px(cy + r)],
               outline=color + (int(255 * alpha),), width=px(width))

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

def plus(dr, cx, cy, s, alpha, color=CLAY, width=6):
    if alpha <= 0: return
    col = color + (int(255 * alpha),)
    dr.line([px(cx - s), px(cy), px(cx + s), px(cy)], fill=col, width=px(width))
    dr.line([px(cx), px(cy - s), px(cx), px(cy + s)], fill=col, width=px(width))

def quarter(base, corner, r, alpha, color=OAT):
    if alpha <= 0: return
    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(90 * alpha),))
    base.paste(ov, (0, 0), ov)

# ---------- assets ----------
mark_img = Image.open(MARK).convert("RGBA")
_icons = {}
def icon(base, name, cx, cy, h_design, alpha=1.0, rise=0.0):
    if alpha <= 0: 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)
        w_pix = max(int(im.width * h_pix / im.height), 2)
        _icons[key] = im.resize((w_pix, h_pix), Image.LANCZOS)
    m = _icons[key]
    if alpha < 1.0:
        m = m.copy()
        a = m.getchannel("A").point(lambda v: int(v * alpha)); m.putalpha(a)
    base.paste(m, (int(px(cx) - m.width / 2), int(px(cy + rise) - m.height / 2)), m)

def mark_at(base, cx, cy, h_design, alpha=1.0):
    h_pix = max(px(h_design), 2)
    w_pix = int(mark_img.width * h_pix / mark_img.height)
    m = mark_img.resize((w_pix, h_pix), Image.LANCZOS)
    if alpha < 1.0:
        a = m.getchannel("A").point(lambda v: int(v * alpha)); m.putalpha(a)
    base.paste(m, (int(px(cx) - w_pix / 2), int(px(cy) - h_pix / 2)), m)

def corner_mark(base, alpha=0.92):
    h_pix = px(44)
    w_pix = int(mark_img.width * h_pix / mark_img.height)
    m = mark_img.resize((w_pix, h_pix), Image.LANCZOS)
    a = m.getchannel("A").point(lambda v: int(v * alpha)); m.putalpha(a)
    base.paste(m, (px(Wd - 44) - w_pix, px(Hd - 36) - h_pix), m)

def decor(base, dr, t, corner="tr", seed=0):
    da = ease_out_cubic(seg(t, 0.1, 0.9)) * (1.0 - seg(t, BEAT_D - 0.35, BEAT_D))
    quarter(base, corner, 300 if not PORT else 240, da)
    pts = [(X(0.10) + (seed * 97) % 110, Y(0.18) + (seed * 61) % 90),
           (X(0.90) - (seed * 43) % 100, Y(0.82) - (seed * 77) % 110),
           (X(0.13) + (seed * 31) % 80, Y(0.84) - (seed * 53) % 70)]
    plus(dr, pts[0][0], pts[0][1], 14, 0.5 * da)
    dot(dr, pts[1][0], pts[1][1], 8, CLAY, 0.45 * da)
    dot(dr, pts[2][0], pts[2][1], 6, SLATE_L, 0.4 * da)

def progress(dr, bi, bt):
    """Through-line: 8 dots along the bottom; active dot clay and larger."""
    spacing = 36
    x0 = CXd - spacing * (BEATS - 1) / 2
    y = Hd - 42
    for i in range(BEATS):
        if i < bi: dot(dr, x0 + i * spacing, y, 6, CLAY, 0.75)
        elif i == bi:
            grow = ease_out_back(seg(bt, 0.0, 0.5))
            dot(dr, x0 + i * spacing, y, 6 + 3 * grow, CLAY, 0.95)
        else: dot(dr, x0 + i * spacing, y, 5, SLATE_L, 0.4)

def wipe(dr, T):
    """Soft clay band sweeping across at each beat boundary."""
    bounds = [INTRO_D + i * BEAT_D for i in range(BEATS + 1)]
    for b in bounds:
        p = seg(T, b - 0.18, b + 0.18)
        if 0 < p < 1:
            bx = -260 + (Wd + 520) * ease_in_out(p)
            dr.rounded_rectangle([px(bx - 120), px(-40), px(bx + 120), px(Hd + 40)], px(60),
                                 fill=CLAY + (int(60 * math.sin(math.pi * p)),))

# ---------- beats ----------
def fade_out(t): return 1.0 - seg(t, BEAT_D - 0.35, BEAT_D)

def b1(base, dr, t):  # FABLE — book + staggered title
    fo = fade_out(t)
    decor(base, dr, t, "tr", 1)
    ia = ease_out_cubic(seg(t, 0.1, 0.7))
    icon(base, "book", CXd, Y(0.26) if SQK >= 1.0 else Y(0.29), 210 * SQK, ia * fo, rise=24 * (1 - ia))
    if fo > 0.05:
        f2 = ImageFont.truetype(AB, int(150 * SS))
        text_stagger(dr, CXd, Y(0.51) if SQK >= 1.0 else Y(0.55), "FABLE", f2, SLATE, max(t - 0.45, 0), per=0.07, dur=0.3, rise=22)
    pa = seg(t, 1.15, 1.65)
    pill(dr, CXd, Y(0.64) if SQK >= 1.0 else Y(0.68), "THE LONG-RUN MODEL", ease_out_cubic(pa) * fo, bg=OAT_D, fg=SLATE,
         scale=0.7 + 0.3 * ease_out_back(pa))
    caption(dr, CXd, Y(0.74) if SQK >= 1.0 else Y(0.79), "an honest take", (t - 1.7) * fo if fo > 0.5 else 0)

def b2(base, dr, t):  # RUNS LONG — path + progress card
    fo = fade_out(t)
    decor(base, dr, t, "bl", 2)
    ia = ease_out_cubic(seg(t, 0.1, 0.7))
    if PORT:
        icon(base, "path", CXd, Y(0.28) if SQK >= 1.0 else Y(0.30), 380 * SQK, ia * fo, rise=26 * (1 - ia))
        ccx, ccy, cw = CXd, Y(0.56) if SQK >= 1.0 else Y(0.62), 840
    else:
        icon(base, "path", 430, 520, 430, ia * fo, rise=26 * (1 - ia))
        ccx, ccy, cw = 1230, 540, 800
    ca = ease_out_cubic(seg(t, 0.4, 1.0))
    ccx_a = ccx + (1 - ca) * 140
    rrect(dr, ccx_a - cw / 2, ccy - 140 * SQK, cw, 280 * SQK, 34, WHITE, ca * fo)
    lp = ease_in_out(seg(t, 1.2, 3.9))
    x0, x1, y = ccx_a - (cw / 2 - 70), ccx_a + (cw / 2 - 70), ccy
    if lp > 0 and ca > 0.9:
        dr.line([px(x0), px(y), px(x0 + (x1 - x0) * lp), px(y)], fill=CLAY + (int(255 * fo),), width=px(10))
        for i in range(9):
            fx = i / 8.0
            if lp >= fx - 0.01: dot(dr, x0 + (x1 - x0) * fx, y, 11, SLATE, fo)
    pill(dr, ccx, ccy - (230 * SQK if SQK >= 1.0 else 150), "RUNS LONG", ease_out_cubic(seg(t, 1.0, 1.5)) * fo)
    caption(dr, ccx, ccy + 220 * SQK, "holds one task across many steps", (t - 2.0) * fo if fo > 0.5 else 0)

def b3(base, dr, t):  # SAY LESS -> GET MORE — bubble + word pops
    fo = fade_out(t)
    decor(base, dr, t, "tl", 3)
    ia = ease_out_back(seg(t, 0.2, 0.8))
    if PORT:
        bx, by = CXd, Y(0.22) if SQK >= 1.0 else Y(0.19)
        gx, gy = (CXd, Y(0.55)) if SQK >= 1.0 else (CXd, Y(0.62))
        ax = (bx, Y(0.335), bx, Y(0.42)) if SQK >= 1.0 else (bx, Y(0.415), bx, Y(0.462))
        capy = Y(0.76) if SQK >= 1.0 else Y(0.88)
    else:
        bx, by = 430, 430
        gx, gy = 1270, 520
        ax = (640, 520, 860, 520)
        capy = 850
    if ia > 0:
        icon(base, "bubble", bx, by, 300 * SQK * min(ia, 1.06), seg(t, 0.2, 0.7) * fo)
    pill(dr, bx, (by + 220) if not PORT else ((by + 190) if SQK >= 1.0 else Y(0.345)), "SAY LESS", seg(t, 0.7, 1.2) * fo, bg=SLATE, fg=IVORY)
    arrow(dr, ax[0], ax[1], ax[2], ax[3], ease_in_out(seg(t, 1.0, 1.6)), alpha=fo)
    ga = seg(t, 1.5, 2.2); gs = ease_out_back(ga)
    if ga > 0:
        w1, h1 = 620 * SQK * gs, 430 * SQK * gs
        rrect(dr, gx - w1 / 2, gy - h1 / 2, w1, h1, 40, KRAFT, min(ga * 2, 1) * fo)
        word_pop(dr, gx, gy - 60 * SQK, "GET", 84 * SQK, SLATE, t - 1.85)
        word_pop(dr, gx, gy + 55 * SQK, "MORE", 84 * SQK, SLATE, t - 2.05)
    caption(dr, CXd, capy, "one line steers the whole behavior", (t - 2.4) * fo if fo > 0.5 else 0)

def b4(base, dr, t):  # REMOVE SCAFFOLDING — blueprint, guides fade
    fo = fade_out(t)
    decor(base, dr, t, "br", 4)
    if PORT:
        guides = [(X(0.08), Y(0.30), X(0.92), Y(0.30)), (X(0.08), Y(0.70), X(0.92), Y(0.70)),
                  (X(0.24), Y(0.16), X(0.24), Y(0.84)), (X(0.76), Y(0.16), X(0.76), Y(0.84))]
    else:
        guides = [(320, 300, 1600, 300), (320, 780, 1600, 780), (560, 170, 560, 910), (1360, 170, 1360, 910)]
    for i, (gx0, gy0, gx1, gy1) in enumerate(guides):
        ga = seg(t, 0.0, 0.5) * (1.0 - seg(t, 1.2 + i * 0.5, 1.7 + i * 0.5))
        dashed(dr, gx0, gy0, gx1, gy1, 0.5 * ga * fo)
    ia = ease_out_cubic(seg(t, 0.2, 0.8))
    icon(base, "blueprint", CXd, Y(0.5), (430 if not PORT else 380) * SQK, ia * fo, rise=20 * (1 - ia))
    pill(dr, CXd, Y(0.20) if not PORT else Y(0.12), "REMOVE SCAFFOLDING", ease_out_cubic(seg(t, 0.6, 1.1)) * fo)
    caption(dr, CXd, Y(0.81), "capability up, hand-holding down", (t - 2.2) * fo if fo > 0.5 else 0)

def b5(base, dr, t):  # IN PARALLEL — nodes + split cards
    fo = fade_out(t)
    decor(base, dr, t, "tr", 5)
    ia = ease_out_cubic(seg(t, 0.1, 0.7))
    icon(base, "nodes", CXd, Y(0.21) if not PORT else Y(0.15), (110 if not PORT else 160) * SQK, ia * fo, rise=18 * (1 - ia))
    ca = ease_out_cubic(seg(t, 0.5, 1.0))
    sp = ease_in_out(seg(t, 1.1, 2.1))
    if PORT:
        # vertical split
        off = 300 * SQK * sp
        cyc = Y(0.52) if SQK >= 1.0 else Y(0.565)
        for k in (-1, 0, 1):
            yk = cyc + k * off
            rrect(dr, CXd - 175 * SQK, yk - 105 * SQK, 350 * SQK, 210 * SQK, 30, SLATE, ca * fo)
            da = seg(t, 2.1 + (k + 1) * 0.18, 2.45 + (k + 1) * 0.18)
            dot(dr, CXd, yk, 15, CLAY, da * fo)
        pill(dr, CXd, Y(0.265) if SQK >= 1.0 else Y(0.245), "IN PARALLEL", ease_out_cubic(seg(t, 1.3, 1.8)) * fo)
        caption(dr, CXd, Y(0.85), "dispatches subagents — and keeps working", (t - 2.6) * fo if fo > 0.5 else 0)
    else:
        off = 430 * sp
        for k in (-1, 0, 1):
            x = 960 + k * off
            rrect(dr, x - 175, 560 - 120, 350, 240, 30, SLATE, ca * fo)
            da = seg(t, 2.1 + (k + 1) * 0.18, 2.45 + (k + 1) * 0.18)
            dot(dr, x, 560, 15, CLAY, da * fo)
        la = seg(t, 2.7, 3.3)
        if sp > 0.95 and la > 0:
            col = SLATE_L + (int(200 * la * fo),)
            dr.line([px(960 - 255), px(560), px(960 - 179), px(560)], fill=col, width=px(5))
            dr.line([px(960 + 179), px(560), px(960 + 255), px(560)], fill=col, width=px(5))
        pill(dr, 960, 350, "IN PARALLEL", ease_out_cubic(seg(t, 1.3, 1.8)) * fo)
        caption(dr, 960, 830, "dispatches subagents — and keeps working", (t - 2.6) * fo if fo > 0.5 else 0)

def b6(base, dr, t):  # MORE OUTPUT — gauge + rising bars
    fo = fade_out(t)
    decor(base, dr, t, "bl", 6)
    ia = ease_out_cubic(seg(t, 0.2, 0.8))
    if PORT:
        icon(base, "gauge", CXd, Y(0.26), 320 * SQK, ia * fo, rise=24 * (1 - ia))
        arrow(dr, CXd, Y(0.40), CXd, Y(0.47), ease_in_out(seg(t, 1.2, 1.8)), alpha=fo)
        bx0, bbase = CXd - 240, Y(0.72)
    else:
        icon(base, "gauge", 480, 540, 340, ia * fo, rise=24 * (1 - ia))
        arrow(dr, 740, 560, 960, 560, ease_in_out(seg(t, 1.2, 1.8)), alpha=fo)
        bx0, bbase = 1080, 820
    heights = (240, 380, 540) if not PORT else (180 * SQK, 280 * SQK, 400 * SQK)
    for i, bh_full in enumerate(heights):
        bp = ease_out_cubic(seg(t, 1.8 + i * 0.35, 2.8 + i * 0.35))
        bh = bh_full * bp
        if bh > 2:
            rrect(dr, bx0 + i * 180, bbase - bh, 130, bh, 22, CLAY if i == 2 else SLATE, fo)
    pill(dr, CXd, Y(0.20) if not PORT else Y(0.12), "MORE OUTPUT", ease_out_cubic(seg(t, 0.9, 1.4)) * fo)
    caption(dr, CXd, Y(0.86), "less effort, more output", (t - 3.0) * fo if fo > 0.5 else 0)

def b7(base, dr, t):  # DONT MICROMANAGE — sliders fade, plane glides
    fo = fade_out(t)
    decor(base, dr, t, "tl", 7)
    sa = ease_out_cubic(seg(t, 0.1, 0.7)) * (1.0 - seg(t, 1.6, 2.6))
    if PORT:
        icon(base, "sliders", CXd, Y(0.36), 340 * SQK, sa * fo)
        p0x, p0y = CXd - 60, Y(0.58)
    else:
        icon(base, "sliders", 620, 520, 380, sa * fo)
        p0x, p0y = 1130, 560
    pa = ease_out_cubic(seg(t, 2.4, 3.2))
    gl = ease_in_out(seg(t, 3.0, 5.0))
    if pa > 0:
        icon(base, "plane", p0x + 220 * gl, p0y - 130 * gl, (330 if not PORT else 300) * SQK, pa * fo)
        dashed(dr, p0x - 230, p0y + 140, p0x - 80 + 200 * gl, p0y + 50 - 100 * gl,
               0.5 * pa * fo, color=SLATE_L, width=5, dash=6, gap=16)
    pill(dr, CXd, Y(0.20) if not PORT else Y(0.12), "DONT MICROMANAGE", ease_out_cubic(seg(t, 0.8, 1.3)) * fo)
    caption(dr, CXd, Y(0.82), "trust over control", (t - 3.2) * fo if fo > 0.5 else 0)

def b8(base, dr, t):  # TRUST THE BRIEF — compass + staggered words + ring
    fo = 1.0 - seg(t, BEAT_D - 0.5, BEAT_D)
    decor(base, dr, t, "br", 8)
    ia = ease_out_cubic(seg(t, 0.1, 0.7))
    icon(base, "compass", CXd, Y(0.30) if not PORT else Y(0.28), 260 * SQK, ia * fo, rise=22 * (1 - ia))
    if fo > 0.05:
        size = 104 if not PORT else 88
        f2 = ImageFont.truetype(AB, int(size * SS))
        text_stagger(dr, CXd, Y(0.57), "TRUST THE BRIEF", f2, SLATE, max(t - 0.6, 0),
                     per=0.045, dur=0.3, rise=20)
    rp = seg(t, 1.6, 2.5)
    if 0 < rp < 1:
        ring(dr, CXd, Y(0.53), (340 if not PORT else 300) + 140 * ease_out_cubic(rp),
             (1 - rp) * 0.45 * fo, width=6)

BEAT_FNS = [b1, b2, b3, b4, b5, b6, b7, b8]

# ---------- intro / outro ----------
def intro(base, dr, t):
    g = 1.0 - seg(t, INTRO_D - 0.4, INTRO_D)
    ma = ease_out_cubic(seg(t, 0.05, 0.6))
    mark_at(base, CXd, Y(0.32) + 30 * (1 - ma), 180, ma * g)
    if g > 0.05:
        text_stagger(dr, CXd, Y(0.52), "FABLE 5", ImageFont.truetype(AB, int(96 * SS)), SLATE,
                     max(t - 0.35, 0), per=0.06, dur=0.28, rise=16)
    rw = 110 * ease_in_out(seg(t, 0.75, 1.2))
    if rw > 2:
        rrect(dr, CXd - rw / 2, Y(0.595), rw, 8, 4, CLAY, g, shadow=False)
    caption(dr, CXd, Y(0.655), "a Claude model — an honest take", (t - 0.95) * g)

def outro(base, dr, t):
    g = 1.0 - seg(t, OUTRO_D - 0.5, OUTRO_D)
    ma = ease_out_cubic(seg(t, 0.05, 0.65))
    mark_at(base, CXd, Y(0.33) + 25 * (1 - ma), 170, ma * g)
    ta = ease_out_cubic(seg(t, 0.4, 1.0))
    text_c(dr, CXd, Y(0.525), "FABLE 5", ImageFont.truetype(AB, int(92 * SS)), SLATE, ta * g)
    rw = 100 * ease_in_out(seg(t, 0.75, 1.2))
    if rw > 2:
        rrect(dr, CXd - rw / 2, Y(0.595), rw, 8, 4, CLAY, g, shadow=False)
    if g > 0.05:
        text_stagger(dr, CXd, Y(0.66), "Trust the brief.", F(GEO_I, 46), CLAY,
                     max(t - 1.0, 0), per=0.03, dur=0.25, rise=10)

# ---------- render loop ----------
def render():
    n_frames = int(TOTAL * FPS)
    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
        base = Image.new("RGB", (CW, CH), IVORY)
        dr = ImageDraw.Draw(base, "RGBA")
        if T < INTRO_D:
            intro(base, dr, T)
        elif T < INTRO_D + BEATS * BEAT_D:
            bt = T - INTRO_D
            bi = min(int(bt // BEAT_D), BEATS - 1)
            BEAT_FNS[bi](base, dr, bt - bi * BEAT_D)
            progress(dr, bi, bt - bi * BEAT_D)
            corner_mark(base)
        else:
            outro(base, dr, T - INTRO_D - BEATS * BEAT_D)
        wipe(dr, T)
        frame = base.resize((Wd, Hd), Image.LANCZOS)
        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 render:", OUT, "exit", proc.returncode)

if __name__ == "__main__":
    render()
