#!/usr/bin/env node // Fill the vertical layout's EMPTY BANDS with um clips, one per note, snaking back and // forth -- for the gimmick windows, where the voice boxes are empty anyway. // // The 1080x1920 layout puts the gameplay in the middle and leaves two bands: 1080x541 // above it and 1080x407 below. During the Pokemon intro alarm and the catch jingle // those bands are dead space, and the gimmick is playing on the main screen with // nothing else happening. So the bands become the effect. // // SNAKING, not scattering: the tiles fill left-to-right along a row, then right-to-left // along the next, so the eye follows one continuous path rather than hunting. That is // the same correction climb-panel.mjs needed -- "never show disconnected clips along // the line, it just progresses up and down" -- applied to a grid instead of a diagonal. // // Tiles ACCUMULATE across the window and clear at its end, which is how the alarm // reads as building to the downbeat rather than strobing. // // THE GRID TILES THE BANDS EXACTLY. Cells are W/COLS by bandHeight/ROWS with no gaps // and no letterboxing: a clip is scaled to COVER its cell and the overflow is cropped. // Fitting inside the cell instead would pad it, and padding is what left the earlier // version with visible dead space at the foot of every band. Cropping is safe here in a // way stretching is not -- at this size a tile is a big coloured pixel, and // alarm-effect.mjs already records that a STRETCHED thumbnail is an illegible smear. // // PASSES is how many times the snake should travel the whole path. More passes means // FEWER, BIGGER tiles, with later ums landing on slots earlier ones already hold: the // grid fills, then refreshes in place. One pass with the biggest grid that still fills // is the other end of the same dial. // // node bars-snake.mjs // A= B= the window, in the OUTPUT's own time // PASSES=1 target number of complete trips through the grid // COLS=auto fix the columns, or let it choose to hit PASSES // BANDS= "y0:y1,y0:y1" free bands // OFFSET= planTime + OFFSET = outputTime import { readFileSync, mkdirSync, existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const [PLANF, VOICE, IN, OUT] = process.argv.slice(2); if (!OUT) throw new Error("usage: bars-snake.mjs "); const A = Number(process.env.A ?? 0), B = Number(process.env.B ?? 1e9); const OFFSET = Number(process.env.OFFSET ?? 0); const W = Number(process.env.W ?? 1080); const PASSES = Number(process.env.PASSES ?? 1); const COLS_ENV = process.env.COLS ?? "auto"; const BANDS = (process.env.BANDS ?? "0:541,1513:1920").split(",").map((s) => s.split(":").map(Number)); const SRC_AR = 16 / 9; const TMP = path.join(SONG_DATA, "barsnake"); if (!existsSync(TMP)) mkdirSync(TMP, { recursive: true }); const plan = JSON.parse(readFileSync(PLANF, "utf8")); const v = plan.voices.find((x) => x.name === VOICE) ?? plan.voices[0]; const notes = [...v.plan] .map((n) => ({ ...n, t: n.slotStart + OFFSET })) .filter((n) => n.t >= A && n.t < B) .sort((a, b) => a.t - b.t); if (!notes.length) throw new Error(`no ${VOICE} notes in ${A}-${B}`); // ---- choose a grid that tiles the bands exactly ----------------------------- // The bands are 541 and 407 tall, which is almost exactly 4:3 -- so rows of (4,3), // (8,6), (12,9) give the SAME cell height in both, and the effect reads as one grid // rather than two. The search does not assume that; it just scores uniformity, and // those pairings win on their own. const TARGET = notes.length / Math.max(1, PASSES); function chooseGrid() { let best = null; const colsRange = COLS_ENV === "auto" ? [2, 3, 4, 5, 6, 7, 8] : [Number(COLS_ENV)]; for (const cols of colsRange) { const cw = W / cols; // rows per band, independently -- a band only has to tile ITSELF exactly const perBand = BANDS.map(([y0, y1]) => { const h = y1 - y0; const opts = []; for (let r = 1; r <= 12; r += 1) { const ch = h / r; const ar = cw / ch; if (ar < 1.05 || ar > 2.8) continue; // no slivers, no near-squares opts.push({ r, ch, ar }); } return opts; }); if (perBand.some((o) => !o.length)) continue; // every combination of rows across the bands const walk = (i, acc) => { if (i === perBand.length) { const total = acc.reduce((s, o) => s + o.r, 0) * cols; if (total < 1) return; const chs = acc.map((o) => o.ch); const uniformity = (Math.max(...chs) - Math.min(...chs)) / Math.max(...chs); const aspect = acc.reduce((s, o) => s + Math.abs(o.ar - SRC_AR), 0) / acc.length; // closeness to the target slot count dominates; then a uniform cell height // across the bands; then how much each cell has to be cropped. const cost = Math.abs(total - TARGET) / TARGET + 1.5 * uniformity + 0.35 * aspect; if (!best || cost < best.cost) best = { cols, cw, rows: acc.map((o) => o), total, cost, uniformity, aspect }; return; } for (const o of perBand[i]) walk(i + 1, [...acc, o]); }; walk(0, []); } if (!best) throw new Error("no grid fits the bands within the aspect bounds"); return best; } const G = chooseGrid(); const slots = []; BANDS.forEach(([y0], bi) => { const { r, ch } = G.rows[bi]; for (let row = 0; row < r; row += 1) { for (let c = 0; c < G.cols; c += 1) { const col = row % 2 === 0 ? c : G.cols - 1 - c; // <- the snake slots.push({ x: Math.round(col * G.cw), y: Math.round(y0 + row * ch), w: Math.round(G.cw), h: Math.round(ch), }); } } }); const trips = notes.length / slots.length; console.log(`${notes.length} ${VOICE} notes in ${A}-${B}s`); console.log(`grid ${G.cols} x ${G.rows.map((o) => o.r).join("+")} = ${slots.length} slots, ` + `cells ${G.rows.map((o) => `${Math.round(G.cw)}x${Math.round(o.ch)}`).join(" / ")} ` + `(cell heights within ${(100 * G.uniformity).toFixed(1)}%, crop ${(100 * G.aspect / SRC_AR).toFixed(0)}%)`); console.log(`the snake travels the path ${trips.toFixed(2)}x` + (notes.length > slots.length ? ` -- ${notes.length - slots.length} later ums land on slots already held, replacing them` : "")); if (slots.length > notes.length) console.log(` WARNING ${slots.length - notes.length} slot(s) never fill`); // One extracted frame per DISTINCT cell size, not per note -- adjacent bands canå·® // differ by a pixel or two and that would double the extraction for nothing. for (const [i, n] of notes.entries()) { const s = slots[i % slots.length]; n._slot = s; n._f = path.join(TMP, `s${String(i).padStart(3, "0")}_${s.w}x${s.h}.jpg`); if (!existsSync(n._f)) { const at = (n.srcStart + Math.min(0.08, (n.srcDur ?? 0.3) / 2)).toFixed(3); // COVER, then crop: fills the cell exactly with no padding and no distortion. execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at, "-i", path.join(SONG_DATA, "media", `${n.video}.mp4`), "-frames:v", "1", "-vf", `scale=${s.w}:${s.h}:force_original_aspect_ratio=increase,crop=${s.w}:${s.h},` + `drawbox=x=0:y=0:w=${s.w}:h=${s.h}:color=white@0.55:t=2`, n._f]); } } console.log(`extracted ${notes.length} portraits`); // Each tile is lit from its own note until the window ends. Where two notes share a // slot the later one is drawn later in the chain, so it covers the earlier -- which is // what makes a second pass read as the grid refreshing rather than as a collision. const parts = []; let cur = "0:v"; notes.forEach((n, i) => { parts.push(`[${cur}][${i + 1}:v]overlay=x=${n._slot.x}:y=${n._slot.y}:` + `enable='between(t,${n.t.toFixed(3)},${B.toFixed(3)})'[o${i}]`); cur = `o${i}`; }); parts.push(`[${cur}]null[v]`); const args = ["-nostdin", "-v", "warning", "-y", "-i", IN]; for (const n of notes) args.push("-loop", "1", "-i", n._f); const DUR = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", IN]).toString().trim(); args.push("-filter_complex", parts.join(";"), "-map", "[v]", "-map", "0:a?", "-t", DUR, "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", OUT); console.log(`snaking ${notes.length} tiles through the bands...`); execFileSync("ffmpeg", args, { stdio: ["ignore", "inherit", "inherit"] }); console.log(`wrote ${OUT}`);