main
anastasia Publish 1.0.2 5025609 2d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
#!/usr/bin/env python3
"""Compose an Adom YouTube thumbnail (1280x720) in the house style.

Two ways to call it:

1. Quick mode (background frame + optional cutout + headline + orbital side):
     make_thumbnail.py -o out.jpg --bg frame.png --cutout john.png \
         --headline "AI PCBs in|Altium." --orbital left --check
   Lines are separated by "|". The LAST line gets the teal-to-purple gradient.
   --bg brand (default) draws the plain navy background (never a grid); a file path uses that
   image as the right-hand visual (or full bleed with --bg-mode photo).

2. Spec mode (full control, layers drawn in order):
     make_thumbnail.py spec.json -o out.jpg --check
   See SKILL.md "Spec file" for every layer type: image, screen, headline,
   arrow, bubble, badge, text.

Extras:
  --check                 also write <out>-xcheck.png with the X / YouTube safe zones
                          and a 320x180 preview, and print any violations.
  --check-only IMG        overlay the safe zones on an existing thumbnail.
  --arrow-assets DIR      render the standard arrow PNGs into DIR.

Needs Pillow (and numpy). Fonts and the white orbital load from ./assets.
"""
import argparse, json, math, os, sys
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops

HERE = os.path.dirname(os.path.abspath(__file__))
ASSETS = os.path.join(HERE, "assets")
FONTS = os.path.join(ASSETS, "fonts")
W, H = 1280, 720

# ---------------------------------------------------------------- brand tokens
NAVY = (13, 17, 23)            # #0d1117 background
SURFACE = (22, 27, 34)         # #161b22
TEXT = (230, 237, 243)         # #e6edf3 (headline white uses pure white, see SKILL.md)
TEAL = (0, 184, 177)           # #00b8b1 primary
ACCENT = (0, 230, 220)         # #00e6dc accent: arrows, kickers, starburst
PURPLE = (140, 107, 247)       # #8c6bf7
# Headline gradient, measured from the 4 channel thumbnails (left to right across the line)
HEADLINE_GRADIENT = [(0.00, "#00b8b1"), (0.40, "#2fd0c3"), (0.70, "#5fb0e6"), (1.00, "#8c6bf7")]

# ---------------------------------------------------------------- safe zones (px at 1280x720)
X_ZONE = (0, 562, 768, 720)        # X/Twitter title pill: left 60 %, bottom 22 %
YT_ZONE = (1088, 634, 1280, 720)   # YouTube duration badge: right 15 %, bottom 12 %
EDGE_MARGIN = 24                   # keep text and faces 24 px off every edge

# ---------------------------------------------------------------- orbital rule
ORBITAL_SIZE = 58                  # px, measured on the channel thumbnails
ORBITAL_MARGIN_X = 30
ORBITAL_MARGIN_Y = 28

# ---------------------------------------------------------------- arrow spec (1280x720)
ARROW_COLOR = ACCENT               # measured #06e3d9 after JPEG, i.e. brand accent #00e6dc
ARROW_STROKE = 11                  # px
ARROW_HEAD_W = 4.0                 # head base width  = 4.0 x stroke (44 px)
ARROW_HEAD_L = 3.6                 # head length      = 3.6 x stroke (40 px)
ARROW_HEAD_ROUND = 0.15            # corner rounding  = 0.15 x stroke (slightly soft corners)
ARROW_GLOW_SIGMA = 8               # px gaussian
ARROW_GLOW_OPACITY = 0.6

# ---------------------------------------------------------------- prompt bubble spec
BUBBLE_FONT_SIZE = 37              # Satoshi Bold, cap height about 26 px
BUBBLE_PAD_X = 46
BUBBLE_HEIGHT = 1.0                # x font size + 2 * pad_y
BUBBLE_PAD_Y = 31
BUBBLE_RADIUS = 20
BUBBLE_FILL = (11, 14, 19, 240)    # #0b0e13 at 94 %
BUBBLE_BORDER = (230, 237, 243, 90)  # light border, 2 px, 35 %
BUBBLE_BORDER_W = 2
BUBBLE_ROTATE = 3.0                # degrees counter-clockwise (right end higher)

SS = 4  # supersampling factor for vector shapes


# ================================================================ helpers
def hexrgb(h):
    h = h.lstrip("#")
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))


def font(role, size):
    """role: display (Familjen Grotesk Bold) | body (Satoshi Medium) | bodybold (Satoshi Bold)."""
    if role == "display":
        f = ImageFont.truetype(os.path.join(FONTS, "FamiljenGrotesk-Variable.ttf"), size)
        f.set_variation_by_name("Bold")
    else:
        f = ImageFont.truetype(os.path.join(FONTS, "Satoshi-Variable.ttf"), size)
        f.set_variation_by_name("Bold" if role == "bodybold" else "Medium")
    return f


