#!/usr/bin/env node // Promote a generated thumbnail to ACCEPTED, and verify the unique-face rule. // // node accept-thumb.mjs [file.jpg] accept it (file records which variant) // node accept-thumb.mjs --verify check the accepted set // node accept-thumb.mjs --list show accepted vs candidates // node accept-thumb.mjs --drop un-accept, freeing its four videos // // Two manifests, on purpose: // thumb-manifest.json every generation, tests included -- a run log // thumb-accepted.json only what was accepted -- the AUTHORITY for uniqueness // // The whole point of the gimmick is that no face appears on two covers. Judging // that against every generation punished experimentation: twelve trial // thumbnails spent twenty source videos, so later thumbnails were drawing from a // pool depleted by images nobody chose. Judging it against ACCEPTED thumbnails // means candidates are free and the guarantee still holds where it matters. import { readFileSync, writeFileSync, existsSync } from "node:fs"; import path from "node:path"; import { SONG_REPORTS, relTo } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const MF = path.join(DIR, "thumb-manifest.json"); const AF = path.join(DIR, "thumb-accepted.json"); const load = (f, empty) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return empty; } }; const manifest = load(MF, { version: 1, thumbs: {}, used: [] }); let accepted = load(AF, { version: 1, thumbs: {}, used: [] }); const save = () => writeFileSync(AF, JSON.stringify(accepted, null, 1)); const cornersOf = (e) => (e.corners ?? []).map((c) => c.video); function verify() { const seen = new Map(); // video -> [thumbnail names] for (const [name, e] of Object.entries(accepted.thumbs)) { for (const v of cornersOf(e)) { if (!seen.has(v)) seen.set(v, []); seen.get(v).push(name); } } const dupes = [...seen.entries()].filter(([, names]) => names.length > 1); const names = Object.keys(accepted.thumbs); console.log(`accepted: ${names.length} thumbnails, ${seen.size} distinct source videos`); for (const n of names) console.log(` ${n.padEnd(24)} ${cornersOf(accepted.thumbs[n]).join(" ")}`); if (!dupes.length) { console.log("\nUNIQUE-FACE RULE HOLDS: no source video appears on two accepted covers."); return true; } console.log("\nRULE BROKEN:"); for (const [v, ns] of dupes) console.log(` ${v} appears on: ${ns.join(", ")}`); return false; } const [cmd, arg] = process.argv.slice(2); if (cmd === "--verify") { process.exit(verify() ? 0 : 1); } if (cmd === "--list") { const acc = new Set(Object.keys(accepted.thumbs)); console.log("ACCEPTED:"); for (const n of acc) console.log(` ${n} -> ${accepted.thumbs[n].out}`); console.log("CANDIDATES (generated, not accepted):"); for (const n of Object.keys(manifest.thumbs)) if (!acc.has(n)) console.log(` ${n} -> ${manifest.thumbs[n].out}`); process.exit(0); } if (cmd === "--drop") { if (!arg || !accepted.thumbs[arg]) { console.error(`not accepted: ${arg}`); process.exit(1); } const freed = cornersOf(accepted.thumbs[arg]); delete accepted.thumbs[arg]; accepted.used = [...new Set(Object.values(accepted.thumbs).flatMap(cornersOf))]; save(); console.log(`dropped ${arg}, freeing ${freed.length} videos: ${freed.join(" ")}`); process.exit(0); } if (!cmd) { console.error("usage: accept-thumb.mjs [file.jpg] | --verify | --list | --drop "); process.exit(1); } const entry = manifest.thumbs[cmd]; if (!entry) { console.error(`no generated thumbnail named "${cmd}" — run make-thumb.mjs first`); process.exit(1); } // Refuse to accept something that would break the rule, rather than recording a // violation and reporting it later. const already = new Map(); for (const [n, e] of Object.entries(accepted.thumbs)) if (n !== cmd) for (const v of cornersOf(e)) already.set(v, n); const clash = cornersOf(entry).filter((v) => already.has(v)); if (clash.length) { console.error(`REFUSED: ${cmd} reuses ${clash.length} video(s) already on an accepted cover:`); for (const v of clash) console.error(` ${v} is on ${already.get(v)}`); console.error(`Regenerate it (make-thumb picks around accepted videos automatically), or --drop the other one.`); process.exit(1); } // Recorded relative to SONG_REPORTS when it lies inside it, as make-thumb.mjs // records it: this file is tracked, and /api/browse/thumbs hands over an // ABSOLUTE `file` (resolved inside the roots), which would otherwise put one // machine's home directory back into it. The readers resolve a relative `out` // against SONG_REPORTS first (lib/paths.mjs resolveInRoots). const out = relTo(SONG_REPORTS, arg ?? entry.out); if (arg && !existsSync(arg)) console.log(`note: ${arg} does not exist yet`); accepted.thumbs[cmd] = { ...entry, out }; accepted.used = [...new Set(Object.values(accepted.thumbs).flatMap(cornersOf))]; save(); console.log(`accepted ${cmd} -> ${out}`); console.log(` claims ${cornersOf(entry).length} videos: ${cornersOf(entry).join(" ")}`); console.log(""); verify();