import path from "node:path"; import { labelFor, resolveInRoots, stateFile } from "./paths"; import { readJson } from "./state"; import { archiveMomentUrl } from "./archive"; // --------------------------------------------------------------------------- // The cover art manifests, read. // // Two files, and the split is the whole design (see song/accept-thumb.mjs): // // thumb-manifest.json every generation, experiments included -- a run log // thumb-accepted.json only what was accepted -- the AUTHORITY for the // unique-face rule, which says no source video may // appear on two accepted covers // // THIS MODULE NEVER WRITES. accept-thumb.mjs is the sole writer, it enforces // the rule at write time, and it writes with a bare writeFileSync -- no lock, // no tmp+rename. Teaching the app to write the same file concurrently would put // two writers with different atomicity guarantees on the one file whose entire // job is to be trusted. The UI proposes; the CLI decides. What lives here is // the read side plus a pure re-implementation of the CLI's own check, so a // proposal can be tested BEFORE the job runs rather than refused after. // --------------------------------------------------------------------------- // `crop` is the box the corner was actually cut from, in SOURCE pixels, and it // is OPTIONAL because most corners predate it being recorded at all. Its absence // means "never recorded", not "centred" -- the same way AsrWord.conf's absence // means unknown. make-thumb.mjs writes it now; the two accepted corners that // find no face at their recorded frameAt are what its absence costs. export type ThumbCorner = { video: string; srcStart?: number; frameAt?: number; crop?: { x: number; y: number; w: number; h: number }; }; export type ThumbEntry = { /** The rendered cover. Relative to SONG_REPORTS (or absolute, in older entries): * resolveInRoots binds a relative path to SONG_REPORTS, its first root. */ out: string; /** * The background video the frame was cut from. RELATIVE TO THE SONG DATA DIR * (SONG_DATA), not to SONG_REPORTS: resolve a relative one with `dataFile(bg)`, * NEVER with `resolveInRoots`, which binds a relative path to SONG_REPORTS and * does not stat -- it would silently name a file that is not there. Absolute * (use as is) when it lay outside SONG_DATA (song/make-thumb.mjs `relTo`). * Nothing reads it yet. */ bg?: string; bgAt?: number; corners?: ThumbCorner[]; }; export type ThumbDoc = { version: number; thumbs: Record; used?: string[] }; const EMPTY: ThumbDoc = { version: 1, thumbs: {} }; export const thumbManifestFile = () => stateFile("thumb-manifest.json"); export const thumbAcceptedFile = () => stateFile("thumb-accepted.json"); const read = async (file: string): Promise => { const j = await readJson(file, EMPTY); return j && typeof j === "object" && j.thumbs && typeof j.thumbs === "object" ? j : EMPTY; }; export const readThumbManifest = () => read(thumbManifestFile()); export const readThumbAccepted = () => read(thumbAcceptedFile()); export const cornersOf = (e: ThumbEntry | null | undefined): string[] => (e?.corners ?? []).map((c) => c.video).filter(Boolean); // --------------------------------------------------------------------------- // Cover names are NOT song ids, and unifying them would be wrong. // // The accepted set is keyed `yoshi`, `mario-rpg`, `metal-slug` and // `mortal-kombat-fatality`; the songs are `yoshi`, `mario-rpg`, `metal-slug`, // `mortal-kombat` and `pokemon`. One song can have several covers (pokemon has // four candidates and no accepted one, which is a legitimate state), and a cover // name carries which SHOT it is -- `-fatality` is information, not noise. So // this is a match, not an identity. // --------------------------------------------------------------------------- /** Cover names in `doc` that belong to a song, exact match first. */ export function thumbNamesFor(doc: ThumbDoc, song: string, aliases: string[]): string[] { const keys = [song, ...aliases.filter((a) => a !== song)]; const names = Object.keys(doc.thumbs); const out: string[] = []; for (const k of keys) { for (const n of names) { if ((n === k || n.startsWith(`${k}-`)) && !out.includes(n)) out.push(n); } } return out; } /** The accepted cover for a song, with the name it is filed under, or null. */ export function acceptedFor( doc: ThumbDoc, song: string, aliases: string[], ): { name: string; entry: ThumbEntry } | null { const name = thumbNamesFor(doc, song, aliases)[0]; return name ? { name, entry: doc.thumbs[name] } : null; } // --------------------------------------------------------------------------- // The rule, checked here as well as there. // // Ten lines, and having them in TypeScript is what lets the UI say "this would // clash with mario-rpg" while the button is still unpressed. Shelling out to // `accept-thumb.mjs --verify` for a READ would be the wrong trade: a subprocess // per page load to learn something two JSON files already say. // --------------------------------------------------------------------------- export type FaceCheck = { ok: boolean; covers: number; videos: number; dupes: { video: string; covers: string[] }[] }; export function verifyUniqueFaces(doc: ThumbDoc): FaceCheck { const seen = new Map(); for (const [name, e] of Object.entries(doc.thumbs)) { for (const v of cornersOf(e)) { const list = seen.get(v); if (list) list.push(name); else seen.set(v, [name]); } } const dupes = [...seen.entries()] .filter(([, names]) => names.length > 1) .map(([video, covers]) => ({ video, covers })); return { ok: dupes.length === 0, covers: Object.keys(doc.thumbs).length, videos: seen.size, dupes }; } /** * Why accepting `name` would be refused, or null if it would be allowed. * * Mirrors accept-thumb.mjs's refusal exactly, including that a cover may be * re-accepted over ITSELF -- pointing an already-accepted cover at a different * rendered file must not read as a clash with its own corners. */ export function wouldClash(accepted: ThumbDoc, name: string, entry: ThumbEntry): string | null { const already = new Map(); for (const [n, e] of Object.entries(accepted.thumbs)) { if (n === name) continue; for (const v of cornersOf(e)) already.set(v, n); } const clash = cornersOf(entry).filter((v) => already.has(v)); if (!clash.length) return null; return clash.map((v) => `${v} is already on ${already.get(v)}`).join("; "); } /** The basename of a cover's rendered file, for display. */ export const thumbBasename = (e: ThumbEntry | null | undefined): string => e?.out ? path.basename(e.out) : ""; // --------------------------------------------------------------------------- // What the bench draws. // --------------------------------------------------------------------------- export type ThumbCandidate = { name: string; file: string; /** Root-relative, for /api/mix/media. Null when `out` is outside the roots. */ label: string | null; accepted: boolean; corners: { video: string; srcStart: number; link: string | null; clipId: string }[]; /** Why accepting this would be refused, or null. */ clash: string | null; }; export type ThumbView = { song: string; accepted: { name: string; label: string | null; file: string } | null; candidates: ThumbCandidate[]; check: FaceCheck; }; const candidate = ( name: string, entry: ThumbEntry, accepted: ThumbDoc, acceptedName: string | null, ): ThumbCandidate => { const abs = resolveInRoots(entry.out); return { name, file: entry.out ?? "", label: abs ? labelFor(abs) : null, accepted: name === acceptedName, corners: (entry.corners ?? []).map((c) => ({ video: c.video, srcStart: c.srcStart ?? 0, link: archiveMomentUrl(c.video, c.srcStart ?? 0), // The SAME key the arrangement and every state file use, so a corner // carries a note target that already works. clipId: `${c.video}@${(c.srcStart ?? 0).toFixed(2)}`, })), clash: wouldClash(accepted, name, entry), }; }; /** Everything one song's cover art bench needs, read from the two manifests. */ export async function thumbView(song: string, aliases: string[]): Promise { const [generated, accepted] = await Promise.all([readThumbManifest(), readThumbAccepted()]); const hit = acceptedFor(accepted, song, aliases); // Candidates come from the RUN LOG, which is a superset of the accepted set -- // and the accepted entry is included in it so the bench can show what is // currently chosen beside what it beat. const names = thumbNamesFor(generated, song, aliases); const candidates = names.map((n) => candidate(n, generated.thumbs[n], accepted, hit?.name ?? null)); // An accepted cover whose name is no longer in the run log still belongs on // screen -- it is the one that is live. if (hit && !names.includes(hit.name)) { candidates.unshift(candidate(hit.name, hit.entry, accepted, hit.name)); } const abs = hit ? resolveInRoots(hit.entry.out) : null; return { song, accepted: hit ? { name: hit.name, label: abs ? labelFor(abs) : null, file: hit.entry.out } : null, candidates, check: verifyUniqueFaces(accepted), }; }