import collections
#!/usr/bin/env python3
"""PIPELINE UNIFICADO — o "agente editor" numa passada só, determinístico. Resolve os conflitos das
camadas separadas pondo-as na ORDEM certa:
  1. base = N_director (motor + conteúdo + cam-por-batida do SigLIP)
  2. boundaries: batidas de alta importância (noiva 18:30) viram segmento próprio (não engolidas)
  3. rhythm: piso por tipo de batida (votos=segura, entrada=dinâmico)  [auto-editor + storyboard]
  4. clean (POR ÚLTIMO, sobrevive ao rhythm): janela ruim = shake forte OU exposição (gradual 2s) OU
     zoom -> troca pra cam estável (cam1 pref). Interrompe o hold só onde a footage é ruim.
  5. trim: apara jolt egrégio nas pontas.
Saída versions/UNI.json.

  ./venv/bin/python unified.py"""
import json, bisect, os
import numpy as np
import vlib as V

COV=V.coverage(); GRID=V.m1_grid(); END=GRID[-1][1]
jerk=V.load_jerk(); face=V.load_face()

# flags pra ISOLAR/medir cada camada nova (flip False + re-rodar pra ver o delta de cada uma)
ENABLE_SPIKE=True    # item 1: dodge de oclusão/expo BREVE (spike-and-return de luma)
ENABLE_PROC=True     # item 2: processional variedade 6s + tight (mff)
ENABLE_SHAKERESCUE=True  # item 5: troca tremido->alt estável quando não-protegido (2ª via do shaky)
ENABLE_SHARP=True    # item 3b: troca desfocado->alt MUITO mais nítida (face_sharp relativo), não-protegido
ENABLE_FOCUS=True    # item 6: focus-override do SUB-SPAN breve desfocado (sobrepõe judge); sem-alt -> marca
ENABLE_ENTRY_RHYTHM=True  # item 7: entrada (fast moment) não vira long shot -> ritmo p/ alt estável+nítida
ENABLE_REACTION=True  # item 8: entrada da noiva INCLUI reação do noivo (pico de joy/close) — aceita shake
ENABLE_EMOTION_JUDGE=True  # item 9b: aplica emotion_inserts.json (saída do JUDGE visual genérico, moment-aware)
ENABLE_STAB_OVERRIDE=True  # item 6c: tremido SEM conteúdo + alt estável -> troca (bug puro; tradeoff = judge)
ENABLE_VARIETY=True  # item 9c: variety/b-roll inserts (localizador SigLIP + judge_variety) fora-clímax
ENABLE_MISSED=True  # item 9d: missed-moment recuperado pela SUB-PARTE estável (shake_windows) + judge confirma
ENABLE_JOY=False  # item 9 (mark-only emoção): DESLIGADO — sinal nativo não alinha c/ missedEmotion (26 marcas,
                  # só 1/14). missedEmotion é cluster de JUDGE narrativo, não threshold nativo. Reativar só
                  # com um judge VISUAL que pondere importância-do-momento. Código mantido p/ esse futuro.
ENABLE_TRASH_POLISH=True  # item6e: auditor final de "possível lixo" (chão/vazio) — só troca se alt clara
# v0.4.4 — evolui do 4.0 (cutOkja 9.7), NÃO empilha 4.1–4.3:
# - 2 cams nas entradas: NÃO alternar 6s (cansa); prefere close (50–100mm/mff↑) e SEGURA
# - wide só se close embacar assunto ou tremer forte
# - b-roll protect + edge_trim (cantos) mantidos (não matam miolo do 4.0)
# 4.0 freeze/cutOkja permanece canônico e recuperável.
ENABLE_PREF_CLOSE_ENTRANCE=True  # v0.4.4: ênfase close em entradas (holds longos, poucas trocas)
ENABLE_EDGE_SHAKE_AUTO=True      # cantos fortes → trim; miolo b-roll fica
ENABLE_BROLL_PROTECT=True        # b-roll bem-vindo salvo tremor forte
BROLL_MFF_KEEP=0.12
BROLL_P80_KILL=1.90
BROLL_P80_EDGE=1.50
BROLL_EDGE_WIN=int(1.2 * 30)

# mediana de sharp POR CÂMERA (baseline própria) — pra "não-blur" relativo (não absoluto, que enviesa)
import numpy as _np0
SHMED={c:(float(_np0.median([x for x in face[c]["sharp"] if x>0])) if c in face and len(face[c].get("sharp",[])) else 0.0)
       for c in V.CAMS}
def not_blur(cam,f0,f1):
    s=V.face_sharp(face,cam,f0,f1); m=SHMED.get(cam,0)
    return (s is None) or m<=0 or s>=0.5*m   # sem rosto = não bloqueia; senão exige >=50% do normal
def tcf(tc):
    p=[int(x) for x in tc.split(":")]; return p[0]*108000+p[1]*1800+p[2]*30+(p[3] if len(p)>3 else 0)

# ---- zoom (se cache completo) ----
ZOOM={}
import glob
P=json.load(open("placements.json"))["clips"]
for c in P:
    n=V.footage_key(c["name"], cam=c.get("cam"))
    f=f"zoomcache/{n}.json"
    if os.path.exists(f):
        zc=json.load(open(f)); t0=int(c["startFrame"])/V.TL_FPS
        ZOOM.setdefault(c["cam"],([],[]))
        for fr in zc["frames"]: ZOOM[c["cam"]][0].append(t0+fr["t"]); ZOOM[c["cam"]][1].append(fr["zoom"])
for cam in ZOOM:
    a=np.argsort(ZOOM[cam][0]); ZOOM[cam]=(np.array(ZOOM[cam][0])[a],np.array(ZOOM[cam][1])[a])

def zoom_at(cam,f0,f1):
    if cam not in ZOOM: return 0.0
    ts,zs=ZOOM[cam]; i0,i1=np.searchsorted(ts,f0/30),np.searchsorted(ts,f1/30)
    return float(np.percentile(zs[i0:i1],85)) if i1>i0 else 0.0

# ---- pan (movimento global SUSTENTADO = câmera varrendo/reenquadrando; jerk não pega pan suave) ----
GMOT={}
for c in P:
    n=V.footage_key(c["name"], cam=c.get("cam"))
    f=f"shakecache/{n}.json"
    if os.path.exists(f):
        sc=json.load(open(f)); t0=int(c["startFrame"])/V.TL_FPS
        GMOT.setdefault(c["cam"],([],[]))
        for fr in sc["frames"]: GMOT[c["cam"]][0].append(t0+fr["t"]); GMOT[c["cam"]][1].append(fr.get("gmot",0))
for cam in GMOT:
    a=np.argsort(GMOT[cam][0]); GMOT[cam]=(np.array(GMOT[cam][0])[a],np.array(GMOT[cam][1])[a])
BASE_CAM=min(GMOT,key=lambda c:np.median(GMOT[c][1])) if GMOT else None

# ---- oclusão NATIVA: salto SÚBITO de luma (alguém passa na frente) — facecache luma 4fps ----
LUMA=collections.defaultdict(lambda:([],[]))
for c in P:
    n=V.footage_key(c["name"], cam=c.get("cam"))
    f=f"facecache/{n}.json"
    if os.path.exists(f):
        fc=json.load(open(f)); t0=int(c["startFrame"])/30
        for fr in fc["frames"]: LUMA[c["cam"]][0].append(t0+fr["t"]); LUMA[c["cam"]][1].append(fr["luma"])
for cam in LUMA:
    a=np.argsort(LUMA[cam][0]); LUMA[cam]=(np.array(LUMA[cam][0])[a],np.array(LUMA[cam][1])[a])
def luma_jump(cam,f0,f1):
    if cam not in LUMA: return 0
    t,l=LUMA[cam]; i0,i1=np.searchsorted(t,f0/30),np.searchsorted(t,f1/30)
    return float(np.max(np.abs(np.diff(l[i0:i1])))) if i1-i0>=2 else 0

# ---- mapa de conteúdo NATIVO (search_media SigLIP + cacheado) — percepção densa sem agente ----
CONTENT=collections.defaultdict(list)  # cam -> [(frame,score)]
if os.path.exists("content_map.json"):
    for h in json.load(open("content_map.json")): CONTENT[h["cam"]].append((h["f"],h["score"]))
def content_score(cam,f0,f1,W=90):
    return sum(s for fr,s in CONTENT.get(cam,[]) if f0-W<=fr<=f1+W)
def pan_at(cam,f0,f1):
    """p60 do gmot num span >=1s (±0.5s respiro). Pan real sustenta gmot alto; sujeito cruzando é
    breve (p60 baixo). Base-cam (tripé) isenta: gmot alto nela é sujeito passando, não reenquadre."""
    if cam==BASE_CAM or cam not in GMOT: return 0.0
    ts,gs=GMOT[cam]; a=min(f0,f1-15)/30.0; b=max(f1,f0+15)/30.0
    i0,i1=np.searchsorted(ts,a),np.searchsorted(ts,b)
    return float(np.percentile(gs[i0:i1],60)) if i1>i0 else 0.0

# ---- storyboard ----
SB=sorted([b for b in json.load(open("storyboard.json")) if b.get("tc_start")],key=lambda b:tcf(b["tc_start"]))
spans=[]
for i,b in enumerate(SB):
    f0=tcf(b["tc_start"]); f1=tcf(SB[i+1]["tc_start"]) if i+1<len(SB) else END
    if f1>f0: spans.append((f0,f1,b["beat"],b.get("importance",0)))
sb_f=[s[0] for s in spans]
def beat_at(f):
    i=bisect.bisect_right(sb_f,f)-1; return spans[max(0,i)] if spans else (0,END,"vows",0.5)
MINHOLD={"vows":5,"pre":5,"officiant":5,"reading":5,"signing":5,"empty_wait":8,
 "entrance_groom":2.5,"entrance_bride":2.5,"entrance_mother":2.5,"entrance_bridesmaids":2.5,
 "flower_girl":2.5,"ring_bearer":2.5,"kiss":2.5,"applause":2.5,"exit":2.5,"declaration":3,"rings":3,
 # v0.3.3: cumprimentos (padrinhos/família no fim) — medium/high importance, ritmo como entrada
 "congratulations":3,"cumprimentos":3,"recessional":2.5,"saida":2.5}

# ---- sinais ----
def p80(cam,f0,f1):
    ts,js=V.jerk_series(jerk,cam,f0,f1); return float(np.percentile(js,80)) if js is not None and len(js) else 9
def expo_change(cam,f0,f1):
    """mudança de exposição GRADUAL como NET-DRIFT (média 2ª metade − 1ª metade) num span >=2s.
    Exposição real é ramp monotônico (cinegrafista abre/fecha íris) -> drift grande. Sujeito andando
    faz a luma oscilar e VOLTAR (wobble) -> drift ~0. Evita o falso-positivo de conteúdo em movimento."""
    a=min(f0,f1-int(2*30)); b=max(f1,f0+int(2*30))
    w=V.face_window(face,cam,a,b)
    if not w or len(w["luma"])<4: return 0.0
    L=w["luma"]; h=len(L)//2
    return abs(float(np.mean(L[h:])-np.mean(L[:h])))
def badness(cam,f0,f1):
    return max(p80(cam,f0,f1)/2.5, expo_change(cam,f0,f1)/16.0, zoom_at(cam,f0,f1)/3.0, pan_at(cam,f0,f1)/12.0)

# ---- helpers ----
def c2s(cps):
    cps=sorted(cps,key=lambda c:c["frame"])
    return [{"f0":c["frame"],"f1":(cps[i+1]["frame"] if i+1<len(cps) else END),"angle":c["angle"]}
            for i,c in enumerate(cps) if (cps[i+1]["frame"] if i+1<len(cps) else END)>c["frame"]]
def to_log(segs):
    log=[]
    for s in sorted(segs,key=lambda x:x["f0"]):
        if log and log[-1][2]==s["angle"]: log[-1][1]=s["f1"]
        else: log.append([s["f0"],s["f1"],s["angle"]])
    return log
def to_cps(log):
    out,prev=[],None
    for f0,f1,a in sorted(log,key=lambda x:x[0]):
        if prev!=a: out.append({"frame":f0,"angle":a}); prev=a
    return out