def lerp_stops(stops, t):
    stops = [(p, hexrgb(c) if isinstance(c, str) else c) for p, c in stops]
    for (p0, c0), (p1, c1) in zip(stops, stops[1:]):
        if t <= p1:
            k = 0 if p1 == p0 else (t - p0) / (p1 - p0)
            return tuple(int(round(a + (b - a) * k)) for a, b in zip(c0, c1))
    return stops[-1][1]


def h_gradient(w, h, stops):
    row = Image.new("RGB", (max(w, 1), 1))
    for x in range(max(w, 1)):
        row.putpixel((x, 0), lerp_stops(stops, x / max(w - 1, 1)))
    return row.resize((max(w, 1), max(h, 1)))


def radial(size, center, radius, color, opacity):
    """RGBA layer with a soft radial glow."""
    w, h = size
    small = Image.new("L", (w // 8, h // 8), 0)
    d = ImageDraw.Draw(small)
    cx, cy, r = center[0] / 8, center[1] / 8, radius / 8
    steps = 40
    for i in range(steps, 0, -1):
        rr = r * i / steps
        v = int(255 * opacity * (1 - i / steps) ** 1.6)
        d.ellipse((cx - rr, cy - rr, cx + rr, cy + rr), fill=v)
    mask = small.resize((w, h), Image.BICUBIC).filter(ImageFilter.GaussianBlur(6))
    layer = Image.new("RGBA", (w, h), color + (0,))
    layer.putalpha(mask)
    return layer


def alpha_mul(im, k):
    im = im.copy()
    a = im.getchannel("A").point(lambda v: int(v * k))
    im.putalpha(a)
    return im


def shadow_of(rgba, blur=18, opacity=0.5, offset=(0, 10), color=(0, 0, 0)):
    a = rgba.getchannel("A")
    pad = blur * 3
    big = Image.new("L", (rgba.width + 2 * pad, rgba.height + 2 * pad), 0)
    big.paste(a, (pad, pad))
    big = big.filter(ImageFilter.GaussianBlur(blur)).point(lambda v: int(v * opacity))
    sh = Image.new("RGBA", big.size, color + (0,))
    sh.putalpha(big)
    return sh, (offset[0] - pad, offset[1] - pad)


def glow_of(rgba, sigma, opacity, color):
    return shadow_of(rgba, blur=sigma, opacity=opacity, offset=(0, 0), color=color)


# ================================================================ background
def brand_background(glow_left=0.55, glow_right=0.5):
    """Plain navy base, teal glow bottom-left, purple glow bottom-right. No grid, ever (Anastasia, 2026-10-21)."""
    bg = Image.new("RGBA", (W, H), NAVY + (255,))
    # slight vertical lift toward the top (#0c0e1a measured top-left)
    top = Image.new("RGBA", (W, H), (12, 14, 26, 0))
    m = Image.linear_gradient("L").resize((W, H)).point(lambda v: int((255 - v) * 0.5))
    top.putalpha(m)
    bg = Image.alpha_composite(bg, top)
    bg = Image.alpha_composite(bg, radial((W, H), (-60, 800), 700, TEAL, glow_left))
    bg = Image.alpha_composite(bg, radial((W, H), (1340, 820), 640, PURPLE, glow_right))
    return bg


def photo_background(path, crop=None, blur=0, darken=0.35, scrim="left", scrim_strength=0.85):
    im = Image.open(path).convert("RGB")
    if crop:
        im = im.crop(tuple(crop))
    # cover-fit
    s = max(W / im.width, H / im.height)
    im = im.resize((math.ceil(im.width * s), math.ceil(im.height * s)), Image.LANCZOS)
    l, t = (im.width - W) // 2, (im.height - H) // 2
    im = im.crop((l, t, l + W, t + H))
    if blur:
        im = im.filter(ImageFilter.GaussianBlur(blur))
    im = im.convert("RGBA")
    if darken:
        im = Image.alpha_composite(im, Image.new("RGBA", (W, H), NAVY + (int(255 * darken),)))
    if scrim in ("left", "right"):
        g = Image.linear_gradient("L").rotate(90 if scrim == "left" else -90, expand=True).resize((W, H))
        g = g.point(lambda v: int(v * scrim_strength))
        sc = Image.new("RGBA", (W, H), NAVY + (0,))
        sc.putalpha(g)
        im = Image.alpha_composite(im, sc)
    im = Image.alpha_composite(im, radial((W, H), (-60, 800), 700, TEAL, 0.45))
    im = Image.alpha_composite(im, radial((W, H), (1340, 820), 640, PURPLE, 0.4))
    return im


# ================================================================ layers
def place_image(canvas, L):
    im = Image.open(L["path"]).convert("RGBA")
    if L.get("crop"):
        im = im.crop(tuple(L["crop"]))
    if L.get("flip"):
        im = im.transpose(Image.FLIP_LEFT_RIGHT)
    x, y, w = L["box"][:3]
    h = round(im.height * w / im.width)
    im = im.resize((round(w), h), Image.LANCZOS)
    if L.get("rotate"):
        im = im.rotate(L["rotate"], resample=Image.BICUBIC, expand=True)
    if L.get("fade_bottom"):
        fb = int(L["fade_bottom"])
        a = im.getchannel("A")
        g = Image.linear_gradient("L").resize((im.width, fb)).point(lambda v: 255 - v)
        mask = Image.new("L", im.size, 255)
        mask.paste(g, (0, im.height - fb))
        im.putalpha(ImageChops.multiply(a, mask))
    if L.get("glow"):
        gl, off = glow_of(im, 14, 0.45, hexrgb(L["glow"]) if isinstance(L["glow"], str) else ACCENT)
        canvas.alpha_composite(gl, (int(x + off[0]), int(y + off[1])))
    if L.get("shadow", True):
        sh, off = shadow_of(im, blur=L.get("shadow_blur", 18), opacity=L.get("shadow_opacity", 0.55))
        canvas.alpha_composite(sh, (int(x + off[0]), int(y + off[1])))
    canvas.alpha_composite(im, (int(x), int(y)))
    return (x, y, x + im.width, y + h)


def _persp_coeffs(dst, src):
    import numpy as np
    A, B = [], []
    for (x, y), (u, v) in zip(dst, src):
        A.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); B.append(u)
        A.append([0, 0, 0, x, y, 1, -v * x, -v * y]); B.append(v)
    return list(np.linalg.solve(np.array(A, float), np.array(B, float)))


def place_screen(canvas, L):
    """App screenshot mapped onto a quad (tl, tr, br, bl), rounded, bordered, optional teal edge glow."""
    im = Image.open(L["path"]).convert("RGBA")
    if L.get("crop"):
        im = im.crop(tuple(L["crop"]))
    r = int(L.get("radius", 14) * im.width / 900)
    mask = Image.new("L", im.size, 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, im.width - 1, im.height - 1), r, fill=255)
    im.putalpha(mask)
    bd = ImageDraw.Draw(im)
    bd.rounded_rectangle((0, 0, im.width - 1, im.height - 1), r, outline=(48, 54, 61, 255),
                         width=max(2, im.width // 400))
    if L.get("dim"):
        dim = Image.new("RGBA", im.size, NAVY + (int(255 * L["dim"]),))
        dim.putalpha(ImageChops.multiply(dim.getchannel("A"), mask))
        im = Image.alpha_composite(im, dim)
    quad = [tuple(p) for p in L["quad"]]
    src = [(0, 0), (im.width, 0), (im.width, im.height), (0, im.height)]
    out = im.transform((W, H), Image.PERSPECTIVE, _persp_coeffs(quad, src), Image.BICUBIC)
    if L.get("glow", True):
        gl, off = glow_of(out, 16, 0.55, ACCENT)
        canvas.alpha_composite(gl, off)
    sh, off = shadow_of(out, blur=24, opacity=0.6, offset=(0, 14))
    canvas.alpha_composite(sh, off)
    canvas.alpha_composite(out)
    xs, ys = [p[0] for p in quad], [p[1] for p in quad]
    return (min(xs), min(ys), max(xs), max(ys))


def draw_headline(canvas, L):
    """Big Familjen Grotesk Bold headline. Last line gradient. Optional kicker above and sub below."""
    lines = L["lines"]
    size = L.get("size", 120)
    maxw = L.get("max_width")
    f = font("display", size)
    if maxw:
        while size > 60 and max(f.getbbox(t)[2] - f.getbbox(t)[0] for t in lines) > maxw:
            size -= 2
            f = font("display", size)
    pitch = round(size * L.get("leading", 0.96))
    x0, y0 = L.get("x", 52), L.get("y", 220)        # y = top of the first line's cap height
    align = L.get("align", "left")
    cap = round(size * 0.65)                         # Familjen cap height = 780/1200 em
    asc = f.getmetrics()[0]
    boxes = []
    layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    ky = y0
    if L.get("kicker"):
        kf = font("display", L.get("kicker_size", 40))
        txt = L["kicker"].upper()
        track = round(L.get("kicker_size", 40) * 0.14)
        kx = x0
        widths = [kf.getlength(ch) + track for ch in txt]
        total = sum(widths) - track
        if align == "right":
            kx = x0 - total
        d = ImageDraw.Draw(layer)
        kc = round(L.get("kicker_size", 40) * 0.65)
        kyy = y0 - kc - L.get("kicker_gap", 34)
        cx = kx
        for ch, wch in zip(txt, widths):
            d.text((cx, kyy + kc), ch, font=kf, fill=ACCENT + (255,), anchor="ls")
            cx += wch
        boxes.append((kx, kyy, kx + total, kyy + kc))
    for i, t in enumerate(lines):
        base = y0 + cap + i * pitch
        bb = f.getbbox(t, anchor="ls")
        tw = bb[2] - bb[0]
        lx = x0 - tw if align == "right" else x0
        last = i == len(lines) - 1
        m = Image.new("L", (W, H), 0)
        ImageDraw.Draw(m).text((lx - bb[0], base), t, font=f, fill=255, anchor="ls")
        tb = m.getbbox()
        if not tb:
            continue
        if last and L.get("gradient", True):
            g = Image.new("RGBA", (W, H), (0, 0, 0, 0))
            gw = tb[2] - tb[0]
            g.paste(h_gradient(gw, tb[3] - tb[1], L.get("stops", HEADLINE_GRADIENT)), (tb[0], tb[1]))
            g.putalpha(m)
        else:
            g = Image.new("RGBA", (W, H), (255, 255, 255, 0))
            g.putalpha(m)
        layer = Image.alpha_composite(layer, g)
        boxes.append(tb)
    if L.get("sub"):
        sf = font("display", L.get("sub_size", 46))
        parts = L["sub"]  # list of [text, "white"|"teal"]
        if isinstance(parts, str):
            parts = [[parts, "white"]]
        sy = y0 + cap + (len(lines) - 1) * pitch + L.get("sub_gap", 64)
        sx = x0
        d = ImageDraw.Draw(layer)
        total = sum(sf.getlength(p[0]) for p in parts)
        if align == "right":
            sx = x0 - total
        start = sx
        for txt, col in parts:
            d.text((sx, sy), txt, font=sf, fill=(ACCENT if col == "teal" else (255, 255, 255)) + (255,), anchor="ls")
            sx += sf.getlength(txt)
        boxes.append((start, sy - round(L.get("sub_size", 46) * 0.72), sx, sy + round(L.get("sub_size", 46) * 0.2)))
    if L.get("shadow", True):
        sh, off = shadow_of(layer, blur=14, opacity=L.get("shadow_opacity", 0.45), offset=(0, 4))
        canvas.alpha_composite(sh, off)
    canvas.alpha_composite(layer)
    bx = (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes))
    return bx


# ---------------------------------------------------------------- arrow
def _bezier(p0, p1, bend, n=200, control=None):
    (x0, y0), (x1, y1) = p0, p1
    dx, dy = x1 - x0, y1 - y0
    length = math.hypot(dx, dy)
    nx, ny = -dy / length, dx / length          # screen-space normal, right of travel
    cx, cy = (x0 + x1) / 2 + nx * bend * length, (y0 + y1) / 2 + ny * bend * length
    if control is not None:                      # explicit control point (e.g. a hook)
        cx, cy = control
    pts = []
    for i in range(n + 1):
        t = i / n
        pts.append(((1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
                    (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1))
    return pts


def arrow_layer(size, start, end, bend=0.25, scale=1.0, color=ARROW_COLOR, glow=True, control=None):
    """Return an RGBA layer (size) with the house arrow from start to end.

    bend: signed curvature, control point offset as a fraction of the chord length
          (0 = straight; 0.2 to 0.35 is the house range). Positive bulges to the
          RIGHT of the direction of travel: a left-to-right arrow with bend +0.3 sags
          downward like the KiCad thumbnail; a top-to-bottom arrow with bend -0.3
          bulges right like the radio thumbnail's hook.
    scale: 1.0 = 11 px stroke at 1280x720.
    control: optional explicit quadratic control point; overrides bend. For the radio-style
          hook use control = (end_x, start_y): it leaves horizontally and lands vertically.
    """
    w, h = size
    sw = ARROW_STROKE * scale
    hl, hw = ARROW_HEAD_L * sw, ARROW_HEAD_W * sw
    pts = _bezier(start, end, bend, control=control)
    # end tangent from the last segment
    ex, ey = end
    k = len(pts) - 1
    while k > 0 and math.hypot(ex - pts[k][0], ey - pts[k][1]) < hl * 0.9:
        k -= 1
    tx, ty = ex - pts[k][0], ey - pts[k][1]
    tl = math.hypot(tx, ty) or 1
    ux, uy = tx / tl, ty / tl
    bx, by = ex - ux * hl, ey - uy * hl                     # head base center
    stroke_pts = pts[:k + 1] + [(bx + ux * sw * 0.6, by + uy * sw * 0.6)]
    big = Image.new("L", (w * SS, h * SS), 0)
    d = ImageDraw.Draw(big)
    r = sw * SS / 2
    sp = [(x * SS, y * SS) for x, y in stroke_pts]
    # dense discs along the path: uniform width, round caps, smooth edges
    dense = []
    for (xa, ya), (xb, yb) in zip(sp, sp[1:]):
        n = max(1, int(math.hypot(xb - xa, yb - ya) / (r * 0.25)))
        dense += [(xa + (xb - xa) * i / n, ya + (yb - ya) * i / n) for i in range(n)]
    dense.append(sp[-1])
    for x, y in dense:
        d.ellipse((x - r, y - r, x + r, y + r), fill=255)
    px, py = -uy, ux
    tri = [(ex, ey), (bx + px * hw / 2, by + py * hw / 2), (bx - px * hw / 2, by - py * hw / 2)]
    # rounded corners: shrink the triangle toward its centroid, draw, then dilate by the rounding radius
    rr = ARROW_HEAD_ROUND * sw
    cxm, cym = sum(p[0] for p in tri) / 3, sum(p[1] for p in tri) / 3
    tri2 = []
    for x, y in tri:
        vx, vy = x - cxm, y - cym
        vl = math.hypot(vx, vy)
        s = (vl - rr * 2.0) / vl
        tri2.append(((cxm + vx * s) * SS, (cym + vy * s) * SS))
    head = Image.new("L", big.size, 0)
    ImageDraw.Draw(head).polygon(tri2, fill=255)
    head = head.filter(ImageFilter.MaxFilter(int(rr * SS * 2) | 1))
    big = ImageChops.lighter(big, head)
    mask = big.resize((w, h), Image.LANCZOS)
    out = Image.new("RGBA", (w, h), color + (0,))
    out.putalpha(mask)
    if glow:
        g = mask.filter(ImageFilter.GaussianBlur(ARROW_GLOW_SIGMA * scale)).point(lambda v: int(min(255, v * ARROW_GLOW_OPACITY * 1.6)))
        gl = Image.new("RGBA", (w, h), color + (0,))
        gl.putalpha(g)
        out = Image.alpha_composite(gl, out)
    return out


def draw_arrow(canvas, L):
    lay = arrow_layer((W, H), tuple(L["from"]), tuple(L["to"]), L.get("bend", 0.25), L.get("scale", 1.0),
                      control=tuple(L["control"]) if L.get("control") else None)
    canvas.alpha_composite(lay)
    return lay.getchannel("A").point(lambda v: 255 if v > 60 else 0).getbbox()


# ---------------------------------------------------------------- prompt bubble
def bubble_layer(text, scale=1.0, rotate=BUBBLE_ROTATE):
    """Dark pill with a thin light border and the prompt in curly quotes. Returns RGBA (with shadow)."""
    t = text.strip().strip('"“”')
    if not t.endswith((".", "?")):
        t += "."
    t = "“" + t + "”"
    fs = round(BUBBLE_FONT_SIZE * scale)
    f = font("bodybold", fs * SS)
    tw = f.getlength(t)
    padx, pady = BUBBLE_PAD_X * scale * SS, BUBBLE_PAD_Y * scale * SS
    bw = int(tw + 2 * padx)
    bh = int(fs * SS * BUBBLE_HEIGHT + 2 * pady)
    m = int(40 * scale * SS)
    im = Image.new("RGBA", (bw + 2 * m, bh + 2 * m), (0, 0, 0, 0))
    d = ImageDraw.Draw(im)
    box = (m, m, m + bw, m + bh)
    rad = BUBBLE_RADIUS * scale * SS
    d.rounded_rectangle(box, rad, fill=BUBBLE_FILL)
    d.rounded_rectangle(box, rad, outline=BUBBLE_BORDER, width=int(BUBBLE_BORDER_W * scale * SS))
    # optical vertical centre on cap height
    cap = fs * SS * 0.70
    d.text((m + padx, m + bh / 2 + cap / 2), t, font=f, fill=(255, 255, 255, 255), anchor="ls")
    im = im.resize((im.width // SS, im.height // SS), Image.LANCZOS)
    if rotate:
        im = im.rotate(rotate, resample=Image.BICUBIC, expand=True)
    sh, off = shadow_of(im, blur=int(16 * scale), opacity=0.55, offset=(0, int(6 * scale)))
    base = Image.new("RGBA", sh.size, (0, 0, 0, 0))
    base.alpha_composite(sh)
    base.alpha_composite(im, (-off[0], -off[1]))
    # return the layer plus the position of the pill's own top-left inside it
    return base, (-off[0], -off[1]), im.size


def draw_bubble(canvas, L):
    lay, inner, isz = bubble_layer(L["text"], L.get("scale", 1.0), L.get("rotate", BUBBLE_ROTATE))
    cx, cy = L["center"]
    x = int(cx - isz[0] / 2 - inner[0])
    y = int(cy - isz[1] / 2 - inner[1])
    canvas.alpha_composite(lay, (x, y)) if x >= 0 and y >= 0 else _paste_clip(canvas, lay, x, y)
    m = int(36 * L.get("scale", 1.0))
    return (cx - isz[0] / 2 + m, cy - isz[1] / 2 + m, cx + isz[0] / 2 - m, cy + isz[1] / 2 - m)


def _paste_clip(canvas, lay, x, y):
    tmp = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
    tmp.paste(lay, (x, y))
    canvas.alpha_composite(tmp)


# ---------------------------------------------------------------- starburst badge
def draw_badge(canvas, L):
    cx, cy = L["center"]
    r = L.get("r", 130)
    pts_n = L.get("points", 22)
    big = Image.new("RGBA", (W * 2, H * 2), (0, 0, 0, 0))
    d = ImageDraw.Draw(big)
    poly = []
    for i in range(pts_n * 2):
        a = math.pi * i / pts_n + math.radians(L.get("rotate", -12))
        rr = r if i % 2 == 0 else r * 0.86
        poly.append(((cx + rr * math.cos(a)) * 2, (cy + rr * math.sin(a)) * 2))
    d.polygon(poly, fill=ACCENT + (255,))
    lay = big.resize((W, H), Image.LANCZOS)
    sh, off = shadow_of(lay, blur=16, opacity=0.5, offset=(0, 8))
    canvas.alpha_composite(sh, off)
    canvas.alpha_composite(lay)
    lines = L.get("lines", ["1st", "EVER"])
    txt = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    td = ImageDraw.Draw(txt)
    f1 = font("display", int(r * 0.78))
    f2 = font("display", int(r * 0.30))
    td.text((cx, cy + r * 0.12), lines[0], font=f1, fill=NAVY + (255,), anchor="ms")
    if len(lines) > 1:
        td.text((cx, cy + r * 0.52), lines[1], font=f2, fill=NAVY + (255,), anchor="ms")
    txt = txt.rotate(L.get("text_rotate", 12), center=(cx, cy), resample=Image.BICUBIC)
    canvas.alpha_composite(txt)
    return (cx - r, cy - r, cx + r, cy + r)


def draw_text(canvas, L):
    f = font(L.get("font", "display"), L.get("size", 40))
    d = ImageDraw.Draw(canvas)
    col = L.get("color", "#ffffff")
    d.text(tuple(L["at"]), L["text"], font=f, fill=hexrgb(col) + (255,), anchor=L.get("anchor", "ls"))
    return d.textbbox(tuple(L["at"]), L["text"], font=f, anchor=L.get("anchor", "ls"))


def draw_orbital(canvas, side):
    o = Image.open(os.path.join(ASSETS, "adom-orbital-white-512.png")).convert("RGBA")
    o = o.resize((ORBITAL_SIZE, ORBITAL_SIZE), Image.LANCZOS)
    x = ORBITAL_MARGIN_X if side == "left" else W - ORBITAL_MARGIN_X - ORBITAL_SIZE
    sh, off = shadow_of(o, blur=8, opacity=0.35, offset=(0, 2))
    canvas.alpha_composite(sh, (x + off[0], ORBITAL_MARGIN_Y + off[1]))
    canvas.alpha_composite(o, (x, ORBITAL_MARGIN_Y))
    return (x, ORBITAL_MARGIN_Y, x + ORBITAL_SIZE, ORBITAL_MARGIN_Y + ORBITAL_SIZE)


DRAW = {"image": place_image, "screen": place_screen, "headline": draw_headline,
        "arrow": draw_arrow, "bubble": draw_bubble, "badge": draw_badge, "text": draw_text}
CHECKED = {"headline", "bubble", "badge", "text"}   # always must stay out of the zones


# ================================================================ compose / check
def compose(spec):
    bg = spec.get("background", {"type": "brand"})
    if bg.get("type", "brand") == "brand":
        canvas = brand_background(bg.get("glow_left", 0.55), bg.get("glow_right", 0.5))
    else:
        canvas = photo_background(bg["path"], bg.get("crop"), bg.get("blur", 0), bg.get("darken", 0.35),
                                  bg.get("scrim", "left"), bg.get("scrim_strength", 0.85))
    important = []
    for L in spec.get("layers", []):
        box = DRAW[L["type"]](canvas, L)
        if L["type"] in CHECKED or L.get("important"):
            important.append((L.get("name", L["type"]), L.get("important_box", box)))
    side = spec.get("orbital", "left")
    obox = draw_orbital(canvas, side)
    important.append(("orbital", obox))
    return canvas.convert("RGB"), important


def _intersects(a, b):
    return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]


def violations(important):
    out = []
    for name, b in important:
        if b is None:
            continue
        if _intersects(b, X_ZONE):
            out.append(f"{name} {tuple(int(v) for v in b)} overlaps the X title zone {X_ZONE}")
        if _intersects(b, YT_ZONE):
            out.append(f"{name} {tuple(int(v) for v in b)} overlaps the YouTube duration zone {YT_ZONE}")
        if name != "orbital" and (b[0] < EDGE_MARGIN or b[1] < EDGE_MARGIN or b[2] > W - EDGE_MARGIN or b[3] > H - EDGE_MARGIN):
            out.append(f"{name} {tuple(int(v) for v in b)} is within {EDGE_MARGIN} px of the edge")
    return out


def check_overlay(img, important=None):
    """1280x720 thumbnail + zone overlay, with a 320x180 preview strip underneath."""
    img = img.convert("RGBA").resize((W, H))
    ov = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    d = ImageDraw.Draw(ov)
    lab = font("bodybold", 22)
    # X zone: red hatch
    d.rectangle(X_ZONE, fill=(255, 40, 60, 70), outline=(255, 40, 60, 255), width=3)
    for k in range(-H, W, 28):
        d.line((X_ZONE[0] + k, X_ZONE[3], X_ZONE[0] + k + (X_ZONE[3] - X_ZONE[1]), X_ZONE[1]), fill=(255, 40, 60, 90), width=2)
    ov2 = Image.new("RGBA", (W, H), (0, 0, 0, 0))
    ov2.paste(ov.crop(X_ZONE), X_ZONE[:2])
    ov = ov2
    d = ImageDraw.Draw(ov)
    d.rectangle(X_ZONE, outline=(255, 40, 60, 255), width=3)
    d.text((X_ZONE[0] + 12, X_ZONE[1] + 10), "X title pill: keep clear (0,562)-(768,720)", font=lab, fill=(255, 255, 255, 255))
    d.rectangle(YT_ZONE, fill=(255, 160, 0, 90), outline=(255, 160, 0, 255), width=3)
    d.text((YT_ZONE[0] + 10, YT_ZONE[1] + 10), "YT 12:34", font=lab, fill=(255, 255, 255, 255))
    d.rectangle((EDGE_MARGIN, EDGE_MARGIN, W - EDGE_MARGIN, H - EDGE_MARGIN), outline=(255, 255, 255, 60), width=1)
    for xo in (ORBITAL_MARGIN_X, W - ORBITAL_MARGIN_X - ORBITAL_SIZE):
        d.rectangle((xo, ORBITAL_MARGIN_Y, xo + ORBITAL_SIZE, ORBITAL_MARGIN_Y + ORBITAL_SIZE), outline=(0, 230, 220, 200), width=2)
    for name, b in (important or []):
        if b:
            d.rectangle(tuple(int(v) for v in b), outline=(255, 255, 0, 200), width=2)
            d.text((b[0] + 4, b[1] + 2), name, font=font("bodybold", 16), fill=(255, 255, 0, 255))
    top = Image.alpha_composite(img, ov).convert("RGB")
    sheet = Image.new("RGB", (W, H + 200), (8, 10, 14))
    sheet.paste(top, (0, 0))
    small = img.convert("RGB").resize((320, 180), Image.LANCZOS)
    sheet.paste(small, (10, H + 10))
    # simulated X card pill over a second small copy
    xs = small.copy()
    xd = ImageDraw.Draw(xs, "RGBA")
    xd.rounded_rectangle((8, 180 - 34, 8 + 190, 180 - 10), 6, fill=(0, 0, 0, 170))
    xd.text((16, 180 - 30), "youtube.com  Video title", font=font("body", 13), fill=(255, 255, 255, 255))
    sheet.paste(xs, (350, H + 10))
    d2 = ImageDraw.Draw(sheet)
    d2.text((700, H + 30), "Left: 320x180 (search size).", font=font("body", 22), fill=(230, 237, 243))
    d2.text((700, H + 62), "Middle: X card with its title pill.", font=font("body", 22), fill=(230, 237, 243))
    d2.text((700, H + 94), "Red: X pill zone. Orange: YT duration.", font=font("body", 22), fill=(230, 237, 243))
    d2.text((700, H + 126), "Yellow: checked elements. Teal: orbital slots.", font=font("body", 22), fill=(230, 237, 243))
    return sheet


def save_thumb(img, path):
    if path.lower().endswith((".jpg", ".jpeg")):
        q = 92
        while True:
            img.save(path, quality=q, optimize=True, progressive=True)
            if os.path.getsize(path) < 2_000_000 or q <= 70:
                break
            q -= 4
    else:
        img.save(path, optimize=True)
    return os.path.getsize(path)


def write_arrow_assets(outdir):
    os.makedirs(outdir, exist_ok=True)
    presets = {
        # name: canvas size, start, end, bend  (KiCad-style swoosh, radio-style hook, straight, short)
        "arrow-swoosh-right": ((360, 170), (30, 40), (320, 60), 0.30),
        "arrow-hook-down": ((200, 320), (30, 40), (150, 290), (150, 40)),
        "arrow-curve-left": ((360, 170), (330, 40), (40, 60), -0.30),
        "arrow-straight-right": ((300, 120), (30, 60), (270, 60), 0.0),
    }
    for name, (size, s, e, b) in presets.items():
        lay = arrow_layer(size, s, e, control=b) if isinstance(b, tuple) else arrow_layer(size, s, e, b)
        lay.save(os.path.join(outdir, name + ".png"), optimize=True)
        print("wrote", os.path.join(outdir, name + ".png"))


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("spec", nargs="?", help="JSON spec file (spec mode)")
    ap.add_argument("-o", "--out", help="output .jpg or .png")
    ap.add_argument("--bg", default="brand", help="'brand' or a background frame image")
    ap.add_argument("--bg-mode", choices=["visual", "photo"], default="visual",
                    help="visual: frame as a tilted screen on the right; photo: full bleed with a scrim")
    ap.add_argument("--cutout", help="transparent PNG of a presenter or board, placed right")
    ap.add_argument("--headline", help="lines separated by |, last line gradient, end with a period")
    ap.add_argument("--kicker", help="small tracked teal caps above the headline")
    ap.add_argument("--orbital", choices=["left", "right"], default="left")
    ap.add_argument("--check", action="store_true", help="also write <out>-xcheck.png")
    ap.add_argument("--check-only", help="write the zone overlay for an existing image")
    ap.add_argument("--arrow-assets", help="render the standard arrow PNGs into this folder")
    a = ap.parse_args()

    if a.arrow_assets:
        write_arrow_assets(a.arrow_assets)
        return
    if a.check_only:
        out = a.out or os.path.splitext(a.check_only)[0] + "-xcheck.png"
        check_overlay(Image.open(a.check_only)).save(out)
        print("wrote", out)
        return
    if a.spec:
        spec = json.load(open(a.spec))
        base = os.path.dirname(os.path.abspath(a.spec))
        for L in spec.get("layers", []):
            if "path" in L and not os.path.isabs(L["path"]):
                L["path"] = os.path.join(base, L["path"])
        bgs = spec.get("background", {})
        if "path" in bgs and not os.path.isabs(bgs["path"]):
            bgs["path"] = os.path.join(base, bgs["path"])
    else:
        if not (a.headline and a.out):
            ap.error("quick mode needs --headline and -o")
        left = a.orbital == "left"
        spec = {"orbital": a.orbital, "layers": []}
        if a.bg != "brand" and a.bg_mode == "photo":
            spec["background"] = {"type": "photo", "path": a.bg, "scrim": "left" if left else "right"}
        else:
            spec["background"] = {"type": "brand"}
            if a.bg != "brand":
                quad = [[690, 90], [1300, 60], [1300, 560], [690, 520]] if left else \
                       [[-20, 60], [590, 90], [590, 520], [-20, 560]]
                spec["layers"].append({"type": "screen", "path": a.bg, "quad": quad, "dim": 0.15})
        if a.cutout:
            spec["layers"].append({"type": "image", "path": a.cutout,
                                   "box": [700, 140, 560] if left else [20, 140, 560]})
        lines = a.headline.split("|")
        spec["layers"].append({"type": "headline", "lines": lines, "kicker": a.kicker,
                               "x": 52 if left else 1228, "align": "left" if left else "right",
                               "y": 250 if len(lines) <= 2 else 200, "size": 120, "max_width": 620})
    img, important = compose(spec)
    size = save_thumb(img, a.out)
    print(f"wrote {a.out} ({size/1e6:.2f} MB, {img.size[0]}x{img.size[1]})")
    probs = violations(important)
    for p in probs:
        print("SAFE-ZONE:", p)
    if not probs:
        print("safe zones: clear")
    if a.check:
        xc = os.path.splitext(a.out)[0] + "-xcheck.png"
        check_overlay(img, important).save(xc, optimize=True)
        print("wrote", xc)


if __name__ == "__main__":
    main()