# Adapted from browser-use/video-use (MIT, (c) 2026 Browser Use). See THIRD_PARTY_NOTICES.md.
"""EDL -> cut.mp4 + cut_timeline.json (Contract 5 of SPEC.md).

Usage:
    uv run python helpers/render_cut.py <edit>/edl.json [-o cut.mp4] [--jobs N] [--no-jcut] [--preview]

Pipeline
  1. Plan: the audio of a range starts exactly at the EDL start. The picture starts on the
     nearest source frame when one is within half an output frame, else at the exact time
     (sparse VFR sources such as screen recordings: the frame on screen at that time is used).
     Lengths are whole output frames. J-cut per junction: the incoming picture skips
     `lead_frames` while its audio keeps them (so it starts earlier, over the outgoing
     picture); the outgoing tail loses up to `tail_trim_frames` only where there is no
     measured speech (edit/regions/<stem>.json, or an inline silencedetect when missing).
     A range with "dry": false keeps its written edges: no lead and no tail trim on it (a video playing
     on screen starts on its first frame).
     A source whose shape differs from the output is cropped around `focus_x`/`focus_y` of
     the range, or around the face found by YuNet when the crop is large.
     "imagem_de": "CAM2" on a range: the audio still comes from the range's source and the picture
     from CAM2 at the same instant, through sincronia.json (sincronizar_cameras.py; the EDL key
     "sincronia" points to another file). A source used only as audio (a lapela .wav) needs no video.
     Two ranges in a row from the same audio source that touch (start of the second within half a
     frame of the end of the first) are one continuous voice: no J-cut and no 30 ms fades there, and
     the second one's audio starts on the exact sample where the first one's ends (a camera switch in
     the middle of a sentence does not dip or skip). Their gain_db must match, or the fades stay.
  2. Extract, in parallel, one picture clip (H.264, final quality, upright, scaled+cropped,
     fps, HDR->SDR, 8-bit guard, grade, bt709/tv tags) and one float WAV (48k stereo,
     gain+limiter, 30 ms fades, sample-exact length) per range into edit/clips/ (cached).
  3. Count the encoded frames of each clip -> out_start/out_end (frame-accurate).
  4. Picture: concat demuxer, stream copy. Audio: adelay in samples + amix normalize=0,
     loudnorm two-pass (I=-14, TP=-1, LRA=11), AAC 192k, length pinned with -t. The true peak
     of the encoded file is measured; above -1.05 dBTP (a margin for verify_cut's ebur128 reading)
     the audio is lowered and encoded again.
  5. Write <output stem>_timeline.json (cut_timeline.json by default). A full render inside
     the edit dir also merges state.json (awaiting stays null: verify_cut asks for approval).
     `--preview` writes cut_preview.mp4 and leaves cut.mp4 and state.json alone.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import re
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from fractions import Fraction
from pathlib import Path

from functools import lru_cache

from common import (ASSETS_DIR, OUTPUT_FORMATS, file_signature, fmt_time, load_json, nominal_fps, now_iso,
                    output_fps_for, probe_video, require_binary, save_json)
from common import has_ffmpeg_filter as _has_ffmpeg_filter
from grade import check_filter, graded_chain, hdr_to_sdr_chain, resolve_grade

SR = 48000
FADE_SAMPLES = 1440            # 30 ms
SPEECH_GUARD_S = 0.10          # the tail trim never gets closer than this to measured speech (a weak
                               # final consonant sits under the speech regions; 0.04 bit it)
MIN_JOIN_GAP_S = 0.10          # silence kept between outgoing and incoming speech after a J-cut
SILENCE_NOISE_DB = -35
MIN_PIC_FRAMES = 6
DEFAULT_JCUT = {"lead_frames": 4, "tail_trim_frames": 2}
LOUDNORM = {"I": -14.0, "TP": -1.0, "LRA": 11.0}
TRUE_PEAK_LIMIT = -1.0         # delivered cut.mp4 (after AAC) must not go above this
TRUE_PEAK_MARGIN = 0.05        # loudnorm (here) and ebur128 (verify_cut) read the true peak a few hundredths apart
CACHE_VERSION = "render_cut/2"
SYNC_FILE = "sincronia.json"
BT709_TAGS = "setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709"
ASPECT_WARN = 1.15             # source/output shape ratio beyond this: warn and frame on the face
CROP_MIN = 0.02                # crops smaller than this are just "fill", not recorded
REPEAT_TOL_S = 0.02
FACE_MODEL = ASSETS_DIR / "models" / "face_detection_yunet_2023mar.onnx"
FACE_SAMPLES = 5


@lru_cache(maxsize=None)
def has_ffmpeg_filter(name: str) -> bool:
    return _has_ffmpeg_filter(name)


class CutError(Exception):
    def __init__(self, message: str, code: int = 1):
        super().__init__(message)
        self.code = code


def log(msg: str = "") -> None:
    print(msg, flush=True)


def warn(msg: str) -> None:
    print(f"aviso: {msg}", file=sys.stderr, flush=True)


def ffmpeg(cmd: list[str], what: str) -> subprocess.CompletedProcess:
    proc = subprocess.run([str(c) for c in cmd], capture_output=True, text=True)
    if proc.returncode != 0:
        raise CutError(f"o ffmpeg falhou ao {what}:\n{(proc.stderr or '').strip()[-1500:]}")
    return proc


def edl_signature(edl: dict) -> str:
    return hashlib.sha1(json.dumps(edl, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]


# ------------------------------------------------------------------ EDL loading


def resolve_source(value: str, edit_dir: Path) -> Path:
    p = Path(value).expanduser()
    if p.is_absolute():
        return p
    for base in (edit_dir, edit_dir.parent):
        cand = Path(os.path.abspath(base / p))
        if cand.exists():
            return cand
    return Path(os.path.abspath(edit_dir / p))


def load_edl(path: Path) -> dict:
    if not path.exists():
        raise CutError(f"EDL não encontrado: {path}", 2)
    try:
        edl = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise CutError(f"o EDL não é um JSON válido ({path.name}, linha {exc.lineno}): {exc.msg}", 2)
    if not isinstance(edl, dict):
        raise CutError("o EDL precisa ser um objeto JSON", 2)
    ranges = edl.get("ranges")
    if not isinstance(ranges, list) or not ranges:
        raise CutError("o EDL não tem trechos em \"ranges\"", 2)
    sources = edl.get("sources") or {}
    if not isinstance(sources, dict) or not sources:
        raise CutError("o EDL não tem \"sources\" (mapa nome -> caminho do vídeo)", 2)
    seen: set[str] = set()
    for i, r in enumerate(ranges):
        rid = str(r.get("id") or f"r{i + 1}")
        r["id"] = rid
        if rid in seen:
            raise CutError(f"id repetido no EDL: {rid}", 2)
        seen.add(rid)
        if r.get("source") not in sources:
            raise CutError(f"trecho {rid}: fonte {r.get('source')!r} não está em \"sources\"", 2)
        try:
            r["start"], r["end"] = float(r["start"]), float(r["end"])
        except (KeyError, TypeError, ValueError):
            raise CutError(f"trecho {rid}: start/end precisam ser números em segundos", 2)
        if r["start"] < 0 or r["end"] <= r["start"]:
            raise CutError(f"trecho {rid}: end ({r['end']}) precisa ser maior que start ({r['start']})", 2)
        try:
            r["gain_db"] = float(r.get("gain_db") or 0.0)
        except (TypeError, ValueError):
            raise CutError(f"trecho {rid}: gain_db inválido", 2)
        for k in ("focus_x", "focus_y"):
            if r.get(k) is None:
                continue
            try:
                r[k] = float(r[k])
            except (TypeError, ValueError):
                raise CutError(f"trecho {rid}: {k} precisa ser um número de 0 a 1", 2)
            if not 0.0 <= r[k] <= 1.0:
                raise CutError(f"trecho {rid}: {k} precisa ficar entre 0 e 1 (0 = esquerda/topo, 1 = direita/base)", 2)
        pic = r.get("imagem_de")
        if pic in (None, "", False) or pic == r["source"]:
            r["imagem_de"] = None
        elif not isinstance(pic, str) or pic not in sources:
            raise CutError(f"trecho {rid}: imagem_de {pic!r} não está em \"sources\" (ponha a câmera lá, com o caminho)", 2)
    return edl


def picture_key(r: dict) -> str:
    """The source the picture of a range comes from."""
    return r.get("imagem_de") or r["source"]


def load_sync(edl: dict, edit_dir: Path, probes: dict) -> tuple[dict[str, tuple[float, float]], Path | None]:
    """{source: (offset, scale)} from sincronia.json for every source a range with imagem_de touches.

    Rule of the file: time in the source = deslocamento + ajuste_s + escala x time in the main one."""
    needed = sorted({k for r in edl["ranges"] if r.get("imagem_de") for k in (r["source"], r["imagem_de"])})
    if not needed:
        return {}, None
    raw = edl.get("sincronia") or SYNC_FILE
    path = resolve_source(str(raw), edit_dir)
    if not path.exists():
        raise CutError(f"trechos com imagem_de precisam da sincronia das câmeras e {path} não existe. Rode "
                       f"helpers/sincronizar_cameras.py --edl <EDIT>/edl_manual.json (ou ponha \"sincronia\" no EDL)", 2)
    doc = load_json(path)
    fontes = doc.get("fontes") if isinstance(doc, dict) else None
    if not isinstance(fontes, dict):
        raise CutError(f"{path.name} não é um arquivo de sincronia (falta \"fontes\")", 2)
    out: dict[str, tuple[float, float]] = {}
    for key in needed:
        f = fontes.get(key)
        if not isinstance(f, dict):
            raise CutError(f"a fonte {key} não está em {path.name}: rode sincronizar_cameras.py com ela junto", 2)
        try:
            off = float(f.get("deslocamento") or 0.0) + float(f.get("ajuste_s") or 0.0)
            scale = float(f.get("escala") or 1.0)
        except (TypeError, ValueError):
            raise CutError(f"{path.name}: deslocamento, ajuste_s ou escala de {key} não é número", 2)
        if not 0.9 < scale < 1.1:
            raise CutError(f"{path.name}: escala {scale} de {key} fora do razoável", 2)
        src = probes[key]["path"]
        measured = Path(str(f.get("arquivo") or ""))
        same_path = measured.exists() and os.path.samefile(measured, src)
        same_sig = f.get("assinatura") == file_signature(src)
        if not same_path and not same_sig:
            raise CutError(f"{path.name} mediu {key} em outro arquivo ({measured}), não em {src}: rode "
                           "sincronizar_cameras.py de novo", 2)
        if same_path and not same_sig and f.get("assinatura"):
            warn(f"{src.name} mudou depois da sincronia ({path.name}): se foi reexportado, rode sincronizar_cameras.py de novo")
        if f.get("confianca") == "fraca":
            warn(f"a sincronia de {key} saiu fraca em {path.name}: confira no olho (sincronizar_cameras.py --prova)")
        out[key] = (off, scale)
    return out, path


def jcut_settings(edl: dict, no_jcut: bool) -> dict:
    raw = edl.get("jcut", None)
    if no_jcut or raw is False:
        return {"enabled": False, "lead_frames": 0, "tail_trim_frames": 0}
    cfg = dict(DEFAULT_JCUT)
    if isinstance(raw, dict):
        for k in cfg:
            if raw.get(k) is not None:
                cfg[k] = max(0, int(raw[k]))
    return {"enabled": cfg["lead_frames"] > 0 or cfg["tail_trim_frames"] > 0, **cfg}


# ------------------------------------------------------------------ source analysis


def source_frame_times(src: Path, t: float, before: float = 3.0, after: float = 3.0) -> list[float]:
    """Presentation times of the source frames around t (packets, no decoding).

    The window is wide and the list is sorted: with B-frames the packets come in decode order,
    and ffprobe stops reading at the first packet past the window end, so a narrow window
    loses frames that sit just before t.
    """
    proc = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals",
         f"{max(0.0, t - before):.3f}%{t + after:.3f}", "-show_entries", "packet=pts_time",
         "-of", "csv=p=0", str(src)], capture_output=True, text=True)
    out = set()
    for line in proc.stdout.splitlines():
        try:
            out.add(float(line.strip().strip(",")))
        except ValueError:
            continue
    return sorted(out)


def picture_start(src: Path, t: float, fps: int) -> tuple[float, bool]:
    """(source time of the first picture frame, snapped?).

    Snaps to the nearest real frame only when it is within half an output frame. A sparse VFR
    source (static screen recording) has no frame near t: the exact time is kept and the
    extraction shows the frame that is on screen at t.
    """
    times = source_frame_times(src, t)
    if times:
        near = min(times, key=lambda x: abs(x - t))
        if abs(near - t) <= 0.5 / fps + 1e-6:
            return near, True
    return t, False


def nominal_rate(src: Path, info: dict) -> float | None:
    """Nominal fps of a source for choosing the output fps; None for sparse or variable sources
    (screen recordings write frames only when the screen changes) that must not drag it down."""
    avg = float(info.get("fps") or 0.0)
    proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                           "stream=r_frame_rate", "-of", "csv=p=0", str(src)], capture_output=True, text=True)
    try:
        r = float(Fraction(proc.stdout.strip().split(",")[0]))
    except (ValueError, ZeroDivisionError):
        r = 0.0
    if avg <= 0 or (r > 0 and avg < 0.8 * r):
        return None
    n = nominal_fps(avg)
    return n if n >= 23.0 else None


def choose_output_fps(probes: dict) -> int:
    rates = [nominal_rate(pr["path"], pr["info"]) for pr in probes.values()]
    rates = [r for r in rates if r]
    return output_fps_for(max(rates)) if rates else 30


def crop_geometry(info: dict, W: int, H: int) -> tuple[str | None, float, float]:
    """(axis cropped "x"/"y" or None, kept fraction of that axis, source/output shape ratio)."""
    dw, dh = info.get("display_width") or W, info.get("display_height") or H
    ratio = (dw / dh) / (W / H)
    if ratio > 1.0 + CROP_MIN:
        return "x", 1.0 / ratio, ratio
    if ratio < 1.0 - CROP_MIN:
        return "y", ratio, ratio
    return None, 1.0, ratio


def face_focus(src: Path, t0: float, t1: float) -> tuple[float, float, int] | None:
    """Median center (0..1, upright frame) of the biggest face on FACE_SAMPLES frames of [t0, t1].

    Returns (x, y, frames with a face), or None when OpenCV or the model is missing."""
    if not FACE_MODEL.exists():
        return None
    os.environ.setdefault("OPENCV_LOG_LEVEL", "ERROR")
    try:
        import cv2
        import numpy as np
    except ImportError:
        return None
    xs, ys = [], []
    for i in range(FACE_SAMPLES):
        t = t0 + (i + 0.5) * (t1 - t0) / FACE_SAMPLES
        proc = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-ss", f"{t:.3f}", "-i", str(src), "-frames:v", "1",
                               "-an", "-vf", "scale=640:-2", "-f", "image2pipe", "-c:v", "png", "-"], capture_output=True)
        if proc.returncode != 0 or not proc.stdout:
            continue
        img = cv2.imdecode(np.frombuffer(proc.stdout, np.uint8), cv2.IMREAD_COLOR)
        if img is None:
            continue
        h, w = img.shape[:2]
        try:
            det = cv2.FaceDetectorYN.create(str(FACE_MODEL), "", (w, h), 0.6, 0.3, 50)
            _, faces = det.detect(img)
        except cv2.error:
            return None
        if faces is None or not len(faces):
            continue
        x, y, fw, fh = max(faces, key=lambda f: f[2] * f[3])[:4]
        xs.append(min(1.0, max(0.0, (x + fw / 2) / w)))
        ys.append(min(1.0, max(0.0, (y + fh / 2) / h)))
    if not xs:
        return 0.5, 0.5, 0
    return float(np.median(xs)), float(np.median(ys)), len(xs)


def parse_silences(stderr: str, t0: float, t1: float) -> list[list[float]]:
    sil: list[list[float]] = []
    cur = None
    for line in stderr.splitlines():
        m = re.search(r"silence_start: (-?[\d.]+)", line)
        if m:
            cur = max(0.0, float(m.group(1)))
            continue
        m = re.search(r"silence_end: (-?[\d.]+)", line)
        if m and cur is not None:
            sil.append([t0 + cur, t0 + float(m.group(1))])
            cur = None
    if cur is not None:
        sil.append([t0 + cur, t1])
    return sil


def _intersect(a: list, b: list) -> list:
    out, i, j = [], 0, 0
    while i < len(a) and j < len(b):
        lo, hi = max(a[i][0], b[j][0]), min(a[i][1], b[j][1])
        if hi > lo:
            out.append([lo, hi])
        if a[i][1] < b[j][1]:
            i += 1
        else:
            j += 1
    return out


def speech_in_window(edit_dir: Path, stem: str, src: Path, info: dict, t0: float, t1: float) -> tuple[list, str]:
    """Measured speech intervals inside [t0, t1] of a source.

    edit/regions/<stem>.json is the authority when present and made from this very file (same
    source_sig); its coarse min_silence (0.25 s)
    hides the short pauses editors cut at, so the edge window is refined with a fine
    silencedetect (same noise floor, 0.08 s) and the two are intersected. Without a regions
    file only the fine pass is used (noise -35 dB).
    """
    t0 = max(0.0, t0)
    if not info.get("has_audio") or t1 - t0 <= 0.01:
        return [], "sem áudio"
    reg = load_json(edit_dir / "regions" / f"{stem}.json")
    reg_spans = None
    noise = SILENCE_NOISE_DB
    if isinstance(reg, dict) and isinstance(reg.get("speech"), list) and "start" not in reg:
        # regions of another file with the same name (video.mov + video.mp4) or of an older copy do not count
        same_file = reg.get("source_sig") in (None, file_signature(src))
        if same_file and abs(float(reg.get("duration") or info["duration"]) - info["duration"]) < 0.2:
            reg_spans = [[max(t0, float(a)), min(t1, float(b))] for a, b in reg["speech"]
                         if float(b) > t0 and float(a) < t1]
            try:
                noise = float(reg.get("noise_db", noise))
            except (TypeError, ValueError):
                pass
    proc = subprocess.run(
        ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-ss", f"{t0:.3f}", "-t", f"{t1 - t0:.3f}",
         "-i", str(src), "-vn", "-af", f"silencedetect=n={noise:g}dB:d=0.08", "-f", "null", "-"],
        capture_output=True, text=True)
    sil = parse_silences(proc.stderr, t0, t1)
    fine, cur = [], t0
    for a, b in sil:
        if a > cur + 1e-3:
            fine.append([cur, a])
        cur = max(cur, b)
    if cur < t1 - 1e-3:
        fine.append([cur, t1])
    if reg_spans is None:
        return fine, "silencedetect"
    return _intersect(sorted(reg_spans), fine), "regions+silencedetect"


def channel_mode(src: Path, info: dict) -> str:
    """stereo | mono | left | right | multi. Phase-cancelled stereo -> use the stronger channel."""
    ch = info.get("audio_channels") or 0
    if ch == 1:
        return "mono"
    if ch != 2:
        return "multi"
    proc = subprocess.run(
        ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-t", "120", "-i", str(src), "-vn", "-af",
         "pan=3c|c0=c0|c1=c1|c2=0.5*c0+0.5*c1,astats=metadata=0:measure_perchannel=RMS_level:measure_overall=none",
         "-f", "null", "-"], capture_output=True, text=True)
    levels = [float(x) if x not in ("-inf", "inf", "nan") else -120.0
              for x in re.findall(r"RMS level dB: (-?[\w.]+)", proc.stderr)]
    if len(levels) < 3:
        return "stereo"
    left, right, both = levels[:3]
    best = max(left, right)
    if best > -90 and both < best - 6:
        warn(f"{src.name}: estéreo com fase invertida (L+R {both:.1f} dB contra {best:.1f} dB). Usando um canal só.")
        return "left" if left >= right else "right"
    return "stereo"


_HWACCEL_CACHE: dict[str, bool] = {}


def hwaccel_for(src: Path, info: dict) -> bool:
    if os.environ.get("EDITOR_VIDEO_HWACCEL", "1") == "0" or sys.platform != "darwin":
        return False
    codec = info.get("codec", "")
    if codec not in ("hevc", "h264", "prores"):
        return False
    if codec not in _HWACCEL_CACHE:
        hw = subprocess.run(["ffmpeg", "-hide_banner", "-hwaccels"], capture_output=True, text=True).stdout
        ok = "videotoolbox" in hw
        if ok:
            ok = subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-hwaccel", "videotoolbox", "-i", str(src),
                                 "-frames:v", "2", "-an", "-f", "null", "-"], capture_output=True).returncode == 0
        _HWACCEL_CACHE[codec] = ok
    return _HWACCEL_CACHE[codec]


# ------------------------------------------------------------------ planning


@dataclass
class Plan:
    idx: int
    rid: str
    source: str
    src: Path
    info: dict
    edl_start: float
    edl_end: float
    gain_db: float
    beat: str = ""
    quote: str = ""
    pic_source: str = ""       # source key of the picture ("imagem_de", else the audio source)
    pic_src: Path = field(default_factory=Path)
    pic_info: dict = field(default_factory=dict)
    pic_a: float = 0.0         # picture time = pic_a + pic_b x audio time (identity without imagem_de)
    pic_b: float = 1.0
    audio_start_s: float | None = None   # set when the audio continues the previous range sample for sample
    joined: bool = False       # the audio continues from the previous range (no J-cut, no fades between them)
    fade_in: bool = True
    fade_out: bool = True
    lead: int = 0
    tail: int = 0
    pic_start: float = 0.0     # source time of the first picture frame
    pic_snapped: bool = True   # pic_start is a real source frame (False: sparse VFR, exact time)
    frames: int = 0            # picture frames
    crop: dict | None = None   # {"axis", "kept", "focus_x", "focus_y", "focus_from"} when the shape differs
    speech_src: str = ""
    gap_in: float = 0.0
    gap_out: float = 0.0
    lead_note: str = ""
    key: str = ""
    video_path: Path = field(default_factory=Path)
    audio_path: Path = field(default_factory=Path)
    meta_path: Path = field(default_factory=Path)
    cached: bool = False
    seconds: float = 0.0

    @property
    def audio_frames(self) -> int:
        return self.lead + self.frames

    @property
    def audio_start(self) -> float:
        """The audio of a range starts where the EDL says (lead frames included), or where the previous
        range's audio ends when the two are one continuous voice."""
        return self.edl_start if self.audio_start_s is None else self.audio_start_s

    @property
    def other_picture(self) -> bool:
        return self.pic_source != self.source

    def to_pic(self, t: float) -> float:
        """Audio-source time -> picture-source time."""
        return self.pic_a + self.pic_b * t