base=c2s(json.load(open("versions/N_director.json"))["cutpoints"])

# 2. boundaries: corta nas fronteiras de batidas de alta importância
bf=[s["f0"] for s in base]
def base_ang(f):
    i=bisect.bisect_right(bf,f)-1; return base[max(0,i)]["angle"]
cut_at=set(s["f0"] for s in base)
for f0,f1,beat,imp in spans:
    if imp>=0.85: cut_at.add(f0)
frames=sorted(cut_at)
log=to_log([{"f0":f,"f1":(frames[i+1] if i+1<len(frames) else END),"angle":base_ang(f)} for i,f in enumerate(frames)])

# 3. rhythm: funde abaixo do piso da batida -> vizinho mais estável
def minhold(f): return int(MINHOLD.get(beat_at(f)[2],3)*30)
ch=True
while ch:
    ch=False
    for i in range(len(log)):
        f0,f1,a=log[i]
        if (f1-f0)<minhold(f0):
            cands=[]
            if i>0: cands.append((i-1,p80(V.ANGLE_CAM[log[i-1][2]],f0,f1)))
            if i+1<len(log): cands.append((i+1,p80(V.ANGLE_CAM[log[i+1][2]],f0,f1)))
            if not cands: break
            j=min(cands,key=lambda x:x[1])[0]
            if j<i: log[j][1]=f1
            else: log[j][0]=f0
            del log[i]; ch=True; break

# 3.5 judge VLM (tato/staging): overrides de CONTEÚDO do critic. Entram ANTES do clean -> o clean
# ainda veta whip/shake DENTRO da faixa escolhida pelo judge (senão o judge reintroduz pan, ex 5:21).
import os as _os
if _os.path.exists("judge_switches_ok.json"):
    # CARVA a span exata que o judge julgou [f0,f1] (não retrofita o segmento director inteiro: ele é
    # grande e a cam alvo pode ter gap nele -> coverage do span todo falhava e o override sumia).
    JSPANS=[]   # spans que o judge fixou: travados contra a emoção (VLM beat-aware > sad cego)
    JSPANS_ANG=[]  # (f0,f1,ang): re-afirmação pós-stage, gated por badness (conteúdo vence stage, mas footage quebrado veta)
    n_js_skip_broll=0
    for v in json.load(open("judge_switches_ok.json")):
        ang=v["to_cam"]
        if "f0" not in v: continue
        jf0,jf1=v["f0"],v["f1"]
        if not V.covers(COV,ang,jf0,jf1): continue
        # v0.4.2: b-roll protect — judge "vai pra base estável" NÃO pode matar close/b-roll bom
        # (Nathália e2e 4.1: ~7 switches 2→1 em 54–59 mataram marcelo b-roll). Número de cam ≠ papel.
        if ENABLE_BROLL_PROTECT:
            from_a = v.get("active")
            if from_a is None:
                from_a = v.get("from")
            if from_a is not None:
                try:
                    from_a = int(from_a)
                except (TypeError, ValueError):
                    from_a = None
            if from_a is not None and from_a != ang and from_a in V.ANGLE_CAM:
                cam_from = V.ANGLE_CAM[from_a]
                w = V.face_window(face, cam_from, jf0, jf1)
                m_from = float(np.median(w["mff"])) if w is not None and len(w.get("mff", [])) else 0.0
                j_from = p80(cam_from, jf0, jf1)
                span_js = max(1, jf1 - jf0)
                ew = min(BROLL_EDGE_WIN, max(15, span_js // 4))
                j_h = p80(cam_from, jf0, min(jf1, jf0 + ew))
                j_t = p80(cam_from, max(jf0, jf1 - ew), jf1)
                # b-roll bom + sem tremor forte no span NEM nos cantos → mantém
                if (m_from >= BROLL_MFF_KEEP and j_from < BROLL_P80_KILL
                        and j_h < BROLL_P80_EDGE and j_t < BROLL_P80_EDGE):
                    n_js_skip_broll += 1
                    continue
        JSPANS.append((jf0,jf1)); JSPANS_ANG.append((jf0,jf1,ang))
        new=[]
        for s,e,a in log:
            if e<=jf0 or s>=jf1: new.append([s,e,a]); continue
            if s<jf0: new.append([s,jf0,a])
            new.append([max(s,jf0),min(e,jf1),ang])
            if e>jf1: new.append([jf1,e,a])
        log=[x for x in new if x[1]>x[0]]
    log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in log])
    if n_js_skip_broll:
        print(f"[v0.4.2 broll-protect] {n_js_skip_broll} judge_switches IGNORADOS (b-roll ok; edges p80<{BROLL_P80_EDGE})")

# 4. clean (por janela, POR ÚLTIMO): footage ruim (shake/exposição/zoom/PAN) -> cam estável
WIN=int(0.5*30); STABLE=0.8
pieces=[]
for f0,f1,a in log:
    ws=list(range(f0,f1,WIN))
    bads=[badness(V.ANGLE_CAM[a],w,min(w+WIN,f1)) for w in ws]
    # judge protege suas spans do clean: o judge já escolheu a cam por CONTEÚDO e validou pan<8/p80<5.
    # clean só intervém aqui se for SEVERO (whip real, badness>2.0) — não por movimento moderado de
    # acompanhamento. (Observability pegou o clean engolindo o judge; este é o fix de interação.)
    injudge=[any(js0<min(w+WIN,f1) and js1>w for js0,js1 in JSPANS) for w in ws]
    # RESCUE rock-estável: troca pra alt TRAVADA (bad<0.4) quando a ativa treme e a alt é claramente
    # melhor (gap>0.3). Pega o tremor SUTIL com tripé estável ao lado (15 marcadores do João: "treme
    # na 2, a 1 ta estavel"). Em judge span exige tremor mais forte (>1.2) pra não derrubar conteúdo
    # com movimento moderado de acompanhamento.
    # avalia num span largo (±0.75s) pra exigir tremor SUSTENTADO, não flicker de 0.5s (senão ping-pong)
    def rescue(w,we):
        a0,a1=max(f0,w-22),min(f1,we+22)
        bw=badness(V.ANGLE_CAM[a],a0,a1)
        if bw<0.55: return None
        best=None
        for c in V.CAMS:
            if V.CAM_ANGLE[c]==a or not V.covers(COV,V.CAM_ANGLE[c],a0,a1): continue
            cb=badness(c,a0,a1)
            if cb<0.35 and cb<bw-0.45 and (best is None or cb<best[1]): best=(c,cb,bw)
        return best
    grow=[]
    for i,(w,b) in enumerate(zip(ws,bads)):
        we=min(w+WIN,f1); rc=rescue(w,we)
        # rescue só p/ shake FORTE sustentado (bw>1.0) com tripé travado: pega os tremores claros que a
        # proteção-judge segurava, sem criar ping-pong em tremor sutil. Tremor SUTIL ("treme um pouco")
        # está abaixo do sinal atual (amostra ~1/s) -> precisa do detector 10fps (backlog).
        if injudge[i]: grow.append(b>2.0 or (rc is not None and rc[2]>1.2))
        else:          grow.append(b>1.0 or (rc is not None and rc[2]>1.0))
    for i in range(len(bads)):
        if grow[i]:
            j=i-1
            while j>=0 and bads[j]>0.6 and not injudge[j]: grow[j]=True; j-=1
            j=i+1
            while j<len(bads) and bads[j]>0.6 and not injudge[j]: grow[j]=True; j+=1
    for i,w in enumerate(ws):
        we=min(w+WIN,f1); use=a
        if grow[i]:
            # entre as alternativas ESTÁVEIS (badness<0.55), prefere a que melhor enquadra o sujeito
            # (mff/faces) — vetar um whip pra uma wide-vazia perde o momento (ex: abraço do pai em 5:20).
            stable=[]
            for c in V.CAMS:
                if V.CAM_ANGLE[c]==a or not V.covers(COV,V.CAM_ANGLE[c],w,we): continue
                cb=badness(c,w,we)
                if cb<0.55:
                    fw=V.face_window(face,c,w-15,we+15); mff=float(np.mean(fw["mff"])) if fw and len(fw.get("mff",[])) else 0.0
                    stable.append((c,cb,mff))
            best,bb=None,bads[i]
            if stable:
                c,cb,_=max(stable,key=lambda x:(round(x[2],2),-x[1]))  # max mff, desempate menor badness
                if cb<bb: best,bb=c,cb
            # mal-menor: tremor/pan SEVERO sem alt limpa -> melhor alt mesmo imperfeita (nunca mostra
            # severo se há algo bem melhor). Geral; cobre começo de entrada onde todas se assentam.
            if best is None and bads[i]>1.8:
                for c in V.CAMS:
                    if V.CAM_ANGLE[c]==a or not V.covers(COV,V.CAM_ANGLE[c],w,we): continue
                    cb=badness(c,w,we)
                    if cb<bads[i]*0.6 and cb<bb: best,bb=c,cb
            if best: use=V.CAM_ANGLE[best]
        pieces.append([w,we,use])
log=to_log([{"f0":f0,"f1":f1,"angle":a} for f0,f1,a in pieces])
# floor: funde <2s no vizinho menos ruim. Cerimônia completa NÃO pode ter flick (regra dono /
# metrics sub2s). protect_fn(s,e)->True isenta (ex. cutaway de oclusão intencional ~1.5s).
FLOOR=int(2*30)
def floor_merge(log, protect_fn=None):
    """Funde segmentos com duração < FLOOR (2s). protect_fn opcional isenta spans (LSPANS)."""
    ch=True
    while ch:
        ch=False
        for i in range(len(log)):
            s,e,_a=log[i]
            if e-s>=FLOOR: continue
            if protect_fn is not None and protect_fn(s,e): continue
            cands=[]
            if i>0: cands.append((i-1,badness(V.ANGLE_CAM[log[i-1][2]],s,e)))
            if i+1<len(log): cands.append((i+1,badness(V.ANGLE_CAM[log[i+1][2]],s,e)))
            if not cands: break
            j=min(cands,key=lambda x:x[1])[0]
            if j<i: log[j][1]=e
            else: log[j][0]=s
            del log[i]; ch=True; break
    return log
# floor pós-clean
log=floor_merge(log)

# 4.5 smooth-shake: tremor (JERK PURO — ignora pan/expo, senão false-positiva em movimento suave tipo
# noivo-olha-noiva) leve-sustentado que o clean-forte não pegou, com tripé travado e SEM emoção NA
# ATIVA -> carva o RUN tremido pra a estável (liso). JERK windowed-max pega spike breve sem diluir.
def shk(cam,w0,w1): return p80(cam,w0,w1)/2.5          # tremor = jerk p80 puro (sustentado, ignora spike único)
def sad_cam(cam,w0,w1):
    w=V.face_window(face,cam,w0,w1); return float(np.max(w["sad"])) if w and len(w.get("sad",[])) else 0.0
for si,(f0,f1,a) in enumerate(log):                    # NÍVEL-SEGMENTO (sem fragmentar)
    ac=V.ANGLE_CAM[a]; bw=shk(ac,f0,f1)
    if not (0.55<bw<=1.8) or sad_cam(ac,f0,f1)>=0.5: continue   # emoção NA ATIVA isenta
    best=None
    for c in V.CAMS:
        if c==ac or not V.covers(COV,V.CAM_ANGLE[c],f0,f1): continue
        cb=shk(c,f0,f1)
        if cb<0.30 and cb<bw-0.35 and (best is None or cb<best[1]): best=(V.CAM_ANGLE[c],cb)
    if best: log[si][2]=best[0]
log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in log])

