#!/usr/bin/env python3
"""Corte seco no vídeo JÁ pronto: tira as pausas que sobraram entre as frases (references/corte-seco.md).

Como o corte é feito no vídeo final (imagem e som juntos, no mesmo quadro), legenda, animações e efeitos
continuam no lugar em relação à fala. Em cada pausa maior que --pausa-min, sobra só --pausa-max de silêncio.

Onde cortar dentro da pausa:
  - nunca na palavra: as bordas são medidas na onda (limiar pela voz do próprio vídeo) e ainda andam até 0,12 s
    pra fora enquanto houver som fraco (consoante fraca, fim de "s"), com folga de 0,06 s depois da palavra
    e 0,07 s antes da próxima;
  - no trecho em que a imagem menos muda: com --sem-grafismo, olha só os grafismos (render sem a pessoa);
    sem ele, olha a diferença entre quadros do próprio vídeo;
  - nunca no ataque de um efeito sonoro (--sfx com o plano de sons; com --voz, qualquer som que não seja voz).

Uso (<SKILL> é a pasta desta skill; o Python dela tem numpy):
    uv run --directory "<SKILL>" python helpers/corte_seco.py VIDEO_FINAL [--pausa-max 0.15]
        [--sem-grafismo RENDER_SO_GRAFISMO.mp4] [--sfx PLANO_SFX.json] [--voz AUDIO_SO_DA_VOZ.mp4] [--cortar 41.06-41.76,...]
        [--saida SAIDA.mp4] [--pausa-min 0.25] [--inicio 0.08] [--limiar -42] [--so-plano] [--crf 16]

Saída: o vídeo cortado (-14 LUFS, 2 passadas, linear) e SAIDA.corte.json com os cortes e o mapa de tempo.
"""
from __future__ import annotations

import argparse
import json
import math
import os
import shutil
import subprocess
import sys
import tempfile
from fractions import Fraction
from pathlib import Path

try:
    import numpy as np
except ImportError:  # pragma: no cover
    sys.exit('Falta o numpy. Rode com o Python da skill: uv run --directory "<SKILL>" python helpers/corte_seco.py ...')

HOP = 0.01            # envelope de 10 ms
PRE0, POS0 = 0.06, 0.07   # folga mínima depois do fim da palavra / antes da próxima (borda no limiar)
POS1 = 0.04           # folga antes do começo "de verdade" da próxima palavra (borda estendida pelo som fraco)
ANDAR = 0.12          # quanto a borda da próxima palavra anda pra trás enquanto tiver som fraco (o "S" de "Só")
FADE = 0.008          # micro fade na emenda do áudio (sem estalo)
SR_ENV = 16000


def erro(msg: str, code: int = 2):
    print("ERRO: " + msg, file=sys.stderr)
    sys.exit(code)


def aviso(msg: str):
    print("aviso: " + msg)


def run(cmd: list, texto=True, **kw):
    r = subprocess.run(cmd, capture_output=True, text=texto, **kw)
    if r.returncode:
        err = r.stderr if texto else r.stderr.decode(errors="replace")
        erro("o ffmpeg falhou:\n" + err[-2000:])
    return r


# ------------------------------------------------------------------ leitura
def sonda(p: str) -> dict:
    r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                        "stream=index,codec_type,r_frame_rate,avg_frame_rate,width,height,duration,start_time,"
                        "sample_rate,channels,nb_frames,color_space,color_primaries,color_transfer,color_range"
                        ":format=duration", "-of", "json", p], capture_output=True, text=True)
    if r.returncode:
        erro(f"não consegui abrir {p}")
    d = json.loads(r.stdout)
    v = next((s for s in d.get("streams", []) if s.get("codec_type") == "video"), None)
    a = next((s for s in d.get("streams", []) if s.get("codec_type") == "audio"), None)
    fmt = float(d.get("format", {}).get("duration", 0) or 0)
    info = dict(formato=fmt, v=v, a=a)
    if v:
        fr = Fraction(v.get("r_frame_rate") or v.get("avg_frame_rate") or "30/1")
        if fr <= 0:
            fr = Fraction(30, 1)
        info["fps"] = fr
        info["dur_v"] = float(v.get("duration") or fmt)
        info["v0"] = float(v.get("start_time") or 0)
        info["w"], info["h"] = int(v.get("width", 0)), int(v.get("height", 0))
    if a:
        info["dur_a"] = float(a.get("duration") or fmt)
        info["a0"] = float(a.get("start_time") or 0)
        info["sr"] = int(a.get("sample_rate", 48000))
        info["ch"] = int(a.get("channels", 2))
    return info


