#!/usr/bin/env python3
"""Mede a linguagem de edição de um vídeo de referência, sem copiar o conteúdo dele.

Uso (<SKILL> é a pasta desta skill; o Python dela já tem numpy, opencv e pillow):

    uv run --directory "<SKILL>" python helpers/medir_referencia.py VIDEO --saida PASTA
        [--limiar 0.20] [--sem-fala] [--sem-texto] [--transcricao ARQUIVO.json]
        [--editor-video OUTRA_PASTA_DA_SKILL]

Usa os helpers irmãos (transcribe.py e texto_na_tela.py) e o detector de rosto e a fonte da própria skill.
--editor-video (ou a variável EDITOR_VIDEO_DIR) aponta para outra instalação, se precisar.

O que sai na PASTA:

    guia-de-estilo.md          o que foi medido, separado em MEDIDO, ESTIMADO e NÃO DÁ PRA SABER
    trechos-medidos.csv        um segundo por linha: o que aparece, o que está escrito na tela, som
    regras-da-referencia.json  valores prontos para o motor de edição (ritmo, legenda, cores, sons, trilha)
    folha-de-quadros/          1 quadro por segundo em grade, com o tempo e os cortes marcados
    cortes-para-conferir*.jpg  o quadro antes e o depois de cada corte achado
    recortes/                  amostras da legenda e dos textos fixos em tamanho real
    medidas/                   dados brutos (nota de cena por quadro, áudio, texto lido, transcrição)

Nada é escrito na pasta do vídeo de referência. Pasta medida por uma versão antiga pode ter os mesmos dois arquivos
com o nome de antes (mapa.csv e receita.json); quem lê a pasta usa o nome novo e, se ele faltar, o antigo.
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import unicodedata
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

try:
    import numpy as np
except ImportError:  # pragma: no cover
    print("erro: falta o numpy. Rode com o Python da skill:\n"
          '  uv run --directory "<SKILL>" python helpers/medir_referencia.py ...', file=sys.stderr)
    sys.exit(2)
os.environ.setdefault("OPENCV_LOG_LEVEL", "ERROR")
try:
    import cv2
except ImportError:  # pragma: no cover
    cv2 = None
from PIL import Image, ImageDraw, ImageFont

VERSAO = 1
SKILL = Path(__file__).resolve().parent.parent   # esta skill (helpers/..)
EV_PADRAO = Path(os.environ.get("EDITOR_VIDEO_DIR") or SKILL)
SR = 22050            # taxa do áudio analisado
JANELA = 1024         # amostras por janela de espectro (46 ms)
PASSO = 256           # amostras entre janelas (11,6 ms)
BANDAS = {"sub": (25, 70), "grave": (70, 200), "corpo": (200, 800), "voz": (800, 3000), "agudo": (3500, 10000)}
PISO_CENA = 0.08      # nota de cena mínima para olhar um pico
RAZAO_PROVAVEL = 8.0  # pico fraco conta como corte provável se for 8x maior que a vizinhança
ARQ_TRECHOS = "trechos-medidos.csv"        # um segundo por linha
ARQ_REGRAS = "regras-da-referencia.json"   # os números para máquina
NOMES_ANTIGOS = {ARQ_TRECHOS: "mapa.csv", ARQ_REGRAS: "receita.json"}   # como a versão antiga chamava


class ErroMedida(RuntimeError):
    pass


# ----------------------------------------------------------------------------- pequenos utilitários

_T0 = time.time()


def falar(msg: str) -> None:
    if re.match(r"\d/\d ", msg):
        msg = f"{msg} ({int(time.time() - _T0)} s)"
    print(msg, flush=True)


def aviso(msg: str) -> None:
    print(f"aviso: {msg}", flush=True)


def utf8_console() -> None:
    for s in (sys.stdout, sys.stderr):
        try:
            s.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
        except (AttributeError, ValueError, OSError):
            pass


def rodar(cmd: list[str], cwd: Path | None = None, checar: bool = True) -> subprocess.CompletedProcess:
    try:
        r = subprocess.run(cmd, cwd=cwd, capture_output=True)
    except FileNotFoundError as exc:
        raise ErroMedida(f"não achei o programa '{cmd[0]}'. Instale o ffmpeg (no Mac: brew install ffmpeg).") from exc
    if checar and r.returncode != 0:
        fim = r.stderr.decode("utf-8", "replace").strip()[-600:]
        raise ErroMedida(f"o comando {Path(cmd[0]).name} falhou: {fim}")
    return r


def mmss(t: float) -> str:
    t = max(0.0, float(t))
    m = int(t // 60)
    return f"{m:02d}:{t - 60 * m:05.2f}"


def mmss_curto(t: float) -> str:
    t = max(0.0, float(t))
    return f"{int(t // 60):02d}:{int(t % 60):02d}"


def br(x: float | None, casas: int = 1) -> str:
    if x is None or (isinstance(x, float) and not math.isfinite(x)):
        return "?"
    return f"{x:.{casas}f}".replace(".", ",")


def pct(x: float | None, casas: int = 0) -> str:
    return "?" if x is None else f"{br(100 * x, casas)}%"


def hexcor(rgb) -> str:
    return "#%02X%02X%02X" % tuple(int(round(max(0, min(255, float(c))))) for c in rgb)


def norm_txt(s: str) -> str:
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(c for c in s if not unicodedata.combining(c)).lower()
    return re.sub(r"[^a-z0-9 ]+", "", re.sub(r"\s+", " ", s)).strip()


def salvar_json(caminho: Path, dados) -> None:
    tmp = caminho.with_suffix(caminho.suffix + ".tmp")
    tmp.write_text(json.dumps(dados, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(caminho)


def python_ev(ev: Path) -> list[str]:
    """Python que roda os helpers da skill em ev: o mesmo deste processo quando ev é esta skill."""
    try:
        if ev.resolve() == SKILL:
            return [sys.executable]
    except OSError:
        pass
    for p in (ev / ".venv" / "bin" / "python", ev / ".venv" / "Scripts" / "python.exe"):
        if p.exists():
            return [str(p)]
    return ["uv", "run", "--directory", str(ev), "python"]


def carregar_fonte(ev: Path, tamanho: int):
    for p in (ev / "assets" / "fonts" / "figtree" / "Figtree-Variable.ttf",
              Path("/System/Library/Fonts/Menlo.ttc"),
              Path("C:/Windows/Fonts/arial.ttf"),
              Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")):
        try:
            f = ImageFont.truetype(str(p), tamanho)
            try:
                f.set_variation_by_axes([700])
            except Exception:
                pass
            return f
        except Exception:
            continue
    try:
        return ImageFont.load_default(size=tamanho)
    except TypeError:
        return ImageFont.load_default()


# ----------------------------------------------------------------------------- 1. ficha técnica

def _fracao(txt: str | None) -> float:
    if not txt or txt in ("0/0", "N/A"):
        return 0.0
    if "/" in txt:
        a, b = txt.split("/", 1)
        return float(a) / float(b) if float(b) else 0.0
    return float(txt)


def _proporcao(w: int, h: int) -> str:
    alvo = {"9:16": 9 / 16, "4:5": 4 / 5, "1:1": 1.0, "16:9": 16 / 9, "3:4": 3 / 4, "4:3": 4 / 3, "2:3": 2 / 3}
    r = w / h
    nome, val = min(alvo.items(), key=lambda kv: abs(kv[1] - r))
    return nome if abs(val - r) / val < 0.03 else f"{w}:{h}"


def ficha_tecnica(video: Path) -> dict:
    r = rodar(["ffprobe", "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(video)])
    d = json.loads(r.stdout.decode("utf-8", "replace"))
    vs = [s for s in d.get("streams", []) if s.get("codec_type") == "video"
          and not (s.get("disposition") or {}).get("attached_pic")]
    if not vs:
        raise ErroMedida("esse arquivo não tem imagem de vídeo.")
    v = vs[0]
    a = next((s for s in d.get("streams", []) if s.get("codec_type") == "audio"), None)
    dur = float(d.get("format", {}).get("duration") or v.get("duration") or 0)
    w, h = int(v["width"]), int(v["height"])
    rot = 0
    for sd in v.get("side_data_list") or []:
        if "rotation" in sd:
            rot = int(round(float(sd["rotation"])))
    if "rotate" in (v.get("tags") or {}):
        rot = int(v["tags"]["rotate"])
    if abs(rot) % 180 == 90:
        w, h = h, w
    fps_med = _fracao(v.get("avg_frame_rate"))
    fps_nom = _fracao(v.get("r_frame_rate"))
    fps = fps_med or fps_nom or 30.0
    sha = hashlib.sha256()
    with open(video, "rb") as fh:
        for bloco in iter(lambda: fh.read(1 << 20), b""):
            sha.update(bloco)
    return {
        "arquivo": video.name,
        "sha256": sha.hexdigest(),
        "tamanho_mb": round(video.stat().st_size / 1e6, 2),
        "duracao_s": round(dur, 3),
        "largura": w, "altura": h, "rotacao": rot,
        "proporcao": _proporcao(w, h),
        "vertical": h > w,
        "fps": round(fps, 3),
        "fps_variavel": bool(fps_nom and fps_med and abs(fps_nom - fps_med) > 0.5),
        "codec_video": v.get("codec_name"),
        "tem_audio": a is not None,
        "codec_audio": a.get("codec_name") if a else None,
        "canais_audio": int(a.get("channels") or 0) if a else 0,
        "taxa_audio_hz": int(a.get("sample_rate") or 0) if a else 0,
    }


# ----------------------------------------------------------------------------- 2. cortes

def pontuar_cenas(video: Path, tmp: Path) -> tuple[np.ndarray, np.ndarray]:
    """Nota de mudança de cena do ffmpeg (0 a 1) para cada quadro."""
    rodar(["ffmpeg", "-v", "error", "-nostdin", "-i", str(video), "-an", "-sn", "-dn", "-vf",
           "scale=320:-2,select='gte(scene\\,0)',metadata=print:key=lavfi.scene_score:file=cenas.txt",
           "-f", "null", "-"], cwd=tmp)
    tempos: list[float] = []
    notas: list[float] = []
    t = None
    for linha in (tmp / "cenas.txt").read_text(errors="replace").splitlines():
        m = re.search(r"pts_time:(-?[\d.]+)", linha)
        if m:
            t = float(m.group(1))
            continue
        m = re.search(r"scene_score=([\d.]+)", linha)
        if m and t is not None:
            tempos.append(t)
            notas.append(float(m.group(1)))
            t = None
    return np.array(tempos, dtype=np.float64), np.array(notas, dtype=np.float64)


def achar_cortes(tempos: np.ndarray, notas: np.ndarray, dur: float, limiar: float) -> list[dict]:
    n = len(notas)
    cand: list[dict] = []
    for i in range(1, n):
        t, s = float(tempos[i]), float(notas[i])
        if s < PISO_CENA or t < 0.1 or t > dur - 0.05:
            continue
        if s < notas[max(0, i - 3):i + 4].max():
            continue
        viz = np.concatenate([notas[max(0, i - 15):max(0, i - 2)], notas[i + 3:i + 16]])
        base = float(np.median(viz)) if len(viz) else 0.0
        razao = s / max(base, 0.003)
        if s >= limiar:
            tipo = "corte"
        elif razao >= RAZAO_PROVAVEL:
            tipo = "provavel"
        else:
            continue
        cand.append({"t": round(t, 3), "quadro": i, "nota": round(s, 3), "razao": round(razao, 1), "tipo": tipo})
    grupos: list[list[dict]] = []
    for c in cand:
        if grupos and c["t"] - grupos[-1][-1]["t"] < 0.25:
            grupos[-1].append(c)
        else:
            grupos.append([c])
    cortes = []
    for g in grupos:
        melhor = dict(max(g, key=lambda c: c["nota"]))
        if any(c["tipo"] == "corte" for c in g):
            melhor["tipo"] = "corte"
        melhor["picos_juntos"] = len(g)
        if len(g) >= 3:
            melhor["obs"] = "vários quadros mudando seguidos: pode ser transição animada"
        cortes.append(melhor)
    return cortes


def medir_ritmo(cortes_t: list[float], dur: float) -> dict:
    cortes_t = sorted(cortes_t)
    bordas = [0.0] + cortes_t + [dur]
    planos = [b - a for a, b in zip(bordas, bordas[1:]) if b - a > 0.01]
    inter = [b - a for a, b in zip(cortes_t, cortes_t[1:])]
    n = len(cortes_t)
    maior_i = int(np.argmax(planos)) if planos else 0
    por10 = []
    for a in range(0, int(math.ceil(dur / 10.0))):
        por10.append(sum(1 for t in cortes_t if a * 10 <= t < (a + 1) * 10))
    return {
        "cortes": n,
        "cortes_por_minuto": round(n / (dur / 60.0), 1) if dur > 0 else 0.0,
        "plano_medio_s": round(float(np.mean(planos)), 2) if planos else round(dur, 2),
        "plano_mediano_s": round(float(np.median(planos)), 2) if planos else round(dur, 2),
        "intervalo_medio_entre_cortes_s": round(float(np.mean(inter)), 2) if inter else None,
        "plano_mais_curto_s": round(min(planos), 2) if planos else None,
        "plano_mais_longo_s": round(max(planos), 2) if planos else None,
        "maior_trecho_sem_corte": {"inicio": round(bordas[maior_i], 2), "fim": round(bordas[maior_i + 1], 2)}
        if planos else None,
        "planos_curtos_pct": round(sum(1 for p in planos if p < 1.5) / len(planos), 3) if planos else 0.0,
        "cortes_nos_3_primeiros_s": sum(1 for t in cortes_t if t < 3.0),
        "primeiro_corte_s": round(cortes_t[0], 3) if cortes_t else None,
        "cortes_por_10s": por10,
    }


# ----------------------------------------------------------------------------- 3. quadros

def tela_padrao(w: int, h: int) -> tuple[int, int]:
    """Tela de referência para converter medidas: 1080x1920 (vertical), 1920x1080 (horizontal) ou 1080x1080."""
    r = w / h
    if r < 0.9:
        return 1080, 1920
    if r > 1.1:
        return 1920, 1080
    return 1080, 1080


def tamanho_saida(w: int, h: int, lado: int) -> tuple[int, int]:
    esc = min(1.0, lado / max(w, h))
    return max(2, int(round(w * esc / 2)) * 2), max(2, int(round(h * esc / 2)) * 2)


def _tempos_showinfo(stderr: bytes) -> list[float]:
    txt = stderr.decode("utf-8", "replace")
    return [float(m.group(1)) for m in re.finditer(r"\bn:\s*\d+\s+pts:\s*-?\d+\s+pts_time:(-?[\d.]+)", txt)]


def quadros_por_segundo(video: Path, pasta: Path, w: int, h: int) -> list[tuple[float, Path]]:
    pasta.mkdir(parents=True, exist_ok=True)
    vf = f"select='isnan(prev_t)+gt(floor(t)\\,floor(prev_t))',scale={w}:{h},showinfo"
    r = rodar(["ffmpeg", "-hide_banner", "-nostdin", "-nostats", "-loglevel", "info", "-i", str(video),
               "-an", "-sn", "-vf", vf, "-fps_mode", "vfr", "-q:v", "3", str(pasta / "q_%05d.jpg")])
    tempos = _tempos_showinfo(r.stderr)
    arqs = sorted(pasta.glob("q_*.jpg"))
    if len(tempos) != len(arqs):
        tempos = [float(i) for i in range(len(arqs))]
    return list(zip(tempos, arqs))


def quadros_por_indice(video: Path, tempos_q: np.ndarray, indices: list[int], pasta: Path,
                       w: int, h: int) -> dict[int, Path]:
    """Tira quadros exatos pelo número (o mesmo da nota de cena). Quadros seguidos saem numa chamada só."""
    n_total = len(tempos_q)
    pedidos = sorted({int(i) for i in indices if 0 <= i < n_total})
    if not pedidos:
        return {}
    pasta.mkdir(parents=True, exist_ok=True)
    grupos: list[list[int]] = []
    for i in pedidos:
        if grupos and i - grupos[-1][-1] == 1 and len(grupos[-1]) < 8:
            grupos[-1].append(i)
        else:
            grupos.append([i])

    def tirar(g: list[int]) -> list[tuple[int, Path]]:
        k0 = g[0]
        alvo = (float(tempos_q[k0 - 1]) + float(tempos_q[k0])) / 2 if k0 > 0 else 0.0
        pref = f"g{k0:07d}_"
        subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{alvo:.4f}", "-i", str(video), "-an", "-sn",
                        "-frames:v", str(len(g)), "-vf", f"scale={w}:{h}", "-fps_mode", "passthrough", "-q:v", "3",
                        str(pasta / f"{pref}%02d.jpg")], capture_output=True)
        return list(zip(g, sorted(pasta.glob(f"{pref}*.jpg"))))

    saida: dict[int, Path] = {}
    with ThreadPoolExecutor(max_workers=max(2, min(6, (os.cpu_count() or 4) // 2))) as ex:
        for pares in ex.map(tirar, grupos):
            saida.update(dict(pares))
    return saida


# ----------------------------------------------------------------------------- 4. rosto

class Rostos:
    def __init__(self, ev: Path):
        self.det = None
        modelo = ev / "assets" / "models" / "face_detection_yunet_2023mar.onnx"
        if cv2 is not None and hasattr(cv2, "FaceDetectorYN") and modelo.exists():
            try:
                self.det = cv2.FaceDetectorYN.create(str(modelo), "", (320, 320), 0.7, 0.3, 50)
            except Exception:
                self.det = None

    @property
    def ok(self) -> bool:
        return self.det is not None

    def medir(self, img) -> dict | None:
        if self.det is None or img is None:
            return None
        if max(img.shape[:2]) > 640:   # as medidas são em fração do quadro; imagem menor acha o mesmo rosto mais rápido
            esc = 640.0 / max(img.shape[:2])
            img = cv2.resize(img, (int(img.shape[1] * esc), int(img.shape[0] * esc)), interpolation=cv2.INTER_AREA)
        h, w = img.shape[:2]
        self.det.setInputSize((w, h))
        try:
            _, faces = self.det.detect(img)
        except Exception:
            return None
        if faces is None or len(faces) == 0:
            return None
        f = max(faces, key=lambda r: r[2] * r[3])
        x, y, fw, fh = [float(v) for v in f[:4]]
        return {"altura": round(fh / h, 4), "largura": round(fw / w, 4),
                "cx": round((x + fw / 2) / w, 4), "cy": round((y + fh / 2) / h, 4),
                "olhos_y": round(float(f[5] + f[7]) / 2 / h, 4), "conf": round(float(f[14]), 3),
                "rostos": int(len(faces))}


def mesma_cena(a, b) -> bool:
    """Dois quadros parecem da mesma gravação (mesmas cores de fundo), mesmo com zoom diferente."""
    if a is None or b is None:
        return False
    ha = cv2.calcHist([cv2.cvtColor(a, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 16], [0, 180, 0, 256])
    hb = cv2.calcHist([cv2.cvtColor(b, cv2.COLOR_BGR2HSV)], [0, 1], None, [30, 16], [0, 180, 0, 256])
    cv2.normalize(ha, ha)
    cv2.normalize(hb, hb)
    return float(cv2.compareHist(ha, hb, cv2.HISTCMP_CORREL)) >= 0.75


def tipo_de_plano(altura_rosto: float | None) -> str:
    if altura_rosto is None:
        return "sem rosto"
    if altura_rosto >= 0.30:
        return "rosto bem perto"
    if altura_rosto >= 0.17:
        return "rosto perto"
    if altura_rosto >= 0.09:
        return "plano médio"
    return "plano aberto"


# ----------------------------------------------------------------------------- 5. cores

def _lab(px_bgr: np.ndarray) -> np.ndarray:
    lab = cv2.cvtColor(px_bgr.reshape(-1, 1, 3).astype(np.uint8), cv2.COLOR_BGR2LAB).reshape(-1, 3).astype(np.float32)
    lab[:, 0] *= 100.0 / 255.0
    lab[:, 1:] -= 128.0
    return lab


def _lab_hex(lab) -> str:
    l8 = np.array([[[lab[0] * 255.0 / 100.0, lab[1] + 128.0, lab[2] + 128.0]]]).clip(0, 255).astype(np.uint8)
    return hexcor(cv2.cvtColor(l8, cv2.COLOR_LAB2RGB)[0, 0])


def _kmeans(lab: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]:
    k = max(1, min(k, len(lab)))
    crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
    cv2.setRNGSeed(7)
    _, rot, cen = cv2.kmeans(lab.astype(np.float32), k, None, crit, 3, cv2.KMEANS_PP_CENTERS)
    return rot.ravel(), cen


def _juntar_cores(itens: list[dict], limite: float) -> list[dict]:
    """itens: {lab, peso}. Junta cores parecidas (distância Lab menor que o limite)."""
    grupos: list[dict] = []
    for it in sorted(itens, key=lambda x: -x["peso"]):
        for g in grupos:
            if float(np.linalg.norm(g["lab"] - it["lab"])) < limite:
                tot = g["peso"] + it["peso"]
                g["lab"] = (g["lab"] * g["peso"] + it["lab"] * it["peso"]) / tot
                g["peso"] = tot
                g["n"] = g.get("n", 1) + it.get("n", 1)
                break
        else:
            grupos.append({"lab": np.array(it["lab"], dtype=np.float32), "peso": it["peso"], "n": it.get("n", 1)})
    return sorted(grupos, key=lambda g: -g["peso"])


def descrever_cor(lab) -> str:
    l, a, b = [float(v) for v in lab]
    croma = math.hypot(a, b)
    if croma < 10:
        if l > 85:
            return "branco"
        if l < 15:
            return "preto"
        return "cinza claro" if l > 60 else ("cinza" if l > 35 else "cinza escuro")
    ang = (math.degrees(math.atan2(b, a)) + 360) % 360
    nomes = [(20, "rosa"), (45, "vermelho"), (70, "laranja"), (100, "amarelo"), (135, "verde-amarelado"),
             (170, "verde"), (215, "verde-azulado"), (260, "azul"), (300, "roxo"), (340, "lilás"), (361, "rosa")]
    nome = next(n for lim, n in nomes if ang < lim)
    if nome == "laranja" and l < 45:
        nome = "marrom"
    tom = "claro" if l > 70 else ("escuro" if l < 35 else "")
    return f"{nome} {tom}".strip()


def paleta_da_imagem(quadros: list[tuple[float, Path]]) -> dict:
    if cv2 is None or not quadros:
        return {}
    amostras = []
    brilho = []
    satur = []
    for _, arq in quadros:
        img = cv2.imread(str(arq))
        if img is None:
            continue
        peq = cv2.resize(img, (48, int(48 * img.shape[0] / img.shape[1])), interpolation=cv2.INTER_AREA)
        amostras.append(peq.reshape(-1, 3))
        hsv = cv2.cvtColor(peq, cv2.COLOR_BGR2HSV)
        brilho.append(float(hsv[..., 2].mean()) / 255.0)
        satur.append(float(hsv[..., 1].mean()) / 255.0)
    if not amostras:
        return {}
    px = np.concatenate(amostras)
    lab = _lab(px)
    rot, cen = _kmeans(lab, 10)
    itens = [{"lab": cen[i], "peso": float((rot == i).mean())} for i in range(len(cen))]
    grupos = _juntar_cores(itens, 12.0)
    dominantes = [{"hex": _lab_hex(g["lab"]), "fracao": round(g["peso"], 3), "nome": descrever_cor(g["lab"]),
                   "luz": round(float(g["lab"][0]), 1), "croma": round(float(np.hypot(g["lab"][1], g["lab"][2])), 1)}
                  for g in grupos[:7]]
    # cores saturadas que aparecem pouco mas chamam atenção (candidatas a destaque)
    rot2, cen2 = _kmeans(lab[np.hypot(lab[:, 1], lab[:, 2]) > 30], 6) if (np.hypot(lab[:, 1], lab[:, 2]) > 30).sum() > 50 \
        else (np.array([]), np.zeros((0, 3)))
    destaque = []
    if len(cen2):
        itens2 = [{"lab": cen2[i], "peso": float((rot2 == i).mean())} for i in range(len(cen2))]
        tot_sat = float((np.hypot(lab[:, 1], lab[:, 2]) > 30).mean())
        for g in _juntar_cores(itens2, 15.0)[:4]:
            destaque.append({"hex": _lab_hex(g["lab"]), "nome": descrever_cor(g["lab"]),
                             "fracao_da_imagem": round(g["peso"] * tot_sat, 4)})
    a_med = float(lab[:, 1].mean())
    b_med = float(lab[:, 2].mean())
    return {
        "dominantes": dominantes,
        "saturadas": destaque,
        "brilho_medio": round(float(np.mean(brilho)), 3),
        "saturacao_media": round(float(np.mean(satur)), 3),
        "quadros_escuros_pct": round(float(np.mean([b < 0.30 for b in brilho])), 3),
        "quadros_claros_pct": round(float(np.mean([b > 0.75 for b in brilho])), 3),
        "temperatura": "quente" if b_med > 6 or a_med > 6 else ("fria" if b_med < -4 else "neutra"),
    }


def cores_do_texto(img, caixa: tuple[int, int, int, int]) -> dict | None:
    """Cor do preenchimento, do contorno e do fundo de uma linha de texto (recorte BGR)."""
    x0, y0, x1, y1 = caixa
    rec = img[y0:y1, x0:x1]
    if rec.size == 0 or rec.shape[0] < 8 or rec.shape[1] < 8:
        return None
    lab = _lab(rec.reshape(-1, 3)).reshape(rec.shape[0], rec.shape[1], 3)
    anel = np.concatenate([lab[:2].reshape(-1, 3), lab[-2:].reshape(-1, 3), lab[:, :2].reshape(-1, 3),
                           lab[:, -2:].reshape(-1, 3)])
    fundo = np.median(anel, axis=0)
    fundo_liso = float(anel[:, 0].std()) < 7.0
    todos = lab.reshape(-1, 3)
    dist = np.linalg.norm(todos - fundo, axis=1)
    tinta = todos[dist > 28]
    if len(tinta) < 0.03 * len(todos) or len(tinta) < 30:
        return None
    rot, cen = _kmeans(tinta, 3)
    d_tinta = dist[dist > 28]
    grupos = []
    for i in range(len(cen)):
        sel = rot == i
        fr = float(sel.mean())
        if not sel.any():
            continue
        px, dd = tinta[sel], d_tinta[sel]
        miolo = px[dd >= np.median(dd)].mean(0)   # o miolo da letra, sem a borda misturada com o fundo
        grupos.append({"lab": miolo, "fracao": fr, "luz": float(miolo[0]), "croma": float(np.hypot(miolo[1], miolo[2]))})
    escuro_fundo = float(fundo[0]) < 30
    contorno = None
    if not escuro_fundo:
        escuros = [g for g in grupos if g["luz"] < 25 and g["fracao"] >= 0.12]
        if escuros:
            contorno = max(escuros, key=lambda g: g["fracao"])
    enche = [g for g in grupos if g is not contorno and g["fracao"] >= 0.10 and (g["luz"] > 50 or g["croma"] > 35)]
    if not enche:
        enche = [max(grupos, key=lambda g: float(np.linalg.norm(g["lab"] - fundo)))]
    enche.sort(key=lambda g: -g["fracao"])
    duas = len(enche) >= 2 and float(np.linalg.norm(enche[0]["lab"] - enche[1]["lab"])) > 30
    return {
        "preenchimento": [{"lab": g["lab"], "hex": _lab_hex(g["lab"]), "fracao": round(g["fracao"], 3)} for g in enche[:2]],
        "contorno_escuro": contorno is not None,
        "fundo_hex": _lab_hex(fundo),
        "fundo_liso": fundo_liso,
        "duas_cores": duas,
    }


def papeis_das_cores(amostras_cor: list[dict]) -> dict:
    """Separa cor do texto, cor de destaque (palavra falada) e cor apagada (palavra que ainda vem)."""
    itens = [{"lab": p["lab"], "peso": 1.0, "n": 1} for c in amostras_cor for p in c["preenchimento"][:2]]
    grupos = [g for g in _juntar_cores(itens, 16.0) if g["peso"] >= max(2, 0.06 * len(amostras_cor))]
    if not grupos:
        return {}

    def croma(g):
        return float(np.hypot(g["lab"][1], g["lab"][2]))
    neutros_claros = [g for g in grupos if croma(g) < 22 and g["lab"][0] >= 70]
    coloridos = [g for g in grupos if croma(g) >= 25]
    apagados = [g for g in grupos if croma(g) < 22 and 35 <= g["lab"][0] < 70]
    out: dict = {}
    if neutros_claros:
        texto = max(neutros_claros, key=lambda g: g["peso"])
        destaque = max(coloridos, key=lambda g: g["peso"]) if coloridos else None
    else:
        texto = max(grupos, key=lambda g: g["peso"])
        resto = [g for g in coloridos if float(np.linalg.norm(g["lab"] - texto["lab"])) > 30]
        destaque = max(resto, key=lambda g: g["peso"]) if resto else None
    out["cor_texto"] = _lab_hex(texto["lab"])
    if destaque is not None:
        out["cor_destaque"] = _lab_hex(destaque["lab"])
        out["duas_cores_pct"] = round(min(1.0, destaque["peso"] / len(amostras_cor)), 3)
    if apagados:
        g = max(apagados, key=lambda g: g["peso"])
        if g["peso"] >= 0.1 * len(amostras_cor):
            out["cor_apagada"] = _lab_hex(g["lab"])
    out["cores_lidas"] = [{"hex": _lab_hex(g["lab"]), "linhas": int(g["peso"])} for g in grupos[:5]]
    return out


# ----------------------------------------------------------------------------- 6. texto na tela

def ler_texto_na_tela(video: Path, ev: Path, medidas: Path, transcricao: Path | None, fps: float) -> dict | None:
    script = ev / "helpers" / "texto_na_tela.py"
    if not script.exists():
        aviso(f"não achei {script}; sigo sem ler o texto da tela.")
        return None
    if transcricao is None:
        # sem transcrição o helper texto_na_tela.py quebra (usa chaves antigas); uma palavra falsa evita isso
        transcricao = medidas / "_sem_fala.json"
        salvar_json(transcricao, {"words": [{"word": "zzzzzz", "start": -100.0, "end": -99.0}]})
    cmd = python_ev(ev) + [str(script), "--video", str(video), "--edit-dir", str(medidas), "--fps", str(fps),
                           "--force", "--transcript", str(transcricao)]
    r = subprocess.run(cmd, capture_output=True, cwd=ev)
    arq = medidas / "texto_na_tela.json"
    if r.returncode != 0 or not arq.exists():
        aviso("a leitura do texto da tela falhou: " + r.stderr.decode("utf-8", "replace").strip()[-300:])
        return None
    return json.loads(arq.read_text(encoding="utf-8"))


def _eh_palavra(txt: str) -> bool:
    letras = sum(ch.isalpha() for ch in txt)
    return letras >= 2 and letras >= 0.5 * max(1, len(txt.replace(" ", "")))


def analisar_textos(doc: dict, palavras: list[dict] | None, W: int, H: int) -> dict:
    amostras = doc.get("amostras", [])
    passo = 1.0 / float(doc.get("fps") or 2.0)
    leu_palavras = doc.get("backend") == "vision"
    # texto que fica: o mesmo texto quase no mesmo lugar em amostras vizinhas (liga em cadeia)
    nos: list[tuple[int, int]] = [(i, j) for i, am in enumerate(amostras) for j in range(len(am["itens"]))]
    pai = {n: n for n in nos}

    def raiz(n):
        while pai[n] != n:
            pai[n] = pai[pai[n]]
            n = pai[n]
        return n
    chaves = {(i, j): norm_txt(amostras[i]["itens"][j].get("texto", "")) for i, j in nos}
    for i, am in enumerate(amostras):
        for j, it in enumerate(am["itens"]):
            k = chaves[(i, j)]
            if len(k) < 2:
                continue
            for di in (1, 2):
                if i + di >= len(amostras):
                    break
                for j2, it2 in enumerate(amostras[i + di]["itens"]):
                    if chaves[(i + di, j2)] == k and abs(it2["centro_y"] - it["centro_y"]) < 0.02 \
                            and abs(it2["centro_x"] - it["centro_x"]) < 0.08:
                        pai[raiz((i + di, j2))] = raiz((i, j))
    faixa_comp: dict = defaultdict(list)
    for n in nos:
        faixa_comp[raiz(n)].append(n[0])
    dur_no = {n: (max(faixa_comp[raiz(n)]) - min(faixa_comp[raiz(n)]) + 1) * passo for n in nos}
    falado = []
    if palavras:
        for w in palavras:
            nt = norm_txt(str(w.get("text", w.get("word", ""))))
            if len(nt) >= 2:
                falado.append((float(w.get("start", 0)), nt))

    def bate_com_fala(t: float, txt: str) -> bool:
        escritas = {p for p in norm_txt(txt).split() if len(p) >= 2}
        if not escritas:
            return False
        ditas = {p for (ts, p) in falado if t - 1.5 <= ts <= t + 1.5}
        return bool(escritas & ditas)

    itens: list[dict] = []
    for i, am in enumerate(amostras):
        for j, it in enumerate(am["itens"]):
            txt = str(it.get("texto", "")).strip()
            dur_s = dur_no[(i, j)]
            e = dict(it)
            e["t"] = float(am["t"])
            e["amostra"] = i
            e["dur_s"] = round(dur_s, 2)
            e["fala"] = bate_com_fala(e["t"], txt) if falado else None
            # texto fixo: fica parado e não é a fala escrita (legenda lenta também fica 3 a 6 s no lugar)
            if falado:
                fixo = (dur_s >= 3.0 and not e["fala"]) or dur_s >= 10.0
            else:
                fixo = dur_s >= 6.0
            e["fixo"] = bool(leu_palavras and len(chaves[(i, j)]) >= 2 and fixo)
            e["palavra"] = _eh_palavra(txt) if leu_palavras else True
            itens.append(e)

    # candidatos a legenda: texto que troca, com letras, mais ou menos no meio da largura
    cand = [e for e in itens if not e["fixo"] and e["palavra"] and 0.2 <= e["centro_x"] <= 0.8]
    usou_fala = False
    if falado:
        cand_fala = [e for e in cand if e["fala"]]
        if len(cand_fala) >= 3:
            cand = cand_fala
            usou_fala = True
    if not usou_fala and leu_palavras:
        cand = [e for e in cand if e["altura"] >= 0.015]   # sem a fala, letreiro miúdo (HUD, rótulo) não conta
    res: dict = {"motor": doc.get("backend"), "amostras": len(amostras), "fps_leitura": doc.get("fps"),
                 "tem_legenda": False, "achada_pela_fala": usou_fala}
    tela_w, tela_h = tela_padrao(W, H)
    res["tela_padrao"] = f"{tela_w}x{tela_h}"
    # grupos por altura na tela (a legenda pode mudar de lugar em alguns trechos)
    grupos_y: list[dict] = []

    def troca_bem(bl: list[dict]) -> bool:
        """Sem a fala para comparar, legenda é o texto que troca (letreiro, marca e produto repetem)."""
        if usou_fala or not leu_palavras:
            return True
        ams = sorted({e["amostra"] for e in bl})
        if len(ams) < 4:
            return False
        por = {i: " ".join(sorted(norm_txt(e["texto"]) for e in bl if e["amostra"] == i)) for i in ams}
        ts = [e["t"] for e in bl]
        return len(set(por.values())) / len(ams) >= 0.15 and max(ts) - min(ts) >= 4.0

    if cand:
        # posição principal: a altura com mais amostras numa janela estreita (a legenda não sai do lugar)
        centros_c = sorted({round(e["centro_y"] / 0.005) * 0.005 for e in cand})
        melhor, melhor_n = None, 0
        for yc in centros_c:
            bl = [e for e in cand if abs(e["centro_y"] - yc) <= 0.012]
            n = len({e["amostra"] for e in bl})
            if n > melhor_n and troca_bem(bl):
                melhor, melhor_n = yc, n
        if melhor is not None:
            principal_bl = [e for e in cand if abs(e["centro_y"] - melhor) <= 0.015]
            h_ref = float(np.median([e["altura"] for e in principal_bl]))
            # linhas vizinhas da mesma legenda (legenda de 2 ou 3 linhas) entram no bloco principal
            ids_p = {id(e) for e in principal_bl}
            por_am: dict[int, list[dict]] = defaultdict(list)
            for e in principal_bl:
                por_am[e["amostra"]].append(e)
            vizinhas = [e for e in cand if id(e) not in ids_p and e["amostra"] in por_am
                        and any(0.5 * h_ref < abs(e["centro_y"] - q["centro_y"]) <= 2.2 * h_ref
                                for q in por_am[e["amostra"]])]
            principal_bl = principal_bl + vizinhas
            usados = {id(e) for e in principal_bl}
            centros = []
            for i_am in por_am:
                ys_am = [e["centro_y"] for e in principal_bl if e["amostra"] == i_am]
                centros.append((min(ys_am) + max(ys_am)) / 2)
            grupos_y.append({"membros": principal_bl, "amostras": sorted({e["amostra"] for e in principal_bl}),
                             "centro_y": float(np.median(centros))})
            # outra posição só vale como bloco contínuo no tempo, com letra do mesmo tamanho da principal
            resto = sorted([e for e in cand if id(e) not in usados], key=lambda e: e["centro_y"])
            blocos: list[list[dict]] = []
            for e in resto:
                if blocos and e["centro_y"] - blocos[-1][-1]["centro_y"] <= 0.015:
                    blocos[-1].append(e)
                else:
                    blocos.append([e])
            for bl in blocos:
                bl = sorted(bl, key=lambda e: e["t"])
                corridas: list[list[dict]] = [[bl[0]]]
                for e in bl[1:]:
                    if e["t"] - corridas[-1][-1]["t"] > 1.5:
                        corridas.append([e])
                    else:
                        corridas[-1].append(e)
                for co in corridas:
                    am_ids = sorted({e["amostra"] for e in co})
                    dur_co = co[-1]["t"] - co[0]["t"] + passo
                    h_co = float(np.median([e["altura"] for e in co]))
                    c_co = float(np.median([e["centro_y"] for e in co]))
                    if abs(c_co - grupos_y[0]["centro_y"]) <= 1.5 * h_ref:
                        # colado na principal: é a mesma legenda (linha de cima ou de baixo)
                        grupos_y[0]["membros"] = grupos_y[0]["membros"] + co
                        grupos_y[0]["amostras"] = sorted(set(grupos_y[0]["amostras"]) | set(am_ids))
                    elif len(am_ids) >= 4 and dur_co >= 2.5 and 0.7 * h_ref <= h_co <= 1.4 * h_ref and troca_bem(co):
                        grupos_y.append({"membros": co, "amostras": am_ids, "centro_y": c_co})
    legenda = [e for g in grupos_y for e in g["membros"]]
    amostras_com = sorted({e["amostra"] for e in legenda})
    # legenda cobre boa parte da fala; título de gancho que repete a fala por alguns segundos não conta
    if usou_fala:
        ts_f = [ts for ts, _ in falado]
        base_am = [i for i, am in enumerate(amostras) if any(am["t"] - 0.5 <= x <= am["t"] + 0.5 for x in ts_f)]
        cobertura = len(set(amostras_com) & set(base_am)) / max(1, len(base_am))
        minimo_ok = cobertura >= 0.20
    else:
        minimo_ok = len(amostras_com) >= 0.15 * len(amostras)
    if grupos_y and len(amostras_com) >= 3 and minimo_ok:
        res["tem_legenda"] = True
        faixa_c = grupos_y[0]["centro_y"]
        principal = grupos_y[0]["membros"]
        alturas = np.array([e["altura"] for e in principal])
        n_pal = [len(re.findall(r"\S+", e["texto"])) for e in principal if e.get("texto")]
        linhas: dict[int, int] = {}
        for i_am in {e["amostra"] for e in principal}:
            ys_am = sorted(e["centro_y"] for e in principal if e["amostra"] == i_am)
            n_lin = 1
            for y0, y1 in zip(ys_am, ys_am[1:]):
                if y1 - y0 > 0.5 * float(np.median(alturas)):
                    n_lin += 1
            linhas[i_am] = n_lin
        letras = "".join(e.get("texto", "") for e in principal)
        alfa = [c for c in letras if c.isalpha()]
        maius = sum(c.isupper() for c in alfa) / len(alfa) if alfa else None
        # largura de cada letra em relação à altura da linha (fonte estreita x larga)
        prop = []
        for e in principal:
            nch = len(e.get("texto", "").replace(" ", ""))
            if nch >= 3 and e["altura"] > 0:
                prop.append((e["largura"] * W / nch) / (e["altura"] * H))
        seq = []
        for i in range(len(amostras)):
            seq.append(" ".join(sorted(norm_txt(e["texto"]) for e in legenda if e["amostra"] == i)))
        trocas = sum(1 for a0, b0 in zip(seq, seq[1:]) if a0 and b0 and a0 != b0)
        minutos_com = len(amostras_com) * passo / 60.0
        com_fala = None
        if falado:
            ts_fala = [ts for ts, _ in falado]
            am_fala = [i for i, am in enumerate(amostras)
                       if any(am["t"] - 0.5 <= ts <= am["t"] + 0.5 for ts in ts_fala)]
            if am_fala:
                com_fala = round(sum(1 for i in am_fala if i in set(amostras_com)) / len(am_fala), 3)
        posicoes = []
        for g in grupos_y:
            ts = [amostras[i]["t"] for i in g["amostras"]]
            posicoes.append({"centro_y": round(g["centro_y"], 4), "centro_y_padrao": int(round(g["centro_y"] * tela_h)),
                             "amostras": len(g["amostras"]),
                             "parte_da_legenda": round(len(g["amostras"]) / max(1, len(amostras_com)), 3),
                             "de": round(min(ts), 2), "ate": round(max(ts) + passo, 2)})
        res.update({
            "centro_y": round(faixa_c, 4),
            "centro_y_px": int(round(faixa_c * H)),
            "centro_y_padrao": int(round(faixa_c * tela_h)),
            "posicoes": posicoes,
            "altura_linha_frac": round(float(np.median(alturas)), 4),
            "altura_linha_px": int(round(float(np.median(alturas)) * H)),
            "altura_linha_padrao": int(round(float(np.median(alturas)) * tela_h)),
            "palavras_por_linha_mediana": float(np.median(n_pal)) if n_pal else None,
            "palavras_por_linha_max": int(max(n_pal)) if n_pal else None,
            "palavras_por_linha_hist": dict(sorted(Counter(n_pal).items())) if n_pal else {},
            "linhas_por_vez": float(np.median(list(linhas.values()))) if linhas else None,
            "linhas_por_vez_pct": {str(k): round(v / len(linhas), 3) for k, v in sorted(Counter(linhas.values()).items())}
            if linhas else {},
            "caixa_alta_pct": round(maius, 3) if maius is not None else None,
            "largura_letra_sobre_altura": round(float(np.median(prop)), 3) if prop else None,
            "tempo_com_legenda_pct": round(len(amostras_com) / max(1, len(amostras)), 3),
            "fala_com_legenda_pct": com_fala,
            "trocas_por_minuto_amostradas": round(trocas / minutos_com, 1) if minutos_com > 0 else None,
            "centro_x_mediano": round(float(np.median([e["centro_x"] for e in principal])), 3),
            "casou_com_fala": bool(falado) and all(e["fala"] for e in legenda),
        })
    # textos fixos (títulos, rótulos, @, marca d'água)
    fixos: dict[tuple, dict] = {}
    for e in itens:
        if not e["fixo"]:
            continue
        k = (norm_txt(e["texto"]), round(e["centro_y"] / 0.04))
        f = fixos.setdefault(k, {"texto": e["texto"], "inicio": e["t"], "fim": e["t"], "centro_y": e["centro_y"],
                                 "altura_frac": e["altura"], "centro_x": e["centro_x"]})
        f["fim"] = max(f["fim"], e["t"])
        f["inicio"] = min(f["inicio"], e["t"])
    lista_fixos = []
    for f in sorted(fixos.values(), key=lambda f: f["inicio"]):
        t = f["texto"]
        marca = bool(re.search(r"@|www\.|\.com|\.br|https?:", t, re.I))
        lista_fixos.append({**f, "fim": round(f["fim"] + passo, 2), "duracao_s": round(f["fim"] + passo - f["inicio"], 2),
                            "altura_px": int(round(f["altura_frac"] * H)),
                            "palavras": len(t.split()), "parece_marca_ou_perfil": marca,
                            "zona": "topo" if f["centro_y"] < 0.3 else ("baixo" if f["centro_y"] > 0.7 else "meio")})
    res["textos_fixos"] = lista_fixos
    res["com_texto_pct"] = round(sum(1 for am in amostras if am["itens"]) / max(1, len(amostras)), 3)
    res["tela_clara_trechos"] = doc.get("tela_cheia", [])
    # texto no gancho (primeiros 3 s)
    gancho = [e for e in itens if e["t"] < 3.0 and e["palavra"]]
    grandes = [e for e in gancho if res.get("altura_linha_frac") and e["altura"] > 1.3 * res["altura_linha_frac"]]
    res["gancho"] = {
        "tem_texto_nos_3s": bool(gancho),
        "texto_maior_que_legenda": bool(grandes),
        "altura_max_px": int(round(max((e["altura"] for e in gancho), default=0) * H)),
        "zona": (lambda y: "topo" if y < 0.3 else ("baixo" if y > 0.7 else "meio"))(
            float(np.median([e["centro_y"] for e in (grandes or gancho)]))) if gancho else None,
    }
    res["_itens"] = itens
    res["_legenda"] = legenda
    res["_principal"] = grupos_y[0]["membros"] if res.get("tem_legenda") else []
    return res


# ----------------------------------------------------------------------------- 7. áudio

def decodificar_audio(video: Path) -> np.ndarray | None:
    r = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-i", str(video), "-vn", "-ac", "2", "-ar", str(SR),
                        "-f", "f32le", "-"], capture_output=True)
    x = np.frombuffer(r.stdout, np.float32)
    if x.size < SR:
        return None
    return x[: x.size // 2 * 2].reshape(-1, 2)


def medir_loudness(video: Path) -> dict:
    r = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-i", str(video), "-vn", "-af",
                        "loudnorm=print_format=json", "-f", "null", "-"], capture_output=True)
    txt = r.stderr.decode("utf-8", "replace")
    m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", txt, re.S)
    if not m:
        return {}
    d = json.loads(m.group(0))

    def f(v):
        try:
            x = float(v)
            return round(x, 1) if math.isfinite(x) else None
        except (TypeError, ValueError):
            return None
    return {"integrado_lufs": f(d.get("input_i")), "pico_real_dbtp": f(d.get("input_tp")),
            "variacao_lu": f(d.get("input_lra"))}


def espectro(sinal: np.ndarray, acumular: np.ndarray | None = None) -> dict:
    n = 1 + max(0, (len(sinal) - JANELA) // PASSO)
    jan = np.hanning(JANELA).astype(np.float32)
    freqs = np.fft.rfftfreq(JANELA, 1.0 / SR)
    sel = {b: (freqs >= a) & (freqs < z) for b, (a, z) in BANDAS.items()}
    E = {b: np.empty(n, np.float32) for b in BANDAS}
    rms = np.empty(n, np.float32)
    fluxo = np.zeros(n, np.float32)
    soma = np.zeros(len(freqs), np.float64)
    n_soma = 0
    ant = None
    for c0 in range(0, n, 2048):
        c1 = min(n, c0 + 2048)
        idx = np.arange(c0, c1)[:, None] * PASSO + np.arange(JANELA)[None, :]
        fr = sinal[idx]
        rms[c0:c1] = np.sqrt(np.mean(fr ** 2, axis=1))
        mag = np.abs(np.fft.rfft(fr * jan, axis=1)).astype(np.float32)
        p = mag ** 2
        for b, s in sel.items():
            E[b][c0:c1] = 10 * np.log10(p[:, s].sum(1) + 1e-10)
        lm = np.log1p(mag)
        prev = np.vstack([(ant if ant is not None else lm[0])[None, :], lm[:-1]])
        fluxo[c0:c1] = np.maximum(0.0, lm - prev).sum(1)
        ant = lm[-1]
        if acumular is not None:
            m = acumular[c0:c1]
            if m.any():
                soma += p[m].sum(0)
                n_soma += int(m.sum())
    return {"n": n, "t": (np.arange(n) * PASSO + JANELA / 2) / SR, "db": 20 * np.log10(rms + 1e-9),
            "bandas": E, "fluxo": fluxo, "freqs": freqs, "espectro_medio": soma / n_soma if n_soma else None}


def mediana_movel(x: np.ndarray, janela: int) -> np.ndarray:
    dec = 4
    xs = x[::dec]
    k = max(1, janela // (2 * dec))
    xp = np.pad(xs, k, mode="edge")
    v = np.lib.stride_tricks.sliding_window_view(xp, 2 * k + 1)
    med = np.median(v, axis=1)
    return np.interp(np.arange(len(x)), np.arange(len(xs)) * dec, med).astype(np.float32)


def picos(x: np.ndarray, limiar: float, dist: int) -> list[int]:
    if len(x) < 3:
        return []
    viz = np.maximum(np.roll(x, 1), np.roll(x, -1))
    cand = np.where((x >= limiar) & (x >= viz))[0]
    cand = cand[np.argsort(-x[cand])]
    livre = np.ones(len(x), bool)
    escolhidos = []
    for i in cand:
        if livre[i]:
            escolhidos.append(int(i))
            livre[max(0, i - dist):i + dist + 1] = False
    return sorted(escolhidos)


def _mascara_fala(tempos: np.ndarray, palavras: list[dict], folga: float = 0.05) -> np.ndarray:
    m = np.zeros(len(tempos), bool)
    for w in palavras:
        a, z = float(w.get("start", 0)) - folga, float(w.get("end", w.get("start", 0))) + folga
        i0, i1 = np.searchsorted(tempos, [a, z])
        m[i0:i1] = True
    return m


def _pausas(palavras: list[dict], dur: float, minimo: float = 0.3) -> list[tuple[float, float]]:
    ps = sorted(palavras, key=lambda w: float(w.get("start", 0)))
    out = []
    for a, b in zip(ps, ps[1:]):
        g0, g1 = float(a.get("end", a.get("start", 0))), float(b.get("start", 0))
        if g1 - g0 >= minimo:
            out.append((g0, g1))
    return out


def _bpm(env: np.ndarray, fr: float) -> tuple[float | None, float]:
    if len(env) < fr * 8:
        return None, 0.0
    e = (env - env.mean()).astype(np.float64)
    nfft = 1 << int(math.ceil(math.log2(2 * len(e))))
    ac = np.fft.irfft(np.abs(np.fft.rfft(e, nfft)) ** 2, nfft)[:len(e)]
    if ac[0] <= 0:
        return None, 0.0
    lo, hi = int(fr * 60 / 180), int(fr * 60 / 70)
    lag = lo + int(np.argmax(ac[lo:hi]))
    return round(60.0 * fr / lag, 1), round(float(ac[lag] / ac[0]), 2)


def picos_tonais(esp: np.ndarray | None, freqs: np.ndarray) -> int:
    if esp is None:
        return 0
    sel = (freqs >= 80) & (freqs <= 2500)
    db = 10 * np.log10(esp[sel] + 1e-12)
    base = np.array([np.median(db[max(0, i - 12):i + 13]) for i in range(len(db))])
    exc = db - base
    return len(picos(exc, 8.0, 3))


def rms_quadros(sinal: np.ndarray) -> np.ndarray:
    """Nível RMS (dBFS) em janelas de JANELA amostras a cada PASSO, sem FFT."""
    n = 1 + max(0, (len(sinal) - JANELA) // PASSO)
    cs = np.concatenate([[0.0], np.cumsum(sinal.astype(np.float64) ** 2)])
    i0 = np.arange(n) * PASSO
    energia = (cs[i0 + JANELA] - cs[i0]) / JANELA
    return (10 * np.log10(np.maximum(energia, 0) + 1e-18)).astype(np.float32)


def analisar_audio(video: Path, dur: float, palavras: list[dict] | None, cortes_t: list[float]) -> dict:
    x = decodificar_audio(video)
    if x is None:
        return {"tem_audio": False}
    res: dict = {"tem_audio": True}
    res["loudness"] = medir_loudness(video)
    L, R = x[:, 0].astype(np.float32), x[:, 1].astype(np.float32)
    M = (L + R) / 2
    S = (L - R) / 2
    rms_m = float(np.sqrt(np.mean(M ** 2)) + 1e-12)
    rms_s = float(np.sqrt(np.mean(S ** 2)) + 1e-12)
    lc, rc = L - L.mean(), R - R.mean()
    den = math.sqrt(float(np.dot(lc, lc)) * float(np.dot(rc, rc)))
    corr = float(np.dot(lc, rc)) / den if den > 0 else 1.0
    del lc, rc, x
    estereo = 20 * math.log10(rms_s / rms_m) > -30 and corr < 0.995
    res["correlacao_esq_dir"] = round(corr, 4)
    res["lado_menos_meio_db"] = round(20 * math.log10(rms_s / rms_m), 1)
    res["estereo"] = bool(estereo)
    fr = SR / PASSO

    mdb = rms_quadros(M)
    n_fr = len(mdb)
    t = (np.arange(n_fr) * PASSO + JANELA / 2) / SR
    fala = _mascara_fala(t, palavras) if palavras else None
    pausa = None
    if palavras:
        pausa = np.zeros(n_fr, bool)
        for g0, g1 in _pausas(palavras, dur, 0.3):
            i0, i1 = np.searchsorted(t, [g0 + 0.12, g1 - 0.12])
            pausa[i0:i1] = True
        if pausa.sum() < 0.5 * fr:
            pausa = None
    if fala is not None and fala.any():
        voz_db = float(np.median(mdb[fala]))
        metodo = "voz pelas palavras da transcrição"
    else:
        voz_db = float(np.percentile(mdb, 80))
        metodo = "voz pelos trechos mais fortes do áudio"
    piso_confiavel = pausa is not None
    if pausa is not None:
        piso_db = float(np.median(mdb[pausa]))
        quieto = pausa
        metodo += ", fundo pelas pausas da fala"
    else:
        metodo += (", fundo pelos trechos mais baixos (a fala quase não para)" if palavras
                   else ", fundo pelos trechos mais baixos")
        ativo = mdb > voz_db - 40
        piso_db = float(np.percentile(mdb[ativo], 10)) if ativo.any() else float(np.percentile(mdb, 10))
        quieto = ativo & (mdb <= np.percentile(mdb[ativo], 15)) if ativo.any() else None
    em = espectro(M, acumular=quieto)
    dif = voz_db - piso_db
    n_tons = picos_tonais(em["espectro_medio"], em["freqs"])
    trilha: dict = {"voz_dbfs": round(voz_db, 1), "piso_entre_frases_dbfs": round(piso_db, 1),
                    "diferenca_db": round(dif, 1), "picos_tonais_no_piso": n_tons, "metodo": metodo}

    # canal lateral (esquerdo menos direito): a voz centralizada some ali, a trilha estéreo fica
    sdb = None
    es = None
    voz_centralizada = False
    if estereo:
        sdb = rms_quadros(S)
        if fala is not None and fala.any():
            voz_centralizada = float(np.median(mdb[fala]) - np.median(sdb[fala])) >= 15.0
        else:
            voz_centralizada = res["lado_menos_meio_db"] <= -15.0
        trilha["lado_mediano_dbfs"] = round(float(np.median(sdb)), 1)
        if fala is not None and pausa is not None:
            s_fala = float(np.median(sdb[fala]))
            s_pausa = float(np.median(sdb[pausa]))
            trilha["lado_na_fala_dbfs"] = round(s_fala, 1)
            trilha["lado_nas_pausas_dbfs"] = round(s_pausa, 1)
            if voz_centralizada:
                trilha["abaixa_na_fala"] = bool(s_pausa - s_fala > 3.0)
        es = espectro(S)
        env = np.maximum(es["fluxo"] - mediana_movel(es["fluxo"], int(fr) | 1), 0)
        bpm, forca = _bpm(env, fr)
        trilha["bpm"] = bpm if forca >= 0.25 else None
        trilha["bpm_forca"] = forca
        seg = []
        for s in range(int(dur)):
            sel = (t >= s) & (t < s + 1)
            seg.append(float(np.median(sdb[sel])) if sel.any() else -120.0)
        seg = np.array(seg)
        if len(seg) >= 6 and voz_centralizada:
            ref = float(np.median(seg[:-3]))
            fim_trilha = None
            for s in range(len(seg) - 1, -1, -1):
                if seg[s] > ref - 10:
                    fim_trilha = s + 1
                    break
            if fim_trilha is not None and dur - fim_trilha >= 1.0:
                trilha["some_antes_do_fim_s"] = round(dur - fim_trilha, 1)
    trilha["voz_centralizada"] = voz_centralizada
    lado_continuo = bool(voz_centralizada and sdb is not None and
                         trilha.get("lado_nas_pausas_dbfs", trilha.get("lado_mediano_dbfs", -99)) > -58)
    if dif >= 35 or piso_db < -60:
        trilha["veredito"] = "sem trilha"
        trilha["explicacao"] = "entre as frases fica quase silêncio"
    elif dif < 8:
        trilha["veredito"] = "som contínuo alto"
        trilha["explicacao"] = "o som de fundo fica quase no nível da voz (música alta ou pouca fala)"
    elif n_tons >= 3 or lado_continuo:
        trilha["veredito"] = "trilha provável"
        trilha["explicacao"] = f"tem som musical contínuo uns {br(dif, 0)} dB abaixo da voz"
    elif not piso_confiavel:
        trilha["veredito"] = "incerto"
        trilha["explicacao"] = ("quase não há pausa na fala, então não deu para medir o som de fundo com segurança; "
                                "ouça a referência ou confie no que a pessoa disser")
    else:
        trilha["veredito"] = "ruído de fundo provável"
        trilha["explicacao"] = f"tem som contínuo uns {br(dif, 0)} dB abaixo da voz, mas não parece música"
    trilha["piso_confiavel"] = piso_confiavel
    res["trilha"] = trilha

    # ataques de som (inclui sílabas)
    flx = em["fluxo"]
    base = mediana_movel(flx, int(fr) | 1)
    mad = mediana_movel(np.abs(flx - base), int(fr) | 1) + 1e-6
    at = picos((flx - base) / mad, 4.0, int(0.1 * fr))
    res["ataques_por_minuto"] = round(len(at) / (dur / 60.0), 1) if dur else None

    # efeitos sonoros prováveis
    # calibrado em 2 reels com efeitos marcados à mão: o nível do canal lateral subindo 6 dB acima da
    # mediana dos 4 s em volta acertou 32 de 44 efeitos, com poucos alarmes falsos
    eventos: list[dict] = []
    if sdb is not None and voz_centralizada:
        exc = sdb - mediana_movel(sdb, int(4 * fr) | 1)
        for i in picos(exc, 6.0, int(0.3 * fr)):
            eventos.append({"t": float(t[i]), "forca_db": round(float(exc[i]), 1), "fonte": "lado"})
    else:
        sub = em["bandas"]["sub"]
        exs = sub - mediana_movel(sub, int(1.5 * fr) | 1)
        voz_b = em["bandas"]["voz"]
        exv = voz_b - mediana_movel(voz_b, int(1.5 * fr) | 1)
        sc = exs - np.maximum(exv, 0)
        p95 = float(np.percentile(sub, 95))
        for i in picos(sc, 18.0, int(0.3 * fr)):
            if mdb[i] > voz_db - 25 and sub[i] >= p95:
                eventos.append({"t": float(t[i]), "forca_db": round(float(sc[i]), 1), "fonte": "grave"})
    if pausa is not None:
        exc_p = mdb - (piso_db + 10)
        exc_p[~pausa] = -99
        for i in picos(exc_p, 0.0, int(0.25 * fr)):
            eventos.append({"t": float(t[i]), "forca_db": round(float(mdb[i] - piso_db), 1), "fonte": "pausa"})
    eventos.sort(key=lambda e: e["t"])
    juntos: list[dict] = []
    for e in eventos:
        if juntos and e["t"] - juntos[-1]["t"] < 0.25:
            if e["forca_db"] > juntos[-1]["forca_db"]:
                e["fonte"] = juntos[-1]["fonte"] + "+" + e["fonte"]
                juntos[-1] = e
            else:
                juntos[-1]["fonte"] += "+" + e["fonte"]
        else:
            juntos.append(e)
    fonte_banda = es["bandas"] if (es is not None and voz_centralizada) else em["bandas"]
    for e in juntos:
        i = min(max(int(np.searchsorted(t, e["t"])), 0), len(t) - 1)
        rel = {b: float(fonte_banda[b][i] - np.median(fonte_banda[b][max(0, i - int(fr)):i + int(fr)]))
               for b in BANDAS}
        baixo = max(rel["sub"], rel["grave"])
        if baixo >= max(rel["agudo"], rel["corpo"]) and baixo > 6:
            e["tipo"] = "grave (impacto, batida)"
        elif rel["agudo"] > rel["corpo"] + 3:
            e["tipo"] = "agudo (estalo, brilho, clique)"
        else:
            e["tipo"] = "cheio (whoosh, transição, pop)"
        e["t"] = round(e["t"], 2)
        e["perto_de_corte"] = any(abs(e["t"] - c) <= 0.2 for c in cortes_t)
    res["efeitos"] = {
        "provaveis": len(juntos),
        "por_minuto": round(len(juntos) / (dur / 60.0), 1) if dur else None,
        "perto_de_corte_pct": round(sum(e["perto_de_corte"] for e in juntos) / len(juntos), 3) if juntos else None,
        "cortes_com_som_pct": round(sum(1 for c in cortes_t if any(abs(e["t"] - c) <= 0.2 for e in juntos))
                                    / len(cortes_t), 3) if cortes_t else None,
        "lista": juntos,
        "confianca": "média" if voz_centralizada else "baixa",
        "metodo": ("picos no canal lateral (esquerdo menos direito, onde a voz centralizada some), "
                   "e sons dentro das pausas da fala" if voz_centralizada else
                   "saltos de grave que a voz não explica e sons dentro das pausas da fala "
                   "(sem canal lateral limpo: menos confiável)"),
    }
    por_seg = []
    for s in range(int(math.ceil(dur))):
        sel = (t >= s) & (t < s + 1)
        if not sel.any():
            por_seg.append(None)
            continue
        item = {"db": round(float(np.percentile(mdb[sel], 90)), 1)}
        if sdb is not None:
            item["lado_db"] = round(float(np.median(sdb[sel])), 1)
        por_seg.append(item)
    res["_por_segundo"] = por_seg
    return res


# ----------------------------------------------------------------------------- 8. fala

def transcrever(video: Path, ev: Path, medidas: Path) -> Path | None:
    script = ev / "helpers" / "transcribe.py"
    if not script.exists():
        aviso(f"não achei {script}; sigo sem transcrição.")
        return None
    cmd = python_ev(ev) + [str(script), str(video), "--edit-dir", str(medidas), "--language", "auto", "--no-prompt"]
    r = subprocess.run(cmd, capture_output=True, cwd=ev)
    arq = medidas / "transcripts" / f"{video.stem}.json"
    if r.returncode != 0 or not arq.exists():
        aviso("a transcrição falhou; sigo sem ela. " + r.stderr.decode("utf-8", "replace").strip()[-300:])
        return None
    return arq


def ler_palavras(arq: Path | None) -> list[dict] | None:
    if not arq or not arq.exists():
        return None
    d = json.loads(arq.read_text(encoding="utf-8"))
    ws = d.get("words") if isinstance(d, dict) else d
    out = []
    for w in ws or []:
        txt = str(w.get("text", w.get("word", ""))).strip()
        if txt and w.get("start") is not None:
            out.append({"text": txt, "start": float(w["start"]), "end": float(w.get("end", w["start"]))})
    return sorted(out, key=lambda w: w["start"]) or None


def medir_fala(palavras: list[dict] | None, dur: float, idioma: str | None) -> dict | None:
    if not palavras:
        return None
    ini, fim = palavras[0]["start"], palavras[-1]["end"]
    span = max(0.1, fim - ini)
    pausas = _pausas(palavras, dur, 0.3)
    return {
        "idioma": idioma,
        "palavras": len(palavras),
        "palavras_por_minuto": round(len(palavras) / (span / 60.0), 0),
        "primeira_palavra_s": round(ini, 2),
        "ultima_palavra_s": round(fim, 2),
        "silencio_no_fim_s": round(max(0.0, dur - fim), 2),
        "pausas_acima_0_3s": len(pausas),
        "pausas_por_minuto": round(len(pausas) / (span / 60.0), 1),
        "maior_pausa_s": round(max((b - a for a, b in pausas), default=0.0), 2),
        "fala_ocupa_pct": round(sum(w["end"] - w["start"] for w in palavras) / dur, 3) if dur else None,
    }


# ----------------------------------------------------------------------------- 9. imagens de conferência

def _rotulo(d: ImageDraw.ImageDraw, xy, texto: str, fonte, cor=(255, 230, 0), fundo=(0, 0, 0)):
    x, y = xy
    caixa = d.textbbox((x + 4, y + 2), texto, font=fonte)
    d.rectangle([x, y, caixa[2] + 4, caixa[3] + 3], fill=fundo)
    d.text((x + 4, y + 2), texto, fill=cor, font=fonte)


def montar_folhas(quadros: list[tuple[float, Path]], cortes: list[dict], sons: list[dict], pasta: Path,
                  W: int, H: int, fonte, fonte_p) -> list[Path]:
    pasta.mkdir(parents=True, exist_ok=True)
    vertical = H >= W
    tw = 160 if vertical else 256
    th = int(round(tw * H / W))
    cols, lins = (10, 6) if vertical else (8, 8)
    por = cols * lins
    saidas = []
    total = (len(quadros) + por - 1) // por
    for f in range(total):
        parte = quadros[f * por:(f + 1) * por]
        topo = 34
        folha = Image.new("RGB", (cols * tw, topo + th * ((len(parte) + cols - 1) // cols)), (18, 18, 18))
        d = ImageDraw.Draw(folha)
        d.text((8, 7), f"folha {f + 1} de {total}   {mmss_curto(parte[0][0])} a {mmss_curto(parte[-1][0])}   "
                       f"borda vermelha = corte, laranja = corte provável, SOM = efeito sonoro provável",
               fill=(230, 230, 230), font=fonte_p)
        for j, (t, arq) in enumerate(parte):
            k = f * por + j
            t_ant = quadros[k - 1][0] if k > 0 else -1.0
            im = Image.open(arq).convert("RGB").resize((tw, th))
            dd = ImageDraw.Draw(im)
            dentro = [c for c in cortes if t_ant < c["t"] <= t + 1e-6]
            if dentro:
                forte = any(c["tipo"] == "corte" for c in dentro)
                cor = (235, 40, 40) if forte else (255, 150, 0)
                dd.rectangle([0, 0, tw - 1, th - 1], outline=cor, width=5)
                _rotulo(dd, (3, th - 24), "CORTE " + ",".join(mmss(c["t"]) if c["t"] >= 60 else mmss(c["t"])[3:]
                                                               for c in dentro[:2]), fonte_p,
                        cor=(255, 255, 255), fundo=cor)
            if any(t_ant < s["t"] <= t + 1e-6 for s in sons):
                _rotulo(dd, (tw - 46, 3), "SOM", fonte_p, cor=(255, 255, 255), fundo=(30, 90, 220))
            _rotulo(dd, (3, 3), mmss_curto(t) if abs(t - round(t)) < 0.05 else mmss(t)[:-1], fonte)
            folha.paste(im, ((j % cols) * tw, topo + (j // cols) * th))
        destino = pasta / f"folha_{f + 1:02d}.jpg"
        folha.save(destino, quality=88)
        saidas.append(destino)
    return saidas


def montar_conferencia(cortes: list[dict], pares: dict[int, Path], saida_base: Path, W: int, H: int,
                       fonte_p) -> list[Path]:
    if not cortes:
        return []
    total_cortes = len(cortes)

    def espalhados(lista: list[dict], n: int) -> list[dict]:
        if len(lista) <= n:
            return lista
        passo = len(lista) / float(n)
        return [lista[int(i * passo)] for i in range(n)]
    if total_cortes > 120:   # vídeo longo: mostra uma amostra, com prioridade para os prováveis
        prov = [c for c in cortes if c["tipo"] != "corte"]
        fortes = [c for c in cortes if c["tipo"] == "corte"]
        escolha = espalhados(prov, 90)
        escolha += espalhados(fortes, 120 - len(escolha))
        cortes = sorted(escolha, key=lambda c: c["t"])
    vertical = H >= W
    tw = 140 if vertical else 220
    th = int(round(tw * H / W))
    cols = 5 if vertical else 3
    por = cols * 6
    saidas = []
    total = (len(cortes) + por - 1) // por
    for f in range(total):
        parte = cortes[f * por:(f + 1) * por]
        cel_w, cel_h = 2 * tw + 12, th + 26
        folha = Image.new("RGB", (cols * cel_w, 30 + cel_h * ((len(parte) + cols - 1) // cols)), (18, 18, 18))
        d = ImageDraw.Draw(folha)
        cab = ("cada par: último quadro antes do corte | primeiro quadro depois. Confira se é corte de verdade."
               + (f"  Amostra de {len(cortes)} de {total_cortes} cortes." if total_cortes > len(cortes) else ""))
        d.text((8, 7), cab, fill=(230, 230, 230), font=fonte_p)
        for j, c in enumerate(parte):
            x = (j % cols) * cel_w
            y = 30 + (j // cols) * cel_h
            for lado, idx in enumerate((c["quadro"] - 1, c["quadro"])):
                arq = pares.get(idx)
                if arq and arq.exists():
                    folha.paste(Image.open(arq).convert("RGB").resize((tw, th)), (x + lado * (tw + 4), y))
            cor = (235, 60, 60) if c["tipo"] == "corte" else (255, 160, 0)
            nome = "corte" if c["tipo"] == "corte" else "provável"
            d.text((x + 2, y + th + 4), f"{mmss(c['t'])}  {nome}  nota {br(c['nota'], 2)}", fill=cor, font=fonte_p)
        destino = saida_base.parent / (saida_base.stem + (f"_{f + 1:02d}" if total > 1 else "") + ".jpg")
        folha.save(destino, quality=88)
        saidas.append(destino)
    return saidas


def salvar_recortes(textos: dict | None, quadros: list[tuple[float, Path]], pasta: Path) -> list[dict]:
    """Amostras da legenda e dos textos fixos, cortadas dos quadros de 1 por segundo."""
    if not textos or cv2 is None:
        return []
    pasta.mkdir(parents=True, exist_ok=True)
    por_t = {round(t, 1): arq for t, arq in quadros}

    def quadro_perto(t: float) -> Path | None:
        for d in (0.0, 0.1, -0.1):
            arq = por_t.get(round(t + d, 1))
            if arq:
                return arq
        return None

    feitos = []

    def espalhar(lista: list[dict], n: int) -> list[dict]:
        vistos, unicos = set(), []
        for e in lista:
            k = norm_txt(e["texto"])
            if k and k not in vistos and quadro_perto(e["t"]):
                vistos.add(k)
                unicos.append(e)
        if len(unicos) > n:
            passo = len(unicos) / float(n)
            unicos = [unicos[int(i * passo)] for i in range(n)]
        return unicos
    ids_principal = {id(e) for e in textos.get("_principal", [])}
    escolha = espalhar(textos.get("_principal", []), 4)
    escolha += espalhar([e for e in textos.get("_legenda", []) if id(e) not in ids_principal], 2)
    escolha.sort(key=lambda e: e["t"])
    for n, e in enumerate(escolha, 1):
        arq = quadro_perto(e["t"])
        img = cv2.imread(str(arq))
        h, w = img.shape[:2]
        cy, ch = e["centro_y"] * h, e["altura"] * h
        y0, y1 = int(max(0, cy - 1.6 * ch)), int(min(h, cy + 1.6 * ch))
        destino = pasta / f"legenda_{n:02d}_{mmss_curto(e['t']).replace(':', 'm')}s.jpg"
        cv2.imwrite(str(destino), img[y0:y1, :])
        feitos.append({"arquivo": destino.name, "t": e["t"], "tipo": "legenda"})
    for n, f in enumerate([f for f in textos.get("textos_fixos", []) if f["duracao_s"] >= 1.0][:4], 1):
        arq = quadro_perto(f["inicio"] + 0.5) or quadro_perto(f["inicio"])
        if not arq:
            continue
        img = cv2.imread(str(arq))
        h, w = img.shape[:2]
        cy, ch = f["centro_y"] * h, f["altura_frac"] * h
        y0, y1 = int(max(0, cy - 2.0 * ch)), int(min(h, cy + 2.0 * ch))
        destino = pasta / f"texto_fixo_{n:02d}_{mmss_curto(f['inicio']).replace(':', 'm')}s.jpg"
        cv2.imwrite(str(destino), img[y0:y1, :])
        feitos.append({"arquivo": destino.name, "t": f["inicio"], "tipo": "texto fixo"})
    return feitos


# ----------------------------------------------------------------------------- 10. regras da referência e guia

ESTILOS_EV = {
    "impacto": "letra alta e estreita, caixa alta, contorno preto grosso",
    "caixa_viva": "caixa colorida que corre de palavra em palavra",
    "palavra_gigante": "uma palavra por vez, enorme",
    "pulso": "letra redonda e grossa, caixa alta, palavra falada cresce",
    "fita": "frase numa fita escura atrás do texto",
    "legenda_limpa": "legenda de TV, frase inteira em até 2 linhas com fundo escuro",
    "papelaria": "minúsculas pesadas com contorno escuro, palavra falada acende",
}


def sugerir_estilo_legenda(leg: dict) -> tuple[str, str]:
    if not leg.get("tem_legenda"):
        return "impacto", "a referência não tem legenda medida; ficou o padrão do EDIT IA PRO"
    ppl = leg.get("palavras_por_linha_mediana") or 2
    alt = (leg.get("altura_linha_frac") or 0.03) * 1920
    maius = leg.get("caixa_alta_pct")
    caixa = leg.get("caixa_atras_pct", 0) >= 0.5
    estreita = (leg.get("largura_letra_sobre_altura") or 0.6) < 0.5
    if ppl <= 1.0 and alt >= 90:
        return "palavra_gigante", "uma palavra por vez e letra grande"
    if caixa and (ppl >= 5 or (leg.get("linhas_por_vez") or 1) >= 2):
        return "legenda_limpa", "frase comprida com fundo escuro atrás"
    if caixa:
        return "fita", "texto com fundo liso atrás"
    if maius is not None and maius >= 0.7:
        if estreita or leg.get("contorno_escuro_pct", 0) >= 0.5:
            return "impacto", "caixa alta com letra estreita ou contorno escuro"
        return "pulso", "caixa alta com letra larga e sem contorno"
    if maius is not None and maius <= 0.35:
        return "papelaria", "letra minúscula pesada"
    return "impacto", "mistura de maiúsculas e minúsculas; confira nos recortes"


def fontes_parecidas(leg: dict) -> dict:
    prop = leg.get("largura_letra_sobre_altura")
    maius = leg.get("caixa_alta_pct") or 0
    if prop is None:
        return {"forma": "não medida", "google_fonts": ["Montserrat", "Inter", "Anton"]}
    # a caixa lida pelo Vision cobre a linha toda; em minúscula a altura inclui hastes e pernas
    estreita, larga = (0.62, 0.90) if maius >= 0.7 else (0.45, 0.60)
    if prop < estreita:
        return {"forma": "estreita (condensada)", "google_fonts": ["Anton", "Bebas Neue", "Oswald"]}
    if prop > larga:
        return {"forma": "larga (geométrica)", "google_fonts": ["Montserrat", "Poppins", "Archivo"]}
    return {"forma": "média", "google_fonts": ["Inter", "Figtree", "Manrope"]}


def montar_regras(ficha: dict, cortes: list[dict], ritmo: dict, ritmo_todos: dict, enq: dict, leg: dict,
                  cores: dict, audio: dict, fala: dict | None, args) -> dict:
    estilo, motivo = sugerir_estilo_legenda(leg)
    fontes = fontes_parecidas(leg)
    destaque = leg.get("cor_destaque")   # sem legenda medida, fica vazio: use a cor da marca da pessoa
    texto_cor = leg.get("cor_texto") or "#FFFFFF"
    trilha = audio.get("trilha", {}) if audio.get("tem_audio") else {}
    efeitos = audio.get("efeitos", {}) if audio.get("tem_audio") else {}
    tem_trilha = trilha.get("veredito") in ("trilha provável", "som contínuo alto")
    vol_db = None
    if tem_trilha and trilha.get("diferenca_db") is not None:
        vol_db = int(round(max(-24, min(-10, -(trilha["diferenca_db"] - 4)))))
    zoom = enq.get("zoom_nos_cortes") or {}
    nivel = zoom.get("nivel_sugerido")
    font_px = leg.get("fonte_estimada_padrao")
    vertical = ficha["vertical"] and ficha["altura"] / max(1, ficha["largura"]) > 1.1
    tamanhos_ev = {"impacto": 134, "caixa_viva": 86, "palavra_gigante": 196, "pulso": 78, "fita": 78,
                   "legenda_limpa": 60, "papelaria": 88}
    escala = None
    if font_px and vertical:
        escala = round(max(0.9, min(1.15, font_px / tamanhos_ev.get(estilo, 86))), 2)
    return {
        "versao": VERSAO,
        "gerado_em": time.strftime("%Y-%m-%dT%H:%M:%S"),
        "gerado_por": "helpers/medir_referencia.py",
        "referencia": {"arquivo": ficha["arquivo"], "sha256": ficha["sha256"]},
        "aviso": "Valores de estilo medidos. Não copie fala, promessa, marca, logo, música, pessoa nem números "
                 "de resultado da referência.",
        "ficha": {k: ficha[k] for k in ("duracao_s", "largura", "altura", "proporcao", "fps", "tem_audio")},
        "ritmo": {
            "origem": "medido",
            "cortes_por_minuto": ritmo["cortes_por_minuto"],
            "plano_medio_s": ritmo["plano_medio_s"],
            "plano_mediano_s": ritmo["plano_mediano_s"],
            "intervalo_medio_entre_cortes_s": ritmo["intervalo_medio_entre_cortes_s"],
            "plano_mais_curto_s": ritmo["plano_mais_curto_s"],
            "plano_mais_longo_s": ritmo["plano_mais_longo_s"],
            "cortes_nos_3_primeiros_s": ritmo["cortes_nos_3_primeiros_s"],
            "cortes_por_10s": ritmo["cortes_por_10s"],
            "cortes": [c["t"] for c in cortes if c["tipo"] == "corte"],
            "cortes_provaveis": [c["t"] for c in cortes if c["tipo"] != "corte"],
            "com_provaveis": {"cortes_por_minuto": ritmo_todos["cortes_por_minuto"],
                              "plano_medio_s": ritmo_todos["plano_medio_s"]},
            "limiar_nota_de_cena": args.limiar,
        },
        "enquadramento": {"origem": "estimado", **{k: v for k, v in enq.items() if not k.startswith("_")}},
        "legenda": {
            "origem": {"posicao_tamanho_palavras": "medido", "cores_fonte_estilo": "estimado"},
            **{k: v for k, v in leg.items() if not k.startswith("_") and k not in ("textos_fixos", "tela_clara_trechos", "gancho")},
            "fonte_parecida": fontes,
        },
        "textos_fixos": [{k: v for k, v in f.items() if k != "texto"} for f in leg.get("textos_fixos", [])],
        "gancho_escrito": leg.get("gancho"),
        "cores": {"origem": "medido nos pixels; papel de cada cor estimado", "texto": texto_cor,
                  "destaque": destaque, **cores},
        "fala": fala,
        "audio": {
            "origem": {"loudness": "medido", "trilha_e_efeitos": "estimado"},
            "tem_audio": audio.get("tem_audio", False),
            "loudness": audio.get("loudness"),
            "estereo": audio.get("estereo"),
            "correlacao_esq_dir": audio.get("correlacao_esq_dir"),
            "trilha": trilha,
            "efeitos": {k: v for k, v in efeitos.items() if k != "lista"} if efeitos else None,
            "efeitos_tempos": [{"t": e["t"], "tipo": e["tipo"]} for e in efeitos.get("lista", [])] if efeitos else [],
            "ataques_por_minuto": audio.get("ataques_por_minuto"),
        },
        "editor_video": {
            "nota": "Campos com o mesmo nome do edit_data.json desta skill. Ponto de partida, não ordem.",
            "accent": destaque,
            "camera": {"zoom_cuts": bool(zoom.get("parece_zoom")),
                       "zoom_levels": [1.0, nivel] if nivel else [1.0, 1.14],
                       "push_in": False},
            "captions": {"enabled": bool(leg.get("tem_legenda")), "style": estilo, "motivo_do_estilo": motivo,
                         "center_y": leg.get("centro_y_padrao") if vertical else None, "font_scale": escala,
                         "nota_posicao": None if vertical else
                         "referência não é vertical: converta pela fração da altura (legenda.centro_y) e confira na amostra",
                         "max_words": int(min(3, max(1, round(leg.get("palavras_por_linha_mediana") or 3))))
                         if estilo != "legenda_limpa" else 8},
            "sfx": {"enabled": (bool(efeitos.get("provaveis")) if efeitos.get("confianca") != "baixa" else None),
                    "volume": 0.6,
                    "eventos_por_minuto_alvo": efeitos.get("por_minuto") if efeitos.get("confianca") != "baixa" else None,
                    "confianca": efeitos.get("confianca"),
                    "som_nos_cortes_pct": efeitos.get("cortes_com_som_pct")},
            "music": {"enabled": bool(tem_trilha), "volume_db": vol_db if vol_db is not None else -15,
                      "duck": bool(trilha.get("abaixa_na_fala", True)),
                      "fade_out": float(trilha.get("some_antes_do_fim_s") or 1.5) if tem_trilha else 1.5},
            "loudness": {"i": -14, "tp": -1.0},
            "ritmo_alvo": {"cortes_por_minuto": ritmo["cortes_por_minuto"], "plano_medio_s": ritmo["plano_medio_s"]},
        },
        "hyperframes": {
            "nota": "Tokens para um frame.md (cores e tipografia). Layout continua livre.",
            "colors": {"texto": texto_cor, "destaque": destaque,
                       "fundo": (cores.get("dominantes") or [{}])[0].get("hex")},
            "typography": {"legenda": {"fontFamily": fontes["google_fonts"][0],
                                       "weight": 800,
                                       "px_na_tela_padrao": font_px,
                                       "tela_padrao": leg.get("tela_padrao"),
                                       "upper": bool((leg.get("caixa_alta_pct") or 0) >= 0.7)}},
            "legenda_centro_y_frac": leg.get("centro_y"),
        },
    }


def _linha_tab(*cols) -> str:
    return "| " + " | ".join(str(c) for c in cols) + " |"


def escrever_guia(caminho: Path, ficha: dict, cortes: list[dict], ritmo: dict, ritmo_todos: dict, enq: dict,
                  leg: dict | None, cores: dict, audio: dict, fala: dict | None, regras: dict,
                  arquivos: dict, args, faltas: list[str]) -> None:
    L: list[str] = []
    a = L.append
    nome = ficha["arquivo"]
    curto = nome if len(nome) <= 60 else nome[:57] + "..."
    fortes = [c for c in cortes if c["tipo"] == "corte"]
    prov = [c for c in cortes if c["tipo"] != "corte"]
    leg = leg or {}
    ev = regras["editor_video"]
    a(f"# Guia de estilo medido: {curto}")
    a("")
    a(f"Gerado em {time.strftime('%d/%m/%Y %H:%M')} pelo `medir_referencia.py` (editor-video).")
    a("Serve para editar OUTRO vídeo com a mesma linguagem de edição. Não serve para copiar fala, promessa,")
    a("marca, logo, música, pessoa ou números de resultado da referência.")
    a("")
    a("Como ler:")
    a("- **MEDIDO**: saiu de uma conta feita no vídeo. Pode confiar, dentro da precisão indicada.")
    a("- **ESTIMADO**: dedução a partir das medidas. Pode errar. Confira na folha de quadros e nos recortes.")
    a("- **NÃO DÁ PRA SABER**: está no fim. Não invente esses itens.")
    a("")

    # resumo
    a("## Resumo")
    a("")
    if ritmo["cortes"]:
        a(f"- Ritmo: {ritmo['cortes']} cortes em {br(ficha['duracao_s'])} s, {br(ritmo['cortes_por_minuto'])} por minuto, "
          f"um plano novo a cada {br(ritmo['plano_medio_s'])} s em média. **MEDIDO**")
    else:
        a(f"- Ritmo: nenhum corte seco em {br(ficha['duracao_s'])} s (plano único ou só transições suaves). **MEDIDO**")
    if prov:
        a(f"- Mais {len(prov)} corte(s) fraco(s) para conferir; com eles seriam {br(ritmo_todos['cortes_por_minuto'])} "
          f"por minuto. **ESTIMADO**")
    if enq.get("rosto_pct") is not None:
        a(f"- Rosto na tela em {pct(enq['rosto_pct'])} dos segundos; tamanho típico: {enq.get('tipo_de_plano', '?')}. **ESTIMADO**")
    if leg.get("tem_legenda"):
        a(f"- Legenda com {br(leg.get('palavras_por_linha_mediana'), 0)} palavra(s) por linha, centro em "
          f"{pct(leg.get('centro_y'))} da altura, {pct(leg.get('caixa_alta_pct'))} em caixa alta. **MEDIDO**")
    elif leg:
        a("- Não achei legenda da fala escrita na tela. **MEDIDO** (leitura de texto)")
    if audio.get("tem_audio"):
        tr = audio["trilha"]
        a(f"- Áudio: {br(audio.get('loudness', {}).get('integrado_lufs'))} LUFS; {tr['veredito']}"
          f" ({tr['explicacao']}). **ESTIMADO**")
        ef = audio["efeitos"]
        a(f"- Efeitos sonoros prováveis: {ef['provaveis']} ({br(ef['por_minuto'])} por minuto). **ESTIMADO**")
    if fala:
        a(f"- Fala: {br(fala['palavras_por_minuto'], 0)} palavras por minuto, primeira palavra em "
          f"{br(fala['primeira_palavra_s'], 2)} s. **MEDIDO**")
    a("")

    a("## 1. Ficha técnica (MEDIDO)")
    a("")
    a(_linha_tab("Item", "Valor"))
    a(_linha_tab("---", "---"))
    a(_linha_tab("Arquivo", f"`{curto}`"))
    a(_linha_tab("Duração", f"{br(ficha['duracao_s'], 2)} s"))
    a(_linha_tab("Resolução", f"{ficha['largura']} x {ficha['altura']} ({ficha['proporcao']})"
                 + (f", girado {ficha['rotacao']} graus" if ficha["rotacao"] else "")))
    a(_linha_tab("Quadros por segundo", br(ficha["fps"], 2) + (" (variável)" if ficha["fps_variavel"] else "")))
    a(_linha_tab("Áudio", f"{ficha['codec_audio']} {ficha['canais_audio']} canal(is) {ficha['taxa_audio_hz']} Hz"
                 if ficha["tem_audio"] else "sem áudio"))
    a(_linha_tab("Impressão digital (sha256)", f"`{ficha['sha256'][:16]}...`"))
    a("")

    a("## 2. Ritmo e cortes")
    a("")
    a(f"Detector de cena do ffmpeg quadro a quadro. Corte = nota de cena maior ou igual a {br(args.limiar, 2)}. "
      f"Corte provável = pico fraco, pelo menos {br(RAZAO_PROVAVEL, 0)} vezes maior que a vizinhança.")
    a("")
    a(_linha_tab("Medida", "Valor", "Status"))
    a(_linha_tab("---", "---", "---"))
    a(_linha_tab("Cortes secos", ritmo["cortes"], "MEDIDO"))
    a(_linha_tab("Cortes por minuto", br(ritmo["cortes_por_minuto"]), "MEDIDO"))
    a(_linha_tab("Duração média de cada plano", f"{br(ritmo['plano_medio_s'], 2)} s (mediana {br(ritmo['plano_mediano_s'], 2)} s)", "MEDIDO"))
    a(_linha_tab("Tempo médio entre cortes", f"{br(ritmo['intervalo_medio_entre_cortes_s'], 2)} s"
                 if ritmo["intervalo_medio_entre_cortes_s"] else "menos de 2 cortes", "MEDIDO"))
    if ritmo["plano_mais_curto_s"] is not None:
        a(_linha_tab("Plano mais curto / mais longo", f"{br(ritmo['plano_mais_curto_s'], 2)} s / {br(ritmo['plano_mais_longo_s'], 2)} s", "MEDIDO"))
    if ritmo["maior_trecho_sem_corte"]:
        m = ritmo["maior_trecho_sem_corte"]
        a(_linha_tab("Maior trecho sem corte", f"{mmss(m['inicio'])} a {mmss(m['fim'])}", "MEDIDO"))
    a(_linha_tab("Planos com menos de 1,5 s", pct(ritmo["planos_curtos_pct"]), "MEDIDO"))
    a(_linha_tab("Cortes nos 3 primeiros segundos", ritmo["cortes_nos_3_primeiros_s"], "MEDIDO"))
    a(_linha_tab("Cortes prováveis (fracos)", len(prov), "ESTIMADO, confira em `cortes-para-conferir.jpg`"))
    a("")
    if ritmo["cortes_por_10s"]:
        a("Cortes a cada 10 s: " + ", ".join(f"{i * 10}-{i * 10 + 10}s: {n}" for i, n in enumerate(ritmo["cortes_por_10s"])) + ".")
        a("")
    if cortes:
        a("Lista: " + ", ".join(f"{mmss(c['t'])}" + ("" if c["tipo"] == "corte" else " (provável)")
                                + (" (vários quadros)" if c.get("obs") else "") for c in cortes) + ".")
        a("")

    a("## 3. Enquadramento e zoom (ESTIMADO, detector de rosto)")
    a("")
    if enq.get("rosto_pct") is None:
        a("Detector de rosto indisponível. Olhe a folha de quadros.")
    else:
        a(_linha_tab("Medida", "Valor"))
        a(_linha_tab("---", "---"))
        a(_linha_tab("Segundos com rosto", pct(enq["rosto_pct"])))
        if enq.get("rosto_altura_mediana"):
            a(_linha_tab("Altura do rosto", f"{pct(enq['rosto_altura_mediana'])} da altura do quadro ({enq['tipo_de_plano']})"))
            a(_linha_tab("Altura dos olhos", f"{pct(enq['olhos_y_mediana'])} a partir do topo"))
            a(_linha_tab("Posição do rosto", enq.get("posicao_horizontal", "?")))
        if enq.get("planos_sem_rosto") is not None:
            a(_linha_tab("Planos sem rosto (imagem de apoio, tela, produto)",
                         f"{enq['planos_sem_rosto']} de {enq['planos_medidos']}"))
        z = enq.get("zoom_nos_cortes") or {}
        if z.get("pares"):
            a(_linha_tab("Mudança de tamanho do rosto nos cortes",
                         f"mediana {pct(z['mudanca_mediana'])} em {z['pares']} cortes na mesma cena com rosto dos dois lados; "
                         f"{z['com_zoom']} mudaram 6% ou mais"))
            a(_linha_tab("Zoom nos cortes", ("parece que sim, nível sugerido " + br(z["nivel_sugerido"], 2))
                         if z.get("parece_zoom") else "não parece"))
    a("")

    a("## 4. Legenda e texto na tela")
    a("")
    if not leg:
        a("Leitura de texto desligada ou indisponível. Olhe a folha de quadros e os recortes.")
    else:
        motor = "Vision da Apple (lê as palavras)" if leg.get("motor") == "vision" else "manchas de texto (sem ler as palavras)"
        a(f"Lido com {motor}, {br(leg.get('fps_leitura'), 0)} amostras por segundo. Tamanhos em px do vídeo original "
          f"e convertidos para a tela padrão {leg.get('tela_padrao', '1080x1920')}.")
        a("")
        if leg.get("tem_legenda"):
            a(_linha_tab("Medida", "Valor", "Status"))
            a(_linha_tab("---", "---", "---"))
            a(_linha_tab("Tempo com legenda", pct(leg["tempo_com_legenda_pct"]) + " das amostras"
                         + (f", {pct(leg['fala_com_legenda_pct'])} dos trechos com fala" if leg.get("fala_com_legenda_pct") is not None else ""), "MEDIDO"))
            a(_linha_tab("A legenda escreve a fala?", "sim, bate com a transcrição" if leg.get("casou_com_fala")
                         else "não conferido (sem transcrição)", "MEDIDO" if leg.get("casou_com_fala") else "ESTIMADO"))
            a(_linha_tab("Posição principal (centro da linha)", f"{pct(leg['centro_y'], 1)} da altura, y = {leg['centro_y_px']} px "
                         f"(na tela padrão: {leg['centro_y_padrao']}), {pct(leg['posicoes'][0]['parte_da_legenda'])} do tempo com legenda", "MEDIDO"))
            for pos in leg.get("posicoes", [])[1:4]:
                a(_linha_tab("Outra posição", f"{pct(pos['centro_y'], 1)} da altura (na tela padrão: {pos['centro_y_padrao']}), "
                             f"{pct(pos['parte_da_legenda'])} do tempo com legenda, entre {mmss(pos['de'])} e {mmss(pos['ate'])}", "MEDIDO"))
            a(_linha_tab("Altura da linha lida", f"{leg['altura_linha_px']} px (na tela padrão: {leg['altura_linha_padrao']})", "MEDIDO"))
            a(_linha_tab("Palavras por linha (posição principal)", f"mediana {br(leg['palavras_por_linha_mediana'], 0)}, máximo {leg['palavras_por_linha_max']}", "MEDIDO"))
            a(_linha_tab("Linhas ao mesmo tempo", ", ".join(f"{k} linha(s) em {pct(v)}" for k, v in
                                                          (leg.get("linhas_por_vez_pct") or {}).items()) or "?", "MEDIDO"))
            a(_linha_tab("Caixa alta", pct(leg["caixa_alta_pct"]) + " das letras", "MEDIDO"))
            a(_linha_tab("Trocas de texto por minuto", br(leg["trocas_por_minuto_amostradas"], 0)
                         + " (amostrado; troca mais rápida que 0,5 s não aparece)", "MEDIDO"))
            a(_linha_tab("Tamanho da fonte", f"perto de {leg.get('fonte_estimada_padrao', '?')} px na tela padrão", "ESTIMADO"))
            a(_linha_tab("Forma da letra", regras["legenda"]["fonte_parecida"]["forma"], "ESTIMADO"))
            a(_linha_tab("Cor do texto (posição principal)", leg.get("cor_texto") or "?", "ESTIMADO"))
            a(_linha_tab("Cor de destaque (palavra falada)", (leg.get("cor_destaque") or "não achei")
                         + (f", em {pct(leg.get('duas_cores_pct'))} das linhas" if leg.get("cor_destaque") else ""), "ESTIMADO"))
            if leg.get("cor_apagada"):
                a(_linha_tab("Cor apagada (palavra que ainda vem)", leg["cor_apagada"], "ESTIMADO"))
            a(_linha_tab("Contorno ou sombra escura", "provável" if (leg.get("contorno_escuro_pct") or 0) >= 0.4 else "não parece", "ESTIMADO"))
            a(_linha_tab("Fundo liso atrás do texto (caixa, tarja)", "provável" if (leg.get("caixa_atras_pct") or 0) >= 0.5 else "não parece", "ESTIMADO"))
            a(_linha_tab("Estilo mais parecido no EDIT IA PRO", f"`{ev['captions']['style']}` ({ev['captions']['motivo_do_estilo']})", "ESTIMADO"))
            a(_linha_tab("Fontes livres parecidas (Google Fonts)", ", ".join(regras["legenda"]["fonte_parecida"]["google_fonts"]), "ESTIMADO"))
        else:
            a("Não achei legenda da fala na tela. **MEDIDO**")
        a("")
        g = leg.get("gancho") or {}
        if g.get("tem_texto_nos_3s"):
            a(f"Texto nos 3 primeiros segundos: sim, na zona {g.get('zona')}, até {g.get('altura_max_px')} px de altura"
              + (", maior que a legenda (título de gancho)." if g.get("texto_maior_que_legenda") else ".") + " **MEDIDO**")
            a("")
        fx = leg.get("textos_fixos") or []
        if fx:
            a(f"Textos que ficam parados 3 s ou mais (títulos, rótulos, marca): {len(fx)}. O texto de cada um está no "
              f"`trechos-medidos.csv`; aqui fica só a forma. **MEDIDO**")
            a("")
            a(_linha_tab("De", "Até", "Zona", "Altura", "Palavras", "Obs."))
            a(_linha_tab("---", "---", "---", "---", "---", "---"))
            for f in fx[:15]:
                a(_linha_tab(mmss(f["inicio"]), mmss(f["fim"]), f["zona"], f"{f['altura_px']} px", f["palavras"],
                             "marca ou perfil, não copiar" if f["parece_marca_ou_perfil"] else ""))
            if len(fx) > 15:
                a(f"(mais {len(fx) - 15} no trechos-medidos.csv)")
            a("")
        if leg.get("tela_clara_trechos"):
            a("Trechos com a tela quase toda clara (gráfico de fundo claro): "
              + ", ".join(f"{mmss(x0)} a {mmss(x1)}" for x0, x1 in leg["tela_clara_trechos"]) + ". **MEDIDO**")
            a("")

    a("## 5. Cores")
    a("")
    if cores:
        a("Medidas nos pixels de 1 quadro por segundo (**MEDIDO**). O papel de cada cor é **ESTIMADO**: cor de pele, parede")
        a("e roupa entram na conta. A cor de destaque dos textos vem da seção 4.")
        a("")
        a(_linha_tab("Cor", "Parte da imagem", "Parece"))
        a(_linha_tab("---", "---", "---"))
        for c in cores.get("dominantes", []):
            a(_linha_tab(f"`{c['hex']}`", pct(c["fracao"]), c["nome"]))
        a("")
        if cores.get("saturadas"):
            a("Cores fortes que aparecem (candidatas a destaque): " + ", ".join(
                f"`{c['hex']}` {c['nome']}" for c in cores["saturadas"]) + ".")
            a("")
        a(f"Tom geral: brilho médio {pct(cores['brilho_medio'])}, saturação média {pct(cores['saturacao_media'])}, "
          f"{pct(cores['quadros_escuros_pct'])} dos quadros escuros, {pct(cores['quadros_claros_pct'])} claros, "
          f"temperatura {cores['temperatura']}.")
        a("")

    a("## 6. Áudio")
    a("")
    if not audio.get("tem_audio"):
        a("Sem áudio. **MEDIDO**")
    else:
        lo = audio.get("loudness", {})
        tr = audio["trilha"]
        ef = audio["efeitos"]
        a("Nada foi ouvido. Tudo vem de medida de sinal.")
        a("")
        a(_linha_tab("Medida", "Valor", "Status"))
        a(_linha_tab("---", "---", "---"))
        a(_linha_tab("Volume percebido (loudness integrado)", f"{br(lo.get('integrado_lufs'))} LUFS", "MEDIDO"))
        a(_linha_tab("Pico real", f"{br(lo.get('pico_real_dbtp'))} dBTP", "MEDIDO"))
        a(_linha_tab("Variação de volume", f"{br(lo.get('variacao_lu'))} LU", "MEDIDO"))
        a(_linha_tab("Estéreo", ("sim" if audio["estereo"] else "não, praticamente mono")
                     + f" (correlação esquerda/direita {br(audio['correlacao_esq_dir'], 3)})", "MEDIDO"))
        a(_linha_tab("Nível da voz / do fundo", f"{br(tr['voz_dbfs'])} / {br(tr['piso_entre_frases_dbfs'])} dBFS "
                     f"(diferença {br(tr['diferenca_db'])} dB; {tr['metodo']})",
                     "MEDIDO" if tr.get("piso_confiavel") else "ESTIMADO"))
        a(_linha_tab("Trilha", f"{tr['veredito']}: {tr['explicacao']}", "ESTIMADO"))
        if "abaixa_na_fala" in tr:
            a(_linha_tab("Trilha abaixa quando fala (ducking)", "parece que sim" if tr["abaixa_na_fala"] else "não parece", "ESTIMADO"))
        if tr.get("bpm"):
            a(_linha_tab("Batida", f"perto de {br(tr['bpm'], 0)} BPM (força {br(tr['bpm_forca'], 2)})", "ESTIMADO"))
        if tr.get("some_antes_do_fim_s"):
            a(_linha_tab("Trilha some antes do fim", f"uns {br(tr['some_antes_do_fim_s'])} s antes", "ESTIMADO"))
        a(_linha_tab("Efeitos sonoros prováveis", f"{ef['provaveis']} ({br(ef['por_minuto'])} por minuto), confiança {ef['confianca']}", "ESTIMADO"))
        if ef.get("cortes_com_som_pct") is not None:
            a(_linha_tab("Cortes com som junto (até 0,2 s)", pct(ef["cortes_com_som_pct"]), "ESTIMADO"))
        a(_linha_tab("Ataques de som por minuto (inclui sílabas da fala)", br(audio.get("ataques_por_minuto"), 0), "MEDIDO"))
        a("")
        if ef["lista"]:
            a("Efeitos prováveis (tempo e família pelo espectro): " + "; ".join(
                f"{mmss(e['t'])} {e['tipo'].split(' ')[0]}" for e in ef["lista"][:40]) + (" ..." if len(ef["lista"]) > 40 else "") + ".")
            a("")
        a(f"Como os efeitos foram achados: {ef['metodo']}. Batida de música e respiração forte também podem aparecer.")
        a("")

    a("## 7. Fala")
    a("")
    if fala:
        a("Transcrição local (Whisper desta skill). Só os números entram aqui. **MEDIDO**")
        a("")
        a(_linha_tab("Medida", "Valor"))
        a(_linha_tab("---", "---"))
        a(_linha_tab("Idioma", fala.get("idioma") or "?"))
        a(_linha_tab("Palavras por minuto", br(fala["palavras_por_minuto"], 0)))
        a(_linha_tab("Primeira palavra", f"{br(fala['primeira_palavra_s'], 2)} s"))
        a(_linha_tab("Pausas acima de 0,3 s", f"{fala['pausas_acima_0_3s']} ({br(fala['pausas_por_minuto'])} por minuto)"))
        a(_linha_tab("Maior pausa", f"{br(fala['maior_pausa_s'], 2)} s"))
        a(_linha_tab("Tempo depois da última palavra", f"{br(fala['silencio_no_fim_s'], 2)} s"))
    else:
        a("Sem transcrição (desligada ou falhou). Ritmo de fala não medido.")
    a("")

    a("## 8. Leitura da IA (preencher olhando a folha de quadros)")
    a("")
    a("Abra `folha-de-quadros/` e `cortes-para-conferir*.jpg` e complete. Escreva o que viu, sem copiar texto da referência.")
    a("")
    for p in ("Tipo de vídeo (gente falando, tela, produto, tutorial, mistura):",
              "Os cortes marcados batem com a folha? Algum provável é corte de verdade?",
              "Elementos além da legenda (título de gancho, setas, prints, emoji, barra de progresso, moldura):",
              "Transições (corte seco, zoom, chicote, flash, deslize):",
              "Imagens de apoio (B-roll): quando entram e quanto tempo ficam:",
              "Como abre (primeiros 3 s) e como fecha (chamada):",
              "O que combina com o vídeo do usuário e o que não combina:"):
        a(f"- {p}")
    a("")

    a("## 9. O que NÃO DÁ PRA SABER só medindo")
    a("")
    fixos = [
        "Nome exato da fonte. Só a forma (estreita, média, larga) e o peso aparente.",
        "Curva exata das animações (entrada da palavra, pulinho, deslize). Para ver de perto, gere uma tira com "
        "`tira_de_quadros.py` a 10 ou 30 quadros por segundo no trecho.",
        "Fusões e cortes suaves (dissolve) e zoom digital lento dentro do plano. O detector só pega troca brusca.",
        "Se cada efeito é whoosh, pop ou estalo. Só a posição e a faixa de frequência (nada foi ouvido).",
        "Nome, autor e licença da música. Nunca use a música da referência.",
        "Programa ou IA usada na edição, e se algo é 3D de verdade.",
        "Resultado de venda ou de alcance da referência.",
    ]
    for f in fixos + faltas:
        a(f"- {f}")
    a("")

    a("## 10. Arquivos desta medida")
    a("")
    for k, v in arquivos.items():
        a(f"- `{k}`: {v}")
    a("")
    texto = "\n".join(L)
    texto = texto.replace("\u2014", ",").replace("\u2013", "-")
    caminho.write_text(texto, encoding="utf-8")


def escrever_trechos_medidos(caminho: Path, dur: float, quadros: list[tuple[float, Path]],
                             rostos_1s: dict[int, dict | None], cortes: list[dict], planos_bordas: list[float],
                             textos: dict | None, audio: dict, palavras: list[dict] | None,
                             notas: tuple[np.ndarray, np.ndarray], brilho_1s: dict[int, float],
                             papel_1s: dict[int, float]) -> None:
    n_seg = int(math.ceil(dur))
    itens = (textos or {}).get("_itens", [])
    leg_ids = {id(e) for e in (textos or {}).get("_legenda", [])}
    sons = (audio.get("efeitos") or {}).get("lista", []) if audio.get("tem_audio") else []
    por_seg = audio.get("_por_segundo", []) if audio.get("tem_audio") else []
    tn, sn = notas
    with open(caminho, "w", newline="", encoding="utf-8") as fh:
        w = csv.writer(fh)
        w.writerow(["segundo", "tempo", "plano", "corte", "o_que_aparece", "escrito_na_tela", "som", "observacao"])
        for s in range(n_seg):
            plano = int(np.searchsorted(planos_bordas, s + 0.5, side="right"))
            cs = [c for c in cortes if s <= c["t"] < s + 1]
            corte_txt = " ".join(mmss(c["t"]) + ("" if c["tipo"] == "corte" else "(provável)") for c in cs)
            partes = []
            r = rostos_1s.get(s)
            if r:
                lado = "esquerda" if r["cx"] < 0.4 else ("direita" if r["cx"] > 0.6 else "centro")
                partes.append(f"{tipo_de_plano(r['altura'])} ({pct(r['altura'])} da altura, {lado})")
                if r.get("rostos", 1) > 1:
                    partes.append(f"{r['rostos']} rostos")
            elif s in rostos_1s:
                partes.append("sem rosto")
            b = brilho_1s.get(s)
            if b is not None:
                partes.append("imagem escura" if b < 0.3 else ("imagem clara" if b > 0.75 else ""))
            if papel_1s.get(s, 0) >= 0.72:
                partes.append("tela clara (gráfico ou print)")
            sel = (tn >= s) & (tn < s + 1)
            if sel.any():
                mov = float(np.mean(sn[sel]))
                if mov > 0.05:
                    partes.append("muito movimento")
            txts = []
            vistos = set()
            for e in itens:
                if s <= e["t"] < s + 1 and e.get("texto"):
                    k = norm_txt(e["texto"])
                    if k in vistos:
                        continue
                    vistos.add(k)
                    tag = "legenda" if id(e) in leg_ids else ("fixo" if e["fixo"] else "texto")
                    zona = "topo" if e["centro_y"] < 0.3 else ("baixo" if e["centro_y"] > 0.7 else "meio")
                    txts.append(f"[{tag}, {zona}] {e['texto']}")
            som = []
            if palavras is not None:
                n_pal = sum(1 for p in palavras if s <= p["start"] < s + 1)
                som.append(f"fala: {n_pal} palavra(s)" if n_pal else "sem fala")
            if s < len(por_seg) and por_seg[s]:
                som.append(f"nível {br(por_seg[s]['db'])} dBFS")
                if por_seg[s].get("lado_db") is not None:
                    som.append(f"estéreo {br(por_seg[s]['lado_db'])} dBFS")
            for e in sons:
                if s <= e["t"] < s + 1:
                    som.append(f"efeito provável {mmss(e['t'])} {e['tipo'].split(' ')[0]}")
            w.writerow([s, mmss_curto(s), plano, corte_txt, "; ".join(p for p in partes if p),
                        " | ".join(txts), "; ".join(som), ""])


# ----------------------------------------------------------------------------- principal

def main(argv: list[str] | None = None) -> int:
    utf8_console()
    ap = argparse.ArgumentParser(description="Mede a linguagem de edição de um vídeo de referência (sem copiar o conteúdo).")
    ap.add_argument("video", type=Path, help="vídeo de referência (reel, anúncio, VSL)")
    ap.add_argument("--saida", type=Path, required=True, help="pasta onde ficam o guia, os trechos medidos, as regras e as imagens")
    ap.add_argument("--limiar", type=float, default=0.20,
                    help="nota de cena do ffmpeg (0 a 1) para contar corte seco (padrão 0.20)")
    ap.add_argument("--sem-fala", action="store_true", help="não transcreve (mais rápido; sem ritmo de fala)")
    ap.add_argument("--sem-texto", action="store_true", help="não lê o texto da tela")
    ap.add_argument("--transcricao", type=Path, help="JSON de palavras já pronto (formato do transcribe.py)")
    ap.add_argument("--editor-video", type=Path, default=EV_PADRAO,
                    help="outra pasta da skill editor-video (padrão: esta skill, ou EDITOR_VIDEO_DIR)")
    ap.add_argument("--fps-texto", type=float, default=2.0, help="amostras por segundo na leitura de texto (padrão 2)")
    args = ap.parse_args(argv)

    video = args.video.expanduser().absolute()
    if not video.is_file():
        print(f"erro: não achei o vídeo {video}", file=sys.stderr)
        return 2
    saida = args.saida.expanduser().absolute()
    if saida == video.parent:
        print("erro: escolha uma pasta de saída só para a medida (não a pasta do vídeo).", file=sys.stderr)
        return 2
    ev = args.editor_video.expanduser().absolute()
    for b in ("ffmpeg", "ffprobe"):
        if shutil.which(b) is None:
            print(f"erro: falta o {b}. No Mac: brew install ffmpeg", file=sys.stderr)
            return 2
    if cv2 is None:
        print('erro: falta o opencv. Rode com o Python da skill (uv run --directory "<SKILL>" ...).', file=sys.stderr)
        return 2
    saida.mkdir(parents=True, exist_ok=True)
    medidas = saida / "medidas"
    medidas.mkdir(exist_ok=True)
    # rodar de novo na mesma pasta: tira as imagens da rodada anterior (só as que este script gera)
    for padrao in ("folha-de-quadros/folha_*.jpg", "recortes/legenda_*.jpg", "recortes/texto_fixo_*.jpg",
                   "cortes-para-conferir*.jpg"):
        for velho_arq in saida.glob(padrao):
            velho_arq.unlink()
    faltas: list[str] = []
    inicio = time.time()
    fonte = carregar_fonte(ev, 18)
    fonte_p = carregar_fonte(ev, 14)

    try:
        falar("1/8 ficha técnica...")
        ficha = ficha_tecnica(video)
        dur, W, H = ficha["duracao_s"], ficha["largura"], ficha["altura"]
        salvar_json(medidas / "ficha.json", ficha)

        falar("2/8 procurando cortes (nota de cena quadro a quadro)...")
        with tempfile.TemporaryDirectory(prefix="sc-medir-") as tmp_s:
            tmp = Path(tmp_s)
            tempos_q, notas_q = pontuar_cenas(video, tmp)
            with open(medidas / "nota_de_cena.csv", "w", newline="") as fh:
                wr = csv.writer(fh)
                wr.writerow(["quadro", "t", "nota"])
                for i, (tq, sq) in enumerate(zip(tempos_q, notas_q)):
                    wr.writerow([i, round(float(tq), 4), round(float(sq), 4)])
            cortes = achar_cortes(tempos_q, notas_q, dur, args.limiar)
            fortes_t = [c["t"] for c in cortes if c["tipo"] == "corte"]
            ritmo = medir_ritmo(fortes_t, dur)
            ritmo_todos = medir_ritmo([c["t"] for c in cortes], dur)
            salvar_json(medidas / "cortes.json", {"limiar": args.limiar, "cortes": cortes})

            falar("3/8 tirando 1 quadro por segundo...")
            qw, qh = tamanho_saida(W, H, 1280)
            quadros = quadros_por_segundo(video, tmp / "q1s", qw, qh)

            falar("4/8 rosto, enquadramento e cores...")
            rostos = Rostos(ev)
            if not rostos.ok:
                faltas.append("Enquadramento: o detector de rosto (YuNet desta skill) não carregou.")
            rostos_1s: dict[int, dict | None] = {}
            brilho_1s: dict[int, float] = {}
            for t, arq in quadros:
                img = cv2.imread(str(arq))
                s = int(math.floor(t + 1e-6))
                rostos_1s[s] = rostos.medir(img) if rostos.ok else None
                if img is not None:
                    brilho_1s[s] = float(cv2.cvtColor(img, cv2.COLOR_BGR2HSV)[..., 2].mean()) / 255.0
            # quadros antes e depois de cada corte, e o meio de cada plano
            bordas = [0.0] + [c["t"] for c in cortes] + [dur]
            fps = ficha["fps"] or 30.0
            meios = []
            for a0, a1 in zip(bordas, bordas[1:]):
                meios.append(int(min(len(tempos_q) - 1, np.searchsorted(tempos_q, (a0 + a1) / 2))))
            pw, ph = tamanho_saida(W, H, 640)
            idx_pedidos = [c["quadro"] - 1 for c in cortes] + [c["quadro"] for c in cortes] + meios
            extra = quadros_por_indice(video, tempos_q, idx_pedidos, tmp / "pares", pw, ph)
            enq: dict = {}
            if rostos.ok:
                vals = [r for r in rostos_1s.values() if r]
                enq["rosto_pct"] = round(len(vals) / max(1, len(rostos_1s)), 3)
                if vals:
                    alt = float(np.median([r["altura"] for r in vals]))
                    cx = float(np.median([r["cx"] for r in vals]))
                    enq.update({"rosto_altura_mediana": round(alt, 3), "tipo_de_plano": tipo_de_plano(alt),
                                "olhos_y_mediana": round(float(np.median([r["olhos_y"] for r in vals])), 3),
                                "rosto_cx_mediana": round(cx, 3),
                                "posicao_horizontal": "esquerda" if cx < 0.4 else ("direita" if cx > 0.6 else "centro"),
                                "rostos_multiplos_pct": round(sum(1 for r in vals if r["rostos"] > 1) / len(vals), 3)})
                sem = 0
                for i in meios:
                    arq = extra.get(i)
                    if arq is not None and rostos.medir(cv2.imread(str(arq))) is None:
                        sem += 1
                enq["planos_medidos"] = len(meios)
                enq["planos_sem_rosto"] = sem
                mud = []
                for c in cortes:
                    a_arq, d_arq = extra.get(c["quadro"] - 1), extra.get(c["quadro"])
                    if not a_arq or not d_arq:
                        continue
                    ia, idp = cv2.imread(str(a_arq)), cv2.imread(str(d_arq))
                    ra, rd = rostos.medir(ia), rostos.medir(idp)
                    if ra and rd and abs(ra["cx"] - rd["cx"]) < 0.2 and mesma_cena(ia, idp):
                        r = rd["altura"] / ra["altura"]
                        if 1 / 1.8 < r < 1.8:
                            mud.append(max(r, 1 / r))
                if mud:
                    grandes = [m for m in mud if m >= 1.06]
                    nivel = round(float(np.median(grandes)), 2) if grandes else None
                    enq["zoom_nos_cortes"] = {
                        "pares": len(mud), "mudanca_mediana": round(float(np.median(mud)) - 1, 3),
                        "com_zoom": len(grandes),
                        "parece_zoom": len(mud) >= 3 and len(grandes) >= max(2, 0.4 * len(mud)),
                        "nivel_sugerido": round(min(1.25, max(1.04, nivel)), 2) if nivel else None,
                    }
            cores = paleta_da_imagem(quadros)

            falar("5/8 fala (transcrição local)...")
            arq_tr = None
            idioma = None
            if args.transcricao:
                arq_tr = args.transcricao.expanduser().absolute()
            elif not args.sem_fala and ficha["tem_audio"]:
                arq_tr = transcrever(video, ev, medidas)
                if arq_tr is None:
                    faltas.append("Fala: a transcrição falhou, então ritmo de fala e 'a legenda escreve a fala' ficaram sem medida.")
            palavras = ler_palavras(arq_tr)
            if arq_tr and arq_tr.exists():
                try:
                    idioma = json.loads(arq_tr.read_text(encoding="utf-8")).get("language")
                except Exception:
                    idioma = None
            fala = medir_fala(palavras, dur, idioma)

            falar("6/8 texto na tela...")
            textos = None
            if not args.sem_texto:
                doc = ler_texto_na_tela(video, ev, medidas, arq_tr if palavras else None, args.fps_texto)
                if doc:
                    textos = analisar_textos(doc, palavras, W, H)
                    if textos.get("motor") != "vision":
                        faltas.append("Texto: sem a Vision da Apple a leitura só acha manchas de texto; palavras por "
                                      "linha e caixa alta não foram medidas.")
                    # cores do texto da legenda
                    if textos.get("tem_legenda"):
                        por_t = {round(t, 1): arq for t, arq in quadros}
                        amostras_cor = []
                        for e in textos["_principal"]:
                            arq = por_t.get(round(e["t"], 1))
                            if not arq:
                                continue
                            img = cv2.imread(str(arq))
                            h, w = img.shape[:2]
                            cy, ch = e["centro_y"] * h, e["altura"] * h
                            cx, cw = e["centro_x"] * w, e["largura"] * w
                            caixa = (int(max(0, cx - cw / 2 - 0.25 * ch)), int(max(0, cy - 0.75 * ch)),
                                     int(min(w, cx + cw / 2 + 0.25 * ch)), int(min(h, cy + 0.75 * ch)))
                            c = cores_do_texto(img, caixa)
                            if c:
                                amostras_cor.append(c)
                        if amostras_cor:
                            papeis = papeis_das_cores(amostras_cor)
                            textos.update(papeis)
                            textos["contorno_escuro_pct"] = round(sum(c["contorno_escuro"] for c in amostras_cor) / len(amostras_cor), 3)
                            textos["caixa_atras_pct"] = round(sum(c["fundo_liso"] for c in amostras_cor) / len(amostras_cor), 3)
                            textos["amostras_de_cor"] = len(amostras_cor)
                        alt1920 = textos.get("altura_linha_padrao")
                        if alt1920:
                            textos["fonte_estimada_padrao"] = int(round(alt1920 * 1.05))
            else:
                faltas.append("Texto: leitura de texto desligada (--sem-texto).")
            if textos is None and not args.sem_texto:
                faltas.append("Texto: a leitura de texto não rodou; legenda sem medida.")

            falar("7/8 áudio (volume, trilha, efeitos)...")
            audio = analisar_audio(video, dur, palavras, [c["t"] for c in cortes]) if ficha["tem_audio"] else {"tem_audio": False}
            if audio.get("tem_audio"):
                salvar_json(medidas / "audio.json", {k: v for k, v in audio.items() if k != "_por_segundo"})
                if not audio.get("estereo"):
                    faltas.append("Áudio mono: sem o canal lateral, trilha e efeitos ficam menos confiáveis.")
                if palavras is None:
                    faltas.append("Áudio: sem transcrição, o nível entre as frases foi estimado pelos trechos mais baixos.")

            falar("8/8 folhas de quadros, recortes, trechos medidos, regras e guia...")
            sons = (audio.get("efeitos") or {}).get("lista", []) if audio.get("tem_audio") else []
            folhas = montar_folhas(quadros, cortes, sons, saida / "folha-de-quadros", W, H, fonte, fonte_p)
            conf = montar_conferencia(cortes, extra, saida / "cortes-para-conferir.jpg", W, H, fonte_p)
            recortes = salvar_recortes(textos, quadros, saida / "recortes")
            papel_1s: dict[int, float] = {}
            if textos:
                for e in (json.loads((medidas / "texto_na_tela.json").read_text(encoding="utf-8")).get("amostras", [])):
                    s = int(math.floor(e["t"]))
                    papel_1s[s] = max(papel_1s.get(s, 0.0), float(e.get("papel", 0)))
            planos_bordas = [c["t"] for c in cortes]
            escrever_trechos_medidos(saida / ARQ_TRECHOS, dur, quadros, rostos_1s, cortes, planos_bordas, textos,
                                     audio, palavras, (tempos_q, notas_q), brilho_1s, papel_1s)
            regras = montar_regras(ficha, cortes, ritmo, ritmo_todos, enq, textos or {}, cores, audio, fala, args)
            salvar_json(saida / ARQ_REGRAS, regras)
            antigos = [n for n in NOMES_ANTIGOS.values() if (saida / n).exists()]
            if antigos:
                falar(f"Aviso: sobrou {' e '.join(antigos)} de uma medida antiga nesta pasta; os de agora são "
                      f"{ARQ_TRECHOS} e {ARQ_REGRAS}.")
            arquivos = {
                "guia-de-estilo.md": "este guia",
                ARQ_REGRAS: "os mesmos números em formato de máquina, com sugestões para o edit_data.json e o HyperFrames",
                ARQ_TRECHOS: "um segundo por linha: plano, corte, o que aparece, o que está escrito na tela, som",
                "folha-de-quadros/": f"{len(folhas)} folha(s), 1 quadro por segundo, cortes e efeitos marcados",
            }
            if conf:
                arquivos["cortes-para-conferir*.jpg"] = "quadro antes e depois de cada corte achado"
            if recortes:
                arquivos["recortes/"] = f"{len(recortes)} recorte(s) de legenda e texto fixo em tamanho de trabalho"
            arquivos["medidas/"] = "dados brutos: nota de cena por quadro, cortes, áudio, texto lido, transcrição"
            escrever_guia(saida / "guia-de-estilo.md", ficha, cortes, ritmo, ritmo_todos, enq, textos, cores, audio,
                          fala, regras, arquivos, args, faltas)
    except ErroMedida as exc:
        print(f"erro: {exc}", file=sys.stderr)
        return 1

    seg = time.time() - inicio
    falar("")
    falar(f"Pronto em {br(seg, 0)} s. Pasta: {saida}")
    falar(f"Cortes: {ritmo['cortes']} ({br(ritmo['cortes_por_minuto'])} por minuto, plano médio {br(ritmo['plano_medio_s'], 2)} s)"
          + (f"; mais {len(cortes) - ritmo['cortes']} provável(is) para conferir" if len(cortes) > ritmo["cortes"] else ""))
    if textos and textos.get("tem_legenda"):
        falar(f"Legenda: centro em {pct(textos['centro_y'])} da altura, {br(textos['palavras_por_linha_mediana'], 0)} palavra(s) por linha, "
              f"linha de {textos['altura_linha_padrao']} px na tela padrão {textos['tela_padrao']}")
    if audio.get("tem_audio"):
        falar(f"Áudio: {br(audio['loudness'].get('integrado_lufs'))} LUFS; {audio['trilha']['veredito']}; "
              f"{audio['efeitos']['provaveis']} efeito(s) provável(is)")
    falar("Agora olhe a folha de quadros e os cortes para conferir, e complete a seção 8 do guia.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
