#!/usr/bin/env python3
"""Vercel Agent v2 — engine v2 showcase.
New vs v1: variable beat durations, a moving camera (push-ins + cut punches),
spring overshoot, count-up numbers, typewriter mono, a drawn-on rollback
timeline set-piece, cold-open narrative. 16:9 first (showcase).
Usage: python3 vercel2_mograph.py [16x9|9x16|1x1]
"""
import subprocess, math, sys
from PIL import Image, ImageDraw, ImageFont

# ---------- brand: Vercel ----------
IVORY = (250, 250, 250)
SLATE = (23, 23, 23)
SLATE_M = (82, 82, 82)
SLATE_L = (130, 130, 130)
CLAY = (0, 112, 243)         # Vercel blue
RED = (238, 68, 68)          # incident red (used once, cold open)
OAT = (234, 234, 234)
OAT_D = (219, 219, 219)
KRAFT = (219, 234, 254)
WHITE = (238, 238, 238)

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

# ---------- variable beat timeline ----------
INTRO_D = 0.0   # v2 has no separate intro card — the cold open IS the intro
OUTRO_D = 7.0
BEAT_DURS = [2.8, 3.2, 10.0, 4.0, 6.0, 6.0, 5.0, 5.0, 5.0, 6.0]
BEATS = len(BEAT_DURS)
BEAT_STARTS = []
_acc = INTRO_D
for _d in BEAT_DURS:
    BEAT_STARTS.append(_acc); _acc += _d
TOTAL = _acc + OUTRO_D  # 60.0

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

# ---------- fonts ----------
AB = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
AR = "/System/Library/Fonts/Supplemental/Arial.ttf"
MONO = "/System/Library/Fonts/Menlo.ttc"
def F(path, size): return ImageFont.truetype(path, int(size * SS))
f_pill = F(AB, 30)
f_caption = F(AR, 38 if not PORT else 34)
_mono_cache = {}
def mono(sz=42):
    if sz not in _mono_cache: _mono_cache[sz] = ImageFont.truetype(MONO, int(sz * SS))
    return _mono_cache[sz]

