"""Gera aumento-5-outubro-2026.gif no padrão do splash abertura.gif: quadro cheio, visual de ilustração,
mesmos brilhos e tempo (18 quadros x 170 ms). Formato 1024x576 porque a foto é horizontal."""
import math, numpy as np, subprocess, os, glob, cv2
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance, ImageChops

SRC = "/home/box/agent-data/agents/b42e05f5-a726-4122-8efd-16794791b6b5/attachments/0e4af2ab1c9fc287448202484a1d39c2cdd985cce91efb730237676a6ceaef1d.jpg"
OUT = "/workspace/apps/aumento-5-outubro-2026.gif"
PREV = "/workspace/apps/aumento-preview.png"
TMP = "/tmp/aumento_frames"; os.makedirs(TMP, exist_ok=True)
for f in glob.glob(TMP + "/*.png"): os.remove(f)
W, H, N, DUR = 1024, 576, 18, 170
USE_TOPETE = False   # topete em desenho removido a pedido do usuário (código mantido desligado)


def cartoon(img_rgb, k=22, up=2, qmix=0.5, ink_a=0.8):
    """filtro de ilustração (sem mexer na geometria): suavização bilateral, cores chapadas (k-means Lab),
    traço de nanquim XDoG fino + contorno grosso, saturação/contraste de pôster."""
    img = cv2.cvtColor(np.asarray(img_rgb), cv2.COLOR_RGB2BGR)
    big = cv2.resize(img, None, fx=up, fy=up, interpolation=cv2.INTER_CUBIC)
    sm = big.copy()
    for _ in range(2): sm = cv2.bilateralFilter(sm, 7, 28, 5)
    lab = cv2.cvtColor(sm, cv2.COLOR_BGR2LAB).reshape(-1, 3).astype(np.float32)
    cv2.setRNGSeed(7)
    _, _, ctr = cv2.kmeans(lab[::9], k, None, (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 1.0), 3, cv2.KMEANS_PP_CENTERS)
    lbl = np.empty(len(lab), np.int32)
    for s0 in range(0, len(lab), 400000):
        lbl[s0:s0 + 400000] = ((lab[s0:s0 + 400000, None, :] - ctr[None]) ** 2).sum(2).argmin(1)
    q = cv2.medianBlur(cv2.cvtColor(ctr[lbl].reshape(sm.shape).astype(np.uint8), cv2.COLOR_LAB2BGR), 3)
    mix = cv2.addWeighted(q, qmix, sm, 1 - qmix, 0).astype(np.float32)
    g = cv2.cvtColor(big, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255
    def xdog(sig, phi=60):
        dog = cv2.GaussianBlur(g, (0, 0), sig) - 0.98 * cv2.GaussianBlur(g, (0, 0), sig * 1.6)
        return np.clip(-np.tanh(phi * np.minimum(dog, 0)), 0, 1)
    edge = np.maximum(xdog(1.2), 0.75 * xdog(2.6, 40))
    edge = cv2.GaussianBlur(edge, (0, 0), 0.6)[..., None]
    out = mix * (1 - ink_a * edge) + np.array([22, 18, 28], np.float32) * ink_a * edge
    out = cv2.resize(np.clip(out, 0, 255).astype(np.uint8), (img.shape[1], img.shape[0]), interpolation=cv2.INTER_AREA)
    hsv = cv2.cvtColor(out, cv2.COLOR_BGR2HSV).astype(np.float32)
    hsv[..., 1] *= 1.35; hsv[..., 2] = (hsv[..., 2] - 128) * 1.08 + 132
    out = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32)
    out[..., 0] *= 1.04; out[..., 2] *= 0.94                     # tom quente do splash
    return np.clip(out, 0, 255)

# 1) recorte da foto real (linhas 897..1502 detectadas no print 1080x2400; 1 px de margem contra borda JPEG)
photo = Image.open(SRC).convert("RGB").crop((0, 898, 1080, 1502))          # 1080x604
# gradação quente/saturada como no splash
photo = ImageEnhance.Color(photo).enhance(1.18)
photo = ImageEnhance.Contrast(photo).enhance(1.07)
a = np.asarray(photo).astype(np.float32)
a[..., 0] *= 1.05; a[..., 1] *= 1.01; a[..., 2] *= 0.93
photo = Image.fromarray(np.clip(a, 0, 255).astype(np.uint8))

