import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { SONG_REPORTS } from "./paths"; // --------------------------------------------------------------------------- // Which finished builds a clip is already sounding in. // // A verdict in this tool is not only a judgement about a candidate -- it can // change a deliverable. Dropping a clip that is placed in a rendered plan means // the next arrange will not pick it, and a video that exists will not be // reproducible from the palette that made it. Nothing said so, so every drop // was made without knowing whether it mattered. // // The plans are the record: each one lists every note with the exact clip it // sounds, keyed the same way the palette is. Measured across the reports // directory that is 1,898 distinct clips over 15 plan files, and 1,335 of them // are used in more than one build -- so this is common rather than a corner // case. // // WHAT THIS USED TO MISS, AND WHY IT MATTERED. // // The walk stopped at depth 2 and the filter was the `.plan.json` SUFFIX. Both // were wrong for where the plans actually live: `videos//plan/` is three // levels down, and 17 of the 22 files there are plain `.json`. The measured // consequence was that 12 of the 271 notes in mario-rpg's rpg-plan-s3.json // reported "used in NO build" while sitting in a shipped song's plan -- and // that is exactly the check the drop confirmation is built on. A false "not // used anywhere" is the one answer that lets live work be deleted quietly. // // The widened filter is `*.plan.json` anywhere, PLUS any `*.json` whose parent // directory is named `plan/`. That second rule is scoped by SHAPE rather than // by name: a file counts only if it has a `voices[]` array with at least one // placement carrying a string video and a numeric srcStart. `plan/` also holds // bare arrays of overlay descriptions, and those are rejected by the shape test // rather than by a naming convention nobody promised to keep. // // EXPECT MORE PROMPTS. More clips now report as in use, so the drop confirm // fires more often. That is the correct direction: the previous silence was the // unsafe state. // --------------------------------------------------------------------------- export type Use = { /** * Root-relative path without its extension -- UNIQUE across the tree. * * Once `videos//plan/` is walked, two songs can both hold `body.json` * and a basename-keyed build would merge them into one. */ build: string; /** The basename, for display. */ label: string; /** Derived from `videos//...`, or null for a plan at the top level. */ song: string | null; voice: string; /** Where in the song it sounds, in seconds. */ at: number; }; /** One source episode, rolled up across every plan in the tree. */ export type VideoUse = { video: string; /** Note placements -- how much of the finished runtime comes from it. */ placements: number; /** Distinct clips taken from it. Not the same number, and not interchangeable. */ clips: number; songs: string[]; builds: number; }; type Index = { /** `${video}@${srcStart.toFixed(2)}` -> every place it is used. */ byKey: Map; /** videoId -> the roll-up above. */ byVideo: Map; builds: number; clips: number; /** Signature of the plan files this was built from. */ signature: string; }; type PlanFile = { voices?: { name?: string; plan?: { video?: string; srcStart?: number; slotStart?: number }[] }[]; }; let cached: Index | null = null; /** Is this parsed JSON actually an arrangement? The whole widening rests here. */ function isPlanShaped(j: unknown): j is PlanFile { if (!j || typeof j !== "object" || Array.isArray(j)) return false; const voices = (j as PlanFile).voices; if (!Array.isArray(voices)) return false; return voices.some((v) => (v?.plan ?? []).some((n) => typeof n?.video === "string" && typeof n?.srcStart === "number"), ); } async function planFiles(): Promise<{ file: string; mtimeMs: number }[]> { const out: { file: string; mtimeMs: number }[] = []; const walk = async (dir: string, depth: number) => { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (e.name.startsWith(".")) continue; const abs = path.join(dir, e.name); if (e.isDirectory()) { if (depth > 0) await walk(abs, depth - 1); continue; } if (!e.name.endsWith(".json")) continue; // `.plan.json` anywhere; any .json inside a plan/ directory. Everything // else in the tree stays unparsed rather than being opened on spec. const inPlanDir = path.basename(dir) === "plan"; if (!e.name.endsWith(".plan.json") && !inPlanDir) continue; try { out.push({ file: abs, mtimeMs: (await stat(abs)).mtimeMs }); } catch { /* vanished */ } } }; // 3, so `videos//plan/` is reached. videos/ is depth 1, the song 2, the // plan directory 3 -- and that last hop is the one that was missing. await walk(SONG_REPORTS, 3); out.sort((a, b) => a.file.localeCompare(b.file)); return out; } const buildIdOf = (file: string): string => path.relative(SONG_REPORTS, file).replace(/\.plan\.json$/, "").replace(/\.json$/, ""); const songOf = (file: string): string | null => { const rel = path.relative(SONG_REPORTS, file).split(path.sep); return rel[0] === "videos" && rel.length > 2 ? rel[1] : null; }; /** * Build (or reuse) the index. Invalidated on the plan files' mtimes rather than * on a clock: a plan changes only when something is rendered, and a stale * "not used anywhere" is exactly the answer that would let a live clip be * dropped without a warning. */ export async function usageIndex(): Promise { const files = await planFiles(); const signature = files.map((f) => `${f.file}:${Math.round(f.mtimeMs)}`).join("|"); if (cached && cached.signature === signature) return cached; const byKey = new Map(); const rolled = new Map; songs: Set; builds: Set }>(); let builds = 0; for (const { file } of files) { let plan: unknown; try { plan = JSON.parse(await readFile(file, "utf8")); } catch { continue; } if (!isPlanShaped(plan)) continue; builds += 1; const build = buildIdOf(file); const label = path.basename(file).replace(/\.plan\.json$/, "").replace(/\.json$/, ""); const song = songOf(file); for (const v of plan.voices ?? []) { for (const n of v.plan ?? []) { if (!n.video || typeof n.srcStart !== "number") continue; // The SAME key the palette and every state file use. A plan's srcStart // came from the candidate's start, so two decimals matches exactly. const key = `${n.video}@${n.srcStart.toFixed(2)}`; const use: Use = { build, label, song, voice: v.name ?? "?", at: +(n.slotStart ?? 0).toFixed(2) }; const list = byKey.get(key); if (list) list.push(use); else byKey.set(key, [use]); const roll = rolled.get(n.video) ?? { placements: 0, clips: new Set(), songs: new Set(), builds: new Set(), }; roll.placements += 1; roll.clips.add(key); roll.builds.add(build); if (song) roll.songs.add(song); rolled.set(n.video, roll); } } } const byVideo = new Map(); for (const [video, r] of rolled) { byVideo.set(video, { video, placements: r.placements, clips: r.clips.size, songs: [...r.songs].sort(), builds: r.builds.size, }); } cached = { byKey, byVideo, builds, clips: byKey.size, signature }; return cached; } /** Every place one clip is used, earliest build first. Empty when unused. */ export async function usesOf(key: string): Promise { const idx = await usageIndex(); return idx.byKey.get(key) ?? []; } /** How many distinct builds a clip appears in. */ export const buildsIn = (uses: Use[]): string[] => [...new Set(uses.map((u) => u.build))];