# 5.6 max-hold (variedade): hold MUITO longo numa cam só em beat DINÂMICO + alt estável -> quebra
# alternando (ex 146s na cam2 = arrastou). Beats estáticos (votos) podem segurar longo de propósito.
# 5.7 occlusion-veto (NATIVO): salto SÚBITO de luma = alguém passa na frente/de costas rápido. Onde a
# ativa tem oclusão (jump>12) e uma cam coberta+estável NÃO tem (jump<6) -> troca o trecho ocluído.
# HÍBRIDO: luma-jump nativo LOCALIZA candidatos (free) -> agentes CLASSIFICAM causa (oclusão/expo/
# intencional/falso) + escolhem cam limpa (corrigem erro do nativo). Só aplica os confirmados
# (oclusão/exposição); falso/intencional NÃO viram corte. Carva a span [f0,f1] igual ao judge.
# SYNC_END: início da zona DESSINCRONIZADA (laranja). DERIVADO do-zero do campo `orange` do placements
# (cada casamento tem o seu) — NÃO hardcoded. Stages 5.5+ não tocam insert nessa zona (timestamp sem sync
# = corte errado). Sem clips laranja (casamento todo syncado) => END (sem cap). Início da zona = começo do
# maior trecho contíguo laranja (ignora 1 clip laranja solto no meio do bom).
def _derive_sync_end():
    P=json.load(open("placements.json"))["clips"]
    oj=sorted(c["startFrame"] for c in P if c.get("orange"))
    if not oj: return END
    runs=[[oj[0],oj[0]]]
    for f in oj[1:]:
        if f-runs[-1][1]<=int(20*30): runs[-1][1]=f      # mesmo trecho se gap <20s
        else: runs.append([f,f])
    # SÓ um BLOCO contíguo de laranja (>=30s) = cauda dessincronizada real -> capa ali. Laranja
    # ESPALHADO (pré/pós + 1 clip solto low-conf, mas SYNCADO) não é zona: sem bloco => END (sem cap).
    big=[r[0] for r in runs if r[1]-r[0]>=int(30*30)]
    return min(big) if big else END
SYNC_END=_derive_sync_end()
print(f"[sync] SYNC_END derivado = {SYNC_END} ({'sem zona laranja' if SYNC_END==END else 'tc %02d:%02d:%02d'%(SYNC_END//108000,(SYNC_END//1800)%60,(SYNC_END//30)%60)})")

LSPANS=[]   # spans dos cutaways de oclusão: protegidos do floor (ping-pong limpo curto > tela tampada)
if _os.path.exists("luma_switches.json"):
    for v in json.load(open("luma_switches.json")):
        w0,w1,ang=v["f0"],v["f1"],v["angle"]
        if w0>=SYNC_END: continue   # zona laranja (unsynced)
        if not V.covers(COV,ang,w0,w1): continue
        if badness(V.ANGLE_CAM[ang],w0,w1)>0.5: continue   # nativo veta: agente escolhe cam, jerk filtra tremida
        LSPANS.append((w0,w1))
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
            sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
                 for s,e,ag in log),[]) if x[1]>x[0]])

# item 1: spike-and-return (oclusão/expo BREVE). luma da ATIVA sobe-e-VOLTA em <2s (alguém passa/luz
# oscila) — o luma_switches por MAGNITUDE perdeu os sutis. Detecta por FORMA na luma do facecache,
# dodge nativo p/ alt coberta+estável que NÃO tem o spike. Append a LSPANS -> protegido do floor abaixo.
def _luma_spikes(cam,f0,f1):
    """spans (sf0,sf1,delta) onde a luma da cam tem pico que retorna à baseline em <=2s."""
    w=V.face_window(face,cam,f0,f1)
    if not w or len(w.get("luma",[]))<6: return []
    ts=w["ts"]*30.0; L=np.asarray(w["luma"],float)
    base=np.median(L); sd=np.std(L)
    if sd<3: return []
    out=[]; i=1
    while i<len(L)-1:
        if abs(L[i]-base)>max(2.5*sd,6):                       # pico real (acima de ruído E magnitude mínima)
            j=i+1
            while j<len(L) and (ts[j]-ts[i])<=60:              # janela de retorno: 2s
                if abs(L[j]-base)<=0.4*sd: break               # voltou à baseline -> spike-and-return
                j+=1
            else: i+=1; continue
            if j<len(L):
                pk=ts[i]; out.append((int(pk-22),int(pk+22),float(abs(L[i]-base)))); i=j+1; continue
        i+=1
    return out
if ENABLE_SPIKE:
    spike_dump=[]
    for s,e,ag in list(log):
        if e>=SYNC_END: continue
        ac=V.ANGLE_CAM[ag]
        for sf0,sf1,delta in _luma_spikes(ac,s,e):
            w0,w1=max(s,sf0),min(e,sf1)
            if w1-w0<int(0.6*30): continue
            best=None                                          # alt coberta+estável SEM o mesmo spike
            for c in V.CAMS:
                an=V.CAM_ANGLE[c]
                if an==ag or not V.covers(COV,an,w0,w1) or badness(c,w0,w1)>0.5: continue
                if _luma_spikes(c,max(s,w0-15),min(e,w1+15)): continue   # alt também ocluída -> não serve
                if best is None or badness(c,w0,w1)<badness(V.ANGLE_CAM[best],w0,w1): best=an
            if best is None: continue
            LSPANS.append((w0,w1)); spike_dump.append({"f0":w0,"f1":w1,"from":ag,"to":best,"delta":round(delta,1)})
            log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
                sum(([[s2,min(w0,e2),a2],[max(s2,w0),min(e2,w1),best],[max(w1,s2),e2,a2]] if not(e2<=w0 or s2>=w1) else [[s2,e2,a2]]
                     for s2,e2,a2 in log),[]) if x[1]>x[0]])
    json.dump(spike_dump,open("spike_candidates.json","w"),indent=1)
    print(f"[item1 spike] {len(spike_dump)} dodges breves")

if LSPANS:
    # floor pós-luma: funde slivers colaterais <2s do cam ORIGINAL deixados pela carva — mas PROTEGE
    # os cutaways (são 1.5s, sub2s por natureza; o dodge tem que ficar).
    def _isluma(s,e): return any(abs(s-a)<8 and abs(e-b)<8 for a,b in LSPANS)
    log=floor_merge(log, protect_fn=_isluma)

# cap GERAL (beat labeling é grosseiro/errado) — 28s na mesma cam já arrasta; emoção forte isenta
# (votos com choro real podem segurar). Quebra alternando active<->alt-estável.
MH=int(28*30); EMOCAP=int(45*30)   # emoção segura até 45s; além disso arrasta mesmo (ex 146s)
def emo_seg(f0,f1):
    return max((np.max(V.face_window(face,c,f0,f1)["sad"]) if (w:=V.face_window(face,c,f0,f1)) and len(w.get("sad",[])) else 0)
               for c in V.CAMS)
def stable_alt(ac,w0,w1):   # melhor alt estável PRO CHUNK (não pro segmento todo)
    best=None
    for c in V.CAMS:
        if c==ac or not V.covers(COV,V.CAM_ANGLE[c],w0,w1) or badness(c,w0,w1)>=0.45: continue
        if best is None or badness(c,w0,w1)<badness(V.ANGLE_CAM[best],w0,w1): best=V.CAM_ANGLE[c]
    return best
newlog=[]
for f0,f1,a in log:
    ac=V.ANGLE_CAM[a]; dur=f1-f0
    cap=EMOCAP if emo_seg(f0,f1)>=0.6 else MH
    if dur<=cap: newlog.append([f0,f1,a]); continue
    t=f0; on_active=True
    while t<f1:
        nt=min(t+MH,f1)
        if f1-nt<int(5*30): nt=f1
        if on_active: newlog.append([t,nt,a])
        else:
            alt=stable_alt(a,t,nt); newlog.append([t,nt,alt if alt else a])
        on_active=not on_active; t=nt
log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in newlog])

# re-afirma JSPANS pós max-hold/occlusion: o judge fixou a cam por CONTEÚDO; stages estruturais (cap de
# hold longo, oclusão) podem ter pisado no span. Re-carva pra cam do judge SÓ se ela ainda estiver limpa
# (badness<0.5) — footage quebrado (pan/shake severo) mantém o que o clean escolheu (ex: cam tremida que o
# próprio João marcou 'tremido, prefere a estável'). Conteúdo vence stage; estabilidade veta conteúdo.
for jf0,jf1,jang in (JSPANS_ANG if 'JSPANS_ANG' in dir() else []):
    if badness(V.ANGLE_CAM[jang],jf0,jf1)>=0.5: continue
    new=[]
    for s,e,a in log:
        if e<=jf0 or s>=jf1: new.append([s,e,a]); continue
        if s<jf0: new.append([s,jf0,a])
        new.append([max(s,jf0),min(e,jf1),jang])
        if e>jf1: new.append([jf1,e,a])
    log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in new if x[1]>x[0]])

# Beats "processional" pra fins de RITMO/VARIEDADE (item2 + item7): gente ENTRANDO, não só o beat
# literal "entrance_*". MINHOLD (acima) já trata ring_bearer/flower_girl/entrance_* no mesmo tier
# 2.5s pro piso de ritmo — mas item2/item7 excluíam ring_bearer/flower_girl do GATE de variedade
# via `startswith("entrance")`, inconsistência interna achada em forense real (caso Paula/Ronaldo,
# hold de 12s em ring_bearer sem nenhuma troca). Fix geral (não específico deste casamento):
# alinhar o gate ao mesmo conjunto que o MINHOLD já reconhece como processional rápido.
# EXAUSTIVO contra o vocabulário canônico de beats (`prompts/storyboard_v1.md` — fechado, os 16
# beats possíveis): TODOS os 6 beats de "gente entrando" (entrance_mother/groom/bridesmaids/bride
# + flower_girl/ring_bearer, os únicos sem prefixo "entrance_" mas com o MESMO papel processional).
# NÃO inclui kiss/applause/exit (money-shots que não se picam por variedade) nem
# declaration/rings/officiant/reading/vows (não são "gente entrando"). Revisão achou que um set
# anterior (por prefixo `startswith("entrance")`) SEM `entrance_bridesmaids` explícito seria uma
# REGRESSÃO real (bridesmaids é tipicamente o trecho processional mais longo de um casamento BR,
# e `startswith` capturava por acidente) — por isso o set é EXPLÍCITO e completo, nunca por prefixo.
PROCESSIONAL={
    "entrance_groom", "entrance_bride", "entrance_mother", "entrance_bridesmaids",
    "flower_girl", "ring_bearer",
}

# item 2: processional.
# v0.4.0: com 3+ cams, variedade ~6s (A-B-C).
# v0.4.4: com 2 cams, alternar 6s CANSA (só base↔detalhe) — prefere a MAIS FECHADA (mff↑)
# e SEGURA 12–15s; wide só se close tremer forte / sem assunto. 3+ cams: cadência 6s original.
PROC_CAP=int(6*30)
# v0.4.4b: com 2 cams, enquadramento FECHADO > estabilidade (salvo tremor forte).
# Ritmo NATURAL: estende enquanto close continua boa; corta se embacar/tremer/oclusão — não grid 14s.
# v0.4.4c padrinhos/entradas 2cam:
#   BASE = cam mais FECHADA (bonita). ABERTA só cobre FURO da fechada
#   (perdeu assunto/casal, whip, embacou). Quando próximo padrinho está
#   em foco+estável na fechada → volta e SEGURA. Ritmo por conteúdo, não metrônomo.
PROC_MIN_CLOSE=int(8.0*30)   # close boa: segura bastante
PROC_MIN_OPEN=int(3.0*30)    # open = cobertura curta do furo
PROC_MAX_2CAM=int(28*30)
PROC_PROBE=int(1.0*30)
CLOSE_J_OK_2=1.85
CLOSE_J_KILL_2=2.20
CLOSE_MFF_2=0.10
CLOSE_READY_MFF=0.13         # “próximo em foco certinho”
CLOSE_READY_J=1.40

def _tight_alt(avoid,w0,w1,prev):
    best=None;bm=-1
    for c in V.CAMS:
        an=V.CAM_ANGLE[c]
        if an in avoid or an==prev or not V.covers(COV,an,w0,w1) or badness(c,w0,w1)>=0.5: continue
        if not not_blur(c,w0,w1): continue
        wd=V.face_window(face,c,w0,w1); m=float(np.median(wd["mff"])) if wd and len(wd.get("mff",[])) else 0
        if m>bm: best,bm=an,m
    return best

def _mff_j(an, w0, w1):
    cam=V.ANGLE_CAM[an]
    if not V.covers(COV, an, w0, w1):
        return 0.0, 9.0
    wd=V.face_window(face, cam, w0, w1)
    m=float(np.median(wd["mff"])) if wd is not None and len(wd.get("mff", [])) else 0.0
    j=p80(cam, w0, w1)
    return m, j

