Commit a9ef8cb8 by PLN (Algolia)

feat(slop): the clip renderer exists — first reel rendered from a ParVagues take (#28)

PLN has said repeatedly he is bad at socials and hoped slop would carry that weight.
The plan for this has been complete since the clip planner landed; the renderer had
never been written, so nothing could actually be produced. It can now.

`visuals/slop/render_slop_clip.mjs` takes either a planned cut (`--idea desire --cut 90`,
from clip_ideas.json with its measured tilt/kick-density/novelty and playset choice) or
an arbitrary window of any file (`--audio ... --start ... --dur ...`), and renders a
vertical 1080x1920 reel with Slopmotion reacting to that audio.

DESIGN NOTES WORTH KEEPING

- It lives in THIS repo, not hexa. hexa is Kevin's project and its main is clean; the
  script borrows its node_modules through createRequire instead of being committed
  into someone else's tree. Its scratch files go in hexa/public/__slop and are removed
  in a finally block — nothing is left behind in his working copy.
- Capture is REALTIME via CDP screencast, not per-frame screenshots. Hydra's animation
  clock is wall time, so frame-stepping plays motion back ~3x fast and drifts away
  from the audio it is supposed to be reacting to. Screencast frames arrive timestamped
  and become a vfr->cfr concat, so a dropped frame is a slightly longer one — invisible
  — rather than a wrong clock.
- The bands are computed from the REAL audio through an AnalyserNode, not from the
  synthetic pump the FX-preview script uses. Kick is a fast rise in the sub band above
  its own slow running average, because an absolute threshold would need retuning per
  track while a relative one rides the mix.
- Backdrop clip choice is a hash of the job name, not Math.random: two renders of the
  same cut must be comparable, which is the entire point of a review pipeline.
- The viewport is 9:16 natively rather than a 16:9 scene centre-cropped afterwards,
  which would throw away most of the motion.

TWO ENVIRONMENT PROBLEMS FOUND AND FIXED WITHOUT COLLATERAL

- The hexa checkout could not boot at all: 9 declared dependencies were missing from
  node_modules, so vite 500'd on @supabase/ssr and @vercel/speed-insights. `npm ci`
  refused (lock out of sync with package.json), and plain `npm install` would have
  rewritten package-lock.json — a TRACKED file in Kevin's repo. Installed the 9 with
  --no-save --no-package-lock instead; his tracked files are byte-identical after.
- Playwright 1.60 wants chromium build 1223 and the cache has 1217. Rather than pulling
  ~150 MB into PLN's shared browser cache unasked, the script finds the newest full
  chromium already present. Full chromium, not chrome-headless-shell, which ships
  without the GPU/ANGLE stack this needs.

WHAT IS NOT DONE: frame rate. 7.0 fps at 1080x1920 under swiftshader — the page itself
renders at 7 fps, so it is the GL backend and the machine, not the capture. The two
follow-up measurements (hardware EGL, and half resolution) both came back at 1.1 fps,
which is not a verdict on either: load average was 12.5 with Pulsar burning a core from
the #7 marker leak. They measure the machine. Recorded in FEEDBACK.md with an explicit
warning to re-measure on a quiet box before concluding anything — a false workaround
adopted from a contended benchmark is very hard to remove later.

Deliberately not pushed further tonight: a sustained software-GL render is a CPU load
test, and those do not run at night here while the rig is up.
parent 6d7df300
...@@ -14,3 +14,28 @@ Format per entry: ...@@ -14,3 +14,28 @@ Format per entry:
--- ---
<!-- entries below --> <!-- entries below -->
## 2026-07-29 — the renderer exists and runs end to end
First actual render: 8 s of take 94 (the 2026-07-29 OPAL practice), liquid-metal
playset, glitch/rgbDelay/liquix/slowmo. Output is a structurally correct vertical
reel — 1080x1920 H.264 + AAC, audio in sync with the visuals that react to it.
**The open quality gate is FRAME RATE, and it is environmental, not a design flaw.**
Measured on this machine while the rig was up:
swiftshader, 1080x1920 7.0 fps (page rendered 60 frames in 8.4 s)
--use-gl=egl (hw), same 1.1 fps under load 12.5
swiftshader, 540x960 1.1 fps under load 12.5
The second and third numbers are not a verdict on hardware GL or on resolution — they
were taken with load average at 12.5 (Pulsar burning a core from the #7 marker leak,
Ardour at 22%, scsynth live). They measure the machine, not the backend. **Re-measure
on a quiet machine before concluding anything about GL.** Getting this wrong in the
other direction is how a tool acquires a permanent false workaround.
30 fps is the target; ffmpeg currently duplicates frames up to CFR, so motion is
choppy while audio and structure are correct.
Not attempted tonight, deliberately: a sustained software-GL render is a CPU load
test, and those do not run at night on this machine while the rig is up.
// render_slop_clip — turn 30 seconds of a ParVagues recording into a vertical
// reel, driven by Slopmotion (hydra-live-hexa) reacting to the actual audio.
//
// WHY THIS LIVES IN THE TIDAL REPO
// It drives hydra-live-hexa but does not belong to it: hexa is Kevin's project and
// its `main` is clean. So the script is ours, and it borrows hexa's node_modules
// via createRequire rather than being committed into someone else's tree.
//
// USAGE
// # 1. start the dev server (node 22):
// cd ~/Work/Sound/hydra-live-hexa && . ~/.nvm/nvm.sh && nvm use 22 && npm run dev
// # 2. render:
// node visuals/slop/render_slop_clip.mjs --idea wap
// node visuals/slop/render_slop_clip.mjs --idea desire --cut 90
// node visuals/slop/render_slop_clip.mjs --audio mix/take94/take94_trimmed_v2.wav \
// --start 300 --dur 30 --playset liquid-metal --name take94_probe
//
// THE ONE NON-OBVIOUS DECISION: capture is REALTIME, via CDP screencast, not
// per-frame screenshots. Hydra's animation clock is wall time, so frame-stepping
// plays motion back roughly 3x fast and drifts away from the audio it is supposed
// to be reacting to. Screencast frames arrive timestamped; ffmpeg turns that
// variable-rate stream into constant-rate video. A dropped frame becomes a slightly
// longer one, which is invisible; a wrong clock is not.
import { createRequire } from "node:module";
import { execFileSync, spawnSync } from "node:child_process";
import {
mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync,
} from "node:fs";
import { resolve, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const TIDAL = resolve(__dirname, "../..");
const HEXA = process.env.HEXA_DIR ?? "/home/pln/Work/Sound/hydra-live-hexa";
const BASE = process.env.SLOP_BASE_URL ?? "http://localhost:5173";
const OUT_DIR = resolve(__dirname, "out");
// Served by the dev server, so the page can fetch the slice over HTTP. Removed in
// a finally block: leaving a multi-MB wav inside someone else's public/ is rude.
const PUB = resolve(HEXA, "public/__slop");
const require = createRequire(resolve(HEXA, "package.json"));
const { chromium } = require("playwright");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Newest full-chromium build in the playwright cache, or undefined to let
// playwright pick (which is right on a machine whose cache matches its version).
function chromeBinary() {
if (process.env.SLOP_CHROME) return process.env.SLOP_CHROME;
const root = resolve(process.env.HOME ?? "/root", ".cache/ms-playwright");
if (!existsSync(root)) return undefined;
const builds = readdirSync(root)
.filter((d) => /^chromium-\d+$/.test(d))
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
for (const b of builds) {
for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome"]) {
const p = resolve(root, b, sub);
if (existsSync(p)) return p;
}
}
return undefined;
}
// ------------------------------------------------------------------ arguments
function args() {
const a = { cut: "30", fps: "30" };
const v = process.argv.slice(2);
for (let i = 0; i < v.length; i++) {
if (!v[i].startsWith("--")) continue;
const k = v[i].slice(2);
a[k] = v[i + 1]?.startsWith("--") || v[i + 1] === undefined ? "true" : v[++i];
}
return a;
}
const A = args();
function loadIdea(stem) {
const p = resolve(__dirname, "clip_ideas.json");
const doc = JSON.parse(readFileSync(p, "utf8"));
const hit = doc.ideas.find(
(i) => i.tidal_stem === stem || String(i.setlist_pos) === stem,
);
if (!hit) {
const have = doc.ideas.map((i) => i.tidal_stem).join(", ");
throw new Error(`no clip idea for "${stem}". Have: ${have}`);
}
return hit;
}
// Resolve what to render into one flat job, so the two entry paths (a planned
// idea vs an ad-hoc window of any file) cannot diverge downstream.
function plan() {
if (A.audio) {
const audio = resolve(TIDAL, A.audio);
if (!existsSync(audio)) throw new Error(`no such audio: ${audio}`);
return {
name: A.name ?? basename(audio).replace(/\.\w+$/, ""),
audio,
start: Number(A.start ?? 0),
dur: Number(A.dur ?? 30),
playsets: [A.playset ?? "liquid-metal"],
fx: A.fx ? JSON.parse(A.fx) : DEFAULT_FX,
triggerHeavy: A.triggerHeavy === "true",
source: "ad-hoc window",
};
}
if (!A.idea) {
throw new Error("need --idea <stem|pos> or --audio <file> --start --dur");
}
const i = loadIdea(A.idea);
const cut = i.promo_cuts[`${A.cut}s`] ?? i.promo_cuts["30s"];
if (!cut) throw new Error(`idea ${A.idea} has no ${A.cut}s cut`);
return {
name: `${i.tidal_stem}_${A.cut}s`,
audio: A.master ? resolve(TIDAL, A.master) : i.master_audio,
start: cut.master_start,
dur: cut.duration,
playsets: A.playset ? [A.playset] : i.visual.playsets,
fx: i.visual.fx,
triggerHeavy: i.visual.triggerHeavy,
source: `${i.opal_title} — ${cut.anchor} anchor, ${i.measured_bpm} BPM`,
};
}
// A neutral, motion-forward set for ad-hoc probes where no plan exists.
const DEFAULT_FX = {
glitch: { base: 0.3 }, rgbDelay: { base: 0.25 },
liquix: { base: 0.5 }, slowmo: { base: 0.4 },
};
// Pick a backdrop clip from the playset, deterministically. Math.random would make
// two renders of the same cut incomparable, which is the opposite of what a
// review pipeline needs.
function pickVideo(playset, seed) {
const lib = JSON.parse(
readFileSync(resolve(HEXA, "src/data/videoLibrary.json"), "utf8"),
);
const row = lib.find((e) => e.folder === playset);
if (!row?.videos?.length) {
throw new Error(`playset "${playset}" not in videoLibrary.json`);
}
let h = 0;
for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return row.videos[h % row.videos.length].path;
}
// ---------------------------------------------------------------------- main
const job = plan();
mkdirSync(OUT_DIR, { recursive: true });
mkdirSync(PUB, { recursive: true });
const slice = resolve(PUB, "clip.wav");
const framesDir = resolve(PUB, "frames");
const outMp4 = resolve(OUT_DIR, `${job.name}.mp4`);
console.log(`slop: ${job.name}`);
console.log(` source ${job.source}`);
console.log(` audio ${basename(job.audio)} @ ${job.start.toFixed(1)}s +${job.dur.toFixed(1)}s`);
let browser;
try {
// --- 1. slice the audio -------------------------------------------------
execFileSync("ffmpeg", [
"-hide_banner", "-loglevel", "error", "-y",
"-ss", String(job.start), "-t", String(job.dur), "-i", job.audio,
"-ac", "2", "-ar", "48000", slice,
]);
const video = pickVideo(job.playsets[0], job.name);
console.log(` playset ${job.playsets[0]} → ${basename(video)}`);
console.log(` fx ${Object.keys(job.fx).join(", ")}`);
// --- 2. drive the page --------------------------------------------------
browser = await chromium.launch({
headless: true,
// Playwright 1.60 wants chromium build 1223; the cache here has 1217, which
// runs these pages fine. Point at what exists rather than pulling ~150 MB
// into PLN's shared browser cache behind his back — and prefer the FULL
// chromium over chrome-headless-shell, because the shell build ships without
// the GPU/ANGLE stack this needs for WebGL.
executablePath: chromeBinary(),
args: [
// GL backend is measurable, not a guess: swiftshader renders these scenes at
// ~7 fps at 1080x1920, which is not a clip. `--gl hw` asks for the real
// integrated GPU via EGL. Falls back by simply measuring worse.
...(A.gl === "hw"
? ["--use-gl=egl", "--enable-gpu-rasterization"]
: ["--use-gl=angle", "--use-angle=swiftshader"]),
"--ignore-gpu-blocklist",
"--autoplay-policy=no-user-gesture-required",
"--disable-features=IsolateOrigins,site-per-process",
],
});
// 540x960 at dpr 2 = 1080x1920. Set on the VIEWPORT rather than upscaling
// later, so Hydra composes for the vertical frame instead of being cropped
// into it — a 16:9 scene centre-cropped to 9:16 loses most of the motion.
const ctx = await browser.newContext({
viewport: { width: 540, height: 960 },
deviceScaleFactor: Number(A.dpr ?? 2),
});
const page = await ctx.newPage();
page.on("console", (m) => {
if (/error/i.test(m.type())) console.log(" [page]", m.text().slice(0, 160));
});
await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForSelector("#hydra-canvas", { timeout: 30000 });
await page.waitForFunction(
() => typeof window.__hydraApplyScene === "function", { timeout: 30000 });
await page.waitForFunction(
() => typeof window.__hydraSetPreviewVideo === "function", { timeout: 30000 });
await sleep(1500);
await page.evaluate((p) => window.__hydraSetPreviewVideo?.(p), video);
await page.evaluate(async (p) => {
const tail = p.split("/").pop() ?? p;
for (let i = 0; i < 100; i++) {
const el = window.s0?.src;
if (el instanceof HTMLVideoElement && el.readyState >= 2 &&
(el.currentSrc.includes(tail) || el.src.includes(tail))) return;
await new Promise((r) => setTimeout(r, 100));
}
}, video);
await sleep(1200);
// FX rows, from the plan's measured tilt/density.
await page.evaluate(({ fx, heavy }) => {
const s = window.hydraSettings;
if (!s?.fx) return;
const CORE = ["videoSpeed", "autoMix", "transition", "colorAdjust"];
for (const k of Object.keys(s.fx)) {
if (CORE.includes(k)) continue;
if (s.fx[k]) { s.fx[k].enabled = false; s.fx[k].isTrigger = false; }
}
for (const [k, cfg] of Object.entries(fx)) {
const row = s.fx[k];
if (!row) continue;
row.enabled = true;
row.base = cfg.base;
if (cfg.params) row.params = { ...(row.params || {}), ...cfg.params };
if (heavy) { row.isTrigger = true; row.syncBand = "kick"; }
}
}, { fx: job.fx, heavy: job.triggerHeavy });
await page.evaluate(() => window.__hydraApplyScene?.());
await page.evaluate((on) => window.__hydraSetTriggerGate?.(on), true);
// Clean full-bleed canvas.
const live = page.locator('button[title^="Minimal performance shell"]').first();
if (await live.count()) { await live.click().catch(() => {}); await sleep(1500); }
await page.locator("body").click({ position: { x: 5, y: 5 } }).catch(() => {});
await page.keyboard.press("h");
await sleep(500);
await page.addStyleTag({
content: `button[aria-label*="interface" i], button[title*="interface" i],
button[title*="Press H" i] { display: none !important; }`,
}).catch(() => {});
// --- 3. real audio -> real bands ---------------------------------------
// The whole point: the visuals react to what PLN actually played, not to the
// synthetic pump the FX-preview script uses. An <audio> element through an
// AnalyserNode, with the band split done here rather than reusing the app's
// audio-source UI — that UI is built for a mic/tab and fights automation.
await page.evaluate(async () => {
const a = new Audio("/__slop/clip.wav");
a.crossOrigin = "anonymous";
a.preload = "auto";
await new Promise((r) => {
a.addEventListener("canplaythrough", r, { once: true });
a.load();
});
const ac = new AudioContext();
const src = ac.createMediaElementSource(a);
const an = ac.createAnalyser();
an.fftSize = 2048;
an.smoothingTimeConstant = 0.6;
src.connect(an);
an.connect(ac.destination);
const bins = new Uint8Array(an.frequencyBinCount);
const hz = ac.sampleRate / an.fftSize;
const band = (lo, hi) => {
const a0 = Math.max(0, Math.floor(lo / hz));
const b0 = Math.min(bins.length - 1, Math.ceil(hi / hz));
let s = 0;
for (let i = a0; i <= b0; i++) s += bins[i];
return s / ((b0 - a0 + 1) * 255);
};
// Kick = a fast rise in the sub band above its own slow average. An absolute
// threshold would have to be retuned per track; a relative one rides the mix.
let floor = 0, prevLow = 0;
const loop = () => {
an.getByteFrequencyData(bins);
const low = band(20, 150), mid = band(150, 2000), high = band(2000, 12000);
floor = floor * 0.98 + low * 0.02;
const kick = Math.max(0, Math.min(1, (low - floor) * 4 + (low - prevLow) * 6));
prevLow = low;
const master = (low + mid + high) / 3;
window.customBands = {
kick, beat: kick, snare: high, bass: low, vocals: mid,
low, mid, high, rhythm: kick, specFast: high, specSlow: low, master,
};
window.__slopFrames = (window.__slopFrames || 0) + 1;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
window.__slopAudio = a;
window.__slopCtx = ac;
});
// --- 4. realtime screencast --------------------------------------------
mkdirSync(framesDir, { recursive: true });
const cdp = await ctx.newCDPSession(page);
const frames = [];
cdp.on("Page.screencastFrame", async (f) => {
frames.push({ t: f.metadata.timestamp, data: f.data });
try { await cdp.send("Page.screencastFrameAck", { sessionId: f.sessionId }); }
catch { /* the session can close mid-flight; a lost ack is not fatal */ }
});
await page.evaluate(() => { window.__slopCtx?.resume(); window.__slopAudio?.play(); });
await cdp.send("Page.startScreencast", {
format: "jpeg", quality: 90, everyNthFrame: 1,
maxWidth: 1080, maxHeight: 1920,
});
const wall = Date.now();
await sleep(job.dur * 1000 + 400);
await cdp.send("Page.stopScreencast").catch(() => {});
const elapsed = (Date.now() - wall) / 1000;
const rendered = await page.evaluate(() => window.__slopFrames ?? 0);
console.log(` captured ${frames.length} frames over ${elapsed.toFixed(1)}s `
+ `(${(frames.length / elapsed).toFixed(1)} fps; page rendered ${rendered})`);
if (frames.length < job.dur * 5) {
throw new Error(
`only ${frames.length} frames for ${job.dur.toFixed(0)}s — under 5 fps is not a `
+ `clip. Is the dev server serving WebGL (swiftshader) correctly?`);
}
// --- 5. vfr -> cfr -> mux ----------------------------------------------
// Durations come from the frames' own timestamps, so the video clock matches
// the audio clock even though the capture rate wobbles.
const t0 = frames[0].t;
let concat = "ffconcat version 1.0\n";
frames.forEach((f, i) => {
const name = `f${String(i).padStart(5, "0")}.jpg`;
writeFileSync(resolve(framesDir, name), Buffer.from(f.data, "base64"));
const next = i + 1 < frames.length ? frames[i + 1].t : f.t + 1 / Number(A.fps);
concat += `file '${name}'\nduration ${Math.max(0.001, next - f.t).toFixed(6)}\n`;
});
concat += `file 'f${String(frames.length - 1).padStart(5, "0")}.jpg'\n`;
writeFileSync(resolve(framesDir, "list.ffconcat"), concat);
void t0;
const r = spawnSync("ffmpeg", [
"-hide_banner", "-loglevel", "error", "-y",
"-f", "concat", "-safe", "0", "-i", resolve(framesDir, "list.ffconcat"),
"-i", slice,
"-vf", `fps=${A.fps},scale=1080:1920:force_original_aspect_ratio=increase,`
+ `crop=1080:1920,format=yuv420p`,
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", "-shortest", outMp4,
], { encoding: "utf8" });
if (r.status !== 0) throw new Error(`ffmpeg mux failed: ${r.stderr?.slice(0, 400)}`);
const size = execFileSync("stat", ["-c", "%s", outMp4], { encoding: "utf8" }).trim();
console.log(`\n -> ${outMp4} (${(Number(size) / 1e6).toFixed(1)} MB)`);
console.log(` judge it, then log the reaction in visuals/slop/FEEDBACK.md (#29)`);
} finally {
await browser?.close().catch(() => {});
// Never leave build droppings inside hexa's public/.
rmSync(PUB, { recursive: true, force: true });
}
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