#!/usr/bin/env python3
"""DECUPAGEM AUTOMÁTICA (do-zero, qualquer casamento) — passo 1 do modelo, antes do sync.
Varre a pasta do casamento: acha CÂMERAS (pastas de vídeo) e GRAVADORES (wav). Por câmera,
agrupa clips por gaps de creation_time (= SESSÕES de gravação), e o VLM (Kimi) rotula 1 frame por
sessão (cerimônia/making-of/festa/ensaio) — a(s) sessão(ões) CERIMÔNIA definem o range da cam.
Áudio: classifica conteúdo (fala vs música) + duração -> espinha (música contínua) + voz (fala).
READ-ONLY (só ffprobe + 1 frame/sessão). Saída: decupagem_<nome>.json (formato do modelo).
Uso: auto_decupagem.py "/Volumes/.../Casamento X" "Nome do Casal" [--gap-min 15]"""
import os, sys, json, re, subprocess, argparse, glob
from vlm import ask_image
from sync_judge import PROMPT as JUDGE_PROMPT
import sync_ingest as si
import cache_manifest as cm
import wedslug

VID_EXT = (".mp4", ".mov", ".MP4", ".MOV")
AUX_HINT = ("drone", "avata", "dji", "aerial")
SCRATCH = os.path.join(si.SCRATCH, "decup")


# PORT VideoTask: usa caminho absoluto para ffmpeg/ffprobe (subprocess do Swift não herda PATH)
FFMPEG = "/opt/homebrew/bin/ffmpeg"
FFPROBE = "/opt/homebrew/bin/ffprobe"


def ffmeta(p):
    r = subprocess.run([FFPROBE, "-v","error","-show_entries",
                        "format=duration:format_tags=creation_time","-of","json",p],
                       capture_output=True, text=True)
    try:
        j = json.loads(r.stdout)["format"]
        dur = float(j.get("duration", 0))
        ct = j.get("tags", {}).get("creation_time")
        cte = None
        if ct:
            from datetime import datetime
            try: cte = datetime.fromisoformat(ct.replace("Z","+00:00")).timestamp()
            except ValueError: pass
        return dur, cte
    except Exception:
        return 0.0, None


def frame(src, ss, dst):
    if os.path.exists(dst): return dst
    subprocess.run([FFMPEG,"-v","error","-ss",str(ss),"-i",src,"-frames:v","1",
                    "-vf","scale=320:-1","-y",dst], capture_output=True)
    return dst if os.path.exists(dst) else None


def vlm_scene(jpg):
    import time
    # RETRY: Kimi rate-limit/rede/vazio é transiente. VLM falho ("?") não pode dropar uma cam inteira
    # (Nathália: cam2_rafa sumiu num run por respostas vazias). Tenta de novo antes de desistir.
    for _try in range(3):
        txt,_ = ask_image(jpg, JUDGE_PROMPT)
        m = re.search(r'\{.*\}', txt, re.S)
        try: v = json.loads(m.group(0)) if m else {}
        except Exception: v = {}
        if v.get("scene"):
            return v.get("scene"), v.get("guests")
        time.sleep(2)
    return "?", None


def clip_num(name):
    m = re.search(r'(\d{3,5})', os.path.splitext(os.path.basename(name))[0])
    return int(m.group(1)) if m else 0


def _normalize_cam_folder_name(d):
    """Nome normalizado pra comparar pastas de câmera: remove tokens genéricos ('cam N',
    'carao N', dígitos soltos) e pontuação — o que sobra é a identidade (operador/pessoa). Ex:
    'Carao 1 Fabricio Cam 1' e 'Fabricio' normalizam ambos pra 'fabricio'."""
    s = d.lower()
    s = re.sub(r'\bcam\s*0*\d+\b', '', s)
    s = re.sub(r'\bcarao\s*0*\d+\b', '', s)
    s = re.sub(r'\d+', '', s)
    s = re.sub(r'[^a-z]+', '', s)
    return s


def _folder_time_bounds(vids):
    """(min_ct, max_ct) de creation_time dos vídeos da pasta — proba só 1º e último por ordem de
    nome (suficiente pra checar continuidade sem re-probar centenas de arquivos). `None` se
    nenhum tiver creation_time legível (não arrisca fusão sem dado)."""
    if not vids:
        return None
    samples = [vids[0], vids[-1]] if len(vids) > 1 else [vids[0]]
    cts = [c for c in (ffmeta(v)[1] for v in samples) if c]
    return (min(cts), max(cts)) if cts else None