def audio_mono(p: str, sr: int = SR_ENV) -> "np.ndarray":
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", p, "-vn", "-ac", "1", "-ar", str(sr), "-f", "f32le",
                          "-acodec", "pcm_f32le", "-"], capture_output=True).stdout
    return np.frombuffer(raw, dtype=np.float32)


def env_db(x: "np.ndarray", sr: int = SR_ENV, hop: float = HOP) -> "np.ndarray":
    w = int(sr * hop)
    n = len(x) // w
    return 10 * np.log10((x[: n * w].astype(np.float64).reshape(n, w) ** 2).mean(axis=1) + 1e-12)


def quadros(p: str, largura: int, altura: int):
    """lê o vídeo quadro a quadro em cinza, pequeno, sem guardar tudo na memória"""
    proc = subprocess.Popen(["ffmpeg", "-v", "error", "-i", p, "-an", "-vf",
                             f"scale={largura}:{altura}:flags=area,format=gray", "-f", "rawvideo", "-"],
                            stdout=subprocess.PIPE)
    n = largura * altura
    while True:
        buf = proc.stdout.read(n)
        if len(buf) < n:
            break
        yield np.frombuffer(buf, dtype=np.uint8)
    proc.wait()


def atividade(p: str, w: int, h: int, guardar: bool = False):
    """atividade[i] = quanto o quadro i mudou em relação ao anterior. Com guardar=True devolve também os quadros
    miniatura (40 px) pra conferência do corte."""
    lw = 96
    lh = max(2, int(round(lw * h / max(1, w) / 2)) * 2)
    act, mini, ant = [], [], None
    for f in quadros(p, lw, lh):
        g = f.astype(np.int16)
        act.append(0.0 if ant is None else float(np.abs(g - ant).mean()))
        ant = g
        if guardar:
            mini.append(f.reshape(lh, lw)[::2, ::2].copy())
    return np.array(act), (np.stack(mini) if guardar and mini else None)


def carregar_sfx(p: str) -> list:
    """intervalos [a, b] onde não pode cortar: do ataque ao pico de cada som (+0,1 s)"""
    try:
        d = json.loads(Path(p).read_text())
    except (OSError, json.JSONDecodeError) as e:
        erro(f"não consegui ler o plano de sons {p}: {e}")
    itens = d.get("sons", d.get("eventos", [])) if isinstance(d, dict) else d
    out = []
    for s in itens:
        pico = s.get("pico", s.get("peak", s.get("t")))
        if pico is None:
            continue
        pico = float(pico)
        ini = float(s["inicio"]) if "inicio" in s else pico - 0.06
        out.append((max(ini, pico - 0.3) - 0.04, pico + 0.06))
    return out


def intervalos(mask: "np.ndarray", hop: float = HOP) -> list:
    out, i = [], 0
    while i < len(mask):
        if mask[i]:
            j = i
            while j < len(mask) and mask[j]:
                j += 1
            out.append((i * hop, j * hop))
            i = j
        else:
            i += 1
    return out


def lag_ms(a: "np.ndarray", b: "np.ndarray", t_a: float, t_b: float, meio: float = 0.25, busca: float = 0.05,
           sr: int = SR_ENV) -> float | None:
    """deslocamento (ms) do trecho de b em t_b em relação ao de a em t_a (janela de 2*meio s)"""
    i, j, L, w = int(t_a * sr), int(t_b * sr), int(busca * sr), int(meio * sr)
    x = a[i - w:i + w]
    y = b[j - w - L:j + w + L]
    if i - w < 0 or j - w - L < 0 or len(x) < 2 * w or len(y) < 2 * w + 2 * L or float((x ** 2).mean()) < 1e-7:
        return None
    c = np.correlate(y, x, mode="valid")
    return (int(c.argmax()) - L) / sr * 1000