def _roles_2cam(w0, w1):
    """close_an = maior mff coberta; open_an = a outra (se existir)."""
    cands=[]
    for c in V.CAMS:
        an=V.CAM_ANGLE[c]
        if not V.covers(COV, an, w0, w1):
            continue
        m, j = _mff_j(an, w0, w1)
        cands.append((an, m, j))
    if not cands:
        return None, None
    if len(cands)==1:
        return cands[0][0], None
    cands.sort(key=lambda x: -x[1])  # mff desc
    return cands[0][0], cands[1][0]

def _hole_on_close(close_an, open_an, w0, w1):
    """Furo na fechada: aberta deve cobrir. (casal/padrinho saindo do frame, whip, embacar)"""
    if close_an is None or open_an is None:
        return False
    mc, jc = _mff_j(close_an, w0, w1)
    mo, jo = _mff_j(open_an, w0, w1)
    # perdeu assunto na close; open ainda tem gente
    if mc < 0.07 and mo >= 0.08:
        return True
    # whip na close; open estável com assunto
    if jc >= CLOSE_J_KILL_2 and mo >= 0.08 and jo < 0.95:
        return True
    # close fraca e open bem mais “presente” (transição entre casais / alguém tampando)
    if mc < 0.09 and mo >= mc + 0.05 and jo < 1.0:
        return True
    return False

def _close_ready(close_an, w0, w1):
    """Próximo padrinho em foco certinho e razoavelmente estável → volta pra fechada e segura."""
    if close_an is None:
        return False
    mc, jc = _mff_j(close_an, w0, w1)
    return mc >= CLOSE_READY_MFF and jc < CLOSE_READY_J

def _pick_closed_2cam(w0, w1, fallback):
    """Default = mais fechada; open só se hole."""
    close_an, open_an = _roles_2cam(w0, w1)
    if close_an is None:
        return fallback
    if open_an is not None and _hole_on_close(close_an, open_an, w0, w1):
        return open_an
    mc, jc = _mff_j(close_an, w0, w1)
    if mc >= CLOSE_MFF_2 and jc < CLOSE_J_KILL_2:
        return close_an
    # close ruim sem open útil
    if open_an is not None:
        return open_an
    return fallback

if ENABLE_PROC:
    proc=[];n_proc=0
    _js=JSPANS if 'JSPANS' in dir() else []
    n_cams=len(V.CAMS)
    for f0,f1,a in log:
        beat=beat_at(f0)[2]
        if (beat not in PROCESSIONAL or f0>=SYNC_END
                or any(js0<f1 and js1>f0 for js0,js1 in _js)):
            proc.append([f0,f1,a]); continue
        span=f1-f0
        # --- v0.4.4c: 2 cams — close base; open só furo; volta quando próximo está ready ---
        if n_cams<=2:
            t=f0; last=None
            while t<f1:
                pe=min(f1, t+int(3*30))
                close_an, open_an = _roles_2cam(t, pe)
                if close_an is None:
                    pick=a if last is None else last
                elif last is None:
                    # começa na close se possível
                    pick = open_an if (open_an is not None and _hole_on_close(close_an, open_an, t, pe)) else close_an
                elif last == close_an:
                    # na fechada: só sai se HOLE (não por estabilidade da aberta)
                    pick = open_an if (open_an is not None and _hole_on_close(close_an, open_an, t, pe)) else close_an
                else:
                    # na aberta (cobrindo furo): volta assim que close estiver ready
                    if _close_ready(close_an, t, pe):
                        pick = close_an
                    else:
                        pick = last  # segura open curto até close travar o próximo
                # min hold: close longo, open curto
                min_hold = PROC_MIN_CLOSE if (close_an is not None and pick==close_an) else PROC_MIN_OPEN
                te=min(f1, t+min_hold)
                while te<f1 and (te-t)<PROC_MAX_2CAM:
                    pe2=min(f1, te+PROC_PROBE)
                    c_an, o_an = _roles_2cam(te, pe2)
                    if c_an is None:
                        te=pe2; continue
                    if pick == c_an:
                        # close: estende até furo
                        if o_an is not None and _hole_on_close(c_an, o_an, te, pe2):
                            break
                    else:
                        # open cobrindo: estende até close ready (próximo padrinho em foco)
                        if _close_ready(c_an, te, pe2):
                            break
                        # open também sem assunto e close pior? fica
                    te=pe2
                if f1-te<int(2.5*30) and te<f1:
                    te=f1
                if proc and proc[-1][2]==pick and proc[-1][1]==t:
                    proc[-1][1]=te
                else:
                    proc.append([t, te, pick])
                    if pick!=a: n_proc+=1
                last=pick; t=te
            continue
        # --- 3+ cams: variedade original 6s ---
        if span<=PROC_CAP:
            proc.append([f0,f1,a]); continue
        t=f0;prev=None
        while t<f1:
            nt=min(t+PROC_CAP,f1)
            if f1-nt<int(2*30): nt=f1
            if t==f0: proc.append([t,nt,a])
            else:
                alt=_tight_alt({a},t,nt,prev) or a; proc.append([t,nt,alt]); prev=alt; n_proc+=1
            t=nt
    log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in proc])
    print(f"[item2 proc] {n_proc} trocas em entradas "
          f"({'2cam close-base + open-só-furo' if n_cams<=2 else '3+cam variety 6s'})")

# v0.4.4: processional 2cam já trata close-hold no item2. Aqui só early TL (storyboard
# “officiant” engolindo entradas): 1–2 inserts LONGOS de close se dual claro — sem re-score 5s
# (4.3 piscou demais com 2 cams; 4.0 era calmo e bom).
if ENABLE_PREF_CLOSE_ENTRANCE:
    PC_MIN = int(3.5 * 30)
    PC_LEN = int(10.0 * 30)           # insert longo (ênfase close, não flash)
    PC_EARLY_END = int(45 * 60 * 30)
    PC_MAX_PER_SEG = 2
    PC_GAP = int(14 * 30)
    n_pc = 0
    out_pc = []
    _js_pc = JSPANS if "JSPANS" in dir() else []

    def _soft_close_alt(a, w0, w1):
        cam = V.ANGLE_CAM[a]
        w = V.face_window(face, cam, w0, w1)
        m_act = float(np.median(w["mff"])) if w is not None and len(w.get("mff", [])) else 0.0
        if m_act >= 0.18:
            return None  # já está “fechada o bastante”
        best = None
        for c in V.CAMS:
            an = V.CAM_ANGLE[c]
            if an == a or not V.covers(COV, an, w0, w1):
                continue
            if badness(c, w0, w1) >= 0.7:
                continue
            wd = V.face_window(face, c, w0, w1)
            m = float(np.median(wd["mff"])) if wd is not None and len(wd.get("mff", [])) else 0.0
            j = p80(c, w0, w1)
            # close claramente melhor; tremor leve OK
            if m < 0.12 or (m - m_act) < 0.05 or j > 1.50:
                continue
            sc = m * 3.0 - j * 0.3
            if best is None or sc > best[0]:
                best = (sc, an)
        return best[1] if best else None

    for f0, f1, a in log:
        beat = beat_at(f0)[2]
        span = f1 - f0
        # processional: já resolvido no item2 (2cam close-hold) — não re-cut aqui
        if beat in PROCESSIONAL or f0 >= SYNC_END or f0 >= PC_EARLY_END:
            out_pc.append([f0, f1, a])
            continue
        if span < PC_MIN + int(4 * 30) or any(js0 < f1 and js1 > f0 for js0, js1 in _js_pc):
            out_pc.append([f0, f1, a])
            continue
        # 1–2 inserts longos no early hold
        inserts = []
        for off in (int(span * 0.15), int(span * 0.55)):
            w0 = f0 + off
            w1 = min(f1, w0 + PC_LEN)
            if w1 - w0 < PC_MIN:
                continue
            alt = _soft_close_alt(a, w0, w1)
            if alt is None:
                continue
            if any(abs((w0 + w1) / 2 - (x0 + x1) / 2) < PC_GAP for x0, x1, _ in inserts):
                continue
            inserts.append((w0, w1, alt))
            if len(inserts) >= PC_MAX_PER_SEG:
                break
        if not inserts:
            out_pc.append([f0, f1, a])
            continue
        inserts.sort()
        cur = f0
        for w0, w1, alt in inserts:
            if w0 > cur:
                out_pc.append([cur, w0, a])
            out_pc.append([w0, w1, alt])
            n_pc += 1
            cur = w1
        if cur < f1:
            out_pc.append([cur, f1, a])
    log = to_log([{"f0": a, "f1": b, "angle": c} for a, b, c in out_pc if b > a])
    print(f"[v0.4.4 entrance-close] {n_pc} inserts longos early "
          f"(processional=item2 close-hold; sem ping-pong 5s)")

# 5. emoção (tato): pico de choro forte e SUSTENTADO -> cam com o rosto mais triste/fechado, se
# estável e coberta. Geral (noiva/noivo/pais chorando), reproduzível do facecache. Só dispara em
# emoção rara e clara (sad>0.88) com vencedor nítido, pra não brigar com a narrativa do diretor.
def sadmff(cam,f0,f1):
    w=V.face_window(face,cam,f0,f1)
    if not w or not len(w.get("sad",[])): return (0.0,0.0)
    return (float(np.mean(w["sad"])), float(np.mean(w["mff"])))
EWIN=int(2*30)
emo_pieces=[]
for f0,f1,a in log:
    ws=list(range(f0,f1,EWIN)); marks=[]
    for w in ws:
        we=min(w+EWIN,f1)
        # judge trava: não deixa o sad cego (cantora/performer = rosto "triste") atropelar a escolha
        # de conteúdo do judge (beat-aware). Sintoma: cantora vencia a família na entrada do noivo.
        if any(js0<we and js1>w for js0,js1 in JSPANS): marks.append(a); continue
        sw0,sw1=w-30,we+30  # avalia sad numa janela 4s (suaviza os gaps do soluço)
        as_,_=sadmff(V.ANGLE_CAM[a],sw0,sw1)
        # dispara só quando o ativo NÃO está na emoção e outra cam claramente está (choro sustentado)
        best,bs=None,0.35
        for c in V.CAMS:
            if V.CAM_ANGLE[c]==a or not V.covers(COV,V.CAM_ANGLE[c],w,we): continue
            cs,cm=sadmff(c,sw0,sw1)
            if cs>0.35 and cs-as_>0.22 and as_<0.22 and cm>0.15 and badness(c,w,we)<0.6 and cs>bs: best,bs=c,cs
        marks.append(V.CAM_ANGLE[best] if best else a)
    # agrupa janelas-de-emoção da MESMA cam preenchendo gaps <=3 janelas (6s): um HOLD contínuo no
    # beat de choro (não ping-pong). Emite só se o grupo >=4s e tem >=2 janelas positivas.
    GAP=3
    i=0
    while i<len(ws):
        if marks[i]!=a:
            X=marks[i]; j=i; last=i; pos=1
            while True:
                k=next((kk for kk in range(last+1,min(last+1+GAP,len(ws))) if marks[kk]==X),None)
                if k is None: break
                last=k; pos+=1
            span=min(ws[last]+EWIN,f1)-ws[i]
            if span>=int(4*30) and pos>=2:
                emo_pieces.append([ws[i],min(ws[last]+EWIN,f1),X])
            i=last+1
        else: i+=1
for w0,w1,ang in emo_pieces:
    log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
        sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
             for s,e,ag in log),[]) if x[1]>x[0]])