def plan_crops(plans: list[Plan], ranges: list[dict], W: int, H: int, edit_dir: Path, jobs: int) -> None:
    """Crop position of every range whose source shape differs from the output.

    focus_x/focus_y from the EDL win; a large crop (shape ratio beyond ASPECT_WARN) is framed on
    the median face center of the range; otherwise the crop stays centered.
    """
    cache_path = edit_dir / "clips" / "focus_cache.json"
    cache = load_json(cache_path, default={}) or {}
    warned: set[str] = set()
    todo = []
    for p, r in zip(plans, ranges):
        axis, kept, ratio = crop_geometry(p.pic_info, W, H)
        if axis is None:
            continue
        fx, fy = r.get("focus_x"), r.get("focus_y")
        big = ratio > ASPECT_WARN or ratio < 1.0 / ASPECT_WARN
        if big and p.pic_source not in warned:
            warned.add(p.pic_source)
            dw, dh = p.pic_info.get("display_width"), p.pic_info.get("display_height")
            part = "largura" if axis == "x" else "altura"
            warn(f"{p.pic_src.name} é {dw}x{dh} e a saída é {W}x{H}: o corte guarda só {kept:.0%} da {part}. "
                 "Enquadro pelo rosto (ou pelo focus_x/focus_y do trecho); confira na folha de quadros.")
        given = fx if axis == "x" else fy
        p.crop = {"axis": axis, "kept": round(kept, 3), "focus_x": 0.5 if fx is None else fx,
                  "focus_y": 0.5 if fy is None else fy, "focus_from": "edl" if given is not None else "centro"}
        if given is None and big:
            key = f"{file_signature(p.pic_src)}|{p.to_pic(p.edl_start):.3f}|{p.to_pic(p.edl_end):.3f}"
            todo.append((p, key, fx, fy))

    def detect(job):
        p, key, fx, fy = job
        hit = cache.get(key)
        if not (isinstance(hit, list) and len(hit) == 3):
            found = face_focus(p.pic_src, p.to_pic(p.edl_start), p.to_pic(p.edl_end))
            hit = list(found) if found else None
        return job, hit

    with ThreadPoolExecutor(max_workers=max(1, jobs)) as pool:
        results = list(pool.map(detect, todo))
    for (p, key, fx, fy), hit in results:
        if hit is None:
            warn(f"trecho {p.rid}: não deu para procurar rosto (OpenCV indisponível); corte no centro")
            continue
        cache[key] = hit
        if hit[2] == 0:
            warn(f"trecho {p.rid}: não achei rosto em {p.pic_src.name}; corte no centro. "
                 "Ajuste focus_x/focus_y no trecho se precisar.")
            continue
        if p.crop["axis"] == "x":
            p.crop["focus_x"] = round(hit[0], 3)
        else:
            p.crop["focus_y"] = round(hit[1], 3)
        p.crop["focus_from"] = "rosto"
    if results:
        save_json(cache_path, cache)