def loudness(p: str, af: str = "") -> dict:
    r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", p, "-vn", "-af",
                        af + "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
                       capture_output=True, text=True).stderr
    try:
        return json.loads(r[r.rindex("{"): r.rindex("}") + 1])
    except ValueError:
        erro(f"não consegui medir o volume de {p}")


def cadeia_loudnorm(wav: str, alvo: float = -14.0, tp: float = -1.0) -> tuple[str, dict]:
    """-14 LUFS em 2 passadas, sempre linear. Se o pico real passaria de -1 dBTP, um limitador com o atraso
    compensado (latency=1, não desloca o áudio) segura só os picos antes."""
    j = loudness(wav)
    ganho = alvo - float(j["input_i"])
    pre = ""
    if float(j["input_tp"]) + ganho > tp - 0.2:
        lim = 10 ** ((tp - ganho - 1.0) / 20)
        pre = f"alimiter=limit={max(0.0625, min(1.0, lim)):.5f}:attack=5:release=60:level=false:latency=1,"
        j = loudness(wav, pre)
    af = (pre + f"loudnorm=I={alvo}:TP={tp}:LRA=11:measured_I={j['input_i']}:measured_TP={j['input_tp']}"
          f":measured_LRA={j['input_lra']}:measured_thresh={j['input_thresh']}:offset={j['target_offset']}"
          ":linear=true:print_format=json,aresample=48000")
    return af, j

def finalizar(video: str, wav: str, saida: str) -> tuple[dict, str, dict]:
    """junta a imagem (copiada) com o áudio em -14 LUFS e confere o pico real DEPOIS do AAC: o codificador pode
    passar do teto em até ~2 dB. Se passou de -0,5 dBTP, refaz com o teto mais baixo (um limitador leve só nos
    picos; o volume integrado continua -14 LUFS)."""
    tp = -1.0
    for tentativa in range(2):
        af, j = cadeia_loudnorm(wav, tp=tp)
        r = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", video, "-i", wav, "-map", "0:v:0",
                            "-map", "1:a:0", "-c:v", "copy", "-af", af, "-c:a", "aac", "-b:a", "256k",
                            "-movflags", "+faststart", saida], capture_output=True, text=True)
        if r.returncode:
            erro("o ffmpeg falhou ao gravar o vídeo final:\n" + r.stderr[-1500:])
        tipo = "linear"
        try:
            tipo = json.loads(r.stderr[r.stderr.rindex("{"): r.stderr.rindex("}") + 1]).get("normalization_type",
                                                                                           "linear")
        except ValueError:
            pass
        fim = loudness(saida)
        if float(fim["input_tp"]) <= -0.5:
            break
        tp = round(tp - (float(fim["input_tp"]) + 0.8), 2)
    return j, tipo, fim