# 2) envelope: foto-adesivo do rosto do Bradock + nome "BRADOCK"
#    plano do envelope medido na foto: canto sup-esq TL, borda de cima U (~18°), borda esquerda V
TL = np.array([287.5, 320.0]); U = np.array([280.0, 92.5]); V = np.array([-65.0, 187.5])
def grade(img):
    img = ImageEnhance.Color(img).enhance(1.18); img = ImageEnhance.Contrast(img).enhance(1.07)
    x = np.asarray(img).astype(np.float32); x[..., 0] *= 1.05; x[..., 1] *= 1.01; x[..., 2] *= 0.93
    return Image.fromarray(np.clip(x, 0, 255).astype(np.uint8))
# tela local do envelope: eixo x ao longo da borda de cima, y perpendicular; 4 px locais = 1 px da foto
# (transformação de semelhança = só rotação + escala uniforme -> rosto sem distorção)
K = 4.0
uh = U / np.linalg.norm(U); nh = np.array([-uh[1], uh[0]])
LW, LH = int(np.linalg.norm(U) * K), int(np.linalg.norm(V) * K)        # ~1180 x 792
canvas = Image.new("RGBA", (LW, LH), (0, 0, 0, 0))                     # adesivo (cor normal)
inkL = Image.new("L", (LW, LH), 0)                                      # tinta (multiplicação)
shadowL = Image.new("L", (LW, LH), 0)                                   # sombra leve do adesivo

# 2a) rosto: recorte oval da 2ª foto real (print 1080x2400; faixa da foto y=896..1503; rosto do homem sentado)
FACE_SRC = "/home/box/agent-data/agents/b42e05f5-a726-4122-8efd-16794791b6b5/attachments/70bb3a7497dc53091842ce3c87a3013b963a01ced7b15552d77883af8766347b.jpg"
FCX, FCY, FRX, FRY = 578, 1318, 86, 110          # centro e semi-eixos do oval (px do print)
face_src = grade(Image.open(FACE_SRC).convert("RGB").crop((FCX - FRX, FCY - FRY, FCX + FRX, FCY + FRY)))
SCY, SCX = 0.395 * LH, 0.52 * LW                  # centro do adesivo no envelope
ORY = 0.300 * LH                                  # semi-eixo vertical da foto (sem borda)
ORX = ORY * FRX / FRY
BORDER = 15                                       # borda branca "foto impressa" (~3,7 px da foto)
fw, fh = int(2 * ORX), int(2 * ORY)
face = face_src.resize((fw, fh), Image.LANCZOS)   # escala uniforme (mesma proporção do recorte)
fm = Image.new("L", (fw, fh), 0); ImageDraw.Draw(fm).ellipse((3, 3, fw - 4, fh - 4), fill=255)
fm = fm.filter(ImageFilter.GaussianBlur(3))      # borda suave
bw, bh = fw + 2 * BORDER, fh + 2 * BORDER
sticker = Image.new("RGBA", (bw, bh), (0, 0, 0, 0))
bm = Image.new("L", (bw, bh), 0); ImageDraw.Draw(bm).ellipse((2, 2, bw - 3, bh - 3), fill=255)
bm = bm.filter(ImageFilter.GaussianBlur(2))
sticker.paste(Image.new("RGBA", (bw, bh), (252, 252, 250, 255)), (0, 0), bm)
sticker.paste(face, (BORDER, BORDER), fm)
sx0, sy0 = int(SCX - bw / 2), int(SCY - bh / 2)
canvas.alpha_composite(sticker, (sx0, sy0))
ImageDraw.Draw(shadowL).ellipse((sx0 + 10, sy0 + 16, sx0 + bw + 10, sy0 + bh + 16), fill=110)
shadowL = shadowL.filter(ImageFilter.GaussianBlur(14))

# 2b) nome embaixo do rosto, tinta escura estilo caneta
font = ImageFont.truetype("/usr/share/fonts/truetype/sand-box/google/Kalam/Kalam-Bold.ttf", 175)
d = ImageDraw.Draw(inkL)
bb = d.textbbox((0, 0), "BRADOCK", font=font, stroke_width=2)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
d.text((SCX - tw / 2 - bb[0], 0.855 * LH - th / 2 - bb[1]), "BRADOCK", font=font, fill=255, stroke_width=2, stroke_fill=255)