# ---------- 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 spring(t, freq=2.6, damp=5.0):
    """Damped spring 0->1 with overshoot; t in [0,1+]."""
    t = max(0.0, t)
    if t >= 1.5: return 1.0
    return 1 - math.exp(-damp * t) * math.cos(freq * math.pi * 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

# ---------- 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):
    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 typewriter(dr, cx, cy, s, font, fill, prog, cps=24.0, alpha=1.0):
    """Reveal characters left-to-right at cps chars/sec; centered on the FULL string."""
    if prog <= 0 or alpha <= 0: return
    n = min(len(s), int(prog * cps))
    if n <= 0: return
    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
    # caret
    if n < len(s):
        dr.rectangle([x + px(2), y + px(4), x + px(6), y + px(40)], fill=CLAY + (int(255 * alpha),))

def countup(dr, cx, cy, target, prog, font, fill, alpha=1.0, fmt="{:.0f}", suffix=""):
    """Number rolls 0 -> target with ease-out."""
    if alpha <= 0 or prog <= 0: return
    v = target * ease_out_cubic(prog)
    text_c(dr, cx, cy, fmt.format(v) + suffix, font, fill, alpha)

def pill(dr, cx, cy, s, alpha=1.0, bg=CLAY, fg=(255, 255, 255), 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.62:
        text_c(dr, cx, cy - 1, s, f_pill, fg, alpha, tracking=4)

def caption(dr, cx, cy, s, prog):
    text_stagger(dr, cx, cy, s, f_caption, SLATE_M, prog, per=0.014, dur=0.22, rise=8)

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 line_draw(dr, x0, y0, x1, y1, prog, color=SLATE, width=8, alpha=1.0):
    """Line draws on from (x0,y0) toward (x1,y1)."""
    if prog <= 0 or alpha <= 0: return
    ex, ey = x0 + (x1 - x0) * prog, y0 + (y1 - y0) * prog
    dr.line([px(x0), px(y0), px(ex), px(ey)], fill=color + (int(255 * alpha),), width=px(width))

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 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(110 * 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, dur, corner="tr", seed=0):
    da = ease_out_cubic(seg(t, 0.1, 0.9)) * (1.0 - seg(t, dur - 0.35, dur))
    quarter(base, corner, 300 if not PORT else 285, da)
    pts = [(X(0.10) + (seed * 97) % 110, Y(0.18) + (seed * 61) % 90),
           (X(0.90) - (seed * 43) % 120, Y(0.82) - (seed * 77) % 140),
           (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)

def progress(dr, bi, bt):
    spacing = 32
    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: dot(dr, x0 + i * spacing, y, 6 + 3 * ease_out_back(seg(bt, 0.0, 0.5)), CLAY, 0.95)
        else: dot(dr, x0 + i * spacing, y, 5, SLATE_L, 0.4)

# ---------- camera ----------
def camera(T):
    """Global (zoom, pan_x, pan_y). Slow push per beat; punch at cuts."""
    z, pxx, pyy = 1.0, 0.0, 0.0
    # which beat
    bi = BEATS - 1
    for i, st in enumerate(BEAT_STARTS):
        if T < st + BEAT_DURS[i]: bi = i; break
    st = BEAT_STARTS[bi]; dur = BEAT_DURS[bi]; bt = T - st
    # slow push-in over the beat
    z = 1.0 + 0.045 * ease_in_out(bt / max(dur, 0.001))
    # cut punch: quick settle-in at each beat start (zoom from 1.06 -> push base)
    p = seg(bt, 0.0, 0.28)
    z += 0.05 * (1 - ease_out_cubic(p))
    # whip pan alternates direction per beat
    dirn = -1 if bi % 2 else 1
    pxx = dirn * 26 * (1 - ease_out_cubic(seg(bt, 0.0, 0.5)))
    # b3 set-piece: lateral drift with the timeline, then reverse on rollback
    if bi == 2:
        drift = ease_in_out(seg(bt, 1.0, 5.2)) - 1.4 * ease_in_out(seg(bt, 6.2, 8.6))
        pxx += 40 * drift
    return z, pxx, pyy

def apply_camera(frame, T):
    z, pan_x, pan_y = camera(T)
    if z <= 1.0005 and abs(pan_x) < 0.5 and abs(pan_y) < 0.5:
        return frame.resize((Wd, Hd), Image.LANCZOS)
    W2, H2 = frame.size
    cw, ch = W2 / z, H2 / z
    cx = W2 / 2 + pan_x * SS
    cy = H2 / 2 + pan_y * SS
    x0 = max(0, min(W2 - cw, cx - cw / 2))
    y0 = max(0, min(H2 - ch, cy - ch / 2))
    return frame.crop((int(x0), int(y0), int(x0 + cw), int(y0 + ch))).resize((Wd, Hd), Image.LANCZOS)

# ---------- beats ----------
def fadeo(t, dur): return 1.0 - seg(t, dur - 0.3, dur)

def b1(base, dr, t, dur):  # COLD OPEN: 11 PM.
    fo = fadeo(t, dur)
    sp = spring(seg(t, 0.1, 0.65))
    f2 = ImageFont.truetype(AB, max(int(220 * SS * (0.85 + 0.15 * sp)), 2))
    text_c(dr, CXd, Y(0.46), "11 PM.", f2, SLATE, min(1, sp * 1.6) * fo)
    caption(dr, CXd, Y(0.68), "friday night", (t - 1.2) * fo if fo > 0.5 else 0)

def b2(base, dr, t, dur):  # a bad deploy just shipped
    fo = fadeo(t, dur)
    ia = spring(seg(t, 0.05, 0.6))
    rrect(dr, CXd - 170, Y(0.28) - 155, 340, 310, 30, OAT, 0.6 * min(1, ia * 1.5) * fo, shadow=False)
    icon(base, "alert", CXd, Y(0.28), 240 * min(ia, 1.08), min(1, ia * 1.5) * fo)
    a2 = ease_out_cubic(seg(t, 0.5, 1.0))
    text_c(dr, CXd, Y(0.58), "A BAD DEPLOY JUST SHIPPED", F(AB, 64 if not PORT else 48), SLATE, a2 * fo, tracking=1)
    tw = ease_out_cubic(seg(t, 1.0, 1.5))
    typewriter(dr, CXd, Y(0.78), "checkout: 500", mono(40), RED, max(t - 1.1, 0), cps=16, alpha=fo)

def b3(base, dr, t, dur):  # SET-PIECE: investigate -> root cause -> rollback
    fo = fadeo(t, dur)
    ty = Y(0.52)
    x0, x1 = X(0.12), X(0.88)
    # timeline draws on
    lp = ease_in_out(seg(t, 0.2, 1.4))
    line_draw(dr, x0, ty, x1, ty, lp, color=OAT_D, width=10, alpha=fo)
    # deploy dots appear along it
    for i in range(6):
        fx = 0.08 + i * 0.145
        da = seg(t, 0.5 + i * 0.18, 0.8 + i * 0.18)
        dot(dr, x0 + (x1 - x0) * fx, ty, 12, SLATE, da * fo)
    # the bad deploy (red) at 0.81
    bad_x = x0 + (x1 - x0) * 0.81
    ba = spring(seg(t, 1.9, 2.5))
    if ba > 0:
        dot(dr, bad_x, ty, 16 * min(ba, 1.1), RED, min(1, ba) * fo)
    # magnifier tracks along the line to the bad deploy
    mg = ease_in_out(seg(t, 1.6, 4.2))
    mx = x0 + (bad_x - x0) * mg
    mga = seg(t, 1.5, 2.0) * (1 - seg(t, 8.6, 9.4))
    rrect(dr, mx - 125, ty - 245, 250, 210, 26, OAT, 0.55 * mga * fo, shadow=False)
    icon(base, "investigate", mx, ty - 135, 170, mga * fo)
    # elapsed counter counts to 4:00
    ca = seg(t, 1.6, 2.0) * (1 - seg(t, 8.8, 9.6))
    if ca > 0:
        mins = 4 * ease_out_cubic(seg(t, 1.6, 5.0))
        mm = int(mins); ss_ = int((mins - mm) * 60)
        text_c(dr, CXd, Y(0.18), f"{mm}:{ss_:02d}", mono(72), SLATE, ca * fo)
        text_c(dr, CXd, Y(0.28), "TO ROOT CAUSE", F(AB, 26), SLATE_L, ca * fo, tracking=4)
    # root cause found: ring pulse on the bad deploy
    rc = seg(t, 5.0, 5.5)
    if 0 < rc < 1:
        ring(dr, bad_x, ty, 30 + 90 * ease_out_cubic(rc), (1 - rc) * 0.8 * fo, color=RED, width=7)
    # ROLLBACK: red dot slides back to previous deploy, line re-colors blue
    rb = ease_in_out(seg(t, 6.2, 7.6))
    if rb > 0:
        prev_x = x0 + (x1 - x0) * 0.665
        rx = bad_x + (prev_x - bad_x) * rb
        line_draw(dr, bad_x, ty, rx, ty, 1.0, color=CLAY, width=10, alpha=fo * seg(t, 6.2, 6.4))
        dot(dr, rx, ty, 16, CLAY, fo * seg(t, 6.2, 6.4))
        icon(base, "rollback", rx, ty - 100, 140, seg(t, 6.2, 6.7) * (1 - seg(t, 8.8, 9.5)) * fo)
    # verdict pill
    va = spring(seg(t, 7.8, 8.5))
    if va > 0:
        pill(dr, CXd, Y(0.76), "ROLLED BACK · APPROVED", min(1, va) * fo, scale=0.8 + 0.2 * min(va, 1.1))
    caption(dr, CXd, Y(0.88), "nobody got paged", (t - 8.4) * fo if fo > 0.5 else 0)

def b4(base, dr, t, dur):  # TITLE reveal
    fo = fadeo(t, dur)
    ia = ease_out_cubic(seg(t, 0.05, 0.6))
    mark_at(base, CXd, Y(0.24), 120, ia * fo)
    if fo > 0.05:
        f2 = ImageFont.truetype(AB, int((124 if not PORT else 90) * SS))
        text_stagger(dr, CXd, Y(0.45), "VERCEL AGENT", f2, SLATE, max(t - 0.4, 0), per=0.05, dur=0.3, rise=24)
    pa = spring(seg(t, 1.1, 1.7))
    pill(dr, CXd, Y(0.58), "THAT WAS THE AGENT", min(1, pa) * fo, scale=0.75 + 0.25 * min(pa, 1.1))
    chips = ["WHAT IT DOES", "HOW IT'S SAFE", "HOW TO GET IT"]
    n = len(chips)
    for i, s2 in enumerate(chips):
        ca = spring(seg(t, 1.6 + i * 0.2, 2.1 + i * 0.2))
        if ca > 0:
            cxp = CXd + (i - (n - 1) / 2) * X(0.20)
            pill(dr, cxp, Y(0.72), s2, min(1, ca * 1.3) * fo, bg=OAT_D, fg=SLATE, scale=0.62 + 0.18 * min(ca, 1.1))
    caption(dr, CXd, Y(0.84), "the next sixty seconds", (t - 1.8) * fo if fo > 0.5 else 0)

def b5(base, dr, t, dur):  # WHAT IT DOES — staggered spring rows
    fo = fadeo(t, dur)
    decor(base, dr, t, dur, "bl", 5)
    icon(base, "merge", 440 if not PORT else CXd, Y(0.47) if not PORT else Y(0.25), 260, ease_out_cubic(seg(t, 0.1, 0.7)) * fo, rise=24 * (1 - ease_out_cubic(seg(t, 0.1, 0.7))))
    cx = 1230 if not PORT else CXd
    rows = ["triage alerts", "review pull requests", "trace cost spikes"]
    for i, s in enumerate(rows):
        a = spring(seg(t, 0.6 + i * 0.35, 1.3 + i * 0.35))
        if a > 0:
            yy = (400 + i * 160) if not PORT else Y(0.45) + i * Y(0.12)
            wob = 1 + 0.06 * (a - 1) if a > 1 else a
            rrect(dr, cx - 330 * min(wob, 1.04), yy - 62, 660 * min(wob, 1.04), 124, 24, WHITE, min(1, a * 1.4) * fo)
            typewriter(dr, cx, yy, s, mono(36), SLATE_M, max(t - (0.8 + i * 0.35), 0), cps=30, alpha=fo)
    pill(dr, cx, Y(0.17) if not PORT else Y(0.12), "DAY TO DAY", ease_out_cubic(seg(t, 0.4, 0.9)) * fo)
    caption(dr, CXd, Y(0.82), "logs, metrics, deploys — investigated", (t - 2.4) * fo if fo > 0.5 else 0)

def b6(base, dr, t, dur):  # IDENTITY + PERMISSION
    fo = fadeo(t, dur)
    decor(base, dr, t, dur, "tl", 6)
    icon(base, "badge", 440 if not PORT else CXd, Y(0.47) if not PORT else Y(0.25), 260, ease_out_cubic(seg(t, 0.1, 0.7)) * fo)
    cx = 1230 if not PORT else CXd
    c1y = 430 if not PORT else Y(0.46)
    c2y = 620 if not PORT else Y(0.62)
    a1 = spring(seg(t, 0.6, 1.2))
    if a1 > 0:
        rrect(dr, cx - 330, c1y - 70, 660, 140, 22, SLATE, min(1, a1 * 1.4) * fo)
        typewriter(dr, cx, c1y, "whoami → vercel-agent", mono(38), (255, 255, 255), max(t - 0.8, 0), cps=22, alpha=fo)
    a2 = spring(seg(t, 1.6, 2.2))
    if a2 > 0:
        rrect(dr, cx - 330, c2y - 70, 660, 140, 22, CLAY, min(1, a2 * 1.4) * fo)
        typewriter(dr, cx, c2y, "scope: approved-only", mono(38), (255, 255, 255), max(t - 1.8, 0), cps=22, alpha=fo)
    pill(dr, cx, Y(0.17) if not PORT else Y(0.12), "ITS OWN IDENTITY", ease_out_cubic(seg(t, 0.4, 0.9)) * fo)
    caption(dr, CXd, Y(0.82), "plan → permission → back to read-only", (t - 2.6) * fo if fo > 0.5 else 0)

def b7(base, dr, t, dur):  # SANDBOX
    fo = fadeo(t, dur)
    decor(base, dr, t, dur, "br", 7)
    ia = spring(seg(t, 0.1, 0.7))
    icon(base, "sandbox", CXd, Y(0.38), 300 * min(ia, 1.06), min(1, ia * 1.4) * fo)
    ra = seg(t, 0.9, 1.7)
    if 0 < ra < 1:
        ring(dr, CXd, Y(0.38), 190 + 160 * ease_out_cubic(ra), (1 - ra) * 0.5 * fo, width=6)
    a2 = ease_out_cubic(seg(t, 1.2, 1.8))
    text_c(dr, CXd, Y(0.62), "ITS CODE RUNS IN A MICRO-VM", F(AB, 54 if not PORT else 40), SLATE, a2 * fo, tracking=1)
    caption(dr, CXd, Y(0.74), "tested before you ever see it", (t - 2.0) * fo if fo > 0.5 else 0)
    pill(dr, CXd, Y(0.15), "SANDBOXED", ease_out_cubic(seg(t, 0.4, 0.9)) * fo)

def b8(base, dr, t, dur):  # STATS — live count-ups
    fo = fadeo(t, dur)
    decor(base, dr, t, dur, "tl", 8)
    f_lab = F(AB, 26)
    if not PORT:
        rrect(dr, 640 - 300, 540 - 200, 600, 400, 32, WHITE, ease_out_cubic(seg(t, 0.3, 0.8)) * fo)
        countup(dr, 640, 500, 4.0, seg(t, 0.6, 2.2), F(AB, 150), CLAY, fo, fmt="{:.0f}", suffix=" MIN")
        text_c(dr, 640, 680, "ALERT → ROOT CAUSE", f_lab, SLATE_L, ease_out_cubic(seg(t, 0.8, 1.3)) * fo, tracking=3)
        rrect(dr, 1300 - 300, 540 - 200, 600, 400, 32, WHITE, ease_out_cubic(seg(t, 0.8, 1.3)) * fo)
        countup(dr, 1300, 500, 0, seg(t, 1.2, 1.6), F(AB, 150), SLATE, fo, fmt="{:.0f}")
        text_c(dr, 1300, 680, "ENGINEERS PAGED", f_lab, SLATE_L, ease_out_cubic(seg(t, 1.3, 1.8)) * fo, tracking=3)
        caption(dr, 960, 850, "the pager can finally sleep too", (t - 2.4) * fo if fo > 0.5 else 0)
    else:
        c1y, c2y = Y(0.40), Y(0.66)
        rrect(dr, CXd - 400 * SQK, c1y - 165 * SQK, 800 * SQK, 330 * SQK, 32, WHITE, ease_out_cubic(seg(t, 0.3, 0.8)) * fo)
        countup(dr, CXd, c1y - 30 * SQK, 4.0, seg(t, 0.6, 2.2), F(AB, 110), CLAY, fo, fmt="{:.0f}", suffix=" MIN")
        text_c(dr, CXd, c1y + 95 * SQK, "ALERT → ROOT CAUSE", f_lab, SLATE_L, ease_out_cubic(seg(t, 0.8, 1.3)) * fo, tracking=3)
        rrect(dr, CXd - 400 * SQK, c2y - 165 * SQK, 800 * SQK, 330 * SQK, 32, WHITE, ease_out_cubic(seg(t, 0.8, 1.3)) * fo)
        countup(dr, CXd, c2y - 30 * SQK, 0, seg(t, 1.2, 1.6), F(AB, 120), SLATE, fo, fmt="{:.0f}")
        text_c(dr, CXd, c2y + 95 * SQK, "ENGINEERS PAGED", f_lab, SLATE_L, ease_out_cubic(seg(t, 1.3, 1.8)) * fo, tracking=3)
        caption(dr, CXd, Y(0.87), "the pager can finally sleep too", (t - 2.4) * fo if fo > 0.5 else 0)
    pill(dr, CXd, Y(0.17) if not PORT else Y(0.14), "THE NUMBERS", ease_out_cubic(seg(t, 0.5, 1.0)) * fo)

def b9(base, dr, t, dur):  # HUMAN IN THE LOOP
    fo = fadeo(t, dur)
    decor(base, dr, t, dur, "bl", 9)
    icon(base, "plan", CXd - X(0.14), Y(0.42), 260, ease_out_cubic(seg(t, 0.2, 0.8)) * fo)
    ar = ease_in_out(seg(t, 0.9, 1.5))
    line_draw(dr, CXd - X(0.05), Y(0.42), CXd + X(0.05), Y(0.42), ar, color=SLATE, width=7, alpha=fo)
    a2 = spring(seg(t, 1.4, 2.0))
    if a2 > 0:
        rrect(dr, CXd + X(0.14) - 160, Y(0.42) - 90, 320, 180, 26, CLAY, min(1, a2 * 1.4) * fo)
        text_c(dr, CXd + X(0.14), Y(0.42), "APPROVE", F(AB, 40), (255, 255, 255), min(1, a2) * fo, tracking=2)
    pill(dr, CXd, Y(0.15), "HUMANS DECIDE", ease_out_cubic(seg(t, 0.4, 0.9)) * fo)
    caption(dr, CXd, Y(0.66), "it proposes — you approve", (t - 2.2) * fo if fo > 0.5 else 0)
    caption(dr, CXd, Y(0.76), "autonomy, with a leash", (t - 2.9) * fo if fo > 0.5 else 0)

def b10(base, dr, t, dur):  # CLOSER
    fo = 1.0 - seg(t, dur - 0.4, dur)
    decor(base, dr, t, dur, "br", 10)
    ia = ease_out_cubic(seg(t, 0.1, 0.7))
    rrect(dr, CXd - 170, Y(0.26) - 160, 340, 320, 28, KRAFT, 0.55 * ia * fo, shadow=False)
    icon(base, "rollback", CXd, Y(0.26), 230, ia * fo, rise=22 * (1 - ia))
    if fo > 0.05:
        f2 = ImageFont.truetype(AB, int((92 if not PORT else 68) * SS))
        text_stagger(dr, CXd, Y(0.52), "THE SAFEGUARDS", f2, SLATE, max(t - 0.5, 0), per=0.045, dur=0.3, rise=20)
        text_stagger(dr, CXd, Y(0.64), "ARE THE PRODUCT", f2, SLATE, max(t - 1.0, 0), per=0.045, dur=0.3, rise=20)
    caption(dr, CXd, Y(0.78), "trust the system, not the model", (t - 2.1) * fo if fo > 0.5 else 0)
    rp = seg(t, 1.8, 2.7)
    if 0 < rp < 1:
        ring(dr, CXd, Y(0.57), 400 + 140 * ease_out_cubic(rp), (1 - rp) * 0.45 * fo, width=6)

BEAT_FNS = [b1, b2, b3, b4, b5, b6, b7, b8, b9, b10]
QC_MINIMAL = {0, 1, 2}   # cold-open + set-piece: deliberately sparse compositions

_qr = None
def outro(base, dr, t):
    global _qr
    g = 1.0 - seg(t, OUTRO_D - 0.5, OUTRO_D)
    ma = ease_out_cubic(seg(t, 0.05, 0.6))
    mark_at(base, CXd - X(0.28) if not PORT else CXd, Y(0.16) if not PORT else Y(0.10), 70, ma * g)
    ta = ease_out_cubic(seg(t, 0.3, 0.9))
    text_c(dr, CXd, Y(0.16) if PORT else Y(0.16), "TRY VERCEL AGENT", ImageFont.truetype(AB, int(64 * SS)), SLATE, ta * g, tracking=1)
    # left column: availability + steps
    lx = CXd - X(0.20) if not PORT else CXd
    r1 = spring(seg(t, 0.8, 1.4))
    if r1 > 0:
        rrect(dr, lx - 330, Y(0.32) - 62, 660, 124, 24, KRAFT, min(1, r1 * 1.4) * g, shadow=False)
        text_c(dr, lx, Y(0.32), "Pro & Enterprise \u00b7 rolling out now", F(AB, 32), SLATE, min(1, r1) * g)
    r2 = spring(seg(t, 1.3, 1.9))
    if r2 > 0:
        rrect(dr, lx - 330, Y(0.47) - 62, 660, 124, 24, SLATE, min(1, r2 * 1.4) * g)
        typewriter(dr, lx, Y(0.47), "dashboard \u2192 Agent \u2192 enable", mono(34), (255, 255, 255), max(t - 1.5, 0), cps=24, alpha=g)
    r3 = spring(seg(t, 1.9, 2.5))
    if r3 > 0:
        rrect(dr, lx - 330, Y(0.62) - 62, 660, 124, 24, CLAY, min(1, r3 * 1.4) * g, shadow=False)
        text_c(dr, lx, Y(0.62), "vercel.com/blog/vercel-agent", mono(30), (255, 255, 255), min(1, r3) * g)
    # right: QR (holds static so it can actually be scanned)
    qa = ease_out_cubic(seg(t, 1.0, 1.6))
    if qa > 0:
        if _qr is None:
            _qr = Image.open(f"{BRAND_DIR}/qr-agent.png").convert("RGBA")
        hq = px(300 if not PORT else 260)
        m = _qr.resize((hq, hq), Image.NEAREST)
        if qa < 1.0:
            a = m.getchannel("A").point(lambda v: int(v * qa)); m.putalpha(a)
        qx = CXd + X(0.24) if not PORT else CXd
        qy = Y(0.47) if not PORT else Y(0.80)
        base.paste(m, (int(px(qx) - hq / 2), int(px(qy) - hq / 2)), m)
        text_c(dr, qx, (Y(0.47) + 190) if not PORT else Y(0.80) + 160, "scan to read the launch post", F(AR, 26), SLATE_L, qa * g)
    caption(dr, CXd, Y(0.88) if not PORT else Y(0.93), "the safeguards are the product — trust the system", (t - 2.6) * g)

# ---------- 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 < TOTAL - OUTRO_D:
            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)
            frame = apply_camera(base, T)
        else:
            outro(base, dr, T - (TOTAL - OUTRO_D))
            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}] v2 video-only:", OUT, "exit", proc.returncode)

if __name__ == "__main__":
    render()