# ------------------------------------------------------------------ plano
def planejar(a, info: dict, det: "np.ndarray", mix_env: "np.ndarray", usa_voz: bool, act: "np.ndarray",
             bloqueios: list, n_quadros: int) -> dict:
    fps = float(info["fps"])
    fd = 1.0 / fps
    floor5, floor10, fala = (float(np.percentile(det, q)) for q in (5, 10, 90))
    thr = a.limiar if a.limiar is not None else max(floor5 + 10, fala - 30)
    fraco = max(floor10 + 6, thr - 12)
    if fala - floor5 < 40 and a.limiar is None:
        aviso(f"a diferença entre a fala ({fala:.0f} dB) e o fundo ({floor5:.0f} dB) é pequena. Se o vídeo já tem "
              "trilha, rode o corte seco ANTES da trilha (ou passe --voz com o áudio só da voz).")
    sil = det < thr
    dur_det = len(det) * HOP
    gaps = [(x, y) for x, y in intervalos(sil) if (y - x) >= a.pausa_min - 1e-6 and y < dur_det - 0.05]
    if not gaps and not a.cortar:
        print("nenhuma pausa maior que", a.pausa_min, "s: nada pra cortar")

    # bloqueios: ataque dos efeitos sonoros do plano + (com --voz) o ataque de qualquer som que não é voz.
    # A cauda de um efeito pode ser cortada (a emenda tem micro fade); o ataque não.
    blq = list(bloqueios)
    if usa_voz:
        n = min(len(det), len(mix_env))
        blq += [(x - 0.04, min(y, x + 0.10)) for x, y in intervalos((mix_env[:n] > thr) & (det[:n] < thr))
                if y - x >= 0.03]

    def bloqueado(s0, s1):
        return any(s0 < y and s1 > x for x, y in blq)

    def k_env(t):
        return min(len(det) - 1, max(0, int(t / HOP)))

    cortes, pulados = [], []
    for x, y in gaps:
        ini = x < 0.02
        # bordas na onda: a palavra seguinte pode começar com consoante fraca ("S", "F") que o limiar não pega.
        # Anda pra trás até 0,12 s enquanto houver som fraco e protege esse trecho também.
        y2 = y
        while y2 > y - ANDAR and det[k_env(y2 - HOP)] > fraco:
            y2 -= HOP
        if ini:   # começo do vídeo: entra quase direto na 1ª palavra
            k_lo, k_fim = 0, math.floor((y2 - a.inicio) * fps + 1e-6)
            nL = k_fim
        else:
            lo = x + PRE0
            hi = min(y - POS0, y2 - POS1)
            k_lo, k_fim = math.ceil(lo * fps - 1e-6), math.floor(hi * fps + 1e-6)   # quadros onde o corte cabe
            nL = min(math.floor(((y - x) - a.pausa_max) * fps + 1e-6), k_fim - k_lo)
        if nL < 2:
            pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="pausa curta demais depois das folgas"))
            continue

        def busca(nL):
            best = None
            centro = (k_lo + k_fim - nL) / 2
            for k in range(k_lo, (k_lo if ini else k_fim - nL) + 1):
                if k + nL >= n_quadros:
                    break
                s0, s1 = k * fd, (k + nL) * fd
                custo = float(act[k: k + nL + 1].sum()) if len(act) else 0.0
                if bloqueado(s0, s1):
                    custo += 1000
                custo += 1e-3 * abs(k - centro)
                if best is None or custo < best[0]:
                    best = (custo, k, nL)
            return best

        best = busca(nL)
        while best is not None and best[0] >= 1000 and nL > 3:   # encolhe até não pegar efeito sonoro
            nL -= 1
            best = busca(nL)
        if best is None or best[0] >= 1000:
            pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="só dava pra cortar em cima de efeito sonoro"))
            continue
        custo, k, nL = best
        # trava de segurança: o trecho removido não pode ter voz acima do limiar
        s0, s1 = k * fd, (k + nL) * fd
        if float(det[k_env(s0):k_env(s1) + 1].max()) >= thr:
            pulados.append(dict(pausa=[round(x, 3), round(y, 3)], motivo="encostaria na fala"))
            continue
        cortes.append(dict(pausa=[round(x, 3), round(y, 3)], q=[k, k + nL], removido=[round(s0, 4), round(s1, 4)],
                           dur=round(nL * fd, 4), sobra=round((y - x) - nL * fd, 3), imagem=round(custo, 2),
                           inicio=ini))

    # cortes pedidos (ex.: tomada repetida que a transcrição juntou numa palavra só)
    forc = []
    if a.cortar:
        for par in a.cortar.split(","):
            try:
                p0, p1 = (float(v) for v in par.strip().split("-"))
            except ValueError:
                erro(f"--cortar '{par}' inválido. Formato: 41.06-41.76,50.2-51.0 (segundos do vídeo de entrada)")
            if p1 <= p0:
                erro(f"--cortar '{par}': o fim tem que ser depois do começo")
            k0, k1 = int(round(p0 * fps)), int(round(p1 * fps))
            forc.append(dict(pausa=[p0, p1], q=[k0, k1], removido=[round(k0 * fd, 4), round(k1 * fd, 4)],
                             dur=round((k1 - k0) * fd, 4), forcado=True))
        cortes = [c for c in cortes if not any(c["q"][0] < f["q"][1] and c["q"][1] > f["q"][0] for f in forc)]
        cortes += forc
    cortes.sort(key=lambda c: c["q"][0])
    # junta cortes que se encostam
    junt = []
    for c in cortes:
        if junt and c["q"][0] <= junt[-1]["q"][1]:
            junt[-1]["q"][1] = max(junt[-1]["q"][1], c["q"][1])
            junt[-1]["removido"][1] = round(junt[-1]["q"][1] * fd, 4)
            junt[-1]["dur"] = round((junt[-1]["q"][1] - junt[-1]["q"][0]) * fd, 4)
        else:
            junt.append(c)
    cortes = junt
    manter, t = [], 0
    for c in cortes:
        if c["q"][0] > t:
            manter.append([t, c["q"][0]])
        t = max(t, c["q"][1])
    if t < n_quadros:
        manter.append([t, n_quadros])
    return dict(limiar_db=round(thr, 1), fraco_db=round(fraco, 1), fundo_db=round(floor5, 1), fala_db=round(fala, 1),
                cortes=cortes, pulados=pulados, manter_q=manter)


