// 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
//
//   # shape: vertical (default, reels) | square (feed) | landscape (YouTube)
//   node visuals/slop/render_slop_clip.mjs --idea wap --shape square
//   # full length, for YouTube — start to end of the master
//   node visuals/slop/render_slop_clip.mjs --idea wap --cut full --shape landscape
//   node visuals/slop/render_slop_clip.mjs --audio Prod/LeJazzCestQuoi.flac \
//        --dur full --shape landscape --name lejazz
//
// CAPTURE IS REALTIME (see below), so a full-length render costs the track's own
// duration in wall time, PER SHAPE. Three shapes of a 7-minute take is ~21
// minutes of headless Chromium. Batch accordingly.
//
// 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));

// --------------------------------------------------------------- output shapes
//
// One output shape used to be baked into three separate places — the viewport,
// the screencast cap, and the ffmpeg -vf — which is why there was no square or
// 16:9 path: adding one meant finding all three and keeping them consistent
// forever. They are now derived from one table.
//
// The viewport is the load-bearing one. Hydra COMPOSES for whatever frame it is
// given, so the shape has to be chosen before the scene renders, not cropped on
// afterwards: a 16:9 scene centre-cropped to 9:16 throws away most of the
// motion, which was the original reason this was set on the viewport at all.
// The ffmpeg crop stays as a belt-and-braces exact-size guarantee, but by then
// it should be a no-op.
const SHAPES = {
  vertical:  { w: 1080, h: 1920 },   // Insta reels / TikTok / Shorts
  square:    { w: 1080, h: 1080 },   // Insta feed
  landscape: { w: 1920, h: 1080 },   // YouTube
};

function shapeOf(name = "vertical") {
  const s = SHAPES[name];
  if (!s) {
    throw new Error(
      `unknown --shape "${name}". Have: ${Object.keys(SHAPES).join(", ")}`);
  }
  return s;
}

// Length of an audio file, for `--dur full` / `--cut full`. ffprobe rather than
// a guess: these recordings are whatever length the take happened to be, and a
// clip that stops short of the ending is worse than no clip.
function audioDuration(file) {
  const out = execFileSync("ffprobe", [
    "-v", "error", "-show_entries", "format=duration",
    "-of", "default=nw=1:nk=1", file,
  ], { encoding: "utf8" }).trim();
  const d = Number(out);
  if (!Number.isFinite(d) || d <= 0) {
    throw new Error(`ffprobe could not read a duration from ${file}`);
  }
  return d;
}

// 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");
  const builds = existsSync(root)
    ? 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;
    }
  }
  // The ms-playwright cache is EMPTY on this machine as of 2026-08-17, and
  // returning undefined does not "let playwright pick" gracefully — it makes
  // playwright reach for the exact build it shipped against
  // (chromium_headless_shell-1234), which was never downloaded, and every render
  // dies with "Executable doesn't exist". The system Chromium is a full build
  // and is what the SoundCloud transport already drives, so prefer it over
  // pulling ~150 MB into PLN's shared cache behind his back.
  if (existsSync("/usr/bin/chromium")) return "/usr/bin/chromium";
  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();
// Resolved once, at startup, so an unknown --shape fails before a multi-minute
// realtime capture rather than after it.
const SHAPE = shapeOf(A.shape ?? "vertical");

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 full` renders to the end of the file from --start. Measured with
      // ffprobe, never assumed — and clamped so a --start past the end fails
      // loudly here instead of producing a zero-frame capture 40s later.
      dur: A.dur === "full"
        ? (() => {
            const rest = audioDuration(audio) - Number(A.start ?? 0);
            if (rest <= 0) {
              throw new Error(
                `--start ${A.start} is at or past the end of ${basename(audio)} `
                + `(${audioDuration(audio).toFixed(1)}s)`);
            }
            return rest;
          })()
        : 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 audio = A.master ? resolve(TIDAL, A.master) : i.master_audio;
  // `--cut full` bypasses promo_cuts entirely and renders the whole master.
  // Kept on the idea path so a full-length YouTube cut still inherits the
  // idea's playsets and fx, rather than falling back to the ad-hoc defaults.
  if (A.cut === "full") {
    return {
      name: `${i.tidal_stem}_full`,
      audio,
      start: 0,
      dur: audioDuration(audio),
      playsets: A.playset ? [A.playset] : i.visual.playsets,
      fx: i.visual.fx,
      triggerHeavy: i.visual.triggerHeavy,
      source: `${i.opal_title} — FULL, ${i.measured_bpm} BPM`,
    };
  }
  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,
    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");
// The shape is part of the identity, not decoration: rendering square, vertical
// and landscape of the same cut would otherwise write the same filename three
// times and silently leave only the last one. A batch that quietly produces a
// third of what it claims is worse than one that errors.
const outMp4 = resolve(OUT_DIR, `${job.name}_${A.shape ?? "vertical"}.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 --------------------------------------------------
  // MEASURED 2026-08-17, and it overturns the previous comment here.
  //
  // `--gl hw` did not get hardware GL. Asked three launch modes what WebGL
  // actually resolves to on this box:
  //
  //   headless + --use-angle=swiftshader -> SwiftShader (Subzero)
  //   headless + --use-gl=egl            -> SwiftShader (Subzero)   <- same!
  //   HEADFUL                            -> Mesa Intel UHD (CML GT2)
  //
  // So in headless the GPU is unreachable regardless of flags, and `--gl hw`
  // silently stayed on software while claiming otherwise — which is why a
  // 1920x1080 landscape render captured 3.5 fps on swiftshader and 3.2 fps on
  // "hw", tripping the under-5-fps guard both times. The old note said the hw
  // path "falls back by simply measuring worse"; it was not falling back, it
  // never left software.
  //
  // Hardware GL therefore REQUIRES headful, on the real display. Same
  // conclusion as the SoundCloud and YouTube transports: be a real browser.
  // Costs a visible window for the duration of the capture.
  const headful = A.gl === "hw";
  browser = await chromium.launch({
    headless: !headful,
    executablePath: chromeBinary(),
    args: [
      ...(headful
        ? ["--enable-gpu-rasterization"]
        : ["--use-gl=angle", "--use-angle=swiftshader"]),
      "--ignore-gpu-blocklist",
      "--autoplay-policy=no-user-gesture-required",
      "--disable-features=IsolateOrigins,site-per-process",
    ],
  });
  // Set on the VIEWPORT rather than upscaling later, so Hydra composes for the
  // target frame instead of being cropped into it — a 16:9 scene centre-cropped
  // to 9:16 loses most of the motion, and the same is true in reverse.
  //
  // The viewport is the output divided by dpr: at the default dpr 2 that is
  // 540x960 for vertical, exactly as before. Deriving it means --shape and --dpr
  // can no longer disagree with the mux about how big a frame is.
  const dpr = Number(A.dpr ?? 2);
  const ctx = await browser.newContext({
    viewport: { width: Math.round(SHAPE.w / dpr), height: Math.round(SHAPE.h / dpr) },
    deviceScaleFactor: dpr,
  });
  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: SHAPE.w, maxHeight: SHAPE.h,
  });
  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=${SHAPE.w}:${SHAPE.h}:force_original_aspect_ratio=increase,`
         + `crop=${SHAPE.w}:${SHAPE.h},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 });
}