def plan_cut(edl: dict, edit_dir: Path, fps: int, jcut: dict, probes: dict, jobs: int,
             W: int = 1080, H: int = 1920, sync: dict | None = None) -> list[Plan]:
    ranges = edl["ranges"]
    n = len(ranges)
    sync = sync or {}
    plans: list[Plan] = []
    for i, r in enumerate(ranges):
        src = probes[r["source"]]["path"]
        info = probes[r["source"]]["info"]
        dur = info["duration"]
        if r["start"] >= dur - 1.0 / fps:
            raise CutError(f"trecho {r['id']}: start {r['start']:.2f}s passa do fim de {src.name} ({dur:.2f}s)", 2)
        end = r["end"]
        if end > dur:
            warn(f"trecho {r['id']}: end {end:.2f}s passa do fim de {src.name}; usando {dur:.2f}s")
            end = dur
        p = Plan(i, r["id"], r["source"], src, info, r["start"], end, r["gain_db"],
                 str(r.get("beat") or ""), str(r.get("quote") or ""))
        p.pic_source = picture_key(r)
        p.pic_src, p.pic_info = probes[p.pic_source]["path"], probes[p.pic_source]["info"]
        if p.other_picture:
            (oa, sa), (ob, sb) = sync[p.source], sync[p.pic_source]
            p.pic_b = sb / sa
            p.pic_a = ob - p.pic_b * oa
            t0, t1 = p.to_pic(p.edl_start), p.to_pic(end)
            pdur = p.pic_info["duration"]
            if t0 < -0.5 / fps or t1 > pdur + 0.5 / fps:
                raise CutError(f"trecho {p.rid}: a {p.pic_source} não tem imagem de {p.edl_start:.2f} a {end:.2f}s da "
                               f"{p.source} (pela sincronia isso é {t0:.2f} a {t1:.2f}s dela, que tem {pdur:.2f}s). "
                               "Use outra câmera nesse trecho ou encurte", 2)
        plans.append(p)
    for a, b in zip(plans, plans[1:]):
        if a.source == b.source and b.edl_start < a.edl_end - REPEAT_TOL_S and b.edl_end > a.edl_start:
            warn(f"trecho {b.rid} repete {a.edl_end - b.edl_start:.2f}s do trecho {a.rid} (mesma fonte): "
                 f"o start {b.edl_start:.2f}s fica antes do end {a.edl_end:.2f}s do anterior")
    # One continuous voice: the next range picks up the same audio source where this one stops.
    for a, b in zip(plans, plans[1:]):
        if a.source != b.source or abs(b.edl_start - a.edl_end) > 0.5 / fps:
            continue
        if abs(a.gain_db - b.gain_db) > 0.05:
            warn(f"trechos {a.rid} e {b.rid} continuam a mesma fala mas têm gain_db diferente "
                 f"({a.gain_db:+.1f} e {b.gain_db:+.1f}): a voz leva um corte de 30 ms ali. Use o mesmo gain_db "
                 "nos dois para ela seguir inteira")
            continue
        b.joined = True
        a.fade_out = b.fade_in = False
    plan_crops(plans, ranges, W, H, edit_dir, jobs)

    lead_req = jcut["lead_frames"] if jcut["enabled"] else 0
    tail_req = jcut["tail_trim_frames"] if jcut["enabled"] else 0

    # Measured silence at both edges of every range (only needed around junctions).
    def edge_gaps(p: Plan) -> tuple[float, float, str]:
        if not jcut["enabled"] or n == 1:
            return 0.0, 0.0, ""
        stem = Path(p.src).stem
        gap_in = gap_out = 0.0
        how = ""
        if p.idx > 0:
            w1 = min(p.edl_end, p.edl_start + 1.0)
            spans, how = speech_in_window(edit_dir, stem, p.src, p.info, p.edl_start, w1)
            first = min((max(a, p.edl_start) for a, b in spans if b > p.edl_start), default=None)
            gap_in = (w1 - p.edl_start) if first is None else max(0.0, first - p.edl_start)
        if p.idx < n - 1:
            w0 = max(p.edl_start, p.edl_end - 1.0)
            spans, how = speech_in_window(edit_dir, stem, p.src, p.info, w0, p.edl_end)
            last = max((min(b, p.edl_end) for a, b in spans if a < p.edl_end), default=None)
            gap_out = (p.edl_end - w0) if last is None else max(0.0, p.edl_end - last)
        return gap_in, gap_out, how

    with ThreadPoolExecutor(max_workers=max(2, jobs * 2)) as pool:
        gaps = list(pool.map(edge_gaps, plans))

    prev: Plan | None = None
    for p, (gap_in, gap_out, how) in zip(plans, gaps):
        p.gap_in, p.gap_out, p.speech_src = gap_in, gap_out, how
        dur = p.pic_info["duration"]
        # "dry": false (a video on screen, a held ending): the edges are the ones written, no J-cut on it.
        whole = not ranges[p.idx].get("dry", True)
        nxt_joined = p.idx < n - 1 and plans[p.idx + 1].joined
        # Outgoing tail: trim only silence, keeping SPEECH_GUARD_S after the last word.
        p.tail = 0
        if tail_req and p.idx < n - 1 and not whole and not nxt_joined:
            p.tail = max(0, min(tail_req, math.floor((gap_out - SPEECH_GUARD_S) * fps + 1e-6)))
        # Incoming lead: the early audio overlaps the outgoing tail, but the two voices must not
        # meet: keep MIN_JOIN_GAP_S of measured silence between them. It also may not reach back
        # before the outgoing picture starts, nor before the source start.
        p.lead = 0
        if lead_req and prev is not None and not whole and not p.joined:
            room = prev.gap_out - prev.tail / fps + gap_in - MIN_JOIN_GAP_S
            p.lead = max(0, min(lead_req, math.floor(room * fps + 1e-6), prev.frames))
            if p.lead < lead_req:
                p.lead_note = "pouco silêncio na junção" if room * fps < lead_req else "trecho anterior curto"
        if p.joined and prev is not None:
            # the audio goes on from the exact sample where the previous range's audio stops
            p.audio_start_s = prev.audio_start + (prev.lead + prev.frames) / fps
        p.pic_start, p.pic_snapped = picture_start(p.pic_src, p.to_pic(p.audio_start + p.lead / fps), fps)
        frames = 0
        for _ in range(4):
            total = min(round((p.to_pic(p.edl_end) - p.pic_start) * fps),
                        math.floor((dur - p.pic_start) * fps + 1e-6))
            frames = total - p.tail
            if frames >= MIN_PIC_FRAMES or (p.tail == 0 and p.lead == 0):
                break
            if p.tail:
                p.tail = max(0, p.tail - (MIN_PIC_FRAMES - frames))
            elif p.lead:
                p.lead = 0
                p.pic_start, p.pic_snapped = picture_start(p.pic_src, p.to_pic(p.audio_start), fps)
        if frames < 1:
            raise CutError(f"trecho {p.rid} é curto demais ({p.edl_end - p.edl_start:.3f}s)", 2)
        p.frames = frames
        prev = p
    return plans