# 2c) tela local -> foto (afim: foto = TL + (x/K)*uh + (y/K)*nh)
A = np.column_stack([uh / K, nh / K]); Ai = np.linalg.inv(A); off = -Ai @ TL
AFF = (Ai[0, 0], Ai[0, 1], off[0], Ai[1, 0], Ai[1, 1], off[1])
def to_photo(img): return img.transform(photo.size, Image.AFFINE, AFF, Image.BICUBIC)
st = to_photo(canvas); ink_mask = to_photo(inkL).filter(ImageFilter.GaussianBlur(0.6)); sh = to_photo(shadowL)
pa = np.asarray(photo).astype(np.float32)
# sombra suave do adesivo
pa *= 1 - (np.asarray(sh).astype(np.float32)[..., None] / 255) * 0.35
# adesivo: segue a luz/sombra do papel (luminância do envelope normalizada)
lum = np.asarray(photo.convert("L").filter(ImageFilter.GaussianBlur(6))).astype(np.float32)[..., None]
shade = np.clip(lum / 235.0, 0.75, 1.0)
sa = np.asarray(st).astype(np.float32); al = sa[..., 3:4] / 255
pa = pa * (1 - al) + sa[..., :3] * shade * al
# visual de ilustração na foto inteira (inclui o adesivo do rosto do Bradock), antes da tinta do nome
pa = cartoon(Image.fromarray(np.clip(pa, 0, 255).astype(np.uint8)))
# tinta (multiplicação: preserva textura/sombra do papel)
m = np.asarray(ink_mask).astype(np.float32)[..., None] / 255 * 0.92
ink = np.array([28, 30, 52], np.float32)
pa = pa * (1 - m) + (pa * ink / 255.0) * m
photo = Image.fromarray(np.clip(pa, 0, 255).astype(np.uint8))
# 2d) adesivo de TOPETE em desenho (cores chapadas, contorno grosso, reflexo branco) sobre o cabelo do
#     homem do envelope; só cobre o alto da cabeça (acima da linha do cabelo), rosto intacto
def bez(p0, p1, p2, p3, n=40):
    t = np.linspace(0, 1, n)[:, None]
    return list(map(tuple, (1-t)**3*np.array(p0) + 3*(1-t)**2*t*np.array(p1) + 3*(1-t)*t**2*np.array(p2) + t**3*np.array(p3)))
def topete_sticker(SS=4):
    """desenha em coordenadas locais (px da foto) com supersample SS; base inferior ~ y=70, largura ~150."""
    W_, H_ = 190, 120
    def S(pts, dx=20, dy=22): return [((x + dx) * SS, (y + dy) * SS) for x, y in pts]
    outline = (bez((0, 70), (-6, 48), (-2, 30), (10, 22)) +            # frente subindo
               bez((10, 22), (6, 12), (14, 2), (30, 4)) +              # cachinho da frente
               bez((30, 4), (18, 14), (26, 26), (40, 20)) +            # curva do cacho
               bez((40, 20), (60, -12), (120, -10), (150, 30)) +       # crista do topete
               bez((150, 30), (158, 44), (156, 60), (150, 72)) +       # nuca
               bez((150, 72), (110, 62), (40, 62), (0, 70)))           # base (assenta no cabelo)
    img = Image.new("RGBA", (W_ * SS, H_ * SS), (0, 0, 0, 0)); d = ImageDraw.Draw(img)
    P = S(outline)
    d.line(P + [P[0]], fill=(255, 255, 255, 255), width=int(9 * SS), joint="curve")     # borda branca de adesivo
    for x, y in P[::4]: d.ellipse((x - 4.5 * SS, y - 4.5 * SS, x + 4.5 * SS, y + 4.5 * SS), fill=(255, 255, 255, 255))
    d.polygon(P, fill=(255, 255, 255, 255))
    d.line(P + [P[0]], fill=(12, 8, 6, 255), width=int(4 * SS), joint="curve")         # contorno preto grosso
    d.polygon(P, fill=(58, 36, 22, 255))                                                # castanho-escuro chapado
    # sombra chapada mais escura na parte de baixo
    d.polygon(S(bez((4, 62), (40, 52), (110, 52), (148, 62)) + bez((148, 62), (150, 68), (150, 70), (150, 72)) +
                bez((150, 72), (110, 62), (40, 62), (0, 70))), fill=(34, 21, 13, 255))
    # mechas (traços escuros)
    for a in [((30, 40), (50, 18), (90, 12), (128, 30)), ((24, 54), (50, 34), (96, 30), (140, 48)), ((14, 34), (16, 20), (24, 14), (34, 14))]:
        d.line(S(bez(*a)), fill=(14, 9, 6, 255), width=int(2.6 * SS), joint="curve")
    d.line(P + [P[0]], fill=(12, 8, 6, 255), width=int(4 * SS), joint="curve")
    # reflexo branco (faixa curva) na crista
    d.line(S(bez((52, 18), (72, 2), (104, 2), (126, 18))), fill=(255, 255, 255, 255), width=int(4.2 * SS), joint="curve")
    d.line(S(bez((132, 24), (135, 27), (137, 30), (139, 34))), fill=(255, 255, 255, 255), width=int(3.2 * SS), joint="curve")
    img = img.resize((W_, H_), Image.LANCZOS)
    return img, np.array([20 + 75, 22 + 66])     # imagem e ponto-âncora (centro da base) na imagem
