#!/usr/bin/env python3
"""slop_bakeoff — 20 short clips, one window, so PLN can grade looks by eye and ear.

PLN, 2026-08-17: *"dont render 2h for now lol render 20x different presets 30s
clips then let me watch/listen/grade through. try to do sth gtreat
soundreactive"*. And on rights: *"slopmotion gave me rights to all packs, we can
use any, lets be free and test stuf"* — so every local pack is fair game, which
is worth recording because it is the opposite of the sample-bank situation.

## The experiment, and why it is shaped this way

Every clip renders the SAME 30-second window. That is the whole point: with the
audio held fixed, the only thing that differs is the look, so the grades mean
something. Vary two things at once and a favourite tells you nothing about why.

19 packs get one clip each at a single reactive baseline, and clip 20 is the
same pack as one of them with reactivity turned OFF. That control earns its slot:
"try to do something great soundreactive" is only answerable if we can see what
the reactivity is actually contributing, and a set of 20 all-reactive clips
cannot answer it. Whichever pack wins, the follow-up round varies FX within it.

The window is chosen on transient density rather than loudness — reactivity
feeds on onsets, and a loud sustained pad scores high on RMS while giving the
kick-synced triggers nothing to fire on.

Realtime capture means 20 x 30 s is about 10 minutes of wall time plus startup,
against 2h25m for the full record. That ratio is the argument for grading first.

    python3 slop_bakeoff.py --check
    python3 slop_bakeoff.py --start 2400 --dur 30
    python3 slop_bakeoff.py --page          # build the grading page only
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from pathlib import Path

TIDAL = Path(__file__).resolve().parents[2]
SLOP = TIDAL / "visuals/slop/render_slop_clip.mjs"
SLOP_OUT = TIDAL / "visuals/slop/out"
HEXA = Path(os.environ.get("HEXA_DIR", "/home/pln/Work/Sound/hydra-live-hexa"))
LOOPS = HEXA / "public/loops"
LIBRARY = HEXA / "src/data/videoLibrary.json"
BAKE = TIDAL / "visuals/slop/bakeoff"
DEFAULT_AUDIO = ("/home/pln/Work/Sound/Prod/Opal26_master/"
                 "Opal26_SunsetForest_v4_streaming_nodrop.flac")
NVM_NODE = Path.home() / ".nvm/versions/node"

# The reactive baseline. Every FX row here becomes a kick-synced trigger under
# --triggerHeavy, so these four are chosen to read differently on a transient:
# glitch and rgbDelay snap, liquix flows, slowmo pulls time. A recipe of four
# subtle washes would make the reactivity invisible, which is the one thing this
# bake-off has to show.
PUNCH = {"glitch": {"base": 0.45}, "rgbDelay": {"base": 0.35},
         "liquix": {"base": 0.5}, "slowmo": {"base": 0.3}}

# Packs that exist in the catalog but hold no local loops, or are *-frames
# still-image dirs, or are symlinks into Kevin's own machine.
SKIP_SUFFIX = ("-frames",)
SKIP_NAMES = {"test", "test-workflow", "workflow-test", "patterns"}


def node22() -> str | None:
    if not NVM_NODE.exists():
        return None
    c = []
    for d in NVM_NODE.iterdir():
        try:
            m = int(d.name.lstrip("v").split(".")[0])
        except ValueError:
            continue
        if m >= 22 and (d / "bin/node").exists():
            c.append((m, d / "bin/node"))
    return str(max(c)[1]) if c else None


def usable_packs() -> list[tuple[str, int]]:
    """Packs with real local loops AND an entry in videoLibrary.json.

    Both conditions matter: the renderer picks its backdrop out of the library,
    so a pack with 30 files on disk and no library row fails at clip 12 of 20 —
    after eleven realtime renders have already been spent.
    """
    lib = set()
    if LIBRARY.exists():
        for row in json.loads(LIBRARY.read_text()):
            if row.get("videos"):
                lib.add(row.get("folder"))
    out = []
    if not LOOPS.exists():
        return out
    for d in sorted(LOOPS.iterdir()):
        if d.is_symlink() and not d.exists():
            continue
        if not d.is_dir() or d.name.startswith("."):
            continue
        if d.name in SKIP_NAMES or d.name.endswith(SKIP_SUFFIX):
            continue
        n = len(list(d.glob("*.mp4")))
        if n >= 5 and d.name in lib:
            out.append((d.name, n))
    return out


def presets(packs: list[tuple[str, int]], control: str) -> list[dict]:
    """19 packs reactive + 1 control, in a deliberate order.

    `parvagues` goes first because it is PLN's own imagery and the most likely
    answer; the control goes last so it is graded after its reactive twin rather
    than in isolation.
    """
    names = [p for p, _ in packs]
    names.sort(key=lambda n: (n != "parvagues", n))
    out = [{"id": f"{i:02d}", "playset": n, "reactive": True, "fx": PUNCH}
           for i, n in enumerate(names, 1)]
    if control in names:
        out.append({"id": f"{len(out) + 1:02d}", "playset": control,
                    "reactive": False, "fx": PUNCH,
                    "note": "control — same pack, reactivity OFF"})
    return out


def clip_path(p: dict, shape: str) -> Path:
    return SLOP_OUT / f"{stem(p)}_{shape}.mp4"


def stem(p: dict) -> str:
    return f"bake{p['id']}_{p['playset']}" + ("" if p["reactive"] else "_flat")


def render(p: dict, a, node: str) -> bool:
    cmd = [node, str(SLOP),
           "--audio", a.audio,
           "--start", str(a.start),
           "--dur", str(a.dur),
           "--shape", a.shape,
           "--fps", str(a.fps),
           "--name", stem(p),
           "--playset", p["playset"],
           # `--gl hw` means HEADFUL, which is the only way to reach the real
           # Intel GPU on this box — headless resolves to SwiftShader whatever
           # the flag says, and caps at 3.5 fps, under the renderer's own
           # under-5-fps guard. Headful measured 11.4 fps captured (page 17).
           # Costs a visible window for the duration of each capture.
           "--gl", "hw",
           "--fx", json.dumps(p["fx"])]
    if p["reactive"]:
        cmd += ["--triggerHeavy", "true"]
    r = subprocess.run(cmd, cwd=str(TIDAL))
    return r.returncode == 0 and clip_path(p, a.shape).exists()


def build_page(ps: list[dict], a) -> Path:
    """A local grading page, in the family of judge.html and bounds.html.

    Local and not an Artifact on purpose: 20 clips is hundreds of megabytes, the
    viewer sandbox blocks a page-initiated download, and the grades need to come
    back as a file. Served by `armada/serve.py`, which is Range-capable so the
    videos actually seek.
    """
    BAKE.mkdir(parents=True, exist_ok=True)
    rows = []
    for p in ps:
        c = clip_path(p, a.shape)
        rows.append({"id": p["id"], "playset": p["playset"],
                     "reactive": p["reactive"], "note": p.get("note", ""),
                     "src": os.path.relpath(c, BAKE), "exists": c.exists()})
    (BAKE / "manifest.json").write_text(json.dumps(
        {"window": {"audio": a.audio, "start": a.start, "dur": a.dur},
         "shape": a.shape, "fx": PUNCH, "clips": rows}, indent=1) + "\n")

    (BAKE / "bakeoff.html").write_text(PAGE)
    return BAKE / "bakeoff.html"


PAGE = r"""<!doctype html>
<meta charset="utf-8"><title>Slop bake-off — grade the looks</title>
<style>
 :root{--bg:#0c0c0d;--card:#16161a;--line:#26262c;--tx:#e9e7e3;--mu:#8c877f;
       --gold:#e0a13a;--good:#5fb37a;--bad:#c9564b}
 *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--tx);
   font:15px/1.5 -apple-system,"Segoe UI",Roboto,sans-serif}
 header{padding:22px 26px;border-bottom:1px solid var(--line);
   display:flex;gap:18px;align-items:baseline;flex-wrap:wrap}
 h1{margin:0;font-size:20px;font-weight:650}
 .sub{color:var(--mu);font-size:13px;font-family:ui-monospace,monospace}
 main{display:grid;gap:20px;padding:24px 26px 90px;
   grid-template-columns:repeat(auto-fill,minmax(340px,1fr))}
 .c{background:var(--card);border:1px solid var(--line);border-radius:3px;
   overflow:hidden;display:flex;flex-direction:column}
 .c.graded{border-color:var(--gold)}
 video{width:100%;aspect-ratio:16/9;background:#000;display:block}
 .m{padding:11px 13px;display:flex;flex-direction:column;gap:8px}
 .t{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
 .nm{font-weight:600}
 .tag{font-family:ui-monospace,monospace;font-size:10.5px;letter-spacing:.08em;
   text-transform:uppercase;color:var(--mu)}
 .tag.flat{color:var(--bad)}
 .g{display:flex;gap:6px}
 .g button{flex:1;background:#0e0e11;color:var(--mu);border:1px solid var(--line);
   border-radius:2px;padding:7px 0;font:inherit;font-size:13px;cursor:pointer}
 .g button:hover{color:var(--tx)}
 .g button[aria-pressed=true]{background:var(--gold);color:#151109;
   border-color:var(--gold);font-weight:600}
 textarea{width:100%;height:120px;background:#0e0e11;color:var(--tx);
   border:1px solid var(--line);border-radius:2px;padding:9px;
   font:12px ui-monospace,monospace}
 footer{position:fixed;inset:auto 0 0 0;background:#101013;
   border-top:1px solid var(--line);padding:12px 26px;display:flex;gap:14px;
   align-items:center}
 footer button{background:var(--gold);color:#151109;border:0;border-radius:2px;
   padding:9px 16px;font:inherit;font-weight:600;cursor:pointer}
 .miss{padding:40px 13px;text-align:center;color:var(--mu);font-size:13px}
</style>
<header>
 <h1>Slop bake-off</h1>
 <span class="sub" id="win"></span>
 <span class="sub" id="cnt"></span>
</header>
<main id="grid"></main>
<footer>
 <button id="copy">Copy grades as JSON</button>
 <button id="save">Download decisions.json</button>
 <span class="sub" id="stat"></span>
</footer>
<script>
const KEY="slop-bakeoff-grades";
const grades=JSON.parse(localStorage.getItem(KEY)||"{}");
const GRADES=["no","meh","good","yes!"];
let man;

function stat(){
  const n=Object.keys(grades).length;
  document.getElementById("stat").textContent=
    n+" of "+man.clips.length+" graded"+(n?" — "+
      GRADES.map(g=>g+":"+Object.values(grades).filter(v=>v.grade===g).length)
      .join("  "):"");
}
function card(c){
  const el=document.createElement("div"); el.className="c";
  el.innerHTML=(c.exists
      ? '<video src="'+c.src+'" controls preload="metadata" loop></video>'
      : '<div class="miss">not rendered</div>')
    +'<div class="m"><div class="t"><span class="nm">'+c.id+" · "+c.playset
    +'</span><span class="tag'+(c.reactive?"":" flat")+'">'
    +(c.reactive?"reactive":"control · flat")+'</span></div>'
    +'<div class="g"></div></div>';
  const g=el.querySelector(".g");
  GRADES.forEach(v=>{
    const b=document.createElement("button"); b.textContent=v;
    b.setAttribute("aria-pressed", grades[c.id]?.grade===v);
    b.onclick=()=>{
      grades[c.id]={playset:c.playset,reactive:c.reactive,grade:v};
      localStorage.setItem(KEY,JSON.stringify(grades));
      [...g.children].forEach(x=>x.setAttribute("aria-pressed",x===b));
      el.classList.add("graded"); stat();
    };
    g.appendChild(b);
  });
  if(grades[c.id]) el.classList.add("graded");
  return el;
}
fetch("manifest.json").then(r=>r.json()).then(m=>{
  man=m;
  document.getElementById("win").textContent=
    "window "+m.window.start+"s +"+m.window.dur+"s · "+m.shape;
  document.getElementById("cnt").textContent=m.clips.length+" presets";
  const grid=document.getElementById("grid");
  m.clips.forEach(c=>grid.appendChild(card(c)));
  stat();
});
const out=()=>JSON.stringify({window:man.window,fx:man.fx,graded:grades},null,1);
document.getElementById("copy").onclick=()=>
  navigator.clipboard.writeText(out()).then(()=>
    document.getElementById("stat").textContent="copied");
document.getElementById("save").onclick=()=>{
  const b=new Blob([out()],{type:"application/json"});
  const a=document.createElement("a");
  a.href=URL.createObjectURL(b); a.download="decisions.json"; a.click();
};
</script>
"""


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--audio", default=DEFAULT_AUDIO)
    ap.add_argument("--start", type=float, default=2400.0)
    ap.add_argument("--dur", type=float, default=30.0)
    ap.add_argument("--shape", default="landscape",
                    choices=["landscape", "square", "vertical"])
    ap.add_argument("--fps", type=int, default=30)
    ap.add_argument("--control", default="parvagues")
    ap.add_argument("--check", action="store_true")
    ap.add_argument("--page", action="store_true",
                    help="rebuild the grading page from what is already rendered")
    ap.add_argument("--overwrite", action="store_true")
    a = ap.parse_args()

    packs = usable_packs()
    ps = presets(packs, a.control)
    print(f"{len(packs)} usable pack(s) -> {len(ps)} preset(s)")
    for p in ps:
        c = clip_path(p, a.shape)
        mark = "=" if c.exists() else " "
        print(f"  {mark} {p['id']}  {p['playset']:<20} "
              f"{'reactive' if p['reactive'] else 'CONTROL, flat':<14}"
              f"{p.get('note', '')}")
    left = [p for p in ps if not clip_path(p, a.shape).exists() or a.overwrite]
    print(f"\n{len(left)} to render — ~{len(left) * a.dur / 60:.0f} min of realtime "
          f"capture (the full record would be 2h25m)")

    if a.page:
        print(f"\ngrading page: {build_page(ps, a)}")
        return 0
    node = node22()
    problems = []
    if node is None:
        problems.append("no node >= 22 in ~/.nvm")
    if not SLOP.exists():
        problems.append(f"renderer missing: {SLOP}")
    if not Path(a.audio).exists():
        problems.append(f"audio missing: {a.audio}")
    if problems:
        print("\nblocked:")
        for x in problems:
            print("  -", x)
        return 1
    if a.check:
        print("\npreflight: clean")
        return 0

    ok = 0
    for n, p in enumerate(left, 1):
        print(f"\n[{n}/{len(left)}] {p['id']} {p['playset']}"
              f"{'' if p['reactive'] else '  (control, flat)'}")
        if render(p, a, node):
            ok += 1
            print(f"    ✓ {clip_path(p, a.shape).name}")
        else:
            print("    ! failed")
    page = build_page(ps, a)
    print(f"\n{ok}/{len(left)} rendered\ngrading page: {page}")
    print(f"  serve it: python3 {TIDAL}/armada/serve.py --root {BAKE}")
    return 0 if ok == len(left) else 1


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