# 5.5 content-insert (NATIVO, sem agente): onde a ativa NÃO tem conteúdo mas outra cam coberta+estável
# tem conteúdo FORTE (search SigLIP: celebrante/músico/abraço/reação/alianças) -> troca o segmento.
# Aditivo e conservador: só dispara com sinal claro (alt forte, ativa fraca, gap), respeita judge.
JSPANS_SET=JSPANS if 'JSPANS' in dir() else []
CW=int(1*30); cins=[]
for f0,f1,a in log:
    ac=V.ANGLE_CAM[a]
    if any(js0<f1 and js1>f0 for js0,js1 in JSPANS_SET): continue   # judge já decidiu aqui
    ws=list(range(f0,f1,CW)); marks=[]
    for w in ws:
        we=min(w+CW,f1); best=None
        av=content_score(ac,w,we,W=45)
        if badness(ac,w,we)<0.6:
            for c in V.CAMS:
                if c==ac or not V.covers(COV,V.CAM_ANGLE[c],w,we) or badness(c,w,we)>0.55: continue
                cv=content_score(c,w,we,W=45)
                if cv>=0.20 and cv>av+0.12 and (best is None or cv>best[1]): best=(V.CAM_ANGLE[c],cv)
        marks.append(best[0] if best else a)
    i=0                                              # carva runs contíguos >=2s (insert deliberado)
    while i<len(ws):
        if marks[i]!=a:
            j=i
            while j+1<len(ws) and marks[j+1]==marks[i]: j+=1
            if ws[j]+CW-ws[i]>=int(2*30): cins.append([ws[i],min(ws[j]+CW,f1),marks[i]])
            i=j+1
        else: i+=1
for w0,w1,ang in cins:
    log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
        sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
             for s,e,ag in log),[]) if x[1]>x[0]])

# 5.55 content-insert AGENTE-DIRETOR (híbrido): nativo localiza holds longos ∩ cluster de conteúdo ->
# agente-diretor confirma O QUE inserir + a cam + o sub-span (4-5s, bordas do conteúdo). Carva o
# sub-span confirmado. jerk-veto: descarta se a cam destino treme (agente vê 3 frames, não o jerk).
if _os.path.exists("content_inserts.json"):
    for v in sorted(json.load(open("content_inserts.json")),key=lambda x:x["f0"]):
        w0,w1,ang=v["f0"],v["f1"],v["angle"]
        if w0>=SYNC_END: continue   # zona laranja
        if not V.covers(COV,ang,w0,w1) or badness(V.ANGLE_CAM[ang],w0,w1)>1.2: continue   # conteúdo supera tremor leve (regra João): veto frouxo p/ inserts
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
            sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
                 for s,e,ag in log),[]) if x[1]>x[0]])

# 5.56 content-insert HOLD CURTO (camada 2): mesma lógica do 5.55 mas pra holds 6-12s (banda abaixo do
# threshold do diretor). Nativo: hold curto ∩ cluster forte; agente-diretor confirma + sub-span.
if _os.path.exists("content_inserts_short.json"):
    for v in sorted(json.load(open("content_inserts_short.json")),key=lambda x:x["f0"]):
        w0,w1,ang=v["f0"],v["f1"],v["angle"]
        if w0>=SYNC_END: continue   # zona laranja
        if not V.covers(COV,ang,w0,w1) or badness(V.ANGLE_CAM[ang],w0,w1)>1.2: continue
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
            sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
                 for s,e,ag in log),[]) if x[1]>x[0]])

# 5.57 key-moment (camada 3): agente-diretor confirma beat exato (aliança/beijo/abraço/vowYes/virar)
# via inspect_timeline. Veto frouxo (2.0). Floor 1.5s (vs 2s geral): money-shots DEVEM aparecer mesmo
# se curtos — qualquer coisa ≥1.5s já é legível; abaixo vira flick. Boundary snap: se o overlap com o
# segmento-cam-errado < 1.5s, expande para cobrir o segmento inteiro (override total = money-shot ganha).
KM_FLOOR=int(1.5*30)
KCUTS=set()   # cortes de key-moment: protegidos do settle/tail-trim (money-shot > entrada levemente instável)
if _os.path.exists("keymoment_inserts.json"):
    for v in sorted(json.load(open("keymoment_inserts.json")),key=lambda x:x["f0"]):
        w0,w1,ang=v["f0"],v["f1"],v["angle"]
        if w0>=SYNC_END: continue   # zona laranja
        # entrada (noiva/noivo) é FAST MOMENT: o ápice é um PUNCH ~3s, não hold longo. Capa pra deixar
        # o pass de ritmo de entrada preencher o resto com cams boas (estáveis+nítidas).
        if str(v.get("moment","")).startswith("entrance"): w1=min(w1,w0+int(3*30))
        if not V.covers(COV,ang,w0,w1): continue
        # boundary snap MÍNIMO: overlap pequeno -> recua só o necessário pra chegar em KM_FLOOR
        sw0,sw1=w0,w1
        for s,e,sa in log:
            if sa==ang or e<=w0 or s>=w1: continue
            ov=min(e,w1)-max(s,w0)
            if 0<ov<KM_FLOOR:
                extra=KM_FLOOR-ov
                sw0=max(s,w0-extra)
                sw1=min(e,w1+extra) if w1<e else sw1
        w0,w1=sw0,sw1
        if not V.covers(COV,ang,w0,w1) or badness(V.ANGLE_CAM[ang],w0,w1)>2.0: continue
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
            sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
                 for s,e,ag in log),[]) if x[1]>x[0]])
        KCUTS.add(w0)   # protege o ponto de entrada do money-shot do settle/tail-trim

# item 9b: EMOÇÃO confirmada pelo JUDGE visual genérico (prompts/judge_emotion.txt). Nativo localiza
# candidatos (emotion_cands.py, do-zero), judge decide moment-aware quais valem -> emotion_inserts.json.
# Aplica igual money-shot (aceita tremor leve: reação/emoção > tremor). Protege do settle.
if ENABLE_EMOTION_JUDGE and _os.path.exists("emotion_inserts.json"):
    for v in sorted(json.load(open("emotion_inserts.json")),key=lambda x:x["f0"]):
        w0,w1,ang=v["f0"],v["f1"],v["angle"]
        if w0>=SYNC_END or not V.covers(COV,ang,w0,w1) or badness(V.ANGLE_CAM[ang],w0,w1)>2.0: continue
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
            sum(([[s,min(w0,e),ag],[max(s,w0),min(e,w1),ang],[max(w1,s),e,ag]] if not(e<=w0 or s>=w1) else [[s,e,ag]]
                 for s,e,ag in log),[]) if x[1]>x[0]])
        KCUTS.add(w0)

# item 5: shake-rescue. Segmento com a ativa TREMIDA (jerk>1.0) e uma alt coberta MUITO mais estável
# (jerk<0.4) -> troca. Só se NÃO-protegido (content/keymoment/luma/judge: conteúdo/momento supera tremor,
# regra João) e syncado. Conservador: dados mostram só ~2 wins (resto é content-protegido ou sem alt).
if ENABLE_SHAKERESCUE:
    _prot=[]
    for _fn in ("content_inserts.json","content_inserts_short.json","keymoment_inserts.json","luma_switches.json"):
        if _os.path.exists(_fn):
            for v in json.load(open(_fn)): _prot.append((v["f0"],v["f1"]))
    _prot+=[(a,b) for a,b in (JSPANS if 'JSPANS' in dir() else [])]
    def _protected(s,e): return any(f0<e and f1>s for f0,f1 in _prot)
    def _mj(cam,s,e): v=V.mean_jerk(jerk,cam,s,e); return v if v else 0.0
    n_resc=0
    for idx,(s,e,a) in enumerate(list(log)):
        if s>=SYNC_END or _protected(s,e): continue
        if _mj(V.ANGLE_CAM[a],s,e)<=1.0: continue           # mesmo sinal que DEFINE "shaky" (mean_jerk)
        best=None
        for c in V.CAMS:
            an=V.CAM_ANGLE[c]
            if an==a or not V.covers(COV,an,s,e): continue
            jb=_mj(c,s,e)
            if jb<0.4 and (best is None or jb<best[1]): best=(an,jb)
        if best and best[0]!=log[idx][2]:
            log[idx][2]=best[0]; n_resc+=1
    log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in log])
    print(f"[item5 shake-rescue] {n_resc} trocas (tremido->alt estável, não-protegido)")

# item 3b: prefer-sharp. Assunto DESFOCADO na ativa (face_sharp baixo) e alt coberta+estável MUITO mais
# nítida (>1.5x) -> troca. RELATIVO cross-cam (mesmo sujeito 3 ângulos), não threshold absoluto. Guarda
# igual ao shake-rescue: só não-protegido (content/keymoment/luma/judge) e syncado. Validado: 3/5 blurry.
if ENABLE_SHARP:
    _prot2=[]
    for _fn in ("content_inserts.json","content_inserts_short.json","keymoment_inserts.json","luma_switches.json"):
        if _os.path.exists(_fn):
            for v in json.load(open(_fn)): _prot2.append((v["f0"],v["f1"]))
    _prot2+=[(a,b) for a,b in (JSPANS if 'JSPANS' in dir() else [])]
    def _prot_sh(s,e): return any(f0<e and f1>s for f0,f1 in _prot2)
    def _mj2(cam,s,e): v=V.mean_jerk(jerk,cam,s,e); return v if v else 0.0
    # mediana de sharp POR CÂMERA (baseline própria). "blur" = ativa MUITO abaixo do SEU normal —
    # não "alt mais nítida" (cam1 é sempre mais nítida -> isso over-fira pra cam1 em tudo).
    SHMED={c:(np.median([x for x in face[c]["sharp"] if x>0]) if c in face and len(face[c].get("sharp",[])) else 0)
           for c in V.CAMS}
    n_sharp=0
    for idx,(s,e,a) in enumerate(list(log)):
        if s>=SYNC_END or _prot_sh(s,e): continue
        ac=V.ANGLE_CAM[a]; sa=V.face_sharp(face,ac,s,e)
        if sa is None or sa<=0 or SHMED.get(ac,0)<=0: continue
        if sa>=0.45*SHMED[ac]: continue                                # ativa NÃO está desfocada (normal pra ela)
        # v0.4.1: close intencional (mff alto) costuma ser "menos sharp" que wide tripé —
        # não trocar close de entrada/porta por wide só por sharp relativo (Nathália 24:36).
        _wa = V.face_window(face, ac, s, e)
        m_act = float(np.median(_wa["mff"])) if _wa is not None and len(_wa.get("mff", [])) else 0.0
        best=None
        for c in V.CAMS:
            an=V.CAM_ANGLE[c]
            if an==a or not V.covers(COV,an,s,e) or _mj2(c,s,e)>0.6: continue   # alt estável
            sc=V.face_sharp(face,c,s,e)
            if sc and sc>sa*1.8 and sc>=0.6*SHMED.get(c,1e9) and (best is None or sc>best[1]): best=(an,sc)  # alt nítida no padrão dela
        if best and best[0]!=log[idx][2]:
            _walt = V.face_window(face, V.ANGLE_CAM[best[0]], s, e)
            m_alt = float(np.median(_walt["mff"])) if _walt is not None and len(_walt.get("mff", [])) else 0.0
            # limiar 0.12/0.06: close médio de porta (mff~0.15–0.18) ainda conta
            if m_act >= 0.12 and (m_act - m_alt) >= 0.06:
                continue  # mantém close; sharp sozinho não vence framing
            log[idx][2]=best[0]; n_sharp+=1
    log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in log])
    print(f"[item3b prefer-sharp] {n_sharp} trocas (desfocado->alt nítida, não-protegido)")

