#!/usr/bin/env node // Build a video thumbnail: a frame of the background, with four small shots of // Jeremy in the corners taken from the very clips that play in that song. // // node make-thumb.mjs [bgTime] // // Corner clips are recorded in thumb-manifest.json and never reused, so every // thumbnail in the library shows four faces nobody has seen on another cover. // That is the whole reason the manifest exists -- with 300+ source videos there // is no shortage, but picking greedily without a record would repeat the // popular ones immediately. import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA, SONG_REPORTS, relTo } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const [NAME, BG, CSV, OUT] = process.argv.slice(2); const BG_TIME = process.argv[6] !== undefined ? Number(process.argv[6]) : null; const W = 1280, H = 720; const CW = Number(process.env.CORNER_W ?? 300), CH = Number(process.env.CORNER_H ?? 300); const M = Number(process.env.MARGIN ?? 26); const MF = path.join(DIR, "thumb-manifest.json"); // every generation, including tests const AF = path.join(DIR, "thumb-accepted.json"); // ONLY what was accepted -- the authority const VF = path.join(DIR, "face-verdicts.json"); // what a person said about the FACES let manifest = { version: 1, thumbs: {}, used: [] }; try { manifest = JSON.parse(readFileSync(MF, "utf8")); } catch {} let accepted = { version: 1, thumbs: {}, used: [] }; try { accepted = JSON.parse(readFileSync(AF, "utf8")); } catch {} // A face is "spoken for" only once its thumbnail has been ACCEPTED. const usedEver = new Set(accepted.used ?? []); if (usedEver.size) console.log(` ${usedEver.size} source videos spoken for by accepted thumbnails`); const EXCLUDE = new Set((process.env.EXCLUDE ?? "").split(",").map((x) => x.trim()).filter(Boolean)); // ---- what the face judger decided ------------------------------------------ // /browse/faces writes this file and NOTHING ELSE writes it -- the same split // thumb-manifest.json has, with the sides swapped. There the CLI is the sole // writer and the app reads; here the app is the sole writer and the CLI reads. // Either way one program owns the file and the other honours it, so there is // never a question of whose copy is right. // // Two things come out of it. A REJECTED video is skipped entirely -- somebody // looked at that face and said it is not usable, and re-picking it every build // would make the judging pointless. An approved CROP is passed to facecrop.py // verbatim, because a framing chosen to exclude a strip of YouTube chrome must // not be silently re-derived by the very clamp that put the chrome there. let verdicts = { version: 1, faces: {} }; try { verdicts = JSON.parse(readFileSync(VF, "utf8")); } catch {} const faces = verdicts.faces ?? {}; const keyOf = (video, srcStart) => `${video}@${Number(srcStart).toFixed(2)}`; const REJECTED = new Set( Object.values(faces).filter((j) => j.verdict === "reject").map((j) => j.video), ); if (REJECTED.size) console.log(` ${REJECTED.size} source videos rejected by the face judger`); // ---- which clips play in this song ----------------------------------------- const rows = readFileSync(CSV, "utf8").trim().split("\n").slice(1) .map((l) => l.split(",")) .map((f) => ({ t: Number(f[0]), voice: f[1], video: f[3], srcStart: Number(f[4]), srcEnd: Number(f[5]) })); if (!rows.length) throw new Error("no clips in " + CSV); // One face per SOURCE VIDEO, and never a video already used on another cover. // Longer clips first: a longer um means a steadier shot, and a frame grabbed // mid-syllable is less likely to catch a blink or a mouth mid-consonant. const byVideo = new Map(); for (const r of rows) { if (usedEver.has(r.video) || EXCLUDE.has(r.video) || REJECTED.has(r.video)) continue; const dur = r.srcEnd - r.srcStart; const cur = byVideo.get(r.video); if (!cur || dur > cur.dur) byVideo.set(r.video, { ...r, dur }); } let cands = [...byVideo.values()].sort((a, b) => b.dur - a.dur); if (cands.length < 4) { // Rather than fail, fall back to reusing the least-recently-used videos, but // SAY SO -- a silently duplicated face is worse than a noted one. console.log(` only ${cands.length} unused source videos in this song; allowing reuse for the rest`); const extra = new Map(); for (const r of rows) { const dur = r.srcEnd - r.srcStart; const cur = extra.get(r.video); if (!cur || dur > cur.dur) extra.set(r.video, { ...r, dur }); } for (const e of [...extra.values()].sort((a, b) => b.dur - a.dur)) if (!cands.some((c) => c.video === e.video)) cands.push(e); } const tmp = path.join(SONG_DATA, `thumbtmp-${process.pid}`); if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true }); mkdirSync(tmp, { recursive: true }); // ---- "is anything happening here" ------------------------------------------ // Decoded here rather than through an ffmpeg filter chain: the convolution+ // signalstats pipeline silently returned 0 for every frame, so every "pick the // best frame" decision was really picking the first one. // // Scored on MOTION x COLOUR, not edge density. Edge density picks TEXT -- the // first version of this landed squarely on a wall of story text, which has more // edges per pixel than any fight scene. A static screen has no motion, and menus // and bio screens are drab where gameplay is saturated. const meanLuma = (file, sr) => { try { const raw = execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-ss", String(sr), "-i", file, "-frames:v", 1, "-vf", "scale=64:36,format=gray", "-f", "rawvideo", "-"], { maxBuffer: 1 << 20 }); let t = 0; for (let i = 0; i < raw.length; i += 1) t += raw[i]; return t / raw.length; } catch { return 0; } }; const actionScore = (file, sr) => { // two frames 0.2s apart, side by side, so one decode gives motion AND colour const raw = execFileSync("ffmpeg", ["-nostdin", "-v", "error", // -fps_mode, not -vsync: ffmpeg 9 REMOVED -vsync, and the removal was silent // here because the throw is swallowed by the caller's catch. Every sample // scored 0, so "pick the busiest frame" quietly became "pick the first frame // that is not dark" -- the exact failure the comment above describes, back // again by a different route. A scorer that cannot fail loudly must at least // be spelled in options the installed ffmpeg still has. "-ss", String(sr), "-i", file, "-frames:v", 2, "-fps_mode", "passthrough", "-vf", "fps=5,scale=160:90,format=rgb24", "-f", "rawvideo", "-"], { maxBuffer: 1 << 24 }); const N = 160 * 90 * 3; if (raw.length < N * 2) return 0; let motion = 0, sat = 0; for (let i = 0; i < N; i += 3) { const r = raw[i], g = raw[i + 1], b = raw[i + 2]; const mx = Math.max(r, g, b), mn = Math.min(r, g, b); sat += mx > 0 ? (mx - mn) / mx : 0; motion += Math.abs(r - raw[N + i]) + Math.abs(g - raw[N + i + 1]) + Math.abs(b - raw[N + i + 2]); } const px = 160 * 90; return (motion / px / 3) * (0.3 + sat / px); }; // ---- find Jeremy's FACE, do not assume where it is ------------------------- // These clips are screen shares: the webcam is a small inset at an edge and the // middle of the frame is a browser window. A centre crop returns a screenshot // of a news article -- which is exactly what the first attempt produced. const PY = path.join(SONG_DATA, "..", "facedet", "bin", "python"); // A human framing, if one was recorded for this exact corner. `fallback` is the // box the manifest recorded for a REUSE rebuild -- also a crop worth honouring, // because re-detecting at the same frameAt is exactly what turned out not to be // reproducible: two accepted corners find no face at all at their recorded time. const cropFor = (c, fallback) => { const j = faces[keyOf(c.video, c.srcStart)]; if (j && j.verdict !== "reject" && j.crop) return { box: j.crop, at: j.frameAt ?? c.srcStart, human: true }; if (fallback) return { box: fallback, at: c.frameAt ?? c.srcStart, human: false }; return null; }; const grabFace = (c, i, fallback = null) => { const src = path.join(SONG_DATA, "media", `${c.video}.mp4`); if (!existsSync(src)) return null; const f = path.join(tmp, `c${i}.jpg`); const chosen = cropFor(c, fallback); const args = chosen ? ["--crop", `${chosen.box.x},${chosen.box.y},${chosen.box.w},${chosen.box.h}`, src, String(chosen.at), f, String(CW)] : [src, String(c.srcStart), f, String(CW)]; try { const out = execFileSync(PY, [path.join(DIR, "facecrop.py"), ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); if (out === "NONE" || !existsSync(f)) return null; const p = out.split(/\s+/); // cwid/ch when facecrop.py reports them, `side` when it does not -- an older // copy of the script still round-trips, it just records a square box. const w = p[5] !== undefined ? Number(p[5]) : Number(p[2]); const h = p[6] !== undefined ? Number(p[6]) : Number(p[2]); return { f, s: Number(p[3]), t: Number(p[4]), crop: { x: Number(p[0]), y: Number(p[1]), w, h }, human: Boolean(chosen && chosen.human), }; } catch { return null; } }; // REUSE=1 rebuilds a thumbnail with the SAME four faces, so variations differ // only by background. Picking fresh each time would burn four more source videos // per variation (the manifest never releases one) and produce four different // thumbnails rather than one thumbnail to choose a background for. const picked = []; if (process.env.REUSE === "1" && manifest.thumbs[NAME]) { const prev = manifest.thumbs[NAME].corners ?? []; prev.forEach((p, i) => { const g = grabFace({ video: p.video, srcStart: p.srcStart, frameAt: p.frameAt }, i, p.crop ?? null); if (g) picked.push({ ...p, file: g.f, frameAt: g.t, sharp: g.s, crop: g.crop, human: g.human }); }); console.log(` reusing ${picked.length} corners from the manifest`); } for (const c of picked.length >= 4 ? [] : cands) { if (picked.length >= 4) break; const g = grabFace(c, picked.length); if (!g) continue; picked.push({ ...c, file: g.f, frameAt: g.t, sharp: g.s, crop: g.crop, human: g.human }); // A hand-framed crop has no detector confidence -- facecrop.py reports -1 for // it precisely so this line can say what actually happened rather than print // a number nobody measured. const how = g.human ? "framed by hand" : `confidence ${g.s.toFixed(3)}`; console.log(` corner ${picked.length}: ${c.video}@${c.srcStart.toFixed(2)} (face at ${g.t.toFixed(2)}s, ${how})`); } if (picked.length < 4) throw new Error(`only found ${picked.length} usable faces`); // ---- the background frame -------------------------------------------------- // Pick the most VISUALLY BUSY frame from a spread of candidates: a thumbnail // wants action, and the alternative (a fixed timestamp) lands on a menu or a // fade as often as not. let bgAt = BG_TIME; if (bgAt === null) { const dur = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", BG], { encoding: "utf8" }).trim()); let best = null; const N = Number(process.env.BG_SAMPLES ?? 24); for (let k = 1; k <= N; k += 1) { const t = (dur * k) / (N + 1); let s = 0; try { s = actionScore(BG, t); } catch {} // a frame with nothing lit in it is a transition, not action if (meanLuma(BG, t) < Number(process.env.MIN_LUMA ?? 28)) continue; if (!best || s > best.s) best = { t, s }; } bgAt = best ? best.t : dur / 3; console.log(` background frame at ${bgAt.toFixed(1)}s (best action score of ${N} sampled: ${best ? best.s.toFixed(1) : "?"})`); } const bgFrame = path.join(tmp, "bg.jpg"); execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", String(bgAt), "-i", BG, "-frames:v", "1", "-vf", `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`, bgFrame], { stdio: "ignore" }); // ---- composite ------------------------------------------------------------- const pos = [[M, M], [W - CW - M, M], [M, H - CH - M], [W - CW - M, H - CH - M]]; const inputs = ["-i", bgFrame, ...picked.flatMap((p) => ["-i", p.file])]; const parts = []; let cur = "0:v"; picked.forEach((p, i) => { const [x, y] = pos[i]; // a border so a dark frame does not bleed into dark gameplay parts.push(`[${i + 1}:v]pad=${CW + 8}:${CH + 8}:4:4:color=white@0.95[b${i}]`); const out = i === picked.length - 1 ? "v" : `s${i}`; parts.push(`[${cur}][b${i}]overlay=${x - 4}:${y - 4}[${out}]`); cur = `s${i}`; }); execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", ...inputs, "-filter_complex", parts.join(";"), "-map", "[v]", "-frames:v", "1", "-q:v", "2", OUT], { stdio: "inherit" }); rmSync(tmp, { recursive: true, force: true }); manifest.thumbs[NAME] = { // `bg` alongside `bgAt`: a timestamp on its own does not say which video it is // a timestamp INTO, and nothing else recorded that. Four accepted covers were // left with a frame nobody could re-cut -- the obvious guess, that a cover's // background is its own song video, is false for all four. Optional in exactly // the way `corners[].crop` is: absent means "not recorded", not "no background". // // Both paths are recorded RELATIVE TO THEIR ROOT, because this file is tracked // and an absolute path is one machine's home directory: `out` to SONG_REPORTS // (the um-song deliverables tree, which lib/paths.mjs resolveInRoots binds a // relative path to first), `bg` to SONG_DATA (the song's bulk data; nothing // reads it back yet). A path outside its root stays absolute. out: relTo(SONG_REPORTS, path.resolve(OUT)), bg: relTo(SONG_DATA, path.resolve(BG)), bgAt: +bgAt.toFixed(2), // THE BOX IS RECORDED NOW. It used to be computed and thrown away -- the // script kept only p[3] and p[4] -- so a corner could be cut and never cut // again the same way, which is the state two accepted corners are in today. // `crop` is additive: entries without it stay valid and mean "never recorded". corners: picked.map((p) => ({ video: p.video, srcStart: +p.srcStart.toFixed(2), frameAt: +p.frameAt.toFixed(2), ...(p.crop ? { crop: p.crop } : {}), })), }; manifest.used = [...new Set([...(manifest.used ?? []), ...picked.map((p) => p.video)])]; writeFileSync(MF, JSON.stringify(manifest, null, 1)); console.log(`\n${OUT}`); console.log(` logged as a CANDIDATE. Accept it with: node accept-thumb.mjs ${NAME}`); console.log(` accepted so far: ${Object.keys(accepted.thumbs).length} thumbnails, ${(accepted.used ?? []).length} videos`);