# ------------------------------------------------------------------ extraction


def crop_filter(W: int, H: int, crop: dict | None) -> str:
    """crop to WxH, centered, or around the focus point (0..1 of the scaled frame), clamped inside."""
    if not crop or (abs(crop["focus_x"] - 0.5) < 1e-6 and abs(crop["focus_y"] - 0.5) < 1e-6):
        return f"crop={W}:{H}"
    fx, fy = crop["focus_x"], crop["focus_y"]
    return (f"crop={W}:{H}:x=max(0\\,min(iw-ow\\,iw*{fx:.4f}-ow/2))"
            f":y=max(0\\,min(ih-oh\\,ih*{fy:.4f}-oh/2))")


def build_vf(info: dict, W: int, H: int, fps: int, grade_filter: str, crop: dict | None = None) -> str:
    # SDR: convert any matrix (e.g. BT.601 phones) and full range to BT.709 tv while scaling.
    # HDR keeps its matrix here; the shared HDR->SDR chain (grade.hdr_to_sdr_chain) converts it.
    # start_time=0: with -noaccurate_seek the frames before the seek point fill the first slot,
    # so a sparse VFR source shows the frame that is on screen at the start time.
    matrix = "" if info.get("is_hdr") else ":out_color_matrix=bt709"
    parts = [f"fps={fps}:start_time=0",
             f"scale={W}:{H}:force_original_aspect_ratio=increase:flags=lanczos:out_range=tv{matrix}",
             crop_filter(W, H, crop), "setsar=1"]
    hdr = hdr_to_sdr_chain(info)
    if hdr:
        parts.append(hdr)
    parts.append(graded_chain(grade_filter))
    # ffmpeg takes the stream color tags from the frames: stamp them so untagged sources are tagged too.
    parts.append(BT709_TAGS)
    return ",".join(parts)