# ------------------------------------------------------------------ render
def renderizar(a, info: dict, plano: dict, saida: str, td: str) -> dict:
    fps = info["fps"]
    num, den = fps.numerator, fps.denominator
    manter = plano["manter_q"]
    # vídeo: select pelos números dos quadros (um decodificador só, exato no quadro)
    sel = "+".join(f"between(n\\,{k0}\\,{k1 - 1})" for k0, k1 in manter)
    vf = f"select={sel},setpts=N*{den}/{num}/TB"
    script = os.path.join(td, "vf.txt")
    Path(script).write_text(vf)
    v_tmp = os.path.join(td, "video.mp4")
    extra = []
    v = info["v"]
    for chave, opt in (("color_space", "-colorspace"), ("color_primaries", "-color_primaries"),
                       ("color_transfer", "-color_trc"), ("color_range", "-color_range")):
        if v.get(chave) and v[chave] not in ("unknown", "reserved"):
            extra += [opt, v[chave]]
    preset = "slow" if info["dur_v"] <= 180 else "medium"
    run(["ffmpeg", "-v", "error", "-y", "-i", a.video, "-an", "-/vf", script, "-fps_mode", "cfr", "-r", f"{num}/{den}",
         "-c:v", "libx264", "-preset", preset, "-crf", str(a.crf), "-pix_fmt", "yuv420p", *extra, v_tmp])
    # áudio: corte por amostra, com micro fade em cada emenda
    sr, ch = info["sr"], info["ch"]
    raw = run(["ffmpeg", "-v", "error", "-i", a.video, "-vn", "-f", "f32le", "-acodec", "pcm_f32le", "-"],
              texto=False).stdout
    au = np.frombuffer(raw, dtype=np.float32).reshape(-1, ch)
    off = info["v0"] - info["a0"]          # áudio que começa depois do vídeo (raro em vídeo renderizado)
    nf = int(FADE * sr)
    pedacos = []
    for i, (k0, k1) in enumerate(manter):
        s0 = max(0, int(round((k0 * den / num + off) * sr)))
        s1 = min(len(au), int(round((k1 * den / num + off) * sr)))
        p = au[s0:s1].copy()
        rampa = np.linspace(0, 1, min(nf, len(p)), dtype=np.float32)[:, None]
        if i > 0 and len(p):
            p[: len(rampa)] *= rampa
        if i < len(manter) - 1 and len(p):
            p[len(p) - len(rampa):] *= rampa[::-1]
        pedacos.append(p)
    alvo = sum(k1 - k0 for k0, k1 in manter) * den / num
    au_out = np.concatenate(pedacos) if pedacos else np.zeros((0, ch), np.float32)
    falta = int(round(alvo * sr)) - len(au_out)
    if falta > 0:   # áudio mais curto que o vídeo no fim: completa com silêncio
        au_out = np.concatenate([au_out, np.zeros((falta, ch), np.float32)])
    a_tmp = os.path.join(td, "audio.wav")
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "f32le", "-ar", str(sr), "-ac", str(ch), "-i", "-",
                    "-c:a", "pcm_f32le", a_tmp], input=au_out.tobytes(), check=True)
    j, tipo, _ = finalizar(v_tmp, a_tmp, saida)
    return dict(volume_antes=float(j["input_i"]), ajuste=tipo)


def mapa_tempo(manter: list, fd: float) -> list:
    """[[início na entrada, fim na entrada, início na saída], ...] em segundos"""
    out, t = [], 0
    for k0, k1 in manter:
        out.append([round(k0 * fd, 4), round(k1 * fd, 4), round(t * fd, 4)])
        t += k1 - k0
    return out


def conferir(a, info: dict, plano: dict, saida: str, mini_src, thr_entrada: float, lufs_antes: float) -> dict:
    fps = float(info["fps"])
    fd = 1 / fps
    manter = plano["manter_q"]
    res = {}
    so = sonda(saida)
    esperado = sum(k1 - k0 for k0, k1 in manter) * fd
    res["duracao_esperada"] = round(esperado, 3)
    res["duracao_video"] = round(so.get("dur_v", 0), 3)
    res["duracao_audio"] = round(so.get("dur_a", 0), 3)
    # imagem: cada quadro da saída tem que ser o quadro certo da entrada
    if mini_src is not None:
        idx = np.concatenate([np.arange(k0, k1) for k0, k1 in manter]) if manter else np.zeros(0, int)
        lh, lw = mini_src.shape[1] * 2, mini_src.shape[2] * 2
        certos = total = 0
        piores = []
        for j, f in enumerate(quadros(saida, lw, lh)):
            if j >= len(idx):
                break
            m = f.reshape(lh, lw)[::2, ::2].astype(np.int16)
            e = int(idx[j])
            viz = {d: float(np.abs(m - mini_src[e + d]).mean()) for d in (-2, -1, 0, 1, 2) if 0 <= e + d < len(mini_src)}
            total += 1
            if viz[0] <= min(viz.values()) + 0.35:
                certos += 1
            else:
                piores.append(j)
        res["quadros_conferidos"] = total
        res["quadros_no_lugar"] = certos
        res["quadros_fora"] = [round(j * fd, 3) for j in piores[:10]]
    # som: cada trecho mantido tem que estar no mesmo lugar da imagem (correlação com a entrada)
    ent, sai = audio_mono(a.video), audio_mono(saida)
    lags = []
    for (t_in0, t_in1, t_out0) in mapa_tempo(manter, fd):
        if t_in1 - t_in0 < 0.7:
            continue
        meio = (t_in1 - t_in0) / 2
        l = lag_ms(ent, sai, t_in0 + meio, t_out0 + meio)
        if l is not None:
            lags.append(abs(l))
    res["trechos_de_som_conferidos"] = len(lags)
    res["desvio_som_ms"] = round(max(lags), 2) if lags else None
    # pausas que sobraram (medidas na saída)
    fim = loudness(saida)
    res["lufs_final"] = float(fim["input_i"])
    res["pico_final_dbtp"] = float(fim["input_tp"])
    ganho = res["lufs_final"] - lufs_antes      # a saída foi normalizada: o limiar acompanha o ganho
    es = env_db(sai)
    sobras = [(x, y) for x, y in intervalos(es < thr_entrada + ganho)
              if (y - x) > a.pausa_max + 0.07 and x > 0.02 and y < len(es) * HOP - 0.05]
    res["pausas_que_sobraram"] = [[round(x, 2), round(y - x, 2)] for x, y in sobras]
    return res


def main(argv=None):
    try:
        sys.stdout.reconfigure(line_buffering=True)   # mensagens aparecem na hora, mesmo em log
    except AttributeError:
        pass
    ap = argparse.ArgumentParser(description="Tira as pausas que sobraram no vídeo pronto, sem cortar palavra.")
    ap.add_argument("video", help="vídeo final já renderizado (sem trilha, de preferência)")
    ap.add_argument("--pausa-max", type=float, default=0.15, help="silêncio que fica entre as frases (0.15 s)")
    ap.add_argument("--pausa-min", type=float, default=0.25, help="só mexe em pausas a partir disso (0.25 s)")
    ap.add_argument("--sem-grafismo", metavar="RENDER", help="render só com os grafismos (sem a pessoa), mesma duração")
    ap.add_argument("--sfx", metavar="PLANO", help="plano de sons (plano_sfx.json): não corta no ataque de efeito")
    ap.add_argument("--voz", metavar="AUDIO", help="áudio só da voz no mesmo tempo do vídeo (ex.: o cut.mp4 do corte, Fase 1)")
    ap.add_argument("--cortar", help="trechos a tirar de qualquer jeito, em segundos: 41.06-41.76,50.2-51.0")
    ap.add_argument("--inicio", type=float, default=0.08, help="silêncio antes da 1ª palavra (0.08 s)")
    ap.add_argument("--limiar", type=float, help="dBFS abaixo do qual é silêncio (padrão: medido no vídeo)")
    ap.add_argument("--crf", type=int, default=16, help="qualidade do vídeo (16 = muito alta)")
    ap.add_argument("--saida", help="arquivo de saída (padrão: <video>_seco.mp4)")
    ap.add_argument("--so-plano", action="store_true", help="só mostra e salva o plano, sem renderizar")
    a = ap.parse_args(argv)

    for b in ("ffmpeg", "ffprobe"):
        if not shutil.which(b):
            erro(f"não achei o {b}. Instale o ffmpeg (no Mac: brew install ffmpeg).")
    if not Path(a.video).exists():
        erro(f"não achei {a.video}")
    if not 0.05 <= a.pausa_max <= 1.0:
        erro("--pausa-max fica entre 0.05 e 1.0 s (o André usa 0.15)")
    if a.pausa_min < a.pausa_max + 0.05:
        a.pausa_min = round(a.pausa_max + 0.1, 3)
    saida = a.saida or str(Path(a.video).with_name(Path(a.video).stem + "_seco.mp4"))
    if Path(saida).resolve() == Path(a.video).resolve():
        erro("a saída não pode ser o mesmo arquivo da entrada")
    info = sonda(a.video)
    if not info.get("v") or not info.get("a"):
        erro("o vídeo precisa ter imagem e som")
    fps = float(info["fps"])
    fd = 1 / fps

    print(f"lendo {Path(a.video).name}: {info['dur_v']:.2f} s, {info['w']}x{info['h']}, {fps:g} quadros/s")
    mix = audio_mono(a.video)
    mix_env = env_db(mix)
    usa_voz = False
    det = mix_env
    if a.voz:
        if not Path(a.voz).exists():
            erro(f"não achei {a.voz}")
        voz = audio_mono(a.voz)
        lags = []
        for t in np.linspace(2, min(len(voz), len(mix)) / SR_ENV - 2, 8):
            l = lag_ms(voz, mix, float(t), float(t))
            if l is not None:
                lags.append(abs(l))
        if lags and max(lags) <= 20:
            det, usa_voz = env_db(voz), True
        else:
            aviso("o áudio de --voz não está no mesmo tempo do vídeo; vou medir as pausas no próprio vídeo")
    bloqueios = carregar_sfx(a.sfx) if a.sfx else []

    act_final, mini = atividade(a.video, info["w"], info["h"], guardar=not a.so_plano)
    n_quadros = len(act_final) or int(round(info["dur_v"] * fps))
    act = act_final
    fonte_img = "diferença entre quadros do próprio vídeo"
    if a.sem_grafismo:
        ig = sonda(a.sem_grafismo)
        if not ig.get("v"):
            erro(f"{a.sem_grafismo} não tem vídeo")
        act_g, _ = atividade(a.sem_grafismo, ig["w"], ig["h"])
        if abs(len(act_g) - n_quadros) <= 1:
            act = act_g[:n_quadros] if len(act_g) >= n_quadros else np.pad(act_g, (0, n_quadros - len(act_g)))
            fonte_img = "render só com grafismo"
        else:
            aviso(f"o render de grafismo tem {len(act_g)} quadros e o vídeo {n_quadros}; usei o próprio vídeo")

    plano = planejar(a, info, det, mix_env, usa_voz, act, bloqueios, n_quadros)
    cortes = plano["cortes"]
    removido = sum(c["dur"] for c in cortes)
    dur_saida = sum(k1 - k0 for k0, k1 in plano["manter_q"]) * fd
    print(f"limiar de silêncio {plano['limiar_db']} dB (fala {plano['fala_db']}, fundo {plano['fundo_db']}) · "
          f"ponto de corte pela {fonte_img}" + (" · pausas medidas no áudio da voz" if usa_voz else ""))
    print(f"{len(cortes)} cortes, {removido:.2f} s fora: {info['dur_v']:.2f} -> {dur_saida:.2f} s")
    for c in cortes:
        if c.get("forcado"):
            print(f"  corte pedido  {c['removido'][0]:7.3f} - {c['removido'][1]:7.3f}  ({c['dur']:.3f} s)")
        else:
            print(f"  pausa {c['pausa'][0]:7.2f}-{c['pausa'][1]:6.2f}  tira {c['removido'][0]:7.3f}-{c['removido'][1]:7.3f}"
                  f"  ({c['dur']:.3f} s, sobra {c['sobra']:.2f} s, imagem {c['imagem']:.1f})"
                  + ("  começo" if c.get("inicio") else ""))
    for p in plano["pulados"]:
        print(f"  mantida: pausa {p['pausa'][0]:.2f}-{p['pausa'][1]:.2f} ({p['motivo']})")
    rel = dict(video=a.video, saida=saida, pausa_max=a.pausa_max, pausa_min=a.pausa_min, fps=fps,
               ponto_de_corte=fonte_img, sfx=a.sfx, voz=a.voz if usa_voz else None,
               limiar_db=plano["limiar_db"], fraco_db=plano["fraco_db"], cortes=cortes, mantidas=plano["pulados"],
               removido_s=round(removido, 3), duracao_entrada=round(info["dur_v"], 3), duracao_saida=round(dur_saida, 3),
               mapa=mapa_tempo(plano["manter_q"], fd))
    rel_path = (saida + ".corte.json")
    if a.so_plano:
        Path(rel_path).write_text(json.dumps(rel, ensure_ascii=False, indent=1))
        print(f"plano salvo em {rel_path} (nada foi renderizado)")
        return
    with tempfile.TemporaryDirectory() as td:
        r = renderizar(a, info, plano, saida, td)
    conf = conferir(a, info, plano, saida, mini, plano["limiar_db"], r["volume_antes"])
    rel["conferencia"] = conf
    rel["ajuste_volume"] = r["ajuste"]
    Path(rel_path).write_text(json.dumps(rel, ensure_ascii=False, indent=1))
    print(f"saída: {saida}")
    print(f"  duração vídeo {conf['duracao_video']:.3f} s, áudio {conf['duracao_audio']:.3f} s "
          f"(esperado {conf['duracao_esperada']:.3f}) · volume {conf['lufs_final']} LUFS, pico "
          f"{conf['pico_final_dbtp']} dBTP (ajuste {r['ajuste']})")
    if "quadros_conferidos" in conf:
        print(f"  imagem: {conf['quadros_no_lugar']} de {conf['quadros_conferidos']} quadros no lugar certo · som: "
              f"{conf['trechos_de_som_conferidos']} trechos conferidos, desvio máximo {conf['desvio_som_ms']} ms")
    if conf["pausas_que_sobraram"]:
        print(f"  pausas acima de {a.pausa_max + 0.07:.2f} s que ficaram (efeito sonoro ou som fraco na borda): "
              + ", ".join(f"{x:.2f} s ({d:.2f})" for x, d in conf["pausas_que_sobraram"][:12]))
    else:
        print(f"  nenhuma pausa acima de {a.pausa_max + 0.07:.2f} s ficou")
    ok = True
    if abs(conf["duracao_video"] - conf["duracao_esperada"]) > 1.5 * fd or abs(conf["duracao_audio"] - conf["duracao_video"]) > 0.05:
        aviso("a duração não bate com o esperado; confira antes de entregar")
        ok = False
    if conf.get("desvio_som_ms") is not None and conf["desvio_som_ms"] > 5:
        aviso("o som escorregou em relação à imagem; não entregue sem conferir")
        ok = False
    if "quadros_conferidos" in conf and conf["quadros_no_lugar"] < conf["quadros_conferidos"] * 0.98:
        aviso(f"quadros fora do lugar em {conf['quadros_fora']}; confira")
        ok = False
    print("  tudo certo" if ok else "  ATENÇÃO: veja os avisos acima")
    print(f"relatório: {rel_path}")


if __name__ == "__main__":
    main()