TOP_ANGLE = 8.0                                   # inclinação da cabeça (graus, horário)
TOP_ANCHOR = np.array([437.0, 150.0])             # centro da base, no meio da faixa do cabelo
tsk, tanc = topete_sticker(SS=6)
def place_topete(img, sx=1.0, sy=1.0, ang=TOP_ANGLE, lift=0.0):
    """compõe o adesivo com escala (squash/stretch ancorado na base), rotação e elevação; não toca no rosto."""
    w, h = img.size
    t = tsk.resize((int(round(tsk.width * sx)), int(round(tsk.height * sy))), Image.LANCZOS)
    an = np.array([tanc[0] * sx, tanc[1] * sy])
    tr = t.rotate(-ang, resample=Image.BICUBIC, expand=True)
    c0 = np.array(t.size) / 2; c1 = np.array(tr.size) / 2; a = math.radians(ang)
    v = an - c0; vr = np.array([v[0]*math.cos(a) - v[1]*math.sin(a), v[0]*math.sin(a) + v[1]*math.cos(a)])
    up = np.array([math.sin(a), -math.cos(a)])          # "para cima" da cabeça inclinada
    p = TOP_ANCHOR + up * lift - (c1 + vr)
    out = img.convert("RGBA"); out.alpha_composite(tr, (int(round(p[0])), int(round(p[1])))); return out.convert("RGB")
def topete_motion(i):
    """2 acenos ('sim') por laço: pulinho + squash ao pousar, stretch no alto, leve inclinação de aceno."""
    ph_ = (2 * i / N) % 1.0
    lift = 7.0 * math.sin(math.pi * ph_)
    land = math.exp(-(min(ph_, 1 - ph_) / 0.09) ** 2)   # perto do pouso
    sx = 1 + 0.05 * land - 0.03 * math.sin(math.pi * ph_)
    sy = 1 - 0.11 * land + 0.04 * math.sin(math.pi * ph_)
    ang = TOP_ANGLE - 2.5 * land + 2.5 * math.sin(math.pi * ph_)
    return sx, sy, ang, lift
A_TOPETE = TOP_ANCHOR + np.array([-12.0, -62.0])  # brilhinho na crista (segue a elevação do topete)
photo_base = photo

# 3) curvas de intensidade dos brilhos medidas no splash (pixels quase brancos por quadro)
STAR1 = np.array([546, 0, 670, 154, 809, 652, 389, 627, 649, 556, 519, 444, 499, 590, 586, 564, 245, 549]) / 809
RING  = np.array([210, 34, 56, 289, 110, 443, 193, 413, 25, 329, 55, 27, 361, 239, 20, 61, 421, 108]) / 443
LENS  = np.array([410, 437, 373, 392, 417, 470, 248, 344, 499, 67, 173, 551, 393, 292, 303, 316, 293, 288]) / 551
# deslocamento do centro da estrela no splash (relativo à média), em px do splash
S1XY = np.array([(638,767),(640,750),(627,743),(651,752),(638,748),(650,754),(640,753),(646,757),(631,748),(647,751),
                 (638,744),(641,747),(649,735),(649,739),(646,740),(634,739),(645,739),(643,738)], float)
S1XY -= S1XY.mean(0)