AUDIO_RESAMPLE = f"aresample={SR}:async=1:first_pts=0"


def channel_filter(mode: str) -> str:
    """How a source's channels become stereo (mode from channel_mode)."""
    return {"mono": "pan=stereo|c0=c0|c1=c0", "left": "pan=stereo|c0=c0|c1=c0",
            "right": "pan=stereo|c0=c1|c1=c1"}.get(mode, "aformat=channel_layouts=stereo")


def af_tail(gain_db: float, samples: int, fade_in: bool = True, fade_out: bool = True) -> list[str]:
    """Gain + limiter, exact length and 30 ms fades of a range (after resampling and channel choice).
    No fade on a side where the voice goes on into the next range (or comes from the previous one).
    compose_final's background-noise removal runs its denoiser before this same tail."""
    parts = []
    if gain_db:
        parts.append(f"volume={gain_db:.2f}dB")
        if gain_db > 0:
            parts.append("alimiter=limit=0.891:attack=5:release=60:level=false:latency=true")
    fade = max(1, min(FADE_SAMPLES, samples // 4))
    parts += [f"apad=whole_len={samples}", f"atrim=end_sample={samples}"]
    if fade_in:
        parts.append(f"afade=t=in:start_sample=0:nb_samples={fade}")
    if fade_out:
        parts.append(f"afade=t=out:start_sample={samples - fade}:nb_samples={fade}")
    parts.append("aformat=sample_fmts=flt:channel_layouts=stereo")
    return parts


def build_af(p: Plan, mode: str, samples: int) -> str:
    return ",".join([AUDIO_RESAMPLE, channel_filter(mode), *af_tail(p.gain_db, samples, p.fade_in, p.fade_out)])


def count_frames(path: Path) -> int:
    proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=nb_frames",
                           "-of", "csv=p=0", str(path)], capture_output=True, text=True)
    try:
        return int(proc.stdout.strip().split(",")[0])
    except ValueError:
        proc = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
                               "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", str(path)],
                              capture_output=True, text=True)
        return int(proc.stdout.strip().split(",")[0] or 0)


def wav_samples(path: Path) -> int:
    """Sample frames in a WAV by walking its chunks (the stdlib `wave` rejects float WAVs)."""
    try:
        with open(path, "rb") as fh:
            if fh.read(4) != b"RIFF":
                return -1
            fh.read(4)
            if fh.read(4) != b"WAVE":
                return -1
            block_align = 0
            while True:
                head = fh.read(8)
                if len(head) < 8:
                    return -1
                cid, size = head[:4], int.from_bytes(head[4:], "little")
                if cid == b"fmt ":
                    fmt = fh.read(size)
                    block_align = int.from_bytes(fmt[12:14], "little")
                    continue
                if cid == b"data":
                    if size in (0, 0xFFFFFFFF):   # streamed header: use the file size
                        size = os.path.getsize(path) - fh.tell()
                    return size // block_align if block_align else -1
                fh.seek(size + (size & 1), 1)
    except OSError:
        return -1


