Commit c8e1aa64 by PLN (Algolia)

feat(slop): 19-preset bake-off — grade the looks before spending 2h25m

PLN: "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". Right call —
19 clips of 30 s is ~10 minutes of realtime capture against 2h25m for the record,
so the taste decision gets made on cheap evidence.

Also recorded: "slopmotion gave me rights to all packs, we can use any, lets be
free and test stuf". Every local pack is fair game, which is worth writing down
precisely because it is the opposite of the sample-bank situation next door.

## Experiment design

Every clip renders the SAME 30 s window. With the audio fixed, the only variable
is the look, so the grades mean something; vary two things and a favourite tells
you nothing about why. 18 packs get one clip each at one reactive baseline, and
clip 19 is `parvagues` again with reactivity OFF. That control earns its slot —
"do something great soundreactive" is unanswerable from 19 all-reactive clips,
because nothing shows what the reactivity contributes. parvagues sorts first as
PLN's own imagery; the control sorts last so it is graded next to its twin.

The reactive baseline is four FX chosen to read differently on a transient —
glitch and rgbDelay snap, liquix flows, slowmo pulls time — since under
`--triggerHeavy` each becomes kick-synced. Four subtle washes would hide the
exact thing being evaluated.

## The window, and a metric that was measuring the wrong thing

Reactivity feeds on onsets, so I scored 30 s windows by summed positive spectral
flux normalised by level. It picked 69:18 at -23.3 dB — the near-silent seam
between Vague de CRIME and REVOLUTION, i.e. PLN's 2 s rest plus a reverb tail.
Dividing by the window mean rewards silence: a quiet window with any activity at
all beats a loud groove. Using the level-gated answer instead — 1:34, -10.1 dB,
inside the techno opener, four-on-the-floor — which is the honest test for
kick-synced triggers. Same shape as ranking orbits by level instead of punch.

Two preflight checks that exist because of what they would have cost: a pack
needs BOTH local loops and a `videoLibrary.json` row (the renderer picks its
backdrop from the library, so a pack with 30 files and no row fails at clip 12,
after eleven realtime renders), and dead symlinks into Kevin's own machine are
skipped rather than attempted.

The grading page follows judge.html and bounds.html: local, not an Artifact,
because 20 clips is hundreds of megabytes and the viewer sandbox blocks a
page-initiated download — and the grades have to come back as a file.
parent e794a441
#!/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"],
"--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())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment