"""Live camera by default: writes in projeto.json the base zoom that alternates sentence by sentence and the
punch-ins on the words that carry the emphasis.

Reads the words of the cut (`<EDIT>/transcripts/cut.json`, with the start measured on the wave when
`palavras_onda.json` exists), the cuts (`cut_timeline.json`) and the loudness of the audio. Then, in the stretches
of the video that have no block yet (or only a plain `tela_cheia`):

  - one `tela_cheia` block per sentence, base zoom alternating between 1.0 and 1.12 (a long sentence is split at a
    comma or at a cut, so something changes on screen every 2 to 4 s);
  - one `zooms` punch-in on the face (1.25 over the 1.0 base, 1.3 over the 1.12 base) on the emphasis word of the
    sentence: a number, a negation, or the loudest word. At least MIN_GAP_S between two punch-ins.

A scene of `cenas_proprias` with `"sobre_a_pessoa": true` (a small card above the head of the person, who stays on
screen) marks a stretch where the camera does NOT come closer: there the base zoom stays 1.0 and no punch-in is
written, so the head cannot rise under the card. Its "de" and "ate" are seconds or a word ("ate" missing = to the end).
(PrimoCode) The pieces that sit over the head (`lettering`, `rotulo`, `carimbo`) get the same calm stretch: with the
1.12 base zoom the head rose under the title and the text ended up glued to the forehead. Under a big title on top
(a `lettering` that is not "base"/"meio", with the captions out of the stretch) the block is 1.12 with
`"foco_y": 0.62`: the face goes DOWN in the frame and the top opens for the text.

It only uses what the build already does (`zooms` in `tela_cheia`, references/enquadramento.md). Blocks written by
hand that are not a plain `tela_cheia` (`prova`, `contida`, `moldura`, `tela_verde`, `comparar`, or a `tela_cheia`
with its own `zooms`, `acompanhar` or `"camera_viva": false`) are left exactly as they are. Everything written here
carries `"auto": "camera_viva"`: running again replaces only that, and `--desfazer` removes it.

Usage:
    uv run python helpers/camera_viva.py <PROJ> [--desfazer] [--min-gap 2.5] [--json]
Exit code 0 when projeto.json was written (or nothing had to change), 2 on error.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import unicodedata
from pathlib import Path

MARCA = "camera_viva"
ZOOM_A, ZOOM_B = 1.0, 1.12
PUNCH_A, PUNCH_B = 1.25, 1.3
# (maquina) o enquadramento não alterna mais 1.0/1.12 em tique-taque: gira por uma escala de quatro degraus e a
# escolha vem do texto da própria frase (mesmo roteiro, mesma escolha; outro roteiro, outro ritmo), nunca repetindo.
ZOOMS_MAQUINA = (1.0, 1.08, 1.12, 1.18)
MIN_GAP_S = 2.5
LONGA_S = 4.5          # a sentence longer than this is split in two
CURTA_S = 1.2          # a sentence shorter than this joins the next one
PUNCH_FICA_S = 0.6     # how long the punch-in stays after the word ends
SNAP_S = 0.25          # a sentence start this close to a cut moves to the cut
FOCO_TITULO = 0.62     # (PrimoCode) under a big title on top: the face center goes this low in the frame (build: foco_y)
DERIVA = 1.04          # (PrimoCode) a block with no punch-in drifts slowly to 4% closer: the frame is never still
DERIVA_MIN_S = 2.0     # (PrimoCode) only blocks this long drift (shorter ones already change fast enough)
PARADO_S = 4.0         # (PrimoCode) a stretch with no change of framing longer than this is reported, with where it is

NEGACOES = {"nao", "nunca", "nem", "nada", "ninguem", "jamais", "nenhum", "nenhuma", "sem", "zero"}
NUMEROS = {"um", "uma", "dois", "duas", "tres", "quatro", "cinco", "seis", "sete", "oito", "nove", "dez", "onze",
           "doze", "treze", "catorze", "quatorze", "quinze", "vinte", "trinta", "quarenta", "cinquenta", "sessenta",
           "setenta", "oitenta", "noventa", "cem", "cento", "duzentos", "trezentos", "quinhentos", "mil", "milhao",
           "milhoes", "bilhao", "bilhoes", "dobro", "triplo", "metade", "primeiro", "primeira", "segundo", "segunda",
           "terceiro", "terceira"}
ARTIGOS_NUM = {"um", "uma"}       # "um" and "uma" are almost always the article: they only count with energy
FRACAS = {"a", "o", "as", "os", "de", "da", "do", "das", "dos", "e", "que", "em", "no", "na", "nos", "nas", "um",
          "uma", "por", "pra", "para", "com", "se", "eu", "ele", "ela", "voce", "me", "te", "foi", "era", "isso",
          "esse", "essa", "este", "esta", "meu", "minha", "seu", "sua", "la", "ja", "mas", "ou", "como", "so", "ai",
          "ne", "tem", "ter", "vai", "vou", "ao", "aos", "quando", "porque", "quem", "tudo", "cada", "mesma", "mesmo"}


class Erro(Exception):
    pass


def norm(s: str) -> str:
    s = unicodedata.normalize("NFD", str(s).lower())
    return re.sub(r"[^a-z0-9%$]", "", "".join(c for c in s if unicodedata.category(c) != "Mn"))


def ler_json(p: Path):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def palavras_do_corte(edit: Path) -> list[dict]:
    """Words of the cut with the start measured on the wave when palavras_onda.json matches the transcript."""
    p = edit / "transcripts" / "cut.json"
    if not p.is_file():
        raise Erro(f"falta {p}: termine o corte primeiro (render_cut.py e a transcrição do cut.mp4)")
    ws = [dict(text=w["text"].strip(), start=float(w["start"]), end=float(w["end"]))
          for w in ler_json(p)["words"] if w["text"].strip()]
    po = edit / "palavras_onda.json"
    if po.is_file():
        try:
            lista = ler_json(po).get("palavras") or []
        except (ValueError, AttributeError):
            lista = []
        por_hora: dict[float, list[dict]] = {}
        for r in lista:
            try:
                por_hora.setdefault(round(float(r["whisper"]), 2), []).append(r)
            except (KeyError, TypeError, ValueError):
                continue
        for w in ws:
            for r in por_hora.get(round(w["start"], 2), []):
                if norm(r.get("text", "")) == norm(w["text"]) and abs(float(r["start"]) - w["start"]) <= 0.45:
                    w["start"] = round(float(r["start"]), 3)
                    w["end"] = round(max(w["end"], w["start"] + 0.05), 3)
                    break
    return ws


def frases(ws: list[dict], cortes: list[float]) -> list[list[int]]:
    """Indexes of the words of each sentence: split on . ! ? and a long sentence split again at a comma or a cut."""
    out, atual = [], []
    for i, w in enumerate(ws):
        atual.append(i)
        if re.search(r"[.!?…]$", w["text"]):
            out.append(atual)
            atual = []
    if atual:
        out.append(atual)

    def dur(f):
        return ws[f[-1]]["end"] - ws[f[0]]["start"]

    partidas = []
    for f in out:
        fila = [f]
        while fila:
            g = fila.pop(0)
            if dur(g) <= LONGA_S or len(g) < 4:
                partidas.append(g)
                continue
            meio = (ws[g[0]]["start"] + ws[g[-1]]["end"]) / 2
            cand = [k for k in range(1, len(g) - 1) if ws[g[k - 1]]["text"].endswith((",", ";", ":"))]
            cand += [k for k in range(1, len(g) - 1)
                     if any(abs(c - ws[g[k]]["start"]) <= SNAP_S for c in cortes)]
            cand = [k for k in cand if ws[g[k]]["start"] - ws[g[0]]["start"] >= CURTA_S
                    and ws[g[-1]]["end"] - ws[g[k]]["start"] >= CURTA_S]
            if not cand:
                partidas.append(g)
                continue
            k = min(cand, key=lambda k: abs(ws[g[k]]["start"] - meio))
            fila = [g[:k], g[k:]] + fila
    juntas: list[list[int]] = []
    for f in partidas:                       # a very short sentence rides with the one before it
        if juntas and (dur(f) < CURTA_S or dur(juntas[-1]) < CURTA_S) and dur(juntas[-1]) + dur(f) <= LONGA_S:
            juntas[-1] = juntas[-1] + f
        else:
            juntas.append(f)
    return juntas


def energia(db, w: dict) -> float:
    """Loudness of a word: the 90th percentile of the wave (10 ms windows) between its start and its end."""
    if db is None:
        return 0.0
    import numpy as np  # noqa: PLC0415
    a, b = int(w["start"] * 100), max(int(w["end"] * 100), int(w["start"] * 100) + 1)
    seg = db[a:b]
    return float(np.percentile(seg, 90)) if len(seg) else -99.0


def escolher_base(t: float, ultima: float) -> float:
    """Escala de base do trecho que começa em `t`: gira pela escala, nunca repete a anterior e nunca salta mais
    de dois degraus (para não parecer um tranco)."""
    k = int(round(t * 1000)) * 2654435761 % (2 ** 32)
    opcoes = [z for z in ZOOMS_MAQUINA if z != ultima]
    if ultima in ZOOMS_MAQUINA:
        i = ZOOMS_MAQUINA.index(ultima)
        perto = [z for z in opcoes if abs(ZOOMS_MAQUINA.index(z) - i) <= 2]
        opcoes = perto or opcoes
    return opcoes[k % len(opcoes)]


def punch_para(base: float, motivo: str) -> float:
    """O aperto na palavra de ênfase: número aperta mais que negação, que aperta mais que a palavra forte."""
    extra = {"número": 0.30, "negação": 0.24}.get(motivo, 0.18)
    return round(max(PUNCH_A if base <= ZOOM_A else PUNCH_B, base + extra), 3)


def enfase(ws: list[dict], f: list[int], db) -> tuple[int, str] | None:
    """(index, reason) of the emphasis word of a sentence: number, negation, or the loudest content word."""
    en = {i: energia(db, ws[i]) for i in f}
    nums = [i for i in f if re.search(r"\d", ws[i]["text"]) or "%" in ws[i]["text"] or "$" in ws[i]["text"]
            or (norm(ws[i]["text"]) in NUMEROS and norm(ws[i]["text"]) not in ARTIGOS_NUM)]
    if nums:
        return max(nums, key=lambda i: en[i]), "número"
    negs = [i for i in f if norm(ws[i]["text"]) in NEGACOES]
    if negs:
        return max(negs, key=lambda i: en[i]), "negação"
    fortes = [i for i in f if norm(ws[i]["text"]) not in FRACAS and len(norm(ws[i]["text"])) >= 3]
    if not fortes:
        return None
    return max(fortes, key=lambda i: en[i]), "palavra mais forte"


def tempo_do_campo(v, ws: list[dict], depois: float = 0.0) -> float | None:
    """Seconds of a "de"/"ate" of a hand-written block: a number, or the first time the word(s) are said."""
    if isinstance(v, (int, float)):
        return float(v)
    alvo = [norm(x) for x in str(v).split() if norm(x)]
    if not alvo:
        return None
    for i in range(len(ws) - len(alvo) + 1):
        if ws[i]["start"] >= depois - 1e-3 and [norm(w["text"]) for w in ws[i:i + len(alvo)]] == alvo:
            return ws[i]["start"]
    return None


def trechos_sobre_a_pessoa(cfg: dict, ws: list[dict], vdur: float) -> list[tuple[float, float]]:
    """Stretches (de, ate) of the `cenas_proprias` marked "sobre_a_pessoa": true, merged. "de" and "ate" are seconds or a
    word (`de_depois_de` / `ate_depois_de` choose the repetition); no "ate" = to the end of the video."""
    out = []
    for i, c in enumerate(cfg.get("cenas_proprias") or []):
        if not isinstance(c, dict) or c.get("sobre_a_pessoa") is not True:
            continue
        nome = c.get("arquivo") or f"cena própria {i + 1}"
        de = tempo_do_campo(c.get("de", 0.0), ws, float(c.get("de_depois_de", c.get("depois_de", 0.0)) or 0.0))
        if de is None:
            raise Erro(f'{nome}: não achei a palavra "{c.get("de")}" (do "de") na fala; escreva em segundos')
        ate = vdur
        if c.get("ate") is not None:
            ate = tempo_do_campo(c["ate"], ws, max(de, float(c.get("ate_depois_de", 0.0) or 0.0)))
            if ate is None:
                raise Erro(f'{nome}: não achei a palavra "{c["ate"]}" (do "ate") na fala; escreva em segundos')
        de, ate = max(0.0, de), min(vdur, ate)
        if ate > de + 0.05:
            out.append((de, ate))
    out.sort()
    juntos: list[tuple[float, float]] = []
    for de, ate in out:
        if juntos and de <= juntos[-1][1] + 0.05:
            juntos[-1] = (juntos[-1][0], max(juntos[-1][1], ate))
        else:
            juntos.append((de, ate))
    return juntos


def mesclar(trechos: list[tuple[float, float]]) -> list[tuple[float, float]]:
    """Ranges sorted and merged where they touch."""
    out: list[tuple[float, float]] = []
    for a, b in sorted(trechos):
        if out and a <= out[-1][1] + 0.05:
            out[-1] = (out[-1][0], max(out[-1][1], b))
        else:
            out.append((a, b))
    return out


def deriva(a: float, b: float, base: float, cortes: list[float]) -> list[dict]:
    """(PrimoCode) the slow drift of a block with no punch-in: one zoom per piece between cuts (a piece under 0.9 s
    joins the one before), going in to base * DERIVA and back out to base, each piece starting where the last ends"""
    marcas = [a] + [c for c in cortes if c - a >= 0.9 and b - c >= 0.9] + [b]
    pedacos = []
    for x, y in zip(marcas, marcas[1:]):
        if pedacos and y - x < 0.9:
            pedacos[-1] = (pedacos[-1][0], y)
        else:
            pedacos.append((x, y))
    out = []
    for k, (x, y) in enumerate(pedacos):
        em = x + 0.05 if k == 0 else x
        out.append(dict(em=round(em, 3), ate=round(y, 3), escala=round(base * (DERIVA if k % 2 == 0 else 1.0), 3),
                        duracao=round(y - em, 3), ease="none", som=False, auto=MARCA))
    return out


def plano(cfg: dict, ws: list[dict], cortes: list[float], vdur: float, db=None, min_gap: float = MIN_GAP_S) -> dict:
    """The new "blocos" of the project and a short report. Pure: reads nothing from disk."""
    manuais, simples = [], []
    for b in cfg.get("blocos") or []:
        if not isinstance(b, dict) or b.get("auto") == MARCA:
            continue
        b = dict(b)
        if isinstance(b.get("zooms"), list):          # punch-ins from a previous run inside a hand-written block
            b["zooms"] = [z for z in b["zooms"] if not (isinstance(z, dict) and z.get("auto") == MARCA)]
            if not b["zooms"]:
                del b["zooms"]
                if b.pop("_zoom_auto", None):
                    b.pop("zoom", None)
        de = tempo_do_campo(b.get("de", 0.0), ws)
        ate = tempo_do_campo(b["ate"], ws, de or 0.0) if "ate" in b else vdur
        if de is None or ate is None:
            raise Erro(f'bloco {b.get("cena")}: não achei a palavra de "de"/"ate" na fala; escreva em segundos')
        plain = (b.get("cena") == "tela_cheia" and not b.get("zooms") and not b.get("acompanhar")
                 and b.get("camera_viva", True) is not False and not b.get("borrar"))
        (simples if plain else manuais).append((de, min(ate, vdur), b))
    manuais.sort(key=lambda x: x[0])

    # the stretches that are free for the live camera: everything outside the hand-written blocks that stay
    livres, t = [], 0.0
    for de, ate, _ in manuais:
        if de > t + 0.05:
            livres.append((t, de))
        t = max(t, ate)
    if t < vdur - 0.05:
        livres.append((t, vdur))

    # lettering, rótulo e carimbo ficam em cima da cabeça: ali a aproximação no rosto empurraria a cabeça para baixo do texto
    ocupado: list[tuple[float, float]] = []
    titulos: list[tuple[float, float]] = []
    for an in cfg.get("animacoes") or []:
        if not isinstance(an, dict) or an.get("tipo") not in ("lettering", "rotulo", "carimbo"):
            continue
        ini = tempo_do_campo(an["em"], ws, float(an.get("depois_de") or 0.0)) if an.get("em") is not None else None
        if ini is None:
            continue
        fim = tempo_do_campo(an["sai"], ws, ini) if an.get("sai") is not None else None
        ocupado.append((ini - 0.1, fim if fim is not None else ini + 2.5))
        # o título grande no alto (lettering que não pediu "base"/"meio", com a legenda fora do trecho): a câmera abre
        # espaço em cima, com o rosto mais baixo no quadro (zoom 1,12 com o foco embaixo), em vez de subir a cabeça
        leg = cfg.get("legenda") if isinstance(cfg.get("legenda"), dict) else {}
        junto = an.get("legenda_junto")
        if junto is None:
            junto = leg.get("estilo") == "discreta"
        if an.get("tipo") == "lettering" and an.get("posicao") not in ("base", "meio") and not junto:
            titulos.append(ocupado[-1])

    fs = frases(ws, cortes)
    limites = []                                   # where each sentence starts on screen
    for f in fs:
        s = ws[f[0]]["start"]
        c = min(cortes, key=lambda c: abs(c - s), default=None)
        limites.append(round(c if c is not None and abs(c - s) <= SNAP_S else max(0.0, s - 0.04), 3))
    if limites:
        limites[0] = 0.0

    sobre = trechos_sobre_a_pessoa(cfg, ws, vdur)  # the camera stays at 1.0 and does not come closer here
    # PrimoCode: o mesmo vale para o lettering, o rótulo e o carimbo: o zoom de base 1,12 também sobe a cabeça (o título
    # ficava colado na testa). Nesses trechos a câmera fica em 1,0 e parada; a peça é o que muda na tela.
    calmos = mesclar(sobre + [(max(0.0, a), min(vdur, b)) for a, b in ocupado if b - a > 0.05])
    avisos = []
    for de, ate, b in manuais:
        for sd, sa in sobre:
            if b.get("cena") == "tela_cheia" and de < sa - 0.05 and ate > sd + 0.05:
                avisos.append(f'tela_cheia seu de {de:.1f} a {ate:.1f} s cruza a cena sobre_a_pessoa de {sd:.1f} a {sa:.1f} s: '
                              f'tire o zoom dali, senão a cabeça sobe sob o cartão')

    def pedacos(la: float, lb: float) -> list[tuple[float, float, bool]]:
        """The free stretch cut at the edges of the calm stretches (sobre_a_pessoa, lettering): [(de, ate, calmo)]"""
        out, t = [], la
        for sd, sa in calmos:
            lo, hi = max(sd, la), min(sa, lb)
            if hi - lo <= 0.05:
                continue
            if lo - t > 0.05:
                out.append((t, lo, False))
                t = lo
            out.append((t, hi, True))
            t = hi
        if lb - t > 0.05:
            out.append((t, lb, False))
        elif out:
            out[-1] = (out[-1][0], lb, out[-1][2])
        return out

    autos, punches, ultima_base, ultimo_punch = [], [], ZOOM_B, -99.0
    derivas: list[tuple[float, float]] = []
    for la, lb in livres:
        for pa, pb, sob in pedacos(la, lb):
            pontos = [pa] + ([] if sob else [x for x in limites if pa + CURTA_S * 0.8 <= x <= pb - CURTA_S * 0.8]) + [pb]
            if sob:     # o trecho calmo troca de enquadramento só onde começa ou acaba um título no alto
                pontos = sorted(set(pontos + [round(x, 3) for t in titulos for x in t if pa + 0.3 < x < pb - 0.3]))
            for a, b in zip(pontos, pontos[1:]):
                titulo = sob and any(x - 0.01 <= (a + b) / 2 <= y + 0.01 for x, y in titulos) and \
                    not any(x < b - 0.05 and a + 0.05 < y for x, y in sobre)
                if titulo:
                    base = ZOOM_B
                elif sob:
                    base = ZOOM_A
                else:
                    base = escolher_base(a, ultima_base)
                ultima_base = base
                bloco = dict(cena="tela_cheia", de=round(a, 3), ate=round(b, 3), zoom=base, auto=MARCA)
                if titulo:
                    bloco["foco_y"] = FOCO_TITULO
                dentro = [f for f in fs if a - 0.05 <= ws[f[0]]["start"] < b - 0.05]
                cands = [] if sob else [e for e in (enfase(ws, f, db) for f in dentro) if e]
                ordem = {"número": 0, "negação": 1, "palavra mais forte": 2}
                cands.sort(key=lambda e: (ordem[e[1]], -energia(db, ws[e[0]])))
                for i, motivo in cands:
                    t0 = ws[i]["start"]
                    if t0 - ultimo_punch < min_gap or t0 < a + 0.45 or b - t0 < PUNCH_FICA_S + 0.25:
                        continue
                    t1 = round(min(ws[i]["end"] + PUNCH_FICA_S, b - 0.05), 3)
                    if any(x < t1 and t0 - 0.1 < y for x, y in ocupado):
                        continue
                    bloco["zooms"] = [dict(em=round(t0, 3), ate=t1, escala=punch_para(base, motivo),
                                           som=False, auto=MARCA)]
                    punches.append(dict(em=round(t0, 2), palavra=ws[i]["text"], motivo=motivo))
                    ultimo_punch = t0
                    break
                # (PrimoCode) sem aproximação, o bloco anda devagar (4%): nada fica parado. Fora do trecho calmo (a cabeça
                # não sobe sob o título). Com corte no meio, um pedaço por tomada, indo e voltando: no corte o rosto
                # muda de lugar e cada pedaço enquadra o rosto da tomada dele
                if "zooms" not in bloco and not sob and b - a >= DERIVA_MIN_S:
                    bloco["zooms"] = deriva(a, b, base, [c for c in cortes if a + 0.1 < c < b - 0.05])
                    derivas.append((a, b))
                autos.append((a, bloco))
    if autos and autos[-1][1]["ate"] >= vdur - 0.05:
        del autos[-1][1]["ate"]                    # the last block goes to the end (and holds on the still end)

    blocos = [b for _, _, b in manuais]
    todos = sorted([(de, b) for de, _, b in manuais] + autos, key=lambda x: x[0])
    # the card of a sobre_a_pessoa scene coming and going is also something that changes on screen
    cartoes = {round(x, 2) for d, a in calmos for x in (d, a) if 0.05 < x < vdur - 0.05}
    trocas = sorted({round(de, 2) for de, _ in todos} | {p["em"] for p in punches} | cartoes)
    # inside a sobre_a_pessoa stretch the camera stays still on purpose: the card is what changes there (main() says how long)
    dentro = lambda x, y: any(x >= sd - 0.01 and y <= sa + 0.01 for sd, sa in calmos)  # noqa: E731
    # (PrimoCode) a moldura flutua devagar no build (moldura_flutua): não é trecho parado
    derivas += [(de, ate) for de, ate, b in manuais if b.get("cena") == "moldura" and b.get("flutua") is not False]
    andando = lambda x, y: any(x >= da - 0.01 and y <= db + 0.01 for da, db in derivas)  # noqa: E731
    vaos = [(x, y) for x, y in zip([0.0] + trocas, trocas + [vdur]) if not dentro(x, y) and not andando(x, y)]
    maior = max((y - x for x, y in vaos), default=0.0)
    # (PrimoCode) onde a câmera fica parada mais de 4 s: o modelo põe uma peça ou uma cena ali
    parados = [[round(x, 2), round(y, 2)] for x, y in vaos if y - x > PARADO_S]
    return dict(blocos=[b for _, b in todos], frases=len(fs), blocos_auto=len(autos), punches=punches,
                mantidos=len(blocos), simples_trocados=len(simples), maior_intervalo=round(maior, 2), parados=parados,
                derivas=len(derivas),
                sobre_a_pessoa=[[round(d, 2), round(a, 2)] for d, a in sobre], avisos=avisos)


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Câmera viva: zoom de base alternando por frase e aproximação no rosto "
                                             "nas palavras de ênfase, escritos no projeto.json.")
    ap.add_argument("projeto", type=Path, help="a pasta do projeto (<PROJ>)")
    ap.add_argument("--desfazer", action="store_true", help="tira tudo o que a câmera viva escreveu (vídeo calmo)")
    ap.add_argument("--min-gap", type=float, default=MIN_GAP_S, help="segundos entre duas aproximações (2,5)")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args(argv)
    try:
        pj = a.projeto / "projeto.json"
        if not pj.is_file():
            raise Erro(f"não achei {pj}: crie o projeto com o build.py novo")
        cfg = ler_json(pj)
        if a.desfazer:
            antes = cfg.get("blocos") or []
            cfg["blocos"] = [b for b in antes if not (isinstance(b, dict) and b.get("auto") == MARCA)]
            for b in cfg["blocos"]:
                if isinstance(b, dict) and isinstance(b.get("zooms"), list):
                    b["zooms"] = [z for z in b["zooms"] if not (isinstance(z, dict) and z.get("auto") == MARCA)]
            pj.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
            print(f"câmera viva: tirei {len(antes) - len(cfg['blocos'])} bloco(s). Rode o build de novo.")
            return 0
        edit = (a.projeto / cfg.get("edit", "../edit")).resolve()
        tl = edit / "cut_timeline.json"
        if not tl.is_file():
            raise Erro(f"falta {tl}: termine o corte primeiro")
        tl = ler_json(tl)
        vdur = float(tl["duration"])
        cortes = sorted(float(s["out_start"]) for s in tl["segments"] if s["out_start"] > 0.01)
        ws = palavras_do_corte(edit)
        db = None
        video = edit / cfg.get("video", "cut.mp4")
        if video.is_file():
            try:
                sys.path.insert(0, str(Path(__file__).resolve().parent))
                import corte_por_onda as onda  # noqa: E402, PLC0415
                db = onda.envelope(video, None)
            except Exception as e:  # noqa: BLE001  (without the wave the emphasis is number, negation, first strong word)
                print(f"aviso: não medi a onda ({e}); a ênfase fica por número e negação", file=sys.stderr)
        r = plano(cfg, ws, cortes, vdur, db, a.min_gap)
        cfg["blocos"] = r["blocos"]
        pj.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    except Erro as e:
        print(f"ERRO: {e}", file=sys.stderr)
        return 2
    if a.json:
        print(json.dumps({k: v for k, v in r.items() if k != "blocos"}, ensure_ascii=False))
        return 0
    print(f"câmera viva: {r['blocos_auto']} trecho(s) de tela cheia alternando {ZOOM_A:g} e {ZOOM_B:g}, "
          f"{len(r['punches'])} aproximação(ões) no rosto; {r['mantidos']} bloco(s) seu(s) mantido(s).")
    for p in r["punches"]:
        print(f"  {p['em']:6.2f} s  \"{p['palavra']}\"  ({p['motivo']})")
    for d, a in r["sobre_a_pessoa"]:
        print(f"  {d:6.2f} a {a:.2f} s  sobre a pessoa: zoom 1,0 e sem aproximação (a cabeça não sobe sob o cartão)"
              + (f"; são {a - d:.1f} s com a câmera parada: o cartão precisa mudar de estado a cada 2 a 4 s" if a - d > 4.0 else ""))
    for aviso in r["avisos"]:
        print(f"  aviso: {aviso}")
    print(f"  maior intervalo sem mudança de câmera: {r['maior_intervalo']:.1f} s"
          + ("  (acima de 4 s: ponha uma peça ou cena nesse trecho)" if r["maior_intervalo"] > 4.0 else ""))
    for d, a in r["parados"]:
        print(f"  parado: {d:6.2f} a {a:.2f} s ({a - d:.1f} s sem mudar o enquadramento)")
    print("  rode o build de novo (amostra ou completo) para ver no estúdio.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