# âncoras na foto 1080x604
A_STAR = np.array([590.0, 381.0])    # ponta do polegar do joinha (equivale à estrela grande no punho do splash)
A_RING = np.array([572.0, 459.0])    # aliança no dedo do joinha (equivale ao brilho do anel no splash)
# homem do envelope (camisa preta): só brilhos sobrepostos, pixels do rosto intactos
A_SMILE = np.array([435.0, 274.0])   # lábio inferior / sorriso
A_EYE = np.array([489.0, 227.0])     # canto externo do olho (lado direito da imagem)
_fr = np.arange(18)
def _bump(c, w): return np.exp(-((np.minimum(np.abs(_fr - c), 18 - np.abs(_fr - c))) / w) ** 2)
SMILE = np.clip(_bump(4, 1.1) + _bump(13, 1.1), 0, 1)          # 2 picos no laço
EYE = np.zeros(18); EYE[[4, 13]] = 0.85; EYE[[5, 14]] = 1.0     # pisca-brilho liga/desliga
LENS_POLY = [(838, 168), (917, 157), (921, 205), (841, 218)]  # lente esquerda dele (direita da imagem)

def lens_layer(i):
    """reflexo azulado fixo + faixa de brilho que varre a lente (como o reflexo dos óculos do splash)."""
    L = Image.new("L", photo.size, 0); ImageDraw.Draw(L).polygon(LENS_POLY, fill=255)
    L = L.filter(ImageFilter.GaussianBlur(1.5))
    g = Image.new("L", photo.size, 0); gd = ImageDraw.Draw(g)
    t = i / N
    cx = 820 + 120 * t                                   # faixa diagonal varrendo da esquerda p/ direita
    gd.polygon([(cx, 150), (cx + 22, 150), (cx - 8, 225), (cx - 30, 225)], fill=int(200 * (0.55 + 0.45 * LENS[i])))
    gd.polygon([(cx + 30, 150), (cx + 38, 150), (cx + 8, 225), (cx, 225)], fill=int(150 * (0.55 + 0.45 * LENS[i])))
    g = g.filter(ImageFilter.GaussianBlur(3))
    base = Image.new("L", photo.size, 0); ImageDraw.Draw(base).ellipse((850, 168, 905, 200), fill=int(70 + 50 * LENS[i]))
    base = base.filter(ImageFilter.GaussianBlur(9))
    tot = ImageChops.add(g, base)
    return ImageChops.multiply(tot, L)

def star(size, inten, rot, long_rays=True):
    """estrela no formato do splash: núcleo + halo + raios longos p/ cima-esq e baixo-esq, raios curtos."""
    S = size * 2 + 1
    yy, xx = np.mgrid[0:S, 0:S].astype(np.float32) - size
    r = np.hypot(xx, yy) + 1e-3; ang = np.arctan2(yy, xx)
    img = np.exp(-(r / (size * 0.055)) ** 2) * 1.2 + np.exp(-(r / (size * 0.17)) ** 2) * 0.45
    rays = [(-125, 1.00, 1.7), (160, 0.85, 1.7), (90, 0.45, 1.9), (-90, 0.30, 1.9), (0, 0.38, 1.9), (35, 0.22, 2.0)] if long_rays \
        else [(-120, 1.0, 1.4), (150, 0.8, 1.4), (90, 0.6, 1.6), (-90, 0.5, 1.6), (0, 0.6, 1.6), (180, 0.5, 1.6)]
    for a_deg, ln, wd in rays:
        a = math.radians(a_deg + rot)
        along = xx * math.cos(a) + yy * math.sin(a)
        perp = np.abs(-xx * math.sin(a) + yy * math.cos(a))
        L = ln * size
        prof = np.clip(1 - along / L, 0, 1) * (along > 0)
        img += prof ** 1.1 * (np.exp(-(perp / (wd * (0.35 + 0.65 * prof))) ** 2) * 1.25 + np.exp(-(perp / (wd * 3.5)) ** 2) * 0.25)
    img = np.clip(img * inten, 0, 1)
    return img

def add_light(frame, alpha, cx, cy, tint=(255, 250, 235)):
    fa = np.asarray(frame).astype(np.float32)
    h, w = alpha.shape; x0 = int(round(cx)) - w // 2; y0 = int(round(cy)) - h // 2
    X0, Y0, X1, Y1 = max(x0, 0), max(y0, 0), min(x0 + w, frame.width), min(y0 + h, frame.height)
    a = alpha[Y0 - y0:Y1 - y0, X0 - x0:X1 - x0][..., None]
    reg = fa[Y0:Y1, X0:X1]
    fa[Y0:Y1, X0:X1] = reg + (np.array(tint, np.float32) - reg) * a     # "screen"-like para branco quente
    return Image.fromarray(np.clip(fa, 0, 255).astype(np.uint8))

