#!/usr/bin/env node // Put a voice's ORDINARY fixed panel back on screen, for the stretches its moving // visual does not cover. // // The run voice is drawn as X strokes (climb-panel.mjs) only while the melody rests -- // 20-26% of the song. For the rest of it the run is an inner voice again and should // look like one: a pip in the bottom-right corner, exactly where render-poly would // have put it if the voice had been in the body plan. // // It is not in the body plan, and cannot be: its audio needs climb-fx applied alone, // which is why it is rendered separately in the first place. So its panel is taken out // of that separate render and composited here. The rect is render-poly's own geometry // -- a "full" role panel is BG_MAIN_SCALE=0.70 of 1280x720, i.e. 896x504 at (192,108) // -- scaled down to the 384x216 pip size and placed at the second pip slot (870,478). // // node run-pip.mjs // // GATE_PLAN= draw only where GATE_VOICE SOUNDS (the inverse of climb-panel's // GATE_VOICE=melody gate, so the two together cover the line exactly once) // PIP=left|right which pip slot (default right) // SRC=W:H:X:Y the panel rect inside the voice-alone render // PIP_W/PIP_H/M pip size and margin import { readFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; const [PLANF, VOICE, LAYER, BASE, OUT] = process.argv.slice(2); if (!OUT) throw new Error("usage: run-pip.mjs "); const FPS = Number(process.env.FPS ?? 30); const W = Number(process.env.W ?? 1280), H = Number(process.env.H ?? 720); const PW = Number(process.env.PIP_W ?? 384), PH = Number(process.env.PIP_H ?? 216); const M = Number(process.env.M ?? 26); const PIP = process.env.PIP ?? "right"; const [SW, SH, SX, SY] = (process.env.SRC ?? "896:504:192:108").split(":").map(Number); const CHUNK = Number(process.env.ENABLE_CHUNK ?? 24); const plan = JSON.parse(readFileSync(PLANF, "utf8")); const v = plan.voices.find((x) => x.name === VOICE) ?? plan.voices[0]; // The gate. climb-panel draws where the melody is SILENT; this draws where it SOUNDS. // Same span construction on purpose -- if the two ever disagreed, the run would be // either doubled or missing for a few frames at every boundary. const GATE_PLAN = process.env.GATE_PLAN ?? ""; const GATE_VOICE = process.env.GATE_VOICE ?? "melody"; let gateBusy = () => true; if (GATE_PLAN) { const gp = JSON.parse(readFileSync(GATE_PLAN, "utf8")); const gv = gp.voices.find((x) => x.name === GATE_VOICE); if (!gv) throw new Error(`no ${GATE_VOICE} in ${GATE_PLAN}`); const spans = gv.plan .map((n) => [n.slotStart, n.slotStart + Math.min(n.slotDur, n.soundedDur ?? n.slotDur)]) .sort((a, b) => a[0] - b[0]); gateBusy = (t) => spans.some(([a, b]) => t >= a - 0.02 && t < b + 0.02); } const notes = [...v.plan].sort((a, b) => a.slotStart - b.slotStart).filter((n) => gateBusy(n.slotStart)); console.log(`${v.plan.length} ${VOICE} notes, ${notes.length} while ${GATE_VOICE} sounds` + ` -> fixed ${PIP} pip; ${v.plan.length - notes.length} left to the strokes`); // How long a panel is LIT -- and this is the one thing in here that must be derived, // not read. // // render-poly gates its panels on `videoDur`, but **`videoDur` does not exist in the // saved plan**: it is computed during the render (`videoDur = min(soundedDur, // max(noteDur * 1.1, 0.10))`) and never written back. Copying render-poly's expression // therefore silently falls through to `slotDur`, which on a long slot is far too long. // // That shipped a visible fault. A run note at 6.835 has noteDur 0.0898 and slotDur // 0.5391, so render-poly lit its panel for 0.10s while this script kept the pip on for // 0.539s -- and for the remaining 0.44s the crop showed the run render's own // BACKGROUND, which is a different part of the longplay entirely ("RED defeated BUG // CATCHER!" appearing inside a Pikachu encounter). // // So derive it the way render-poly does. `soundedDur` is likewise absent from the saved // plan, but it is bounded by `slotDur`, and `videoDur` is the MINIMUM of the two terms // -- so `min(slotDur, max(noteDur*1.1, 0.10))` equals it whenever the clip sounds for // at least its own video window, which is the ordinary case. const litFor = (n) => Math.max(1 / FPS, Math.min( n.videoDur ?? Math.max((n.noteDur ?? n.slotDur) * 1.1, 0.10), n.slotDur, )); const terms = notes.map((n) => { const dur = litFor(n); return `between(t,${n.slotStart.toFixed(3)},${(n.slotStart + dur).toFixed(3)})`; }); const longest = Math.max(...notes.map(litFor)); console.log(` panels lit for ${Math.min(...notes.map(litFor)).toFixed(3)}-${longest.toFixed(3)}s ` + `(derived as render-poly's videoDur, NOT read from the plan -- it is not saved there)`); // ffmpeg cannot allocate one enable expression with 400+ terms; split the layer and // chain a gated overlay per chunk. The windows are disjoint so the result is identical. const chunks = []; for (let i = 0; i < terms.length; i += CHUNK) chunks.push(terms.slice(i, i + CHUNK).join("+")); const x = PIP === "left" ? M : W - PW - M; const y = H - PH - M; const parts = [ `[1:v]crop=${SW}:${SH}:${SX}:${SY},scale=${PW}:${PH},setsar=1,fps=${FPS}[lay]`, `[lay]split=${chunks.length}${chunks.map((_, k) => `[L${k}]`).join("")}`, ]; let cur = "0:v"; chunks.forEach((expr, k) => { parts.push(`[${cur}][L${k}]overlay=x=${x}:y=${y}:shortest=0:eof_action=pass:enable='${expr}'[st${k}]`); cur = `st${k}`; }); parts.push(`[${cur}]null[v]`); const DUR = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", BASE]).toString().trim(); console.log(`overlaying ${notes.length} panels at ${PW}x${PH} (${x},${y}) in ${chunks.length} chunks...`); execFileSync("ffmpeg", ["-nostdin", "-v", "warning", "-y", "-i", BASE, "-i", LAYER, "-filter_complex", parts.join(";"), "-map", "[v]", "-map", "0:a?", "-t", DUR, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", OUT], { stdio: ["ignore", "inherit", "inherit"] }); console.log(`wrote ${OUT}`);