# item 6: focus-override do SUB-SPAN. Blur do assunto é BREVE (entrada/transiente) — o segmento todo
# parece nítido (mediana dilui). Acha o pedaço desfocado fino, e SE há alt estável+nítida cobrindo,
# sobrepõe SÓ ali (mesmo em span protegido — blur severo ganha do framing). Sem alt -> aceita e MARCA
# "DESFOQUE SEM OPÇÕES MELHORES" (blur_noopt.json) pro usuário ajustar manual.
if ENABLE_FOCUS:
    _SHM={c:(np.median([x for x in face[c]["sharp"] if x>0]) if c in face and len(face[c].get("sharp",[])) else 0)
          for c in V.CAMS}
    def _sharp_alt(avoid,b0,b1):
        best=None
        for c in V.CAMS:
            an=V.CAM_ANGLE[c]
            if an==avoid or not V.covers(COV,an,b0,b1): continue
            if (V.mean_jerk(jerk,c,b0,b1) or 0)>0.6: continue
            sc=V.face_sharp(face,c,b0,b1)
            if sc and sc>=0.55*_SHM.get(c,1e9) and (best is None or sc>best[1]): best=(an,sc)
        return best[0] if best else None
    # MARK-ONLY: auto-trocar o sub-span de blur FRAGMENTA (A-B-A, alts tremidas) — ping_pong/sub2s/shaky
    # explodem (medido). Então só DETECTA + classifica (tem alt estável? ou sem opção) e marca pro
    # usuário aplicar a troca que quiser. Surfaca o problema sem regredir o corte. (marker = eval)
    blur_marks=[]
    for s,e,a in log:
        ac=V.ANGLE_CAM[a]
        if s>=SYNC_END or _SHM.get(ac,0)<=0: continue
        w=V.face_window(face,ac,s,e)
        if not w or "sharp" not in w or not len(w["sharp"]): continue
        ts=w["ts"]*30; sh=w["sharp"]; mf=w.get("mff",np.ones(len(sh))); fl=0.25*_SHM[ac]
        # blur BRUTO (sharp<0.25 do normal — só desfoque óbvio) + SUSTENTADO (>=1.3s) + assunto real
        # (mff>0.12). Menos sensível de propósito (João: pegava cena correta). per-window carve fragmenta.
        bspans=[]; i=0
        while i<len(sh):
            if sh[i]>0 and sh[i]<fl and (i>=len(mf) or mf[i]>0.12):
                j=i
                while j+1<len(sh) and sh[j+1]>0 and sh[j+1]<fl: j+=1
                b0,b1=max(s,int(ts[i])),min(e,int(ts[j]))
                if b1-b0>=int(1.3*30): bspans.append((b0,b1))   # >=1.3s sustentado (mais bruto)
                i=j+1
            else: i+=1
        for b0,b1 in bspans:
            alt=_sharp_alt(a,b0,b1)
            blur_marks.append({"f0":b0,"f1":b1,"cam":a,"alt":alt,
                "note":(f"DESFOQUE — trocar p/ cam{alt}" if alt else "DESFOQUE SEM OPÇÕES MELHORES (ajustar manual)")})
    blur_marks.sort(key=lambda x:x["f0"])
    json.dump(blur_marks,open("blur_marks.json","w"),indent=1)
    _wa=sum(1 for m in blur_marks if m["alt"])
    print(f"[item6 focus MARK-ONLY] {len(blur_marks)} desfoques marcados ({_wa} com alt estável, {len(blur_marks)-_wa} sem opção)")

# item 7: ritmo de ENTRADA (depois do keymoment).
# 3+ cams: hold>4.5s → variedade ~4.5s pra alt estável+nítida+mff.
# v0.4.4b 2 cams: DESLIGA. O filtro "jerk<0.5 + not_blur" favorece a ABERTA (tripé) e o
# grid 4.5s vira metrônomo base↔detalhe (padrinhos) — pior que o 4.0. Close-hold fica no item2.
if ENABLE_ENTRY_RHYTHM:
    if len(V.CAMS) <= 2:
        print("[item7 entry-rhythm] skip 2cam (evita aberta+metrônomo 4.5s; close no item2)")
    else:
        ECAP=int(4.5*30)
        def _entry_alt(avoid,b0,b1,prev):
            best=None;bm=-1
            for c in V.CAMS:
                an=V.CAM_ANGLE[c]
                if an in avoid or an==prev or not V.covers(COV,an,b0,b1): continue
                if (V.mean_jerk(jerk,c,b0,b1) or 0)>0.5 or not not_blur(c,b0,b1): continue
                wd=V.face_window(face,c,b0,b1); m=float(np.median(wd["mff"])) if wd and len(wd.get("mff",[])) else 0
                if m>bm: best,bm=an,m
            return best
        out7=[]; n7=0
        _js7=JSPANS if 'JSPANS' in dir() else []
        for s,e,a in log:
            if (beat_at(s)[2] not in PROCESSIONAL or (e-s)<=ECAP or s>=SYNC_END
                    or any(js0<e and js1>s for js0,js1 in _js7)):
                out7.append([s,e,a]); continue
            t=s; prev=None; first=True
            while t<e:
                nt=min(t+ECAP,e)
                if e-nt<int(2*30): nt=e
                if first: out7.append([t,nt,a]); first=False
                else:
                    alt=_entry_alt({a},t,nt,prev) or a; out7.append([t,nt,alt]); prev=alt
                    if alt!=a: n7+=1
                t=nt
        log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in out7])
        print(f"[item7 entry-rhythm] {n7} inserts de ritmo em entradas (3+ cam)")

# item 9: missedEmotion (sad OU joy) — MARK-ONLY. Auto-trocar fragmenta (riso é constante: joy global=43
# inserts ping_pong 14->25; mesmo apertado 13 inserts só pegou 2/14 + regrediu). A decisão "vale a pena
# trocar?" é MOMENT-AWARE (precisa judge/importância-do-momento que o nativo não tem). Então só DETECTA
# emoção forte (choro/riso) numa cam coberta que a ativa não tem e MARCA pro judge/humano decidir.
if ENABLE_JOY:
    def _emo(cam,f0,f1):
        w=V.face_window(face,cam,f0,f1)
        if not w: return (0.0,0.0,0.0)
        sa=float(np.max(w["sad"])) if len(w.get("sad",[])) else 0.0
        jo=float(np.max(w["joy"])) if len(w.get("joy",[])) else 0.0
        mp=float(np.median(w["mff"])) if len(w.get("mff",[])) else 0.0
        return (sa,jo,mp)
    EW9=int(2*30); emo_marks=[]
    for s,e,a in log:
        if s>=SYNC_END: continue
        ac=V.ANGLE_CAM[a]
        for w0 in range(s,e,EW9):
            we=min(w0+EW9,e)
            if we-w0<int(1.5*30): continue
            sa_a,jo_a,_=_emo(ac,w0,we)
            for c in V.CAMS:
                an=V.CAM_ANGLE[c]
                if an==a or not V.covers(COV,an,w0,we): continue
                sa,jo,mp=_emo(c,w0,we)
                if mp<0.15: continue                          # close real
                kind=None
                if sa>=0.70 and sa-sa_a>=0.40: kind="choro"   # choro FORTE, ativa sem
                elif jo>=0.93 and jo-jo_a>=0.55: kind="riso"  # riso MUITO forte, ativa sem
                if kind:
                    emo_marks.append({"f0":w0,"f1":we,"cam":a,"alt":an,"kind":kind,"note":f"EMOÇÃO ({kind}) na cam{an} — judge: vale trocar?"})
                    break
    # funde marcas contíguas da mesma alt; só SUSTENTADAS (>=3s, descarta lampejo)
    emo_marks.sort(key=lambda x:x["f0"]); mm=[]
    for m in emo_marks:
        if mm and mm[-1]["alt"]==m["alt"] and m["f0"]-mm[-1]["f1"]<=EW9: mm[-1]["f1"]=m["f1"]
        else: mm.append(m)
    mm=[m for m in mm if m["f1"]-m["f0"]>=int(3*30)]
    json.dump(mm,open("emotion_marks.json","w"),indent=1)
    print(f"[item9 emotion MARK-ONLY] {len(mm)} emoções fortes marcadas (judge/humano decide)")

# item 8: REAÇÃO DO NOIVO na entrada da noiva (regra de domínio, João). Entrada da noiva DEVE incluir a
# reação do noivo logo que ela aparece — mesmo curtíssima (>1s) e mesmo com tremor leve (reação > tremor,
# igual money-shot). Acha a cam não-anchor com PICO de joy (sorriso/reação) + close (mff) na 1ª parte da
# entrada e força ~1.5s nela. Não veta por jerk. Protege do settle.
if ENABLE_REACTION:
    RWIN=int(0.75*30)
    def _cam_at(f):
        for s,e,a in log:
            if s<=f<e: return a
        return None
    n8=0
    for bf0,bf1,beat,imp in spans:
        if beat!="entrance_bride" or bf0>=SYNC_END: continue
        win0,win1=bf0,min(bf1,bf0+int(8*30))    # "ela entra" = começo do beat
        anchor=_cam_at(win0)
        best=None
        for c in V.CAMS:
            an=V.CAM_ANGLE[c]
            if an==anchor or not V.covers(COV,an,win0,win1): continue
            w=V.face_window(face,c,win0,win1)
            if not w or not len(w.get("joy",[])): continue
            jp=float(np.max(w["joy"])); mp=float(np.median(w["mff"]))
            if jp>=0.7 and mp>0.12:
                pf=int(w["ts"][int(np.argmax(w["joy"]))]*30)
                if best is None or jp>best[1]: best=(an,jp,pf)
        if not best: continue
        an,jp,pf=best
        r0,r1=max(win0,pf-RWIN),min(win1,pf+RWIN)
        if r1-r0<int(1.0*30): continue
        if _cam_at((r0+r1)//2)==an: continue     # já está na cam da reação
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in
            sum(([[s,min(r0,e),ag],[max(s,r0),min(e,r1),an],[max(r1,s),e,ag]] if not(e<=r0 or s>=r1) else [[s,e,ag]]
                 for s,e,ag in log),[]) if x[1]>x[0]])
        KCUTS.add(r0); n8+=1
    print(f"[item8 reação-noivo] {n8} inserts de reação na entrada da noiva")

# 6. settle do ponto de ENTRADA: nunca cortar PARA dentro de uma tremida. Se a cam que entra começa
# instável (badness elevada nos ~1.2s iniciais) e a cam anterior segue estável ali, ATRASA o corte
# (estende a anterior) até a de entrada assentar. v0.4.1: MAXTRIM 3.0s + aplica no teto + 2 probes
# + re-pass final (cortes criados por long-hold/edge_auto depois do settle cedo — 64:44 Nathália).
PROBE=int(0.4*30); MAXTRIM=int(3.0*30)
def shaky_in(cam,a,b): return badness(V.ANGLE_CAM[cam],a,b)>0.45
def apply_settle(log, tag=""):
    n = 0
    for i in range(1, len(log)):
        f0, f1, a = log[i]
        pf0, pf1, pa = log[i - 1]
        if pa == a:
            continue
        if f0 in KCUTS:
            continue  # money-shot: não atrasa entrada mesmo com shake leve
        if not any(shaky_in(a, f0 + k, f0 + k + PROBE) for k in range(0, int(1.2 * 30), PROBE)):
            continue
        trim = PROBE
        while trim < MAXTRIM and (f1 - (f0 + trim)) > int(2.0 * 30):
            ba0 = badness(V.ANGLE_CAM[a], f0 + trim, f0 + trim + PROBE)
            ba1 = badness(V.ANGLE_CAM[a], f0 + trim + PROBE, f0 + trim + 2 * PROBE)
            if ba0 < 0.40 and ba1 < 0.45:
                break
            if badness(V.ANGLE_CAM[pa], f0 + trim, f0 + trim + PROBE) > 0.55:
                trim = 0
                break
            trim += PROBE
        if 0 < trim <= MAXTRIM and (f1 - (f0 + trim)) >= int(2.0 * 30):
            ba0 = badness(V.ANGLE_CAM[a], f0 + trim, f0 + trim + PROBE)
            ba1 = badness(V.ANGLE_CAM[a], f0 + trim + PROBE, f0 + trim + 2 * PROBE)
            settled = ba0 < 0.40 and ba1 < 0.45
            prev_ok = badness(V.ANGLE_CAM[pa], f0 + trim - PROBE, f0 + trim) <= 0.55
            if settled or (trim >= MAXTRIM and prev_ok):
                log[i][0] = f0 + trim
                log[i - 1][1] = f0 + trim
                n += 1
    out = to_log([{"f0": a, "f1": b, "angle": c} for a, b, c in log if b > a])
    if tag:
        print(f"[v0.4.1 settle{tag}] {n} cortes atrasados (entrada tremida)")
    return out

log = apply_settle(log, tag="")

# 6b. tail-trim (espelho do settle): se a cam que SAI treme no FIM (~1.2s) e a próxima é estável ali,
# ADIANTA o corte (a próxima cobre o rabinho tremido). João: "tremidin no fim", "esse fim já devia ter mudado".
# (Versão fina/spike desta sessão revertida: o João viu como churn/regressão — o olho > a métrica de jerk.)
TAIL=int(1.2*30)
for i in range(len(log)-1):
    f0,f1,a=log[i]; na=log[i+1][2]
    if na==a or f1-f0<=int(2.5*30): continue
    t0=f1-TAIL
    if badness(V.ANGLE_CAM[a],t0,f1)<=0.45 or badness(V.ANGLE_CAM[na],t0,f1)>0.45: continue
    cut=max(f0+int(2*30),t0)
    if cut<f1: log[i][1]=cut; log[i+1][0]=cut