# 4/5) quadro cheio (sem faixas): janela da foto 1074x604 (16:9) -> 1024x576
CX0, CW = 3.0, 1074.0
PH, PY = H, 0
ZC = np.array([560.0, 300.0])     # centro do zoom (entre os dois rostos)

frames = []
for i in range(N):
    z = 1 + 0.055 * (1 - math.cos(2 * math.pi * i / N)) / 2          # zoom suave ida-e-volta (laço sem salto)
    tsx, tsy, tang, tlift = topete_motion(i)
    ph = place_topete(photo_base, tsx, tsy, tang, tlift) if USE_TOPETE else photo_base.copy()
    gl = lens_layer(i)
    ph = Image.composite(Image.new("RGB", ph.size, (235, 245, 255)), ph, gl)
    # janela da foto visível neste quadro (escala uniforme => rostos sem distorção)
    vw, vh = CW / z, 604 / z
    x0 = ZC[0] - (ZC[0] - (CX0 + CW / 2)) / z - vw / 2 - (CX0 + CW / 2 - ZC[0]) * 0 
    x0 = ZC[0] + ((CX0) - ZC[0]) / z; y0 = ZC[1] + (0 - ZC[1]) / z
    x0 = min(max(x0, 0), 1080 - vw); y0 = min(max(y0, 0), 604 - vh)
    panel = ph.resize((W, PH), Image.LANCZOS, box=(x0, y0, x0 + vw, y0 + vh))
    def to_out(p): return ((p[0] - x0) * W / vw, (p[1] - y0) * PH / vh + PY)
    frame = panel
    sc = W / vw
    # estrela grande (joinha) – intensidade/posição seguindo o splash
    if STAR1[i] > 0.05:
        sx, sy = to_out(A_STAR + S1XY[i] * 0.35)
        frame = add_light(frame, star(int(165 * sc * (0.7 + 0.4 * STAR1[i])), 0.45 + 0.85 * STAR1[i], rot=(i * 7) % 20 - 10), sx, sy)
    if RING[i] > 0.05:
        rx, ry = to_out(A_RING)
        frame = add_light(frame, star(int(48 * sc * (0.65 + 0.5 * RING[i])), 0.4 + 0.9 * RING[i], rot=i * 11 % 30, long_rays=False), rx, ry)
    if SMILE[i] > 0.08:
        mx, my = to_out(A_SMILE)
        frame = add_light(frame, star(int(34 * sc * (0.6 + 0.5 * SMILE[i])), 0.35 + 0.85 * SMILE[i], rot=12 + i * 9 % 25, long_rays=False), mx, my)
    if EYE[i] > 0:
        ex_, ey_ = to_out(A_EYE)
        frame = add_light(frame, star(int(30 * sc * (0.7 + 0.4 * EYE[i])), 0.5 + 0.7 * EYE[i], rot=-8 + 14 * (i % 2), long_rays=False), ex_, ey_)
    tb = 0.5 + 0.5 * math.cos(2 * math.pi * (i - 9) / 9)          # brilhinho do topete: picos nos quadros 0 e 9
    if USE_TOPETE and tb > 0.35:
        aT = math.radians(tang); upv = np.array([math.sin(aT), -math.cos(aT)])
        tx, ty = to_out(TOP_ANCHOR + (A_TOPETE - TOP_ANCHOR) * np.array([tsx, tsy]) + upv * tlift)
        frame = add_light(frame, star(int(26 * sc * (0.6 + 0.5 * tb)), 0.4 + 0.7 * tb, rot=20 + 10 * (i % 3), long_rays=False), tx, ty)
    frames.append(frame)
    frame.save(f"{TMP}/f{i:02d}.png")

frames[4].save(PREV)   # quadro representativo (estrela no pico)

# 6) GIF: paleta única, sem pontilhado (combina com cores chapadas), 170 ms/quadro, laço infinito
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", f"{1000/DUR}", "-i", f"{TMP}/f%02d.png",
                "-vf", "split[a][b];[a]palettegen=max_colors=256:stats_mode=full[p];[b][p]paletteuse=dither=none",
                "-loop", "0", OUT], check=True)
print("ok", os.path.getsize(OUT))