def extract(p: Plan, cfg: dict) -> Plan:
    t0 = time.time()
    fps, W, H = cfg["fps"], cfg["width"], cfg["height"]
    spf = SR / fps
    src_fps = p.pic_info.get("fps") or fps
    vf = build_vf(p.pic_info, W, H, fps, cfg["grade_filter"], p.crop)
    samples = int(round(p.audio_frames * spf))
    audio_start = p.audio_start
    mode = cfg["channel_modes"].get(p.source, "stereo") if p.info.get("has_audio") else "silent"
    af = build_af(p, mode, samples)
    enc = ["-c:v", "libx264", "-profile:v", "high", "-preset", cfg["x264_preset"], "-crf", str(cfg["crf"]),
           "-pix_fmt", "yuv420p", "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
           "-color_range", "tv", "-video_track_timescale", str(fps * 1000), "-movflags", "+faststart"]
    key_src = {"v": CACHE_VERSION, "src": str(p.src), "sig": file_signature(p.src), "pic": round(p.pic_start, 6),
               "snap": p.pic_snapped, "frames": p.frames, "astart": round(audio_start, 6), "samples": samples, "vf": vf, "af": af,
               "enc": enc, "mode": mode}
    if p.other_picture:
        key_src.update(pic_src=str(p.pic_src), pic_sig=file_signature(p.pic_src))
    p.key = hashlib.sha1(json.dumps(key_src, sort_keys=True).encode()).hexdigest()[:12]
    stem = f"{cfg['mode']}_{p.idx:03d}_{re.sub(r'[^A-Za-z0-9_-]', '_', p.rid)}_{p.key}"
    clips = cfg["clips_dir"]
    p.video_path, p.audio_path, p.meta_path = clips / f"{stem}.mp4", clips / f"{stem}.wav", clips / f"{stem}.json"
    meta = load_json(p.meta_path)
    if (isinstance(meta, dict) and meta.get("key") == p.key and p.video_path.exists() and p.audio_path.exists()):
        p.frames = int(meta["frames"])
        p.cached = True
        return p

    # A snapped start seeks a quarter frame early so that frame lands on the first output slot.
    pic_seek = max(0.0, p.pic_start - (0.25 / max(src_fps, fps) if p.pic_snapped else 0.0))
    tmp_v, tmp_a = p.video_path.with_suffix(".part.mp4"), p.audio_path.with_suffix(".part.wav")

    def run_extract(hw: bool) -> subprocess.CompletedProcess:
        cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
        if hw:
            cmd += ["-hwaccel", "videotoolbox"]
        cmd += ["-noaccurate_seek", "-ss", f"{pic_seek:.6f}", "-i", str(p.pic_src)]
        if p.info.get("has_audio"):
            cmd += ["-ss", f"{max(0.0, audio_start):.6f}", "-i", str(p.src)]
            amap = "1:a:0"
        else:
            cmd += ["-f", "lavfi", "-i", f"anullsrc=r={SR}:cl=stereo"]
            amap = "1:a:0"
        cmd += ["-map", "0:v:0", "-vf", vf, "-frames:v", str(p.frames), "-an", *enc, "-f", "mp4", str(tmp_v)]
        cmd += ["-map", amap, "-af", af, "-vn", "-c:a", "pcm_f32le", "-ar", str(SR), "-ac", "2",
                "-f", "wav", str(tmp_a)]
        return subprocess.run(cmd, capture_output=True, text=True)

    use_hw = hwaccel_for(p.pic_src, p.pic_info)
    proc = run_extract(use_hw)
    if proc.returncode != 0 and use_hw:
        warn(f"trecho {p.rid}: decodificação por hardware falhou, tentando por software")
        proc = run_extract(False)
    if proc.returncode != 0:
        tmp_v.unlink(missing_ok=True)
        tmp_a.unlink(missing_ok=True)
        raise CutError(f"o ffmpeg falhou ao extrair o trecho {p.rid}:\n{proc.stderr.strip()[-1500:]}")

    got = count_frames(tmp_v)
    if got <= 0:
        raise CutError(f"trecho {p.rid}: nenhum quadro extraído de {p.pic_src.name}")
    if got != p.frames:
        warn(f"trecho {p.rid}: esperava {p.frames} quadros e saíram {got}; ajustando o áudio")
        p.frames = got
        samples = int(round(p.audio_frames * spf))
        af = build_af(p, mode, samples)
        cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
        if p.info.get("has_audio"):
            cmd += ["-ss", f"{max(0.0, audio_start):.6f}", "-i", str(p.src), "-map", "0:a:0"]
        else:
            cmd += ["-f", "lavfi", "-i", f"anullsrc=r={SR}:cl=stereo", "-map", "0:a:0"]
        cmd += ["-af", af, "-vn", "-c:a", "pcm_f32le", "-ar", str(SR), "-ac", "2", "-f", "wav", str(tmp_a)]
        ffmpeg(cmd, f"refazer o áudio do trecho {p.rid}")
    got_samples = wav_samples(tmp_a)
    if got_samples >= 0 and got_samples != samples:
        raise CutError(f"trecho {p.rid}: o áudio saiu com {got_samples} amostras, esperava {samples}")
    os.replace(tmp_v, p.video_path)
    os.replace(tmp_a, p.audio_path)
    save_json(p.meta_path, {"key": p.key, "frames": p.frames, "samples": samples, "id": p.rid,
                            "source": str(p.src), "picture": str(p.pic_src), "pic_start": p.pic_start,
                            "pic_snapped": p.pic_snapped, "audio_start": audio_start})
    p.seconds = time.time() - t0
    return p


# ------------------------------------------------------------------ assembly


def concat_escape(path: Path) -> str:
    return str(path).replace("'", "'\\''")


def mix_audio(plans: list[Plan], fps: int, total_samples: int, out_wav: Path) -> None:
    spf = SR / fps
    cmd = ["ffmpeg", "-y", "-v", "error", "-nostdin"]
    for p in plans:
        cmd += ["-i", str(p.audio_path)]
    chains, labels = [], []
    pos = 0
    for i, p in enumerate(plans):
        delay = int(round((pos - p.lead) * spf))
        if delay < 0:
            raise CutError(f"trecho {p.rid}: atraso de áudio negativo ({delay}); J-cut inconsistente")
        chains.append(f"[{i}:a]adelay=delays={delay}S:all=1[a{i}]")
        labels.append(f"[a{i}]")
        pos += p.frames
    if len(plans) == 1:
        graph = f"{chains[0]};[a0]apad=whole_len={total_samples},atrim=end_sample={total_samples}[out]"
    else:
        graph = (";".join(chains) + ";" + "".join(labels)
                 + f"amix=inputs={len(plans)}:normalize=0:duration=longest:dropout_transition=0,"
                 + f"apad=whole_len={total_samples},atrim=end_sample={total_samples}[out]")
    cmd += ["-filter_complex", graph, "-map", "[out]", "-c:a", "pcm_f32le", "-ar", str(SR), "-f", "wav", str(out_wav)]
    ffmpeg(cmd, "mixar o áudio")


def loudnorm_measure(wav_path: Path) -> dict | None:
    proc = subprocess.run(
        ["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(wav_path), "-af",
         f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}:print_format=json",
         "-f", "null", "-"], capture_output=True, text=True)
    text = proc.stderr
    start, end = text.rfind("{"), text.rfind("}")
    if start < 0 or end <= start:
        return None
    try:
        data = json.loads(text[start:end + 1])
    except json.JSONDecodeError:
        return None
    try:
        if not math.isfinite(float(data["input_i"])) or float(data["input_i"]) < -70:
            return None
    except (KeyError, ValueError):
        return None
    return data


def loudnorm_modo(measured: dict | None) -> str | None:
    """O modo que o loudnorm de 2 passadas vai usar com estas medidas (a regra do ffmpeg, af_loudnorm.c): linear só
    quando o pico real medido mais o ganho que falta até o alvo não passa do TP. Senão ele cai calado no dinâmico,
    que comprime a voz. (PrimoCode: o portão reprova o dinâmico; ver helpers/ganho_por_trecho.py.)"""
    if not measured:
        return None
    try:
        offset_tp = float(measured["input_tp"]) + (LOUDNORM["I"] - float(measured["input_i"]))
    except (KeyError, ValueError):
        return None
    return "linear" if offset_tp <= LOUDNORM["TP"] else "dinamico"


def true_peak_db(path: Path) -> float | None:
    """True peak (dBTP) of the first audio track with 2 decimals (loudnorm's measurement; ebur128,
    used by verify_cut, prints 1 decimal and hides a -0.96 as -1.0). -120 for silence."""
    proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-nostdin", "-i", str(path), "-vn", "-af",
                           f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}:print_format=json",
                           "-f", "null", "-"], capture_output=True, text=True)
    m = re.search(r'"input_tp"\s*:\s*"(-?inf|-?[\d.]+)"', proc.stderr or "")
    if not m:
        return None
    return -120.0 if "inf" in m.group(1) else float(m.group(1))


# ------------------------------------------------------------------ main


def acquire_lock(clips_dir: Path):
    """One render per edit dir at a time (clips cache and pruning are shared)."""
    fh = open(clips_dir / ".render.lock", "a+")
    try:
        import fcntl
        fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except ImportError:          # Windows: no advisory lock, run anyway
        pass
    except OSError:
        fh.close()
        raise CutError("já tem um render_cut rodando nesta pasta edit. Espere ele terminar.", 2)
    return fh


