#!/usr/bin/env node // The spec sheet, from the CLI. // // videos//spec.json is a LEAN, HAND-EDITABLE description of what a song is // made of -- its background video, its one-off sounds, its drum map, its vocal // hooks, its arrangement. The browse UI reads the same file and turns the declared // structures into the operations it offers, so pointing Metal Slug at a different // background is one edit that both sides see. // // node spec.mjs --list // node spec.mjs --init create the song directory // node spec.mjs print it // node spec.mjs --set set one field // node spec.mjs --unset // node spec.mjs --sound [--at ] [--gain ] [--note "..."] // node spec.mjs --drop-sound // // is dotted: background.path, background.handoverAt, drums.shift, // hooks.trimSet, voices.plan, title, builder, note. // // node spec.mjs metal-slug --set background.path jer-metalslug-bg.mp4 // // Numbers are written as numbers, everything else as a string. Unknown top-level // keys are PRESERVED: this file is hand-edited, and silently dropping something // somebody typed is worse than ignoring it. // // VIDEO_ROOT overrides where the song directories live (default // ~/reports/quartering-uh-song/videos). import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, renameSync, mkdirSync } from "node:fs"; import os from "node:os"; import path from "node:path"; const ROOT = process.env.VIDEO_ROOT ?? path.join(os.homedir(), "reports", "quartering-uh-song", "videos"); const argv = process.argv.slice(2); const file = (song) => path.join(ROOT, song, "spec.json"); function read(song) { try { const j = JSON.parse(readFileSync(file(song), "utf8")); if (j && typeof j === "object") return { version: 1, song, ...j }; } catch { /* absent */ } return { version: 1, song }; } function write(song, spec) { const f = file(song); if (!existsSync(path.dirname(f))) throw new Error(`no such song directory: ${path.dirname(f)}`); const tmp = `${f}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(spec, null, 1)); renameSync(tmp, f); } /** The declared structures, and what each one lets the UI do. */ function structuresOf(spec) { const out = []; if (spec.background) out.push(`background ${spec.background.path}${spec.background.fit ? ` (fit=${spec.background.fit})` : ""}`); for (const s of spec.sounds ?? []) out.push(`sound ${s.id} <- ${s.path}${s.at !== undefined ? ` @${s.at}s` : ""}`); if (spec.drums) out.push(`drums ${spec.drums.path}${spec.drums.shift !== undefined ? ` shift=${spec.drums.shift}` : ""}`); if (spec.hooks) out.push(`hooks trimSet=${spec.hooks.trimSet}`); if (spec.voices) out.push(`voices plan/${spec.voices.plan}`); return out; } if (!argv.length || argv[0] === "--list") { if (!existsSync(ROOT)) { console.log(`no ${ROOT} yet`); process.exit(0); } for (const s of readdirSync(ROOT).filter((d) => statSync(path.join(ROOT, d)).isDirectory()).sort()) { const spec = read(s); const st = structuresOf(spec); console.log(`${s.padEnd(16)} ${existsSync(file(s)) ? `${st.length} structures` : "(no spec.json)"}`); for (const line of st) console.log(` ${line}`); } process.exit(0); } const SONG = argv[0]; if (SONG.startsWith("--")) throw new Error("usage: spec.mjs [--set ] ..."); // --init makes the shape the scan expects. It creates NO cut files: a cut that // does not exist has to read as a hole in the set, and a zero-byte wide.mp4 // would read as a built one. if (argv.includes("--init")) { const dir = path.join(ROOT, SONG); if (existsSync(dir)) throw new Error(`${dir} already exists`); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(SONG)) throw new Error(`bad song name: ${SONG}`); mkdirSync(path.join(dir, "plan"), { recursive: true }); mkdirSync(path.join(dir, "variants"), { recursive: true }); writeFileSync( path.join(dir, "README.md"), `# ${SONG}\n\n_New song, nothing built yet._\n\nFill in \`spec.json\` with what this song is made of.\n`, ); console.log(`created ${dir}`); } const spec = read(SONG); const flagAt = (name) => argv.indexOf(name); const after = (name, n = 1) => { const i = flagAt(name); return i < 0 ? null : argv.slice(i + 1, i + 1 + n); }; // --init on its own must still write the spec.json, or a new song has a // directory and no sheet -- which is the state this is meant to prevent. let changed = argv.includes("--init"); const setPath = (obj, dotted, value) => { const parts = dotted.split("."); let cur = obj; for (const p of parts.slice(0, -1)) { if (typeof cur[p] !== "object" || cur[p] === null) cur[p] = {}; cur = cur[p]; } const last = parts[parts.length - 1]; if (value === undefined) delete cur[last]; else cur[last] = value; }; for (let i = 0; i < argv.length; i += 1) { if (argv[i] !== "--set") continue; const [p, raw] = argv.slice(i + 1, i + 3); if (!p || raw === undefined) throw new Error("--set needs "); const n = Number(raw); setPath(spec, p, raw !== "" && Number.isFinite(n) && String(n) === raw.trim() ? n : raw); changed = true; } for (let i = 0; i < argv.length; i += 1) { if (argv[i] !== "--unset") continue; const p = argv[i + 1]; if (!p) throw new Error("--unset needs "); setPath(spec, p, undefined); changed = true; } const sound = after("--sound", 2); if (sound) { const [id, src] = sound; if (!id || !src) throw new Error("--sound needs "); const at = after("--at")?.[0]; const gain = after("--gain")?.[0]; const note = after("--note")?.[0]; spec.sounds = (spec.sounds ?? []).filter((s) => s.id !== id); spec.sounds.push({ id, path: src, ...(at !== undefined && at !== null ? { at: Number(at) } : {}), ...(gain !== undefined && gain !== null ? { gain: Number(gain) } : {}), ...(note ? { note } : {}), }); spec.sounds.sort((a, b) => (a.at ?? 0) - (b.at ?? 0) || a.id.localeCompare(b.id)); changed = true; } const drop = after("--drop-sound")?.[0]; if (drop) { spec.sounds = (spec.sounds ?? []).filter((s) => s.id !== drop); if (!spec.sounds.length) delete spec.sounds; changed = true; } if (changed) { write(SONG, spec); console.log(`wrote ${file(SONG)}`); } console.log(JSON.stringify(spec, null, 1)); if (!changed) { console.log("\nstructures:"); for (const line of structuresOf(spec)) console.log(` ${line}`); }