log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in log if b>a])

# 6c. stability-override SEGURO: segmento tremido (jerk>0.9) SEM conteúdo próprio, com alt MUITO mais
# estável (jerk<0.5) -> troca pra alt. Só quando NÃO perde conteúdo (content_at da cam tremida vazio ou
# ⊆ alt): bug puro (cam balançando mostrando nada). Tradeoff conteúdo×estabilidade fica pro judge, não aqui.
# Money-shot (KCUTS) e judge (JSPANS) isentos: lá o momento justifica.
if ENABLE_STAB_OVERRIDE:
    n_stab=0; _js=JSPANS if 'JSPANS' in dir() else []
    for i,(f0,f1,a) in enumerate(log):
        if f0 in KCUTS or any(s<f1 and e>f0 for s,e in _js): continue
        cam=V.ANGLE_CAM[a]; jk=V.mean_jerk(jerk,cam,f0,f1) or 0
        if jk<=0.9: continue
        ca=set(V.content_at(f0,f1).get(cam,[]))
        best=None
        for c in V.CAMS:
            if V.CAM_ANGLE[c]==a or not V.covers(COV,V.CAM_ANGLE[c],f0,f1): continue
            jc=V.mean_jerk(jerk,c,f0,f1) or 9
            if jc>=0.5 or jc>=jk-0.5: continue
            if ca and not ca.issubset(set(V.content_at(f0,f1).get(c,[]))): continue  # perderia conteúdo -> judge decide
            if best is None or jc<best[1]: best=(V.CAM_ANGLE[c],jc)
        if best: log[i][2]=best[0]; n_stab+=1
    log=to_log([{"f0":a,"f1":b,"angle":c} for a,b,c in log if b>a])
    print(f"[item6c stab-override] {n_stab} trocas tremido-sem-conteúdo -> estável")

# 6c2. soft-content stab (v0.3.2): conteúdo "fraco" (abraco/músico/plateia/etc) NÃO protege
# footage claramente pior em estabilidade quando existe alt tripé. Sintoma review 21:02: cam com
# hit SigLIP `abraco` mas no olho = cantora + p80~1.0, alt p80~0.05. HARD content (beijo/alianças/
# sim) continua protegido — money-shot justifica tremor leve.
# Geral (não tunado a TC): p80(ativa)>0.90 + alt p80<0.45 e gap≥0.40 → troca.
# Só money-shots inequívocos. `declaracao`/`votos`/`abraco` no dense SigLIP disparam em
# cantora/orador/abraço fraco (review 21:02: cam2 tinha declaracao+abraco e era cantora).
HARD_CONTENT_PROTECT = {
    "beijo", "kiss", "aliancas", "aliancas_entrada", "rings",
    "resposta_sim", "vow_yes",
}
if ENABLE_STAB_OVERRIDE:
    n_soft = 0
    _js2 = JSPANS if "JSPANS" in dir() else []
    for i, (f0, f1, a) in enumerate(log):
        if f0 in KCUTS or any(s < f1 and e > f0 for s, e in _js2):
            continue
        cam = V.ANGLE_CAM[a]
        ja = p80(cam, f0, f1)
        if ja <= 0.90:
            continue
        ca = set(V.content_at(f0, f1).get(cam, []) or [])
        if ca & HARD_CONTENT_PROTECT:
            continue  # money-shot real: mantém
        best = None
        for c in V.CAMS:
            if V.CAM_ANGLE[c] == a or not V.covers(COV, V.CAM_ANGLE[c], f0, f1):
                continue
            jc = p80(c, f0, f1)
            if jc >= 0.45 or jc >= ja - 0.40:
                continue
            if best is None or jc < best[1]:
                best = (V.CAM_ANGLE[c], jc)
        if best:
            log[i][2] = best[0]
            n_soft += 1
    log = to_log([{"f0": a, "f1": b, "angle": c} for a, b, c in log if b > a])
    print(f"[item6c2 soft-content-stab] {n_soft} trocas (conteúdo fraco não protege shake vs tripé)")

# 9c. VARIETY/b-roll: insert breve fora de clímax pra cam com plateia/músicos/celebrante (localizador
# SigLIP variety_cands + judge_variety genérico). Saída congelada variety_inserts.json (replay). Carva a span.
if ENABLE_VARIETY and _os.path.exists("variety_inserts.json"):
    n_var=0
    for v in sorted(json.load(open("variety_inserts.json")),key=lambda x:x["f0"]):
        vf0,vf1,ang=v["f0"],v["f1"],v["angle"]
        if not V.covers(COV,ang,vf0,vf1): continue
        new=[]
        for s,e,a in log:
            if e<=vf0 or s>=vf1: new.append([s,e,a]); continue
            if s<vf0: new.append([s,vf0,a])
            new.append([max(s,vf0),min(e,vf1),ang])
            if e>vf1: new.append([vf1,e,a])
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in new if x[1]>x[0]]); n_var+=1
    print(f"[item9c variety] {n_var} inserts de variedade/b-roll (judge_variety fora-clímax)")

# 9d. MISSED-MOMENT: cena que o João marcou (noiva/corredor/vó/choro/resposta-sim) recuperada pela
# SUB-PARTE ESTÁVEL (shake_windows acha o trecho não-tremido dentro do shot) + judge confirmou o conteúdo.
# Importante aceita tremor leve; cena comum só limpa. Congelado missed_inserts.json (replay).
if ENABLE_MISSED and _os.path.exists("missed_inserts.json"):
    n_mi=0
    for v in sorted(json.load(open("missed_inserts.json")),key=lambda x:x["f0"]):
        mf0,mf1,ang=v["f0"],v["f1"],v["angle"]
        if not V.covers(COV,ang,mf0,mf1): continue
        new=[]
        for s,e,a in log:
            if e<=mf0 or s>=mf1: new.append([s,e,a]); continue
            if s<mf0: new.append([s,mf0,a])
            new.append([max(s,mf0),min(e,mf1),ang])
            if e>mf1: new.append([mf1,e,a])
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in new if x[1]>x[0]]); n_mi+=1
    print(f"[item9d missed-moment] {n_mi} recuperados (sub-parte estável + judge)")

# 6d. stability-JUDGE: tremido COM conteúdo (orquestra/celebrante/plateia) — o judge VLM (genérico, regra
# estabilidade+conteúdo moment-aware) decidiu caso-a-caso trocar pra estável. Ápices (resposta-sim/abraço)
# o judge MANTÉM tremido. Saída congelada: stab_switches.json (replay determinístico).
if _os.path.exists("stab_switches.json"):
    n_sj=0
    for v in json.load(open("stab_switches.json")):
        jf0,jf1,to=v["f0"],v["f1"],v["to"]
        if not V.covers(COV,to,jf0,jf1): continue
        new=[]
        for s,e,a in log:
            if e<=jf0 or s>=jf1: new.append([s,e,a]); continue
            if s<jf0: new.append([s,jf0,a])
            new.append([max(s,jf0),min(e,jf1),to])
            if e>jf1: new.append([jf1,e,a])
        log=to_log([{"f0":x[0],"f1":x[1],"angle":x[2]} for x in new if x[1]>x[0]]); n_sj+=1
    print(f"[item6d stab-judge] {n_sj} trocas tremido-com-conteúdo -> estável (judge moment-aware)")

# floor FINAL (v0.3+): variety/missed/content/stab carvam DEPOIS do floor pós-clean e deixam
# slivers (ex. 6f @ 18:20 no review tooShort). Cerimônia completa: sem cut <2s exceto dodge
# oclusão (LSPANS). Sem isto, métrica sub2s explode quando variety/missed ligam de verdade.
def _protect_final(s,e):
    return any(abs(s-a)<8 and abs(e-b)<8 for a,b in LSPANS) if LSPANS else False
_n_before=len(log)
log=floor_merge(log, protect_fn=_protect_final if LSPANS else None)
_n_after=len(log)
if _n_before!=_n_after:
    print(f"[floor final] fundiu {_n_before-_n_after} slivers <{FLOOR/30:.0f}s ({_n_before}->{_n_after} segs)")

# 6c3. long-hold RHYTHM (v0.3.3): freio estratégico pós soft-stab.
# Soft-content-stab pode colapsar ~90–300s numa cam tripé (review 100:44 cumprimentos:
# 14 cuts → 1 hold). Em trechos medium/high importance reabre ritmo com INSERTS de
# sub-janela ESTÁVEL na alt (3.5–7s, p80<1.35) — o mesmo perfil dos inserts bons do
# 0.3.1 (p80 ~1.0–1.3). NÃO chunk fixo de 10s com p80 no span inteiro (handheld de
# cumprimentos tem spikes: p80-de-10s explode mesmo com 4s limpos no meio → 0 inserts).
# NÃO reabre whip (p80≥1.5). Floor de novo.
# Gate: beats de saída/aplauso/entrada/cumprimentos, importance≥0.65, último ~18% da
# timeline, OU content map com padrinhos/cumprimentos (storyboard costuma rotular "vows").
# Beats de FIM / cumprimentos. Entradas ficam no item7 (p80 mais rígido) — não
# reabrir aqui ou o soft-stab mid-cerimônia regride.
RHYTHM_BEATS = {
    "exit", "applause", "congratulations", "cumprimentos", "recessional", "saida",
}
# Gate por conteúdo: SÓ padrinhos/cumprimentos (medium→high). Nunca plateia genérica.
RHYTHM_GATE_CONTENT = {
    "entrada_padrinhos", "padrinhos", "congratulations", "cumprimentos",
}
# Rank do insert (bônus de score), sem abrir o gate sozinho.
RHYTHM_RANK_CONTENT = RHYTHM_GATE_CONTENT | {
    "reacao_publico", "publico_assistindo", "saida", "exit", "applause",
}
MAX_HOLD_RHYTHM = int(18 * 30)
MIN_INS_RHYTHM = int(3.5 * 30)   # ≥ floor; espelha inserts bons do 0.3.1
MAX_INS_RHYTHM = int(7 * 30)
GAP_RHYTHM = int(10 * 30)        # respiro na base entre inserts
STEP_RHYTHM = 30                 # varre a 1s
RHYTHM_P80 = 1.35                # tolera handheld leve em medium/high (não whip)

def _wants_rhythm(f0, f1, imp, beat, active_ang):
    if beat in RHYTHM_BEATS:
        return True
    # fim da cerimônia (~últimos 18%): cumprimentos/padrinhos (VLM marca "vows")
    if f0 >= int(0.82 * END):
        return True
    # content map: padrinhos/cumprimentos no hold (qualquer cam)
    try:
        cmap = V.content_at(f0, f1) or {}
        for hits in cmap.values():
            if set(hits or []) & RHYTHM_GATE_CONTENT:
                return True
    except Exception:
        pass
    return False

def _rhythm_inserts(active_ang, f0, f1):
    """Candidatos de insert estável na alt; greedy por score (estabilidade + conteúdo)."""
    cands = []
    for c in V.CAMS:
        an = V.CAM_ANGLE[c]
        if an == active_ang:
            continue
        t = f0
        while t + MIN_INS_RHYTHM <= f1:
            best = None  # (score, t0, t1, an, p80)
            # prefere o insert MAIS LONGO ainda estável neste t (menos fragmenta)
            for L in range(MAX_INS_RHYTHM, MIN_INS_RHYTHM - 1, -30):
                w1 = t + L
                if w1 > f1 or not V.covers(COV, an, t, w1):
                    continue
                jc = p80(c, t, w1)
                if jc >= RHYTHM_P80:
                    continue
                bonus = 0.0
                try:
                    for hits in (V.content_at(t, w1) or {}).values():
                        if set(hits or []) & RHYTHM_RANK_CONTENT:
                            bonus = 2.0
                            break
                except Exception:
                    pass
                sc = -jc + bonus
                if best is None or sc > best[0]:
                    best = (sc, t, w1, an, jc)
                break  # já pegou o mais longo ok neste t
            if best is not None:
                cands.append(best)
            t += STEP_RHYTHM
    cands.sort(reverse=True)  # score desc
    picked = []  # (t0, t1, an)
    for sc, a0, a1, an, jc in cands:
        if any(not (a1 <= p0 or a0 >= p1) for p0, p1, _ in picked):
            continue  # overlap
        if any(abs(a0 - p0) < GAP_RHYTHM for p0, p1, _ in picked):
            continue  # inserts demais perto
        # flancos da base ≥ MIN (evita sliver na borda do hold)
        if a0 - f0 < MIN_INS_RHYTHM and a0 > f0:
            continue
        if f1 - a1 < MIN_INS_RHYTHM and a1 < f1:
            continue
        picked.append((a0, a1, an))
    picked.sort()
    return picked

