"""render_take.py — renderiza uma composição seek(t) do take único, com desfoque de movimento de obturador real.

  python render_take.py comp/index.html --out render/demo-v1.mp4 --audio audio/mix.wav        # 30 qps, 1080x1920
  python render_take.py comp/index.html --out render/demo-v1-60.mp4 --fps 60 --audio audio/mix.wav
  python render_take.py comp/index.html --stills 0.5,3,6.2 --out revisao/stills-v1.png       # folha de quadros
  python render_take.py comp/index.html --out render/previa.mp4 --previa --audio audio/mix.wav   # prévia em ~1 min
  python render_take.py comp/index.html --out render/final.mp4 --fps 60 --trechos 4 --turbo --audio audio/mix.wav
      # final: refaz só os trechos de 4 s que mudaram desde o último render (cache em render/_trechos/)

Contrato da página: window.__ready (Promise), window.__seek(t) → Promise (resolve com as imagens decodificadas),
window.__meta = {dur, w, h}; opcional window.__motion(t0, t1) → px que algo anda na tela entre t0 e t1.

Obturador: cada quadro é a média, em luz linear, de S capturas espalhadas em `--shutter` graus em volta do instante
do quadro. Primeiro captura as duas pontas: se forem iguais, nada mexeu e o quadro sai sem custo. S vem do __motion
(cópias a no máximo `--gap` px uma da outra, entre --smin e --smax); sem __motion, --samples. Se um único passo entre
capturas vizinhas concentra a mudança (corte seco, letra que aparece), só se média o lado do corte que contém o
instante do quadro: corte continua corte, não vira dissolve. Vários Chromium em paralelo (--workers).
Método do onetake (05-pesquisa/referencias/2026-10-12-onetake-take-unico.md), código nosso.

Velocidade (13/10, medido no Reel Soldier; ver 07-videos/projetos/2026-10-13-video-mais-rapido/README.md):
captura pelo CDP com optimizeForSpeed (PNG sem perda, mesmos pixels), Chromium na GPU (padrão; --sem-gpu volta ao
SwiftShader), --previa (sem obturador, JPEG, resolução cheia: 62 s em ~22 s) e --trechos N (hash de cada quadro numa
passada rápida na CPU; só os trechos que mudaram são refeitos, o resto vem do cache e é emendado sem recodificar).
"""
import argparse, base64, hashlib, io, json, math, multiprocessing as mp, os, shutil, subprocess, sys, time
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
sys.stdout.reconfigure(encoding="utf-8")

_u = np.arange(256) / 255.0
S2L = np.where(_u <= 0.04045, _u / 12.92, ((_u + 0.055) / 1.055) ** 2.4).astype(np.float32)
_l = np.arange(65536) / 65535.0
L2S = np.clip(np.rint(np.where(_l <= 0.0031308, _l * 12.92, 1.055 * np.power(_l, 1 / 2.4) - 0.055) * 255), 0, 255).astype(np.uint8)


def url_de(comp):
    path, _, q = comp.partition("?")
    return Path(path).resolve().as_uri() + ("?" + q if q else "")


def lado_do_corte(pngs):
    """Índices das capturas a integrar: todas, a não ser que um passo carregue a mudança (≥ 3× qualquer outro)."""
    n = len(pngs)
    if n < 4:
        return list(range(n))
    small = [np.asarray(Image.open(io.BytesIO(p)).convert("L").reduce(8), np.int16) for p in pngs]
    d = np.array([np.abs(small[k + 1] - small[k]).mean() for k in range(n - 1)])
    j = int(d.argmax())
    if d[j] <= 0 or d[j] < 3 * np.delete(d, j).max():
        return list(range(n))
    return list(range(j + 1, n)) if j < n // 2 else list(range(j + 1))


def integrar(pngs):
    keep = lado_do_corte(pngs)
    acc = None
    for k in keep:
        lin = S2L[np.asarray(Image.open(io.BytesIO(pngs[k])).convert("RGB"))]
        acc = lin if acc is None else acc + lin
    return L2S[np.clip(np.rint(acc * (65535.0 / len(keep))), 0, 65535).astype(np.uint16)], len(keep) < len(pngs)


