import path from "node:path"; import { stat } from "node:fs/promises"; import { labelFor, resolveInRoots } from "./paths"; import { readJson, withStateLock, writeJsonAtomic } from "./state"; import { isSegment, songDir, type Song } from "./browse"; import { TRIM_SETS } from "./trim"; // --------------------------------------------------------------------------- // videos//spec.json -- the SPEC SHEET. // // A lean, hand-editable description of what a song IS: the repeating structures // it is made of and the files that feed them. Editable from the UI and from the // CLI (song/spec.mjs), so a change made either way is a change both sides see. // // WHY THIS IS NOT build.json. They look similar and they are opposites: // // spec.json CONFIG. What the song is made of. Hand-authored, small, edited // often, and the thing you change to change the next build -- // "point Metal Slug at the music background" is an edit here. // build.json RECORD. What one run actually did, stamped with the bytes it // produced. Machine-written, never hand-edited, and it goes stale // ON PURPOSE so the UI can say a recipe no longer describes the // file. // // Merging them would mean a file you edit and a file you trust are the same // file, and then neither is. Two files, one job each. // // THE STRUCTURES DRIVE THE UI. `operationsFor()` turns the declared structures // into the operations a song offers -- a song with a `hooks` structure gets the // trim editor and the apply chain; one without does not. That is the whole // point of declaring them: the UI stops guessing what a song supports. // --------------------------------------------------------------------------- export const SPEC_VERSION = 1; /** A file the song is built from. `path` is resolved against the media roots. */ export type SpecSource = { path: string; note?: string }; export type Background = SpecSource & { /** How a non-16:9 source is fitted. 640x480 gameplay must PAD, not crop. */ fit?: "pad" | "crop" | "stretch"; /** 0 = the background does not change when the ums come in. */ dim?: number; /** Seconds into the background where the song takes over. */ handoverAt?: number; }; export type SpecSound = SpecSource & { id: string; /** Song seconds where it plays. */ at?: number; gain?: number; }; export type SpecDrums = SpecSource & { /** songTime = (midiTime - trim) * tempoScale + shift */ shift?: number; tempoScale?: number; trim?: number; }; export type SpecHooks = { /** A key into TRIM_SETS -- which is what makes the trim editor reachable. */ trimSet: string; note?: string; }; export type SpecVoices = { /** The plan in plan/ that describes the arrangement. */ plan: string; note?: string; }; /** * Where this song is going, in loudness terms. * * Declared rather than assumed, for the same reason `renderOffset` is: the * default (-14 LUFS / -1 dBTP, what YouTube normalises to) is right for these * deliverables and wrong for something bound elsewhere, and a verdict measured * against the wrong target is a verdict that lies. */ export type SpecLoudness = { targetLufs?: number; truePeak?: number; note?: string }; export type Spec = { version: number; song: string; title?: string; /** The script that builds it, for the record. Never run from the UI. */ builder?: string; background?: Background; sounds?: SpecSound[]; drums?: SpecDrums; hooks?: SpecHooks; voices?: SpecVoices; loudness?: SpecLoudness; /** * Seconds to ADD to a time in the shipped file to get song time. * * Normally 0 -- a render starts at song zero. Mortal Kombat is the exception: * 8.000s of intro bio screen was sliced out of the FINISHED file, so a mark at * 0:42 on the shipped mp4 is song 0:50. Declaring it here is what lets a * timestamp on the video resolve to the right clip; carrying it in somebody's * head is what made the last one wrong. */ renderOffset?: number; note?: string; }; export const specFile = (id: string) => path.join(songDir(id), "spec.json"); export const emptySpec = (id: string): Spec => ({ version: SPEC_VERSION, song: id }); export async function readSpec(id: string): Promise { if (!isSegment(id)) return emptySpec(id); const j = await readJson(specFile(id), emptySpec(id)); return j && typeof j === "object" ? { ...emptySpec(id), ...j, song: id } : emptySpec(id); } // --------------------------------------------------------------------------- // Validation. // // Deliberately forgiving about what it does not know and strict about what it // does. An unknown key is kept, not dropped: this file is hand-edited, and a UI // that silently deletes a note somebody typed is worse than one that ignores it. // What IS checked is anything that would make an operation lie -- a source that // resolves outside the roots, or a trimSet that does not exist. // --------------------------------------------------------------------------- export type SpecProblem = { where: string; message: string; /** * `error` the sheet is wrong: a path that escapes the roots, a trim set that * does not exist. An operation built on it would lie. * `pending` the sheet is fine but the file is not there YET -- which is a * normal state to save from, because you point the background at * something you are about to render. */ level: "error" | "pending"; }; export async function validateSpec(spec: Spec): Promise { const problems: SpecProblem[] = []; const checkSource = async (where: string, p: string | undefined) => { if (!p) { problems.push({ where, message: "no path set", level: "error" }); return; } const abs = resolveInRoots(p); if (!abs) { problems.push({ where, message: `${p} is not inside a known root`, level: "error" }); return; } // Existence is the check worth having. Containment alone passes any // plausible name, so a background pointing at a file that is simply not // there reported nothing at all -- which is the one thing the sheet should // never do quietly. try { await stat(abs); } catch { problems.push({ where, message: `${p} does not exist yet`, level: "pending" }); } }; const checks: Promise[] = []; if (spec.background) checks.push(checkSource("background", spec.background.path)); for (const s of spec.sounds ?? []) checks.push(checkSource(`sound:${s.id || "?"}`, s.path)); if (spec.drums) checks.push(checkSource("drums", spec.drums.path)); await Promise.all(checks); if (spec.hooks && !TRIM_SETS[spec.hooks.trimSet]) { problems.push({ where: "hooks", message: `no trim set named ${spec.hooks.trimSet}`, level: "error" }); } const ids = (spec.sounds ?? []).map((s) => s.id); const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); if (dupes.length) { problems.push({ where: "sounds", message: `duplicate ids: ${[...new Set(dupes)].join(", ")}`, level: "error", }); } return problems; } /** Absolute path + the label /api/mix/media wants, or null. */ export function resolveSource(p: string | undefined): { abs: string; label: string } | null { const abs = resolveInRoots(p); return abs ? { abs, label: labelFor(abs) } : null; } // --------------------------------------------------------------------------- // Structures -> operations. // --------------------------------------------------------------------------- export type Operation = { id: string; label: string; /** Which declared structure enables it. */ structure: string; href?: string; /** Why it is NOT available, when it is not. */ blocked?: string; }; /** * What this song can be worked on, derived from what it declares. * * Every operation names the structure that enables it, so a missing one reads * as "this song does not declare hooks" rather than as a button that vanished. */ export function operationsFor(spec: Spec, song: Song | null): Operation[] { const ops: Operation[] = []; ops.push( spec.background ? { id: "background", label: "background video", structure: "background", ...(resolveSource(spec.background.path) ? {} : { blocked: "the file does not resolve" }), } : { id: "background", label: "background video", structure: "background", blocked: "not declared" }, ); ops.push({ id: "sounds", label: `sounds (${spec.sounds?.length ?? 0})`, structure: "sounds", ...(spec.sounds?.length ? {} : { blocked: "none declared" }), }); ops.push( spec.hooks && TRIM_SETS[spec.hooks.trimSet] ? { id: "trim-hooks", label: "trim the vocal hooks", structure: "hooks", href: `/browse/trim/${spec.hooks.trimSet}`, } : { id: "trim-hooks", label: "trim the vocal hooks", structure: "hooks", blocked: spec.hooks ? `no trim set named ${spec.hooks.trimSet}` : "not declared", }, ); ops.push({ id: "drums", label: "drum map", structure: "drums", ...(spec.drums ? {} : { blocked: "not declared" }), }); const plan = spec.voices?.plan; const hasPlan = !!plan && !!song?.plans.some((p) => p.name === plan); ops.push({ id: "provenance", label: "per-note provenance", structure: "voices", ...(hasPlan ? {} : { blocked: plan ? `plan/${plan} is not there` : "no plan named — pick one on a cut page" }), }); return ops; } // --------------------------------------------------------------------------- // Writing. // --------------------------------------------------------------------------- const num = (v: unknown): number | undefined => { const n = Number(v); return Number.isFinite(n) ? n : undefined; }; const str = (v: unknown): string | undefined => { const s = typeof v === "string" ? v.trim() : ""; return s ? s : undefined; }; /** Drop empty structures rather than storing `{}` -- lean is the point. */ function tidy(spec: Spec): Spec { const out: Spec = { version: SPEC_VERSION, song: spec.song }; if (str(spec.title)) out.title = str(spec.title); if (str(spec.builder)) out.builder = str(spec.builder); if (str(spec.note)) out.note = str(spec.note); // 0 is the default, so storing it would be noise -- but it must survive being // set to a negative number, which `if (num)` would drop. if (num(spec.renderOffset) !== undefined && num(spec.renderOffset) !== 0) { out.renderOffset = num(spec.renderOffset); } if (spec.background && str(spec.background.path)) { out.background = { path: str(spec.background.path)!, ...(str(spec.background.note) ? { note: str(spec.background.note) } : {}), ...(spec.background.fit ? { fit: spec.background.fit } : {}), ...(num(spec.background.dim) !== undefined ? { dim: num(spec.background.dim) } : {}), ...(num(spec.background.handoverAt) !== undefined ? { handoverAt: num(spec.background.handoverAt) } : {}), }; } const sounds = (spec.sounds ?? []) .filter((s) => str(s.id) && str(s.path)) .map((s) => ({ id: str(s.id)!, path: str(s.path)!, ...(str(s.note) ? { note: str(s.note) } : {}), ...(num(s.at) !== undefined ? { at: num(s.at) } : {}), ...(num(s.gain) !== undefined ? { gain: num(s.gain) } : {}), })); if (sounds.length) out.sounds = sounds; if (spec.drums && str(spec.drums.path)) { out.drums = { path: str(spec.drums.path)!, ...(str(spec.drums.note) ? { note: str(spec.drums.note) } : {}), ...(num(spec.drums.shift) !== undefined ? { shift: num(spec.drums.shift) } : {}), ...(num(spec.drums.tempoScale) !== undefined ? { tempoScale: num(spec.drums.tempoScale) } : {}), ...(num(spec.drums.trim) !== undefined ? { trim: num(spec.drums.trim) } : {}), }; } if (spec.hooks && str(spec.hooks.trimSet)) { out.hooks = { trimSet: str(spec.hooks.trimSet)!, ...(str(spec.hooks.note) ? { note: str(spec.hooks.note) } : {}), }; } if (spec.voices && str(spec.voices.plan)) { out.voices = { plan: str(spec.voices.plan)!, ...(str(spec.voices.note) ? { note: str(spec.voices.note) } : {}), }; } // Every field here is negative, so `if (num(...))` would drop -14 and -1 -- // the only values anyone would ever set. if (spec.loudness) { const l: SpecLoudness = { ...(num(spec.loudness.targetLufs) !== undefined ? { targetLufs: num(spec.loudness.targetLufs) } : {}), ...(num(spec.loudness.truePeak) !== undefined ? { truePeak: num(spec.loudness.truePeak) } : {}), ...(str(spec.loudness.note) ? { note: str(spec.loudness.note) } : {}), }; if (Object.keys(l).length) out.loudness = l; } return out; } export async function writeSpec(id: string, spec: Spec): Promise { const clean = tidy({ ...spec, song: id }); await withStateLock(async () => { await writeJsonAtomic(specFile(id), clean); }); return clean; }