#!/usr/bin/env node // The rapid third voice as a SNAKING LINE of small clips, instead of a fixed panel. // // The run is 90ms notes. A static pip showing one of them at a time says nothing about // a line that is moving; it just flickers in a corner. Drawn as a walker instead -- // small tiles stepping along a single row, left to right, then back right to left -- // the voice becomes something the eye can follow, and the direction of travel is doing // the same job the X strokes do during the melody's rests. // // GAPS IN THE TRAIL ARE THE POINT. A solid trail of tiles is a bar, and a bar does not // move. Holding each tile for TRAIL and dropping one note in every HOLES leaves a // broken comet of two or three tiles that reads as travelling. Without the drops the // trail closes up at this note rate and the motion disappears. // // Portraits are extracted from the SOURCE MEDIA, never cropped out of a rendered panel. // That is deliberate: cropping a render only shows the voice while its panel happens to // be lit, and `videoDur` -- which decides that -- is computed at render time and is NOT // in the saved plan, so a crop drifts onto the render's own background. That bug put a // stray battle screen inside a Pokemon encounter (see run-pip.mjs). // // node run-line.mjs // GATE_PLAN= GATE_VOICE=melody draw only where that voice SOUNDS // W= H= frame size (default 1280x720) // LINE_Y= top of the line (default: near the bottom) // TILE_W= tile width (height follows 16:9) // TRAIL=0.28 how long a tile stays after its note // HOLES=3 drop one note in every N, to break the trail // CHUNK=150 tiles per ffmpeg pass; more than this and the graph gets unwieldy 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: run-line.mjs "); const W = Number(process.env.W ?? 1280), H = Number(process.env.H ?? 720); const TILE_W = Number(process.env.TILE_W ?? 150); const TILE_H = Number(process.env.TILE_H ?? Math.round(TILE_W * 9 / 16)); const LINE_Y = Number(process.env.LINE_Y ?? H - TILE_H - 26); // TRAIL and HOLES mean OPPOSITE things in the two modes, so they default per mode. // // A LINE is about travel, and travel needs gaps: hold each tile briefly and drop one // note in every 3, and what moves along the row is a broken comet. A solid row is a bar, // and a bar does not move. // // A SPINNER is about a box staying alive. Its four cells are a full cycle of only 4 // notes -- 0.36s at this note rate -- so holding each tile for 0.45s keeps all four lit // and one corner refreshes at a time. Dropping notes there just punches holes in a box // that is supposed to look full. And a tile outstaying its own note costs nothing: at // 90ms the eye cannot pair a face with the sound that made it anyway. const MODE_EARLY = process.env.MODE ?? "line"; // "auto" is a spinner wherever it matters, so it takes the SPINNER's defaults. Reading // it as a line gave the box a gap in one corner every third note -- the same "it never // quite fills" the tile sizing had already been blamed for. const LINEY = MODE_EARLY === "line"; const TRAIL = Number(process.env.TRAIL ?? (LINEY ? 0.28 : 0.45)); const HOLES = Number(process.env.HOLES ?? (LINEY ? 3 : 1)); const CHUNK = Number(process.env.CHUNK ?? 150); const OFFSET = Number(process.env.OFFSET ?? 0); const TMP = path.join(SONG_DATA, "runline"); 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 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 A = Number(process.env.A ?? 0), B = Number(process.env.B ?? 1e9); let notes = [...v.plan].sort((a, b) => a.slotStart - b.slotStart) .filter((n) => n.slotStart >= A && n.slotStart < B) .filter((n) => gateBusy(n.slotStart)); const before = notes.length; // The walker steps once per NOTE, including the dropped ones -- the holes are in what // is DRAWN, not in where the line has got to. Dropping a step instead would make the // trail stutter backwards. notes = notes.map((n, i) => ({ ...n, step: i })).filter((n) => HOLES < 2 || n.step % HOLES !== HOLES - 1); // MODE=line one row, walked back and forth (a triangle wave over the cells) // MODE=spin a 2x2 block, one corner at a time, going round clockwise // // The spinner is the same walker on a four-cell cycle instead of a row. It reads as // rotation rather than travel, and it needs far less width -- which is what makes it // the option when there is no room for a line. const MODE = MODE_EARLY; // BOX=W:H:X:Y confines the pattern to a sub-rectangle. In the vertical cut that is the // run's OWN secondary box (480x270 at 552,1537); in 16:9 it is the second pip // (384x216 at 870,478). The pattern replaces the static panel that container held. const BOX = (process.env.BOX ?? "").split(":").filter(Boolean).map(Number); const [BW, BH, BX, BY] = BOX.length === 4 ? BOX : [W, H, 0, 0]; const COLS = MODE === "line" ? Math.max(2, Math.floor(BW / TILE_W)) : 2; const span = COLS - 1; const SPIN = [[0, 0], [1, 0], [1, 1], [0, 1]]; // clockwise from top-left // A quadrant is exactly half the box each way, so four of them TILE it. Anything else // leaves the box looking half-empty, which is what happens if the tiles are cut at some // other width and merely placed at quadrant positions. const QW = Math.round(BW / 2), QH = Math.round(BH / 2); const GX = Number(process.env.GRID_X ?? BX); const GY = Number(process.env.GRID_Y ?? BY); const x0 = Math.round(BX + (BW - COLS * TILE_W) / 2); const lineY = BOX.length === 4 ? Math.round(BY + (BH - TILE_H) / 2) : LINE_Y; // MODE=auto -- 2x2 ONLY where the voice is actually rattling. // // The grid earns its place when the notes come faster than the eye can follow one at a // time; at an ordinary note rate it just looks like a box that keeps failing to fill. // So each note is classified by its own spacing: closer than RAPID to its neighbour and // it takes a quadrant, otherwise it takes the WHOLE box as a single clip. const RAPID = Number(process.env.RAPID ?? 0.15); const gapAt = (i) => { const a = i > 0 ? notes[i].slotStart - notes[i - 1].slotStart : Infinity; const b = i < notes.length - 1 ? notes[i + 1].slotStart - notes[i].slotStart : Infinity; return Math.min(a, b); }; const isRapid = (i) => (MODE === "spin" ? true : MODE === "line" ? false : gapAt(i) <= RAPID); // where a note lands and how big it is: [x, y, w, h] const placeOf = (n, i) => { if (MODE === "line") { const m = n.step % (2 * span); return [x0 + (m <= span ? m : 2 * span - m) * TILE_W, lineY, TILE_W, TILE_H]; } if (!isRapid(i)) return [BX, BY, BW, BH]; // one clip, filling the container const [c, r] = SPIN[((n.step % 4) + 4) % 4]; return [GX + c * QW, GY + r * QH, QW, QH]; }; console.log(`${v.plan.length} ${VOICE} notes, ${before} while ${GATE_VOICE} sounds` + (HOLES >= 2 ? `, drawing ${notes.length} (1 in ${HOLES} dropped to break the trail)` : "")); const rapidN = notes.filter((_, i) => isRapid(i)).length; console.log(MODE === "line" ? `line of ${COLS} cells of ${TILE_W}x${TILE_H} at y=${lineY}, trail ${TRAIL}s, ` + `snaking back and forth; a full sweep is ${span} notes` : `${BW}x${BH} container at (${BX},${BY}): ${rapidN} rapid notes as ${QW}x${QH} quadrants ` + `(clockwise), ${notes.length - rapidN} slower ones filling the whole box; trail ${TRAIL}s`); // The cache key is the CLIP and the SIZE, never the note's index. // // Keying by index shared tiles between builds that have different notes at the same // index, and -- worse -- between a run at one tile size and a run at another, because // the size in the name was the requested TILE_W rather than the size actually cut. The // 2x2 quadrants came out 150x84 inside 192x108 boxes that way: reused from a line test, // correctly named for it, wrong for this. for (const [i, n] of notes.entries()) { const [, , w, h] = placeOf(n, i); n._place = placeOf(n, i); const id = `${n.video}@${Number(n.srcStart).toFixed(2)}`.replace(/[^A-Za-z0-9@._-]/g, "_"); n._f = path.join(TMP, `${id}_${w}x${h}.jpg`); if (!existsSync(n._f)) { const at = (n.srcStart + Math.min(0.08, (n.srcDur ?? 0.3) / 2)).toFixed(3); execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at, "-i", path.join(SONG_DATA, "media", `${n.video}.mp4`), "-frames:v", "1", "-vf", `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},` + `drawbox=x=0:y=0:w=${w}:h=${h}:color=white@0.6:t=2`, n._f]); } } console.log(`extracted ${notes.length} tiles`); // Chunked passes: one ffmpeg per CHUNK tiles, each reading the previous output. A // single graph with several hundred looped image inputs is where ffmpeg starts to // struggle, and a chunk boundary costs nothing because the tiles never overlap in time // across one. const DUR = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", IN]).toString().trim(); let src = IN; const passes = Math.ceil(notes.length / CHUNK); for (let p = 0; p < passes; p += 1) { const group = notes.slice(p * CHUNK, (p + 1) * CHUNK); const dst = p === passes - 1 ? OUT : path.join(TMP, `pass${p}_${path.basename(OUT)}`); const parts = []; let cur = "0:v"; group.forEach((n, i) => { const [x, y] = n._place; const a = (n.slotStart - OFFSET).toFixed(3); const b = (n.slotStart + TRAIL - OFFSET).toFixed(3); parts.push(`[${cur}][${i + 1}:v]overlay=x=${x}:y=${y}:enable='between(t,${a},${b})'[o${i}]`); cur = `o${i}`; }); parts.push(`[${cur}]null[v]`); const args = ["-nostdin", "-v", "warning", "-y", "-i", src]; for (const n of group) args.push("-loop", "1", "-i", n._f); args.push("-filter_complex", parts.join(";"), "-map", "[v]", "-map", "0:a?", "-t", DUR, "-c:v", "libx264", "-preset", "medium", "-crf", "19", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", dst); console.log(` pass ${p + 1}/${passes}: ${group.length} tiles`); execFileSync("ffmpeg", args, { stdio: ["ignore", "inherit", "inherit"] }); src = dst; } console.log(`wrote ${OUT}`);