GPU_ARGS = ["--enable-gpu", "--use-angle=d3d11", "--ignore-gpu-blocklist"]


def abrir(p, a):
    args = ["--force-color-profile=srgb", "--disable-lcd-text", "--font-render-hinting=none"]
    br = p.chromium.launch(args=args + (GPU_ARGS if a.get("gpu") else []))
    pg = br.new_context(viewport={"width": a["w"], "height": a["h"]}, device_scale_factor=a["scale"]).new_page()
    erros = []
    pg.on("pageerror", lambda e: erros.append(str(e)))
    pg.on("console", lambda m: erros.append(m.text) if m.type == "error" else None)
    pg.goto(url_de(a["comp"]))
    pg.evaluate("window.__ready")
    pg._cdp = pg.context.new_cdp_session(pg)
    return br, pg, erros


def capturar(pg, a):
    """PNG sem perda (padrão) ou JPEG (prévia), direto pelo CDP com optimizeForSpeed. Com escala < 1 o CDP
    devolveria a imagem no tamanho cheio: o clip com a mesma escala mantém o tamanho real (ex.: 270×480 a 0,25)."""
    if a["captura"] == "playwright":
        return pg.screenshot(type="png")
    opts = {"format": "jpeg", "quality": 90} if a["captura"] == "jpeg" else {"format": "png"}
    if a["scale"] != 1:
        opts["clip"] = {"x": 0, "y": 0, "width": a["w"], "height": a["h"], "scale": a["scale"]}
    return base64.b64decode(pg._cdp.send("Page.captureScreenshot", dict(opts, optimizeForSpeed=True))["data"])


def trabalhador(a):
    from playwright.sync_api import sync_playwright
    st = {"capturas": 0, "parados": 0, "cortes": 0, "erros": [], "hist": {}}
    fps, dur, aberto = a["fps"], a["dur"], (a["shutter"] / 360.0) / a["fps"]
    with sync_playwright() as p:
        br, pg, erros = abrir(p, a)
        adapt = a["shutter"] > 0 and pg.evaluate("typeof window.__motion === 'function'")

        def grab(t):
            pg.evaluate(f"window.__seek({t:.6f})")
            st["capturas"] += 1
            return capturar(pg, a)

        feitos = 0
        quadros = a["lista"][a["k"]::a["n"]] if a.get("lista") else range(a["f0"] + a["k"], a["f1"], a["n"])
        for f in quadros:
            if feitos and feitos % 200 == 0:          # Chrome que tira milhares de capturas cresce; recicla
                br.close(); br, pg, erros2 = abrir(p, a); erros += erros2
            feitos += 1
            t, out = f / fps, os.path.join(a["dir"], f"{f:05d}.{a['ext']}")
            if a.get("retomar") and os.path.exists(out) and os.path.getsize(out) > 0:
                continue
            if a["shutter"] <= 0:
                open(out, "wb").write(grab(t)); continue
            S = a["samples"]
            if adapt:
                try:
                    far = pg.evaluate(f"window.__motion({max(t - aberto / 2, 0):.6f}, {min(t + aberto / 2, dur - 1e-6):.6f})")
                    S = min(a["smax"], max(a["smin"], math.ceil(far * a["scale"] / a["gap"])))
                except Exception:
                    pass
            ts = [min(max(t + aberto * ((i + 0.5) / S - 0.5), 0.0), dur - 1e-6) for i in range(S)]
            ini, fim = grab(ts[0]), grab(ts[-1])
            if ini == fim:
                open(out, "wb").write(ini); st["parados"] += 1; continue
            pngs = [ini] + [grab(x) for x in ts[1:-1]] + [fim]
            img, corte = integrar(pngs)
            st["cortes"] += corte
            if a["ext"] == "jpg":
                Image.fromarray(img).save(out, quality=92)
            else:
                Image.fromarray(img).save(out, compress_level=1)
            st["hist"][S] = st["hist"].get(S, 0) + 1
        br.close()
    st["erros"] = erros[:5]
    return st