def render(args: argparse.Namespace) -> int:
    t_all = time.time()
    require_binary("ffmpeg")
    require_binary("ffprobe")
    edl_path = Path(os.path.abspath(args.edl.expanduser()))
    edl = load_edl(edl_path)
    edl_sig = edl_signature(json.loads(edl_path.read_text(encoding="utf-8")))  # raw file, before normalizing
    edit_dir = edl_path.parent
    jcut = jcut_settings(edl, args.no_jcut)

    probes: dict[str, dict] = {}
    audio_keys = {r["source"] for r in edl["ranges"]}
    picture_keys = {picture_key(r) for r in edl["ranges"]}
    for key, value in edl["sources"].items():
        if key not in audio_keys and key not in picture_keys:
            continue
        src = resolve_source(str(value), edit_dir)
        if not src.exists():
            raise CutError(f"vídeo da fonte {key!r} não encontrado: {src}", 2)
        try:
            info = probe_video(src)
        except Exception as exc:  # noqa: BLE001
            raise CutError(f"não consegui ler {src.name}: {exc}", 2)
        if key in picture_keys and not info.get("has_video"):
            if key in audio_keys:
                raise CutError(f"{src.name} não tem trilha de vídeo: nos trechos dela, ponha \"imagem_de\" com a "
                               "câmera de onde vem a imagem", 2)
            raise CutError(f"{src.name} não tem trilha de vídeo", 2)
        probes[key] = {"path": src, "info": info}
    sync, sync_path = load_sync(edl, edit_dir, probes)

    out_cfg = edl.get("output") or {}
    fmt = out_cfg.get("format") or "vertical"
    if fmt not in OUTPUT_FORMATS and not (out_cfg.get("width") and out_cfg.get("height")):
        raise CutError(f"output.format {fmt!r} desconhecido (use {', '.join(OUTPUT_FORMATS)})", 2)
    W = int(out_cfg.get("width") or OUTPUT_FORMATS[fmt][0])
    H = int(out_cfg.get("height") or OUTPUT_FORMATS[fmt][1])
    fps = int(round(float(out_cfg.get("fps") or choose_output_fps({k: v for k, v in probes.items()
                                                                      if k in picture_keys}))))
    if fps <= 0 or SR % fps:
        raise CutError(f"output.fps {fps} não suportado (use 24, 25, 30, 48, 50 ou 60)", 2)
    if args.preview:
        W, H = (W // 2) - (W // 2) % 2, (H // 2) - (H // 2) % 2
    W, H = W - W % 2, H - H % 2

    try:
        grade_filter = resolve_grade(edl.get("grade"))
    except ValueError as exc:
        raise CutError(str(exc), 2)
    err = check_filter(grade_filter)
    if err:
        raise CutError(f"o grade do EDL não funciona no ffmpeg: {err}", 2)

    jobs = args.jobs if args.jobs and args.jobs > 0 else min(4, max(1, (os.cpu_count() or 4) // 3))
    out_path = (Path(os.path.abspath(args.output.expanduser())) if args.output
                else edit_dir / ("cut_preview.mp4" if args.preview else "cut.mp4"))
    timeline_path = out_path.with_name(f"{out_path.stem}_timeline.json")
    clips_dir = edit_dir / "clips"
    clips_dir.mkdir(parents=True, exist_ok=True)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    lock = acquire_lock(clips_dir)

    mode = "prev" if args.preview else "full"
    log(f"Corte: {len(edl['ranges'])} trechos de {len(probes)} fonte(s) -> {W}x{H} {fps} fps"
        f"{' (prévia)' if args.preview else ''}")
    if jcut["enabled"]:
        log(f"J-cut: áudio entra {jcut['lead_frames']} quadros antes, cauda aparada até "
            f"{jcut['tail_trim_frames']} quadros fora da fala")
    else:
        log("Junções secas (sem J-cut)")
    if sync:
        log(f"Imagem de outra câmera em {sum(1 for r in edl['ranges'] if r.get('imagem_de'))} trecho(s), "
            f"sincronia de {sync_path.name}")
    if any(pr["info"].get("is_hdr") for pr in probes.values()) and not has_ffmpeg_filter("zscale"):
        warn("fonte HDR e o ffmpeg não tem zscale: conversão para SDR aproximada, confira as cores")

    t_plan = time.time()
    plans = plan_cut(edl, edit_dir, fps, jcut, probes, jobs, W, H, sync)
    channel_modes = {}
    for key, pr in probes.items():
        if key in audio_keys:
            channel_modes[key] = channel_mode(pr["path"], pr["info"]) if pr["info"].get("has_audio") else "silent"
    t_plan = time.time() - t_plan

    cfg = {"fps": fps, "width": W, "height": H, "grade_filter": grade_filter, "channel_modes": channel_modes,
           "x264_preset": "veryfast" if args.preview else "medium", "crf": 26 if args.preview else 18,
           "clips_dir": clips_dir, "mode": mode}
    t_ext = time.time()
    log(f"Extraindo {len(plans)} trechos ({jobs} em paralelo)...")
    with ThreadPoolExecutor(max_workers=jobs) as pool:
        futures = [pool.submit(extract, p, cfg) for p in plans]
        plans = [f.result() for f in futures]
    t_ext = time.time() - t_ext
    for a, b in zip(plans, plans[1:]):
        if b.joined and abs(a.audio_start + (a.lead + a.frames) / fps - b.audio_start) > 0.5 / SR:
            warn(f"a fala que continua de {a.rid} para {b.rid} saiu fora do lugar (o trecho {a.rid} mudou de "
                 "quadros na extração): a voz pode pular ali. Rode de novo")

    # Frame-accurate positions from the encoded clips.
    pos = 0
    total_frames = sum(p.frames for p in plans)
    total_samples = int(round(total_frames * SR / fps))
    duration = total_frames / fps

    t_mix = time.time()
    mix_wav = clips_dir / f"{mode}_mix.wav"
    mix_audio(plans, fps, total_samples, mix_wav)
    measured = loudnorm_measure(mix_wav)
    t_mix = time.time() - t_mix

    concat_list = clips_dir / f"{mode}_concat.txt"
    lines = ["ffconcat version 1.0"]
    for p in plans:
        lines.append(f"file '{concat_escape(p.video_path)}'")
        lines.append(f"duration {p.frames / fps:.6f}")
    concat_list.write_text("\n".join(lines) + "\n", encoding="utf-8")

    norm_wav = clips_dir / f"{mode}_norm.wav"
    if measured:
        af = (f"loudnorm=I={LOUDNORM['I']}:TP={LOUDNORM['TP']}:LRA={LOUDNORM['LRA']}"
              f":measured_I={measured['input_i']}:measured_TP={measured['input_tp']}"
              f":measured_LRA={measured['input_lra']}:measured_thresh={measured['input_thresh']}"
              f":offset={measured['target_offset']}:linear=true,aresample={SR}")
        ffmpeg(["ffmpeg", "-y", "-v", "error", "-nostdin", "-i", str(mix_wav), "-af", af, "-c:a", "pcm_f32le",
                "-ar", str(SR), "-ac", "2", "-f", "wav", str(norm_wav)], "normalizar o volume")
        audio_in = norm_wav
    else:
        warn("não deu para medir a loudness (áudio mudo?); o áudio sai sem normalizar")
        audio_in = mix_wav

    t_mux = time.time()
    tmp_out = out_path.with_name(f".{out_path.stem}.part.mp4")

    def mux(gain_db: float) -> None:
        af_mux = ["-af", f"volume={gain_db:.2f}dB"] if abs(gain_db) > 0.001 else []
        ffmpeg(["ffmpeg", "-y", "-v", "error", "-nostdin", "-f", "concat", "-safe", "0", "-i", str(concat_list),
                "-i", str(audio_in), "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", *af_mux,
                "-c:a", "aac", "-b:a", "192k", "-ar", str(SR), "-ac", "2", "-t", f"{duration:.6f}",
                "-movflags", "+faststart", "-f", "mp4", str(tmp_out)], f"montar o {out_path.name}")

    peak_gain = 0.0
    try:
        mux(peak_gain)
        out_tp = true_peak_db(tmp_out)
        # The AAC encoder adds inter-sample peaks over the loudnorm target: lower just enough and encode again.
        peak_goal = TRUE_PEAK_LIMIT - TRUE_PEAK_MARGIN
        for _ in range(3):
            if out_tp is None or out_tp <= peak_goal:
                break
            peak_gain += peak_goal - out_tp - 0.15
            mux(peak_gain)
            out_tp = true_peak_db(tmp_out)
        if out_tp is not None and out_tp > TRUE_PEAK_LIMIT:
            warn(f"o pico real ficou em {out_tp:.2f} dBTP, acima de {TRUE_PEAK_LIMIT:.0f}, mesmo baixando "
                 f"{peak_gain:.1f} dB: diminua o gain_db do trecho mais alto")
        os.replace(tmp_out, out_path)
    finally:
        tmp_out.unlink(missing_ok=True)
        norm_wav.unlink(missing_ok=True)
    t_mux = time.time() - t_mux

    got_frames = count_frames(out_path)
    if got_frames != total_frames:
        warn(f"o arquivo final tem {got_frames} quadros, o timeline espera {total_frames}")

    segments = []
    for p in plans:
        out_start_f, out_end_f = pos, pos + p.frames
        src_start = p.pic_start
        src_end = p.pic_start + p.frames / fps
        imagem = {"imagem_de": None}
        if p.other_picture:
            # src_start/src_end stay on the audio source's clock (the voice tools read them); the picture has its own
            imagem = {"imagem_de": p.pic_source, "imagem_path": str(p.pic_src),
                      "imagem_src_start": round(src_start, 6), "imagem_src_end": round(src_end, 6)}
            src_start = p.audio_start + p.lead / fps
            src_end = p.audio_start + (p.lead + p.frames) / fps
        segments.append({
            "id": p.rid, "source": p.source, "source_path": str(p.src),
            "audio_mode": channel_modes.get(p.source, "stereo"),
            "src_start": round(src_start, 6), "src_end": round(src_end, 6), **imagem,
            "audio_continua": p.joined, "audio_fade_in": p.fade_in, "audio_fade_out": p.fade_out,
            "out_start": round(out_start_f / fps, 6), "out_end": round(out_end_f / fps, 6),
            "audio_out_start": round((out_start_f - p.lead) / fps, 6), "audio_out_end": round(out_end_f / fps, 6),
            "beat": p.beat, "quote": p.quote,
            "out_start_frame": out_start_f, "out_end_frame": out_end_f, "frames": p.frames,
            "lead_frames": p.lead, "tail_trim_frames": p.tail,
            "audio_src_start": round(p.audio_start, 6), "pic_snapped": p.pic_snapped,
            "edl_start": p.edl_start, "edl_end": p.edl_end, "gain_db": p.gain_db,
            "crop": p.crop,
        })
        pos = out_end_f

    timeline = {
        "edl_sig": edl_sig, "fps": fps, "width": W, "height": H,
        "duration": round(duration, 6), "frames": total_frames,
        "video": out_path.name, "preview": bool(args.preview),
        "jcut": jcut, "grade": edl.get("grade") or "",
        "loudnorm_input": ({k: float(measured[k]) for k in ("input_i", "input_tp", "input_lra")} if measured else None),
        "loudnorm_modo": loudnorm_modo(measured),
        "true_peak_db": out_tp, "peak_guard_db": round(peak_gain, 2),
        "hdr_sources": sorted(k for k, pr in probes.items() if pr["info"].get("is_hdr")),
        "sincronia": ({"arquivo": str(sync_path), "fontes": {k: {"deslocamento": v[0], "escala": v[1]}
                                                              for k, v in sync.items()}} if sync else None),
        "rendered_at": now_iso(),
        "segments": segments,
    }
    save_json(timeline_path, timeline)

    # Only a full render inside the edit dir drives the preview. It does not ask for approval yet:
    # verify_cut.py does that after checking the file.
    state_note = ""
    if out_path.parent == edit_dir and not args.preview:
        state_path = edit_dir / "state.json"
        state = load_json(state_path, default={}) or {}
        state.update({
            "phase": "cut", "awaiting": None,
            "message": "Corte renderizado. Conferindo antes de mostrar...",
            "cut_video": out_path.name, "cut_timeline": timeline_path.name, "updated_at": now_iso(),
        })
        save_json(state_path, state)
        state_note = " · state.json atualizado (aprovação só depois do verify_cut)"

    # Drop stale cached clips of this quality mode and the temp mix.
    keep = {q.video_path.name for q in plans} | {q.audio_path.name for q in plans} | {q.meta_path.name for q in plans}
    for f in clips_dir.glob(f"{mode}_*"):
        if f.name not in keep:
            f.unlink(missing_ok=True)

    log("")
    log(f"{'id':6s} {'fonte':10s} {'origem':>15s}  {'no corte':>17s}  {'quadros':>7s}  lead tail")
    for s, p in zip(segments, plans):
        pic = f"  imagem da {p.pic_source} em {p.pic_start:.2f}s" if p.other_picture else ""
        log(f"{s['id']:6s} {s['source'][:10]:10s} {s['src_start']:6.2f}-{s['src_end']:6.2f}s  "
            f"{fmt_time(s['out_start'])}-{fmt_time(s['out_end'])}  {s['frames']:7d}  {p.lead:4d} {p.tail:4d}"
            f"{'  (cache)' if p.cached else ''}{'  lead reduzido: ' + p.lead_note if p.lead_note else ''}"
            f"{'  voz continua do anterior' if p.joined else ''}{pic}")
    for s in segments:
        if s["crop"] and s["crop"]["focus_from"] != "centro":
            c = s["crop"]
            log(f"Enquadramento {s['id']}: focus_x {c['focus_x']:.2f} focus_y {c['focus_y']:.2f} ({c['focus_from']})")
    if measured:
        log(f"Loudness antes: {float(measured['input_i']):.1f} LUFS, pico {float(measured['input_tp']):.1f} dBTP"
            f" -> alvo {LOUDNORM['I']:.0f} LUFS (ajuste {loudnorm_modo(measured)})")
        if loudnorm_modo(measured) == "dinamico":
            warn("o loudnorm vai cair no modo DINÂMICO (comprime a voz): rode helpers/ganho_por_trecho.py no EDL antes")
    if out_tp is not None and out_tp <= -100:
        log("Pico real no arquivo: sem som")
    elif out_tp is not None:
        guard =f" (volume {peak_gain:+.1f} dB para não passar de {TRUE_PEAK_LIMIT:.0f})" if peak_gain else ""
        log(f"Pico real no arquivo: {out_tp:.2f} dBTP{guard}")
    log(f"Pronto: {out_path} · {fmt_time(duration)} ({total_frames} quadros){state_note}")
    log(f"Timeline: {timeline_path}")
    log(f"Tempo: total {time.time() - t_all:.1f}s (plano {t_plan:.1f}s, extração {t_ext:.1f}s, "
        f"áudio {t_mix:.1f}s, montagem {t_mux:.1f}s)")
    mix_wav.unlink(missing_ok=True)
    concat_list.unlink(missing_ok=True)
    lock.close()
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description="Renderiza o corte (cut.mp4 + cut_timeline.json) a partir do edl.json.")
    ap.add_argument("edl", type=Path, help="caminho do edl.json (a pasta dele é a pasta edit/)")
    ap.add_argument("-o", "--output", type=Path, help="vídeo de saída (padrão: cut.mp4 ao lado do EDL)")
    ap.add_argument("--jobs", type=int, default=0, help="trechos extraídos em paralelo (padrão automático)")
    ap.add_argument("--no-jcut", action="store_true", help="junções secas, sem J-cut")
    ap.add_argument("--preview", action="store_true",
                    help="prévia rápida: metade da resolução, CRF 26, grava cut_preview.mp4 (não mexe no cut.mp4 nem no state.json)")
    args = ap.parse_args()
    try:
        return render(args)
    except CutError as exc:
        print(f"erro: {exc}", file=sys.stderr)
        return exc.code
    except KeyboardInterrupt:
        print("interrompido", file=sys.stderr)
        return 130


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