#!/usr/bin/env node // A visual for the encounter ALARM -- the 3.4s of rapid ums before the tune proper. // // The alarm is the one stretch of the video with its own identity: 95 notes across // three voices at a 90ms IOI, bounded hard at both ends (bg 3.960 to 7.374, where // the battle theme's downbeat lands). The normal panel layout treats it like any // other passage, so it goes by as a blur of faces. // // This accumulates ONE element per melody note -- 34 of them, about one every 100ms // -- and clears the lot on the downbeat. The melody's notes are used rather than all // 95: three voices at once is mush, and the melody is the voice leading the alarm. // // MODE=spiral thumbnails on an outward spiral from the centre // MODE=bars alternating left/right bars stacking down the screen // // It runs as a POST-PASS on a finished render, not inside render-poly: it needs no // clip decoding beyond one frame each, so it costs one encode and can be iterated on // without touching the 25-minute body render. // // node alarm-effect.mjs 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, IN, OUT] = process.argv.slice(2); const MODE = process.env.MODE ?? "spiral"; const START = Number(process.env.START ?? 3.960); const CLEAR = Number(process.env.CLEAR ?? 7.374); const VOICE = process.env.VOICE ?? "melody"; const SCRIM = Number(process.env.SCRIM ?? 0.45); // how far the picture dims behind it // Seconds to subtract from every time, so the effect can be built against a short cut // of the song instead of re-encoding 100s per variation. const OFFSET = Number(process.env.OFFSET ?? 0); const W = 1280, H = 720; // geometry knobs, so a variation costs an env var rather than an edit const BAR_INSET = Number(process.env.BAR_INSET ?? 40); const TILE = Number(process.env.TILE ?? 96); // one thumbnail = one "pixel" const PER_ROW = Number(process.env.PER_ROW ?? 6); // how far a bar grows before wrapping const GAPX = Number(process.env.GAPX ?? 4); const GAPY = Number(process.env.GAPY ?? 4); // The rendered body has the voice panels burned in. Dimming them with a scrim is not // enough -- the ums are meant to STOP being panels for the length of the transition // and become the figure instead. Pointing BG at the spliced background replaces the // window with the game's own frames, so the panels are simply absent rather than // faded. Its clock and the finished video's share an origin, so no re-pinning. const BG = process.env.BG ?? ""; const SPIRAL_R0 = Number(process.env.SPIRAL_R0 ?? 34); const SPIRAL_DR = Number(process.env.SPIRAL_DR ?? 8.6); const THUMB_W = Number(process.env.THUMB_W ?? 96); const THUMB_DW = Number(process.env.THUMB_DW ?? 1.2); const TMP = path.join(SONG_DATA, "alarmfx"); if (!existsSync(TMP)) mkdirSync(TMP, { recursive: true }); const plan = JSON.parse(readFileSync(PLANF, "utf8")); const v = plan.voices.find((x) => x.name === VOICE); if (!v) throw new Error(`no voice ${VOICE}`); const notes = v.plan.filter((n) => n.slotStart >= START - 1e-6 && n.slotStart < CLEAR - 1e-6) .sort((a, b) => a.slotStart - b.slotStart); console.log(`${notes.length} ${VOICE} notes in the alarm (${START}s -> ${CLEAR}s), mode ${MODE}`); // One VOICE PORTRAIT per note -- the same thing the fixed panels show, tiled. // // Not a face crop: the portrait is what the rest of the video puts on screen for a // note, so the transition should be built out of those rather than out of a // different-looking element. It gets the panel's own treatment from render-poly -- // aspect preserved, padded, never stretched. // // Size is set by arithmetic, not taste: 33 tiles of 16:9 inside 1280x720 caps the // average tile at ~223x125, so anything much larger cannot be laid out. const TILE_W = Number(process.env.TILE_W ?? 200); const TILE_H = Number(process.env.TILE_H ?? Math.round(TILE_W * 9 / 16)); const thumbs = []; for (const [i, n] of notes.entries()) { const f = path.join(TMP, `p${String(i).padStart(2, "0")}_${TILE_W}.jpg`); const at = (n.srcStart + Math.min(0.08, (n.srcDur ?? 0.3) / 2)).toFixed(3); const media = path.join(SONG_DATA, "media", `${n.video}.mp4`); if (!existsSync(f)) { execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at, "-i", media, "-frames:v", "1", "-vf", `scale=${TILE_W - 2}:${TILE_H - 2}:force_original_aspect_ratio=decrease,pad=${TILE_W}:${TILE_H}:(ow-iw)/2:(oh-ih)/2`, f]); } thumbs.push({ f, t: n.slotStart, i }); } console.log(`${thumbs.length} voice portraits at ${TILE_W}x${TILE_H}`); // ---- geometry --------------------------------------------------------------- const place = (i, total) => { if (MODE === "bars") { // Each BAR IS BUILT FROM thumbnails, one per note, as if the thumbnails were big // pixels -- the bar grows outward a tile at a time. The tiles stay square because // a face crop is square; stretching one to fill a bar shape is what made the // first attempt an illegible smear. // // Rows alternate their anchored side, so successive bars sweep in from opposite // edges rather than all growing the same way. const per = PER_ROW; const row = Math.floor(i / per), col = i % per; const rows = Math.ceil(total / per); const blockH = rows * TILE_H + (rows - 1) * GAPY; const y = Math.round((H - blockH) / 2 + row * (TILE_H + GAPY)); const x = row % 2 === 0 ? BAR_INSET + col * (TILE_W + GAPX) // grows rightward : W - BAR_INSET - TILE_W - col * (TILE_W + GAPX); // grows leftward return { w: TILE_W, h: TILE_H, x: Math.round(x), y }; } // spiral: golden angle keeps successive thumbs from stacking on one arm, and the // radius grows so the figure opens outward as the alarm accelerates const ang = i * 2.39996; // golden angle in radians const r = SPIRAL_R0 + i * SPIRAL_DR; return { w: TILE_W, h: TILE_H, x: Math.round(W / 2 + r * Math.cos(ang) - TILE_W / 2), y: Math.round(H / 2 + r * Math.sin(ang) * 0.62 - TILE_H / 2), // squashed, the frame is wide }; }; const parts = []; // scrim first: the panels underneath are ums too, so without this the spiral competes // with them instead of replacing them const S = (START - OFFSET).toFixed(3), C = (CLEAR - OFFSET).toFixed(3); parts.push(`[0:v]drawbox=x=0:y=0:w=${W}:h=${H}:color=black@${SCRIM}:t=fill:enable='between(t,${S},${C})'[bg0]`); let last = "bg0"; thumbs.forEach((th, i) => { const g = place(i, thumbs.length); parts.push(`[${i + 1}:v]scale=${g.w}:${g.h},pad=${g.w + 6}:${g.h + 6}:3:3:white[p${i}]`); parts.push(`[${last}][p${i}]overlay=x=${g.x - 3}:y=${g.y - 3}:enable='between(t,${(th.t - OFFSET).toFixed(3)},${C})'[bg${i + 1}]`); last = `bg${i + 1}`; }); const args = ["-nostdin", "-v", "warning", "-y", "-i", IN]; for (const th of thumbs) args.push("-loop", "1", "-i", th.f); // The PNG inputs are `-loop 1`, i.e. infinite, so the output needs an explicit end or // it never stops. Take it from the base video rather than a sentinel -- ffmpeg will // not parse "1e9" as a duration. const DUR = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", IN]).toString().trim(); args.push("-filter_complex", parts.join(";") + `;[${last}]null[v]`, "-map", "[v]", "-map", "0:a", "-t", DUR, "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "copy", "-movflags", "+faststart", OUT); console.log(`overlaying ${thumbs.length} thumbnails, scrim ${SCRIM}...`); execFileSync("ffmpeg", args, { stdio: ["ignore", "inherit", "inherit"] }); console.log(`wrote ${OUT}`);