def trabalhador_seguro(a):
    """O navegador (ou o driver do Playwright) às vezes cai num render longo: reabre e continua de onde parou."""
    for tentativa in range(4):
        # a partir da 3ª tentativa sem GPU: "Unable to capture screenshot" da GPU às vezes se repete (13/10); na CPU nunca falhou
        gpu = a.get("gpu") and tentativa < 2
        try:
            return trabalhador(dict(a, retomar=a.get("retomar") or tentativa > 0, gpu=gpu))
        except Exception as e:
            print(f"navegador {a['k']} caiu ({type(e).__name__}: {str(e)[:80]}), tentativa {tentativa + 2}"
                  + ("" if tentativa < 1 or not a.get("gpu") else " sem GPU"), flush=True)
            time.sleep(2)
    raise RuntimeError(f"navegador {a['k']} caiu 4 vezes")


def info(a):
    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        br, pg, erros = abrir(p, a)
        m = pg.evaluate("window.__meta || {}")
        br.close()
    return m, erros


def stills(a, times, out):
    from playwright.sync_api import sync_playwright
    ims = []
    with sync_playwright() as p:
        br, pg, erros = abrir(p, a)
        for t in times:
            pg.evaluate(f"window.__seek({t})")
            ims.append(Image.open(io.BytesIO(pg.screenshot(type="png"))).convert("RGB"))
        br.close()
    cols = min(len(ims), a.get("cols") or 6)
    th = 640; tw = round(ims[0].width * th / ims[0].height)
    rows = math.ceil(len(ims) / cols)
    folha = Image.new("RGB", (cols * tw, rows * (th + 34)), (24, 24, 24))
    dr = ImageDraw.Draw(folha)
    try:
        fonte = ImageFont.truetype("arial.ttf", 24)
    except OSError:
        fonte = ImageFont.load_default()
    for i, (t, im) in enumerate(zip(times, ims)):
        x, y = (i % cols) * tw, (i // cols) * (th + 34)
        folha.paste(im.resize((tw, th), Image.LANCZOS), (x, y + 34))
        dr.text((x + 8, y + 4), f"{t:.2f} s", fill=(255, 220, 90), font=fonte)
    folha.save(out)
    print("folha:", out, "erros:", erros[:3] or "nenhum")


def commit_livre_gb():
    """Memória que o Windows ainda pode comprometer (RAM + arquivo de paginação). Sem isso o render derruba o PC."""
    try:
        import ctypes
        class M(ctypes.Structure):
            _fields_ = [("a", ctypes.c_ulong), ("b", ctypes.c_ulong)] + [(n, ctypes.c_ulonglong) for n in "cdefghi"]
        m = M(); m.a = ctypes.sizeof(M)
        ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
        return m.f / 2**30
    except Exception:
        return 999.0


GB_POR_NAVEGADOR, GB_RESERVA = 1.8, 6.0   # medido em 13/10: 8 navegadores no final a 60 qps = 14 GB


def renderizar(job, f0, f1, pasta, lista=None):
    """Quadros f0..f1-1 (ou só os de `lista`) em `pasta` (nome = número absoluto do quadro), em job['n'] navegadores."""
    pasta.mkdir(parents=True, exist_ok=True)
    n = max(1, min(job["n"], len(lista) if lista else f1 - f0))
    with mp.get_context("spawn").Pool(n) as pool:
        res = pool.map(trabalhador_seguro, [dict(job, dir=str(pasta), f0=f0, f1=f1, n=n, k=k, lista=lista) for k in range(n)])
    tot = {"capturas": 0, "parados": 0, "cortes": 0, "hist": {}, "erros": []}
    for r in res:
        for k in ("capturas", "parados", "cortes"):
            tot[k] += r[k]
        for k, v in r["hist"].items():
            tot["hist"][k] = tot["hist"].get(k, 0) + v
        tot["erros"] += r["erros"]
    return tot


def codificar(pasta, f0, f1, ext, fps, out, crf, preset, audio=None, de=0.0):
    seq = pasta / "_seq"
    shutil.rmtree(seq, ignore_errors=True); seq.mkdir()
    for i, f in enumerate(range(f0, f1)):          # renumera a partir de 0 para o ffmpeg (sem mover o original)
        src = pasta / f"{f:05d}.{ext}"
        try:
            os.link(src, seq / f"q{i:05d}.{ext}")
        except OSError:
            shutil.copy2(src, seq / f"q{i:05d}.{ext}")
    cmd = ["ffmpeg", "-v", "error", "-y", "-framerate", str(fps), "-i", str(seq / f"q%05d.{ext}")]
    if audio:
        cmd += ["-ss", f"{de:.3f}", "-i", audio]
    cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart",
            "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
    if audio:
        cmd += ["-af", "apad", "-c:a", "aac", "-b:a", "256k", "-shortest"]   # apad: a voz acabar antes não corta o vídeo
    subprocess.run(cmd + [str(out)], check=True)
    shutil.rmtree(seq, ignore_errors=True)


DIGITAIS_N = 8   # sempre o mesmo: o Chromium guarda estado entre seeks e outro nº de navegadores muda ~6% dos quadros


def digitais(job, f0, f1, pasta):
    """Impressão digital de cada quadro (hash), para saber o que mudou desde o último render. Captura em escala 1,
    na CPU, JPEG, sem obturador: assim o Chromium desenha o mesmo quadro bit a bit (medido em 13/10: 0 de 160
    diferentes). Na GPU (157 de 1.200) e em escala reduzida (11 de 160) o mesmo quadro varia entre passadas, e com
    outro número de navegadores também (226 de 3.720): por isso DIGITAIS_N fixo."""
    shutil.rmtree(pasta, ignore_errors=True)
    n = DIGITAIS_N
    cabe = max(2, int((commit_livre_gb() - GB_RESERVA) / 1.3))
    if cabe < n:
        print(f"memória curta: digitais em {cabe} navegadores (o cache pode refazer trechos que não mudaram)", flush=True)
        n = cabe
    renderizar(dict(job, n=n, scale=1, shutter=0, captura="jpeg", ext="jpg", retomar=False, gpu=False), f0, f1, pasta)
    out = {f: hashlib.sha1((pasta / f"{f:05d}.jpg").read_bytes()).hexdigest()[:16] for f in range(f0, f1)}
    shutil.rmtree(pasta, ignore_errors=True)
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("comp"); ap.add_argument("--out", required=True)
    ap.add_argument("--fps", type=int, default=30); ap.add_argument("--scale", type=float, default=None)
    ap.add_argument("--shutter", type=float, default=None); ap.add_argument("--samples", type=int, default=8)
    ap.add_argument("--gap", type=float, default=18); ap.add_argument("--smin", type=int, default=3); ap.add_argument("--smax", type=int, default=16)
    ap.add_argument("--workers", type=int, default=None)
    ap.add_argument("--de", type=float, default=0); ap.add_argument("--ate", type=float, default=None)
    ap.add_argument("--audio"); ap.add_argument("--crf", type=int, default=None)
    ap.add_argument("--stills"); ap.add_argument("--cols", type=int, default=6)
    ap.add_argument("--manter-quadros", action="store_true")
    ap.add_argument("--retomar", action="store_true", help="mantém os quadros de um render que caiu e faz só os que faltam")
    ap.add_argument("--previa", action="store_true", help="rápida: sem obturador (sem desfoque de movimento), JPEG; para ver, não para entregar")
    ap.add_argument("--turbo", action="store_true", help="usa o PC inteiro (mais navegadores); para o render final")
    ap.add_argument("--trechos", type=float, default=0, help="segundos por trecho: refaz só os trechos que mudaram (cache)")
    ap.add_argument("--refazer", help="com --trechos: 'a,b' em segundos = o que você mexeu; pula as digitais e refaz só esses trechos")
    ap.add_argument("--sem-gpu", action="store_true", help="Chromium sem GPU (SwiftShader), como antes de 13/10")
    ap.add_argument("--captura", choices=["png", "jpeg", "playwright"], default=None)
    a = ap.parse_args()
    cpu = os.cpu_count() or 4
    if a.previa:
        scale, shutter, captura, crf, preset = a.scale or 1, 0 if a.shutter is None else a.shutter, a.captura or "jpeg", a.crf or 23, "veryfast"
    else:
        scale, shutter, captura, crf, preset = a.scale or 1, 180 if a.shutter is None else a.shutter, a.captura or "png", a.crf or 16, "medium"
    workers = a.workers or (12 if a.turbo else min(10, cpu - 2))   # acima de ~12 não rende mais (medido) e alguns travam
    livre = commit_livre_gb()
    cabe = max(2, int((livre - GB_RESERVA) / GB_POR_NAVEGADOR))
    if workers > cabe:
        print(f"memória: {livre:.0f} GB livres → {cabe} navegadores em vez de {workers}", flush=True)
        workers = cabe
    ext = "jpg" if captura == "jpeg" else "png"
    base = dict(comp=a.comp, scale=scale, w=1080, h=1920, cols=a.cols, gpu=not a.sem_gpu, captura=captura, ext=ext)
    meta, erros = info(base)
    base.update(w=int(meta.get("w", 1080)), h=int(meta.get("h", 1920)))
    if a.stills:
        return stills(base, [float(x) for x in a.stills.split(",")], a.out)
    dur = float(meta.get("dur", 10)); ate = a.ate if a.ate is not None else dur
    out = Path(a.out).resolve(); out.parent.mkdir(parents=True, exist_ok=True)
    f0, f1 = round(a.de * a.fps), round(ate * a.fps)
    job = dict(base, fps=a.fps, dur=dur, n=workers, shutter=shutter, samples=a.samples, gap=a.gap, smin=a.smin, smax=a.smax, retomar=a.retomar)
    modo = "prévia" if a.previa else ("turbo" if a.turbo else "normal")
    print(f"{f1 - f0} quadros · {modo} · {workers} navegadores · escala {scale:g} · obturador {shutter:g}° · captura {captura}"
          f" · GPU {'sim' if base['gpu'] else 'não'} · erros na carga: {erros[:2] or 'nenhum'}", flush=True)
    t0 = time.time()
    rel = {"fps": a.fps, "shutter": shutter, "scale": scale, "captura": captura, "gpu": base["gpu"], "workers": workers,
           "modo": modo, "quadros": f1 - f0}

    if a.trechos > 0:
        N = max(1, round(a.trechos * a.fps))
        cfg = f"{a.fps}qps-{scale:g}x-obt{shutter:g}-{captura}-crf{crf}-{base['w']}x{base['h']}"
        cache = out.parent / "_trechos" / cfg
        cache.mkdir(parents=True, exist_ok=True)
        segs = [(max(s, f0), min(s + N, f1)) for s in range((f0 // N) * N, f1, N)]
        if a.refazer:   # atalho: quem mexeu diz onde; sem digitais (o .sha desses trechos fica sem valer)
            ra, rb = (float(x) for x in a.refazer.split(","))
            fa, fb = math.floor(ra * a.fps), math.ceil(rb * a.fps)
            dig = None
        else:
            dig = digitais(job, f0, f1, out.parent / ("_digitais-" + out.stem))
        t_dig = time.time() - t0
        tot, lista, mudou = {"capturas": 0, "parados": 0, "cortes": 0}, [], []
        for s, e in segs:
            mp4, sha = cache / f"t{s:05d}-{e:05d}.mp4", cache / f"t{s:05d}-{e:05d}.sha"
            lista.append(mp4)
            tem = mp4.exists() and mp4.stat().st_size > 0
            if dig is None:
                if not tem or (e > fa and s <= fb):
                    mudou.append((s, e, mp4))
                    sha.unlink(missing_ok=True)
                    sha.with_suffix(".novo").write_text("")
                continue
            novo = hashlib.sha1("|".join(dig[f] for f in range(s, e)).encode()).hexdigest()
            if not (tem and sha.exists() and sha.read_text().strip() == novo):
                mudou.append((s, e, mp4))
                sha.with_suffix(".novo").write_text(novo)
        feitos, reaproveitados = len(mudou), len(segs) - len(mudou)
        print(f"{len(segs)} trechos de {a.trechos:g} s · {feitos} mudaram, {reaproveitados} vêm do cache "
              f"(digitais em {t_dig:.0f} s)", flush=True)
        pasta = cache / "_quadros"
        lote = []
        for i, (s, e, mp4) in enumerate(mudou):          # lotes de até ~900 quadros: um pool só por lote
            lote.append((s, e, mp4))
            if i == len(mudou) - 1 or sum(b - a_ for a_, b, _ in lote) + (mudou[i + 1][1] - mudou[i + 1][0]) > 900:
                shutil.rmtree(pasta, ignore_errors=True)
                r = renderizar(job, 0, 0, pasta, lista=[f for s_, e_, _ in lote for f in range(s_, e_)])
                for k in tot:
                    tot[k] += r[k]
                for s_, e_, mp4_ in lote:
                    codificar(pasta, s_, e_, ext, a.fps, mp4_, crf, preset)
                    nv = mp4_.with_suffix(".novo")
                    if nv.read_text():
                        os.replace(nv, mp4_.with_suffix(".sha"))   # só depois do mp4 pronto
                    else:
                        nv.unlink()
                lote = []
        shutil.rmtree(pasta, ignore_errors=True)
        lst = cache / "_lista.txt"
        lst.write_text("".join(f"file '{m.as_posix()}'\n" for m in lista), encoding="utf-8")
        tmp = out.with_name(out.stem + "._junta.mp4")
        subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", str(tmp)], check=True)
        cmd = ["ffmpeg", "-v", "error", "-y", "-i", str(tmp)]
        if a.audio:
            cmd += ["-ss", f"{a.de:.3f}", "-i", a.audio, "-map", "0:v", "-map", "1:a", "-af", "apad", "-c:a", "aac", "-b:a", "256k", "-shortest"]
        cmd += ["-c:v", "copy", "-movflags", "+faststart"]
        subprocess.run(cmd + [str(out)], check=True)
        tmp.unlink()
        seg = time.time() - t0
        print(f"{len(segs)} trechos de {a.trechos:g} s: {feitos} refeitos, {reaproveitados} do cache · digitais {t_dig:.0f} s · total {seg:.0f} s")
        rel.update(trechos=len(segs), refeitos=feitos, reaproveitados=reaproveitados, segundos_digitais=round(t_dig, 1), **tot)
    else:
        fr = out.parent / ("_quadros-" + out.stem)
        if not a.retomar:
            shutil.rmtree(fr, ignore_errors=True)
        tot = renderizar(job, f0, f1, fr)
        print(f"{tot['capturas']} capturas, {tot['parados']} quadros parados, {tot['cortes']} cortes mantidos secos, "
              f"{time.time() - t0:.0f} s; erros: {tot['erros'][:3] or 'nenhum'}")
        codificar(fr, f0, f1, ext, a.fps, out, crf, preset, a.audio, a.de)
        if not a.manter_quadros:
            shutil.rmtree(fr, ignore_errors=True)
        seg = time.time() - t0
        rel.update(capturas=tot["capturas"], parados=tot["parados"], cortes=tot["cortes"],
                   amostras_por_quadro={str(k): tot["hist"][k] for k in sorted(tot["hist"])})
    rel["segundos"] = round(seg, 1)
    json.dump(rel, open(out.with_suffix(".render.json"), "w"), indent=1)
    print(f"ok: {out} ({seg:.0f} s)")


if __name__ == "__main__":
    main()