def merge_same_camera_folders(cameras):
    """Funde 2+ pastas de câmera com nome normalizado IGUAL e que NÃO se sobrepõem no tempo
    (troca de cartão/pasta do MESMO operador durante o evento — descoberto no casamento
    Paula/Ronaldo: 'Fabricio' e 'Carao 1 Fabricio Cam 1' são a MESMA câmera física em 2 pastas
    separadas). GAP REAL: nem o palmier nem este script tinham essa lógica — 'cameras[d] =
    sorted(vids)' tratava 1 pasta = 1 câmera sempre, sem comparação nenhuma entre pastas.

    Critério decisivo é TEMPORAL, não só nome: se os intervalos de tempo das 2 pastas SE
    SOBREPÕEM, mantém separadas (2 câmeras simultâneas com nome parecido por coincidência não
    devem fundir — nome só agrupa CANDIDATOS, continuidade sem overlap é quem confirma "mesmo
    operador trocou de cartão", não "2 pessoas gravando ao mesmo tempo"). Pastas sem
    creation_time legível nunca fundem (não arrisca decisão sem dado)."""
    groups = {}
    for d in cameras:
        groups.setdefault(_normalize_cam_folder_name(d), []).append(d)

    merged = {}
    for folders in groups.values():
        if len(folders) == 1:
            merged[folders[0]] = cameras[folders[0]]
            continue

        bounds = {d: _folder_time_bounds(cameras[d]) for d in folders}
        with_time = sorted((d for d in folders if bounds[d]), key=lambda d: bounds[d][0])
        without_time = [d for d in folders if not bounds[d]]

        chains = []  # [{"folders": [...], "bounds": (lo, hi)}, ...] desta chave de nome
        for d in with_time:
            lo, hi = bounds[d]
            placed = False
            for chain in chains:
                glo, ghi = chain["bounds"]
                if hi < glo or lo > ghi:  # SEM overlap temporal -> mesma câmera, funde
                    chain["folders"].append(d)
                    chain["bounds"] = (min(glo, lo), max(ghi, hi))
                    placed = True
                    break
            if not placed:
                chains.append({"folders": [d], "bounds": (lo, hi)})

        for chain in chains:
            group_folders = chain["folders"]
            primary = group_folders[0]
            all_vids = sorted(v for d in group_folders for v in cameras[d])
            name = primary if len(group_folders) == 1 else f"{primary}+{'+'.join(group_folders[1:])}"
            merged[name] = all_vids
            if len(group_folders) > 1:
                print(f"  decup: fundindo câmeras (mesmo operador, sem overlap temporal): "
                      f"{group_folders} -> '{name}' ({len(all_vids)} clips)")

        for d in without_time:
            merged[d] = cameras[d]

    return merged


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("base"); ap.add_argument("casamento")
    ap.add_argument("--gap-min", type=float, default=15, help="gap de creation_time (min) = nova sessão")
    ap.add_argument("--out", default=None)
    a = ap.parse_args()
    os.makedirs(SCRATCH, exist_ok=True)
    base = a.base
    subs = [d for d in os.listdir(base) if os.path.isdir(os.path.join(base,d))]
    cameras, recorders, aux = {}, {}, []
    all_source_files = []  # TODO arquivo descoberto (qualquer classificação) — assinatura de cache
    for d in subs:
        p = os.path.join(base, d)
        vids = si.find_videos(p)   # recursivo + exclui Proxy/Remux + dedupe (mesmo finder do sync)
        wavs = glob.glob(os.path.join(p,"**","*.WAV"), recursive=True) + glob.glob(os.path.join(p,"**","*.wav"), recursive=True)
        all_source_files += vids + wavs
        if wavs and not vids:
            recorders[d] = wavs
        elif vids:
            if any(h in d.lower() for h in AUX_HINT) or len(vids) < 8:
                aux.append(d);
            else:
                cameras[d] = sorted(vids)
    cameras = merge_same_camera_folders(cameras)
    print(f"câmeras: {list(cameras)}\ngravadores: {list(recorders)}\naux: {aux}\n")

    # CACHE (backlog-29): decupagem nunca tinha cache — toda tentativa (inclusive retry depois de
    # falha transiente) reclassificava TODAS as sessões por VLM do zero, mesmo pasta/casamento
    # idênticos. Mesmo utilitário genérico já usado em sync_ingest.py (`cache_manifest`,
    # script_hash + assinatura mtime+size dos inputs) — não inventa esquema novo. Assinatura =
    # TODO arquivo de vídeo/áudio encontrado na pasta (qualquer câmera/gravador/aux): se QUALQUER
    # um for adicionado/removido/modificado, invalida e reclassifica do zero (nunca reusa cache
    # potencialmente incompleto). Editar este .py também invalida (script_hash).
    out = a.out or f"decupagem_{wedslug.slug(a.casamento)}.json"
    _decup_sig = cm.input_signature(sorted(set(all_source_files)))
    _decup_sig["gap_min"] = a.gap_min  # --gap-min diferente = classificação de sessão diferente
    if cm.is_fresh(".", "auto_decupagem", __file__, _decup_sig) and os.path.exists(out):
        print(f"[cache] auto_decupagem: pasta+script inalterados desde o último run — reusando {out}")
        return

    # ---- CÂMERAS: sessões por creation_time + VLM rotula ----
    cam_out = {}
    cer_tods = []  # tod (segundos-do-dia LOCAL) dos clips de CERIMÔNIA das cams — define a janela
    # de tempo p/ escolher a espinha certa (gravador que sobrepõe a cerimônia, não a festa longa)
    def _ct_tod(ct):  # epoch -> segundos-do-dia LOCAL (bate com o _HHMMSS do gravador)
        if not ct: return None
        from datetime import datetime
        lt = datetime.fromtimestamp(ct)
        return lt.hour*3600 + lt.minute*60 + lt.second
    # Número da cam: RESPEITAR o que a pasta/arquivo já define (cinegrafista já numerou "cam 1/2/3").
    # Só inventar índice sequencial quando não há número — senão vira cam5_cam3luis (redundante/errado).
    def _fold_cam_num(name):
        m = re.search(r'\bcam\s*0*(\d+)', name.lower())
        return int(m.group(1)) if m else None
    def _slug(name):  # tira o token "cam N" pra não repetir (cam3_cam3luis -> cam3_luis)
        return re.sub(r'[^a-z0-9]+', '', re.sub(r'\bcam\s*0*\d+', '', name.lower()))[:10]
    # pré-reserva os números que as PASTAS já definem, pra fallback não colidir com eles
    _used = set(filter(None, (_fold_cam_num(d) for d in cameras)))
    def _assign_num(name):
        n = _fold_cam_num(name)
        if n is None:                      # pasta sem número -> próximo livre
            n = 1
            while n in _used: n += 1
        _used.add(n); return n
    for d, vids in cameras.items():
        cam_num = _assign_num(d)
        meta = []
        for v in vids:
            dur, ct = ffmeta(v)
            meta.append({"name": os.path.basename(v), "src": v, "dur": dur, "ct": ct, "num": clip_num(v)})
        have_ct = [m for m in meta if m["ct"]]
        have_ct.sort(key=lambda m: m["ct"])
        # sessões: gap de ct > gap_min
        sessions, cur = [], []
        for m in have_ct:
            if cur and m["ct"] - cur[-1]["ct"] > a.gap_min*60:
                sessions.append(cur); cur = []
            cur.append(m)
        if cur: sessions.append(cur)
        if not sessions:  # sem ct: 1 sessão só (ordem de nome)
            sessions = [sorted(meta, key=lambda m: m["num"])]
        print(f"[{d}] {len(vids)} clips -> {len(sessions)} sessões")
        def label_clip(m):
            fr = frame(m["src"], min(2, m["dur"]/2), os.path.join(SCRATCH, f"{d}_{m['name']}.jpg"))
            sc, g = vlm_scene(fr) if fr else ("?", None)
            return sc in ("CEREMONY", "SAIDA"), sc
        cer_clips = []
        for s in sessions:
            s = sorted(s, key=lambda m: m["num"])
            nums = [m["num"] for m in s]; longest = max(m["dur"] for m in s)/60
            # MULTI-FRAME: 1 frame por sessão erra (sessão de 138min c/ cerimônia dentro deu ENSAIO —
            # lição Paula, perdeu cam luis). Amostra ~5 clips espalhados; se ALGUM é cerimônia, a
            # sessão é CANDIDATA -> refina POR CLIP (só na candidata, barato) p/ o range exato.
            idxs = sorted(set(int(i*(len(s)-1)/max(1,min(5,len(s))-1)) for i in range(min(5,len(s)))))
            votes = [label_clip(s[i]) for i in idxs]
            has_cer = any(v[0] for v in votes)
            scenes = "/".join(sorted({v[1] for v in votes}))
            print(f"   sessão C{min(nums)}-C{max(nums)} ({len(s)} clips, master {longest:.0f}min) "
                  f"amostra[{scenes}] {'✓CANDIDATA' if has_cer else ''}")
            if not has_cer:
                continue
            # refina com STRIDE LIMITADO (~12 amostras/sessão, NÃO todos os clips — senão 149 clips =
            # 149 VLM travava). Cerimônia é contígua: range = span das amostras-cerimônia, inclui os
            # clips no meio. Amostras já feitas (idxs) reaproveitadas.
            done = {s[i]["num"]: votes[k][0] for k,i in enumerate(idxs)}
            stride = max(1, len(s)//12)
            for i in range(0, len(s), stride):
                if s[i]["num"] not in done:
                    done[s[i]["num"]] = label_clip(s[i])[0]
            cer_nums = [n for n,ok in done.items() if ok]
            if cer_nums:
                lo_n, hi_n = min(cer_nums), max(cer_nums)
                run = [m for m in s if lo_n <= m["num"] <= hi_n]   # inclui o meio (contíguo)
                print(f"      -> cerimônia C{lo_n}-C{hi_n} ({len(run)} clips, {len(cer_nums)} amostras+)")
                cer_clips += run
        if cer_clips:
            cer_clips = sorted({m["num"]: m for m in cer_clips}.values(), key=lambda m: m["num"])
            nums = [m["num"] for m in cer_clips]
            cer_tods += [x for x in (_ct_tod(m.get("ct")) for m in cer_clips) if x is not None]
            longest = max(cer_clips, key=lambda m:m["dur"])
            cam_out[f"cam{cam_num}_{_slug(d)}"] = {
                # "pasta" pode ser VÁRIAS pastas reais separadas por "|" quando
                # `merge_same_camera_folders` fundiu 2+ pastas do mesmo operador (nome + sem
                # overlap temporal) — `d` codifica isso com "+" entre os nomes reais das pastas.
                # O lado Swift (BaldeCMotorService/EditorShellView) precisa fazer split("|") e
                # checar `hasPrefix` contra CADA uma ao resolver o arquivo real de um clip.
                "pasta": d.replace("+", "|"),
                "estilo": f"master {max(m['dur'] for m in cer_clips)/60:.0f}min" if max(m['dur'] for m in cer_clips)>300 else "run-and-gun",
                "cerimonia": f"C{min(nums)}-C{max(nums)}", "n_clips": len(cer_clips),
                "master_clip": longest["name"], "master_dur_min": round(longest["dur"]/60,1)}
        else:
            print(f"   (nenhuma cerimônia em {d})")

    # ---- GRAVADORES: ENCADEIA CHUNKS (gravador corta por tamanho -> track = vários arquivos
    # consecutivos), classifica conteúdo por GRUPO, espinha = grupo MÚSICA contínuo mais longo ----
    from classify_audio_content import classify_window
    def tod(fn):  # segundos-do-dia do timestamp _HHMMSS no nome (Paula/Nathália)
        m = re.search(r'_(\d{6})(?=\D|$)', os.path.basename(fn))
        if not m: return None
        s = m.group(1); h,mi,se = int(s[:2]),int(s[2:4]),int(s[4:6])
        return h*3600+mi*60+se if h<24 and mi<60 and se<60 else None
    # DEDUP de TRACKS PARALELAS (comum em Zoom/Tascam): a MESMA tomada é gravada em vários arquivos
    # (_Tr1_2 / _TrLR / _TrMic / _LR / _MIC) com o MESMO timestamp. Concatenar as N como sequenciais
    # infla a espinha N× (Nathália: ~186→559min) e estoura a RAM da audalign → travou a máquina.
    # Mesmo timestamp (na mesma pasta) = 1 gravação → escolhe UMA track (prefere LR/mix, evita Mic).
    def track_key(fn):
        m = re.search(r'(\d{6}_\d{6}|\d{8}_\d{6}|_\d{6})(?=\D|$)', os.path.basename(fn))
        return (os.path.dirname(fn), m.group(1)) if m else (os.path.dirname(fn), os.path.basename(fn))
    def track_pref(fn):
        b = os.path.basename(fn).lower()
        if "mic" in b: return 3          # mic onboard = pior qualidade
        if "lr" in b: return 0           # mix estéreo LR = melhor p/ espinha
        if "tr1_2" in b or "tr1" in b or "1_2" in b: return 1
        return 2
    def dedup_parallel(wavs):
        best = {}
        for w in wavs:
            k = track_key(w)
            if k not in best or track_pref(w) < track_pref(best[k]):
                best[k] = w
        return list(best.values())

    rec_out, groups = {}, []   # groups: {"files":[(dur,relpath,tod)], "kind", "total"}
    for d, wavs in recorders.items():
        wavs = dedup_parallel(wavs)
        items = []
        for w in wavs:
            dur,_ = ffmeta(w)
            if dur >= 30: items.append((tod(w) if tod(w) is not None else 0, dur, w))
        items.sort()
        cur = []
        for t, dur, w in items:
            if cur and t is not None and cur[-1][0] is not None and t - (cur[-1][0]+cur[-1][1]) > 120:
                groups.append((d, cur)); cur = []
            cur.append((t, dur, w))
        if cur: groups.append((d, cur))
    grp_info = []
    for d, g in groups:
        total = sum(x[1] for x in g)
        mid = g[len(g)//2][2]
        tags = []
        for frac in (0.3, 0.6):
            dur = ffmeta(mid)[0]
            try: r = classify_window(mid, int(dur*frac), 20)
            except Exception: r = None
            if r: tags.append(r)
        kind = "VOZ" if sum(1 for t in tags if t[0]=="FALA") >= 1 else "MUSICA"
        files = [os.path.relpath(x[2], base) for x in g]
        for f in files: rec_out[os.path.basename(f)] = {"pasta": d, "conteudo": kind, "grupo_total_min": round(total/60)}
        tlo = min(x[0] for x in g); thi = max(x[0] + x[1] for x in g)  # janela tod do grupo
        hint = any("cerimon" in os.path.basename(f).lower() for f in files)
        # chunks com tod/dur (pra recortar masters longos à janela da cerimônia)
        chunks = [{"file": os.path.relpath(x[2], base), "tod": x[0], "dur": x[1]} for x in g]
        grp_info.append({"pasta": d, "kind": kind, "total_min": round(total/60,1),
                         "files": files, "chunks": chunks,
                         "tod_lo": tlo, "tod_hi": thi, "cer_hint": hint})
        print(f"[grupo {d}] {len(g)} chunks, {total/60:.0f}min ({tlo//3600:02.0f}:{tlo%3600//60:02.0f}-{thi//3600:02.0f}:{thi%3600//60:02.0f}) -> {kind}{' [cerimonia no nome]' if hint else ''}")
    music = [g for g in grp_info if g["kind"]=="MUSICA"]
    voz_g = [g for g in grp_info if g["kind"]=="VOZ"]
    # ESPINHA = gravador que SOBREPÕE a cerimônia das cams (não o mais longo — a festa da noite é
    # longa e vencia). Sinais: (1) overlap tod com a janela da cerimônia das cams; (2) "cerimonia" no
    # nome do arquivo (cinegrafista rotulou). João: sync é o árbitro, mas aqui pré-seleciona por
    # tempo pra nem carregar o gravador errado. Overlap é geral (tz não importa: ct local vs _HHMMSS
    # local). Fallback: sem janela/overlap, o mais longo (comportamento antigo) + aviso.
    # janela ROBUSTA (percentil 10-90) — min/max é poluído por clip mislabel do VLM (1 clip de festa
    # marcado cerimônia esticava a janela pro dia todo → tudo "sobrepunha"). Percentil rejeita outlier.
    def _pct(xs, p):
        s = sorted(xs); return s[min(len(s)-1, int(p*len(s)))]
    cer_lo, cer_hi = (_pct(cer_tods, 0.10), _pct(cer_tods, 0.90)) if cer_tods else (None, None)
    def overlaps(g):
        if cer_lo is None: return False
        return g["tod_lo"] <= cer_hi and g["tod_hi"] >= cer_lo
    # duração PLAUSÍVEL de ESPINHA única: ~15-150min. >150 = ambiente do dia (não 1 cerimônia).
    # Masters N: grupos longos multi-chunk AINDA entram como candidatos se overlappam a
    # cerimônia — master_ingest tenta audalign; se falhar, descarta. v0.4 (Nathália/Gleydson):
    # som1/som2 265–420min sumiam do masters_cerimonia por plausible cap e nunca tentavam sync.
    def plausible(g): return 15 <= g["total_min"] <= 150
    def master_candidate(g):
        """Espinha curta OU (overlap/cer_hint e ≥15min) — tenta sync depois."""
        if plausible(g):
            return True
        if g["total_min"] < 15:
            return False
        return bool(overlaps(g) or g["cer_hint"])
    def spine_score(g):
        return (1 if (overlaps(g) and plausible(g)) else 0, 1 if g["cer_hint"] and plausible(g) else 0,
                1 if plausible(g) else 0, g["total_min"] if plausible(g) else -g["total_min"])
    if cer_tods:
        print(f"janela cerimônia (cams, p10-p90): {cer_lo//3600:02d}:{cer_lo%3600//60:02d}-{cer_hi//3600:02d}:{cer_hi%3600//60:02d}")
    cand = [g for g in music if (overlaps(g) and plausible(g)) or (g["cer_hint"] and plausible(g))] or \
           [g for g in music if plausible(g)] or music
    spine_group = max(cand, key=spine_score, default=None)
    if spine_group and not (overlaps(spine_group) and plausible(spine_group)) and cer_tods:
        print("  ⚠️ nenhum grupo música sobrepõe a cerimônia c/ duração plausível — usando fallback (confira).")
    voz_group = max(voz_g, key=lambda g: (1 if g["cer_hint"] else 0, 1 if overlaps(g) else 0, g["total_min"]), default=None)
    # ESPINHA por TRANSCRIPT (conteúdo) — robusto a música sob a voz do celebrante (espectro fala-vs-
    # música ERRA na cerimônia BR). Precisa do app (inspect_media on-device). Fallback: método espectral.
    # Decisão por GRUPO (soma cer/festa de todos os chunks plausíveis do gravador), score CONTÍNUO
    # (cer-festa), não por 1 chunk isolado com threshold binário — 1 chunk na fronteira (cer=1 vs
    # cer=2) fazia a espinha PULAR de gravador entre runs (transcript on-device não é 100%
    # determinístico). Agregar por grupo estabiliza sem perder o sinal (João: "robusto pros 2 cenários").
    _ts_spine = _ts_voz = None
    try:
        import classify_recorders as _cr
        _grp_score = {}  # id(group) -> (cer_sum, festa_sum, first_cerimonia_chunk)
        for _d, _g in groups:
            _gcer = _gfesta = 0
            _gfirst = None
            for _tod, _dur, _wav in _g:
                if not (15*60 <= _dur <= 150*60):
                    continue
                _c = _cr.classify_chunk(_wav)
                if not _c:
                    continue
                print(f"  [transcript] {os.path.basename(_wav)}: {_c['label']} (cer={_c['cer']} festa={_c['festa']})")
                _gcer += _c["cer"]; _gfesta += _c["festa"]
                if _c["label"] in ("cerimonia", "fala-indef") and _c["cer"] >= 1 and _gfirst is None:
                    _gfirst = _wav
            if _gfirst is not None:
                _grp_score[id((_d, _g))] = (_gcer, _gfesta, _gfirst, (_d, _g))
        if _grp_score:
            _best_id = max(_grp_score, key=lambda k: _grp_score[k][0] - _grp_score[k][1])
            _bcer, _bfesta, _bfirst, _bgrp = _grp_score[_best_id]
            if _bcer > _bfesta:  # grupo tem que ser NET cerimônia (mais celebrante que festa)
                _ts_spine = os.path.relpath(_bfirst, base)
                _ts_voz = _ts_spine
                print(f"  ESPINHA por transcript (score grupo cer={_bcer} festa={_bfesta}): {_ts_spine}")
    except Exception as _e:
        print(f"  [transcript] indisponível ({type(_e).__name__}) — fallback espectral")
    # espinha por transcript detecta 1 CHUNK (onde ouve o celebrante), mas o gravador corta a tomada
    # em vários arquivos -> pega o GRUPO INTEIRO desse chunk (concat no build), senão a música/espinha
    # entra 1 parte só (João: "música pegou só a parte 2"). Ordenado por timestamp (files já vem).
    if _ts_spine:
        _spine_grp = next((g for g in grp_info if _ts_spine in g["files"]), None) or spine_group
    else:
        _spine_grp = spine_group
    spine_files = _spine_grp["files"] if _spine_grp else ([_ts_spine] if _ts_spine else [])
    _spine_min = _spine_grp["total_min"] if _spine_grp else 0
    voz = _ts_voz or (voz_group["files"][0] if voz_group else None)
    # MASTERS: candidatos (overlap/cer_hint/espinha). Longos multi-chunk → trim à janela cer.
    # master_ingest tenta audalign nos não-espinha; rank fraco → não inclui (tentar, não desistir cedo).
    def trim_to_ceremony(g):
        g = dict(g)
        if g["total_min"] <= 150 or cer_lo is None or not g.get("chunks"):
            return g
        kept = [c for c in g["chunks"]
                if c["tod"] <= cer_hi and (c["tod"] + c["dur"]) >= cer_lo]
        if not kept:
            return g
        g["files"] = [c["file"] for c in kept]
        g["total_min"] = round(sum(c["dur"] for c in kept) / 60.0, 1)
        g["tod_lo"] = min(c["tod"] for c in kept)
        g["tod_hi"] = max(c["tod"] + c["dur"] for c in kept)
        print(f"  master-trim {g['pasta']}: {len(g.get('chunks', []))}ch → {len(kept)}ch "
              f"na janela cer ({g['total_min']:.0f}min)")
        return g
    cer_masters = [trim_to_ceremony(g) for g in grp_info if master_candidate(g)] \
        or [g for g in grp_info if plausible(g)] or ([_spine_grp] if _spine_grp else [])
    _seen, _ord = set(), []
    for g in ([_spine_grp] if _spine_grp else []) + cer_masters:
        k = g["pasta"] + "|" + (g["files"][0] if g["files"] else "")
        if g and k not in _seen: _seen.add(k); _ord.append(g)
    masters = [{"pasta": g["pasta"], "arquivos": g["files"], "chunks": len(g["files"]),
                "dur_min": g["total_min"], "espinha": (g is _spine_grp)} for g in _ord]
    print(f"MASTERS cerimônia: {len(masters)} gravador(es) -> " +
          ", ".join(f"{m['pasta'].split('/')[-1]}({m['chunks']}ch{'*' if m['espinha'] else ''})" for m in masters))
    decup = {
        "casamento": a.casamento, "base": base,
        "metodo": "auto_decupagem.py: sessões por creation_time + VLM rotula cerimônia; áudio por conteúdo (fala/música). READ-ONLY.",
        "cameras": cam_out,
        "aux": {x: "aux (drone/curto)" for x in aux},
        "gravadores_audio": {
            "voz_cerimonia": {"arquivo": voz, "via": "classificador conteúdo=FALA"} if voz else {},
            "_todos": rec_out},
        "masters_cerimonia": masters,  # TODOS os gravadores da cerimônia (modelo N-masters, João 05/07)
        "audio_cerimonia": {"arquivos": spine_files,   # = ESPINHA (compat: sync/placements usam spine_key)
                            "arquivo_espinha": _ts_spine,
                            "papel": "ESPINHA (grupo contínuo, chunks concatenados)",
                            "chunks": len(spine_files),
                            "dur_min": _spine_min},
    }
    out = a.out or f"decupagem_{wedslug.slug(a.casamento)}.json"
    json.dump(decup, open(out,"w"), indent=1, ensure_ascii=False)
    cm.mark_done(".", "auto_decupagem", __file__, _decup_sig)
    print(f"\n-> {out}  (cams: {list(cam_out)} | espinha: {len(spine_files)} chunks "
          f"{spine_group['total_min'] if spine_group else 0}min | voz: {voz})")


if __name__ == "__main__":
    main()