n_rh = 0
rh_log = []
for f0, f1, a in log:
    span = f1 - f0
    _b = beat_at(f0)
    beat, imp = _b[2], _b[3]
    if span <= MAX_HOLD_RHYTHM or not _wants_rhythm(f0, f1, imp, beat, a):
        rh_log.append([f0, f1, a])
        continue
    cam = V.ANGLE_CAM[a]
    ca = set(V.content_at(f0, f1).get(cam, []) or [])
    if ca & HARD_CONTENT_PROTECT:
        rh_log.append([f0, f1, a])
        continue
    inserts = _rhythm_inserts(a, f0, f1)
    if not inserts:
        rh_log.append([f0, f1, a])
        continue
    cursor = f0
    for i0, i1, ian in inserts:
        if i0 > cursor:
            rh_log.append([cursor, i0, a])
        rh_log.append([i0, i1, ian])
        n_rh += 1
        cursor = i1
    if cursor < f1:
        rh_log.append([cursor, f1, a])
log = to_log([{"f0": a, "f1": b, "angle": c} for a, b, c in rh_log if b > a])
if n_rh:
    print(f"[item6c3 long-hold-rhythm] {n_rh} inserts estáveis em holds longos (cumprimentos/padrinhos/fim/entradas)")
    log = floor_merge(log, protect_fn=_protect_final if LSPANS else None)

# v0.4.1: re-settle depois de long-hold/rhythm (novos cortes nascem depois do settle cedo).
log = apply_settle(log, tag="-late")

# 6e. FINE-CUT (v0.3.4c): localiza margem + aplica SÓ decisões do critic VLM.
# Lições:
#   0.3.4a — weak/edge auto matou corredores (mff baixo de propósito).
#   0.3.4b — empty nativo auto matou wide com convidados + detalhe de mãos
#            (mff=0 sem rosto ≠ chão; facecache não vê mãos/alianças).
# Nativo LOCALIZA candidatos empty; VLM (finecut_judge.py → finecut_swaps.json)
# decide KEEP|SWAP com “na dúvida KEEP”. Sem JSON de swaps = zero troca (seguro).
def _face_pack(cam, w0, w1):
    """mff_med, face_frac, sad_max, joy_max."""
    w = V.face_window(face, cam, w0, w1)
    if not w or not len(w.get("mff", [])):
        return 0.0, 0.0, 0.0, 0.0
    mf = w["mff"]
    med = float(np.median(mf))
    frac = float(np.mean([1.0 if (x and x > 0.05) else 0.0 for x in mf]))
    sad = float(np.max(w["sad"])) if len(w.get("sad", [])) else 0.0
    joy = float(np.max(w["joy"])) if len(w.get("joy", [])) else 0.0
    return med, frac, sad, joy

if ENABLE_TRASH_POLISH:
    EMPTY_MFF = 0.02
    EMPTY_FRAC = 0.15
    WEAK_MFF = 0.07
    ALT_MFF = 0.08
    EDGE_P80_LO, EDGE_P80_HI = 0.85, 1.40
    ALT_STABLE = 0.50
    EDGE_GAP = 0.40
    MIN_TRASH = int(1.5 * 30)
    MAX_TRASH = int(30 * 30)

    # swaps do critic VLM (finecut_judge.py) — replay determinístico
    _fc_swaps = []
    if _os.path.exists("finecut_swaps.json"):
        try:
            _fc_swaps = json.load(open("finecut_swaps.json")) or []
        except Exception:
            _fc_swaps = []

    def _fc_hit(f0, f1, a):
        for s in _fc_swaps:
            if abs(int(s.get("f0", -1)) - f0) < 8 and abs(int(s.get("f1", -1)) - f1) < 8:
                if int(s.get("from", a)) == a:
                    return int(s["to"])
        return None

    trash_border, trash_kept, trash_vlm = [], [], []
    n_tp = 0
    out_tp = []
    for f0, f1, a in log:
        span = f1 - f0
        # aplica swap VLM mesmo se fora da rede (JSON manda)
        to_vlm = _fc_hit(f0, f1, a)
        if to_vlm is not None and to_vlm != a:
            trash_vlm.append({"f0": f0, "f1": f1, "from": a, "to": to_vlm, "kind": "vlm_swap"})
            out_tp.append([f0, f1, to_vlm])
            n_tp += 1
            continue
        if span < MIN_TRASH or span > MAX_TRASH:
            out_tp.append([f0, f1, a])
            continue
        cam = V.ANGLE_CAM[a]
        ja = p80(cam, f0, f1)
        m_act, frac_act, sad_act, joy_act = _face_pack(cam, f0, f1)

        best = None
        for c in V.CAMS:
            an = V.CAM_ANGLE[c]
            if an == a or not V.covers(COV, an, f0, f1):
                continue
            m_alt, fr_alt, _, _ = _face_pack(c, f0, f1)
            jc = p80(c, f0, f1)
            sc = m_alt * 2.0 - jc
            if best is None or sc > best[0]:
                best = (sc, an, m_alt, fr_alt, jc)

        kind = None
        if m_act < EMPTY_MFF and frac_act < EMPTY_FRAC:
            kind = "empty"
        elif (EDGE_P80_LO <= ja <= EDGE_P80_HI and best is not None
              and best[4] < ALT_STABLE and (ja - best[4]) >= EDGE_GAP):
            kind = "edge_shake"
        elif (m_act < WEAK_MFF and best is not None
              and best[2] >= 0.14 and (best[2] - m_act) >= 0.08):
            kind = "weak_subject"

        # v0.4.2: tremor FORTE no SPAN inteiro → pode trocar o hold.
        # Só CANTOS ruins (head/tail) → APARA o canto (alt cobre o tremor), MIOLO do insert FICA.
        # B-roll com tremor leve no meio → NÃO mexe.
        hard_shake = (ja >= 1.40 and best is not None and best[4] <= 0.40
                      and (ja - best[4]) >= 0.90 and best[2] >= 0.12 and span <= int(14 * 30))
        edge_ok = (kind == "edge_shake" and best is not None
                   and best[4] <= 0.45 and (ja - best[4]) >= 0.50
                   and best[2] >= 0.10 and span <= int(8 * 30))
        ew = min(BROLL_EDGE_WIN, max(15, span // 4))
        j_head = p80(cam, f0, min(f1, f0 + ew)) if span > ew else ja
        j_tail = p80(cam, max(f0, f1 - ew), f1) if span > ew else ja
        head_hot = j_head >= BROLL_P80_EDGE
        tail_hot = j_tail >= BROLL_P80_EDGE
        span_hot = ja >= BROLL_P80_KILL
        _js_now = JSPANS if "JSPANS" in dir() else []
        in_judge = any(js0 < f1 and js1 > f0 for js0, js1 in _js_now)
        alt_ok = (best is not None and best[4] <= 0.50 and V.covers(COV, best[1], f0, f1))

        # 1) só cantos quentes + miolo ok → trim de edge (não joga fora o insert)
        if (ENABLE_EDGE_SHAKE_AUTO and not in_judge and alt_ok and not span_hot
                and (head_hot or tail_hot) and span >= int(4 * 30)):
            mid0, mid1 = f0, f1
            carved = False
            # head: alt cobre o tremor; depois volta pro insert
            if head_hot and (f1 - (f0 + ew)) >= int(2.0 * 30):
                out_tp.append([f0, f0 + ew, best[1]])
                mid0 = f0 + ew
                carved = True
            # tail
            if tail_hot and (f1 - ew - mid0) >= int(2.0 * 30):
                mid1 = f1 - ew
                carved = True
            if carved and mid1 > mid0:
                out_tp.append([mid0, mid1, a])  # miolo = b-roll/insert original
                if mid1 < f1:
                    out_tp.append([mid1, f1, best[1]])
                trash_vlm.append({
                    "f0": f0, "f1": f1, "from": a, "to": best[1], "kind": "edge_trim_auto",
                    "p80": round(ja, 2), "p80_head": round(j_head, 2), "p80_tail": round(j_tail, 2),
                    "mff": round(m_act, 3), "note": "v0.4.2: só cantos pra alt; miolo mantido",
                })
                n_tp += 1
                continue
            # se não coube trim, cai no fluxo abaixo

        # 2) span inteiro muito tremido → swap total (ou edge_ok clássico em span curto sem b-roll)
        if ENABLE_BROLL_PROTECT and m_act >= BROLL_MFF_KEEP and not span_hot:
            edge_ok = False
            hard_shake = False
        if (ENABLE_EDGE_SHAKE_AUTO and (edge_ok or hard_shake) and not in_judge):
            kauto = "hard_shake" if hard_shake and kind != "edge_shake" else (kind or "hard_shake")
            rec = {
                "f0": f0, "f1": f1, "angle": a, "kind": kauto,
                "p80": round(ja, 2), "mff": round(m_act, 3),
                "face_frac": round(frac_act, 2),
                "sad": round(sad_act, 2), "joy": round(joy_act, 2),
                "note": "auto full-span edge/hard v0.4.2", "to": best[1],
                "p80_head": round(j_head, 2), "p80_tail": round(j_tail, 2),
            }
            if best is not None:
                rec.update({"alt": best[1], "mff_alt": round(best[2], 3), "p80_alt": round(best[4], 2)})
            trash_vlm.append({**rec, "from": a, "to": best[1], "kind": kauto + "_auto"})
            out_tp.append([f0, f1, best[1]])
            n_tp += 1
            continue

        if kind is None:
            out_tp.append([f0, f1, a])
            continue

        rec = {
            "f0": f0, "f1": f1, "angle": a, "kind": kind,
            "p80": round(ja, 2), "mff": round(m_act, 3),
            "face_frac": round(frac_act, 2),
            "sad": round(sad_act, 2), "joy": round(joy_act, 2),
            "note": "margin — sem auto nativo; só VLM finecut_swaps.json troca",
        }
        if best is not None:
            rec.update({"alt": best[1], "mff_alt": round(best[2], 3), "p80_alt": round(best[4], 2)})

        # empty com alt = candidato ao critic; resto fica audit
        if kind == "empty" and best is not None:
            trash_border.append(rec)
        else:
            trash_kept.append(rec)
        out_tp.append([f0, f1, a])  # empty/weak: NUNCA auto nativo
    log = to_log([{"f0": a, "f1": b, "angle": c} for a, b, c in out_tp if b > a])
    n_edge_auto = sum(1 for x in trash_vlm if x.get("kind") in (
        "edge_shake_auto", "hard_shake_auto", "edge_trim_auto"))
    json.dump(
        {
            "vlm_swaps_applied": trash_vlm,
            "empty_candidates": trash_border,
            "other_margin": trash_kept,
            "n_vlm": n_tp,
            "n_edge_shake_auto": n_edge_auto,
        },
        open("trash_audit.json", "w"), indent=1,
    )
    print(
        f"[item6e fine-cut] {n_tp} swaps (VLM+edge_auto); edge_auto={n_edge_auto}; "
        f"{len(trash_border)} empty-cands audit; {len(trash_kept)} other-margin"
    )
    # re-settle: edge_auto troca o span inteiro; vizinhos podem nascer com head tremido
    log = apply_settle(log, tag="-post-trash")

cps=to_cps(log)
json.dump({"cutpoints":cps},open("versions/UNI.json","w"))
m=V.metrics(c2s(cps),COV,jerk)
print("UNI métricas:",json.dumps(m,ensure_ascii=False))
print(f"zoomcache cams: {list(ZOOM.keys())}")
