#!/usr/bin/env node // Record HOW a shipped file was made, next to the file. // // The tree does not otherwise say. Pokemon's wide.mp4 is pkmn-v12-body.json plus // pkmn-v12-run.json plus pkc-jingle-merged.json, built by pkmn-video.sh with // FORMAT=wide CUT=long -- and nothing on disk records any of that. Across sessions // that is the difference between "rebuild this" and "work out what this was first", // which has already cost real time here more than once. // // --------------------------------------------------------------------------- // THE LAYOUT CHANGED, AND SO DID THIS SCRIPT. // // This was written for one-directory-per-VIDEO: // // videos//.mp4 + plan/ + assets.json + build.json + README.md // // The user replaced that with one-directory-per-SONG, where a song ships four // cuts and keeps its alternatives beside them: // // videos//{wide,wide-short,vertical,vertical-short}.mp4 // videos//variants/-.mp4 // videos//{plan/,README.md,clips.csv,verdicts.json} // // Only plan/ and README.md survived the move. So build.json is now ONE FILE PER // SONG, holding one entry per built file: // // {"version":2,"song":"pokemon","builds":{ // "wide.mp4": {script,env,plans,assets,duration,file,note,written}, // "variants/wide-hardcut.mp4": {...}}} // // Keyed by the SONG-RELATIVE PATH rather than by cut name, which is the same key // verdicts.json uses. Variants are the files actually under judgement, so they are // exactly the ones whose provenance is worth having -- "which of these two is the // 2-voice arrangement" is not answerable from a filename. // // `file` records the bytes and mtime AS BUILT. A recipe is only true of the file it // was written for, and everything in this tree gets rebuilt; without that stamp a // stale build.json is indistinguishable from a current one. The browse UI reads it // and says so. // // node video-dir.mjs --song --rel [--script s] [--env K=V ...] // [--plan f.json ...] [--asset f ...] [--note "..."] // node video-dir.mjs --list // // Recording is ADDITIVE: writing `wide.mp4` leaves every other entry alone, so a // builder that makes one cut at a time does not erase the other three. // // VIDEO_ROOT overrides where the song directories live (default // ~/reports/quartering-uh-song/videos). import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync, renameSync } from "node:fs"; import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; const ROOT = process.env.VIDEO_ROOT ?? path.join(os.homedir(), "reports", "quartering-uh-song", "videos"); const argv = process.argv.slice(2); const take = (flag) => { const out = []; for (let i = 0; i < argv.length; i += 1) if (argv[i] === flag) out.push(argv[i + 1]); return out; }; const one = (flag) => take(flag)[0]; const buildFile = (song) => path.join(ROOT, song, "build.json"); function readBuild(song) { try { const j = JSON.parse(readFileSync(buildFile(song), "utf8")); if (j && typeof j === "object" && j.builds) return j; } catch { /* absent, or the old per-video shape -- start clean */ } return { version: 2, song, builds: {} }; } // Same atomic-write contract as umtool/lib/state.ts: a temp file and a rename, so a // reader never sees a half-written recipe. function writeBuild(song, value) { const file = buildFile(song); mkdirSync(path.dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(value, null, 1)); renameSync(tmp, file); } if (argv[0] === "--list" || argv.includes("--list")) { if (!existsSync(ROOT)) { console.log(`no ${ROOT} yet`); process.exit(0); } const songs = readdirSync(ROOT) .filter((d) => statSync(path.join(ROOT, d)).isDirectory()) .sort(); console.log(`${songs.length} songs in ${ROOT}`); for (const s of songs) { const b = readBuild(s); const entries = Object.entries(b.builds); console.log(` ${s} (${entries.length} recorded)`); for (const [rel, e] of entries) { const stale = staleness(s, rel, e); console.log( ` ${rel.padEnd(38)} ${e.duration ? `${Number(e.duration).toFixed(1)}s` : "?"}` + ` ${e.script ?? "(no script)"}${stale ? ` ** ${stale}` : ""}`, ); } } process.exit(0); } /** "" when the recipe still describes the file on disk, else why it does not. */ function staleness(song, rel, entry) { const abs = path.join(ROOT, song, rel); if (!existsSync(abs)) return "the file is gone"; if (!entry.file) return ""; const st = statSync(abs); if (entry.file.bytes !== st.size) return `rebuilt since (${entry.file.bytes} -> ${st.size} bytes)`; if (Math.abs(new Date(entry.file.mtime).getTime() - st.mtimeMs) > 2000) return "touched since"; return ""; } const SONG = one("--song"); const REL = one("--rel"); if (!SONG || !REL) { throw new Error("usage: video-dir.mjs --song --rel [...]"); } const abs = path.join(ROOT, SONG, REL); if (!existsSync(abs)) throw new Error(`no such file: ${abs}`); const st = statSync(abs); const dur = execFileSync("ffprobe", [ "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", abs, ]) .toString() .trim(); // Plans are NAMED, not copied. The builders already copy the plan files they used // into plan/ (pkmn-video.sh line 120 does exactly that), and a second copy here // would be a second thing to keep in step. What is missing is which of the five // files in plan/ belongs to which cut -- so that is what gets written down. const plans = take("--plan").map((p) => path.basename(p)); for (const p of plans) { if (!existsSync(path.join(ROOT, SONG, "plan", p))) console.log(` NOTE plan/${p} is not there yet`); } // Assets are RECORDED, not copied: they are hundreds of megabytes and shared // between builds. What matters is knowing exactly which ones, and being told if // one has changed underneath. const assets = take("--asset").map((a) => { if (!existsSync(a)) { console.log(` MISSING asset ${a}`); return { path: a, missing: true }; } const s = statSync(a); return { path: a, bytes: s.size, mtime: new Date(s.mtimeMs).toISOString() }; }); const env = {}; for (const kv of take("--env")) { const i = kv.indexOf("="); if (i > 0) env[kv.slice(0, i)] = kv.slice(i + 1); } const doc = readBuild(SONG); doc.builds[REL] = { script: one("--script") ?? null, env, plans, assets, duration: Number(dur), note: one("--note") ?? null, file: { bytes: st.size, mtime: new Date(st.mtimeMs).toISOString() }, written: new Date().toISOString(), }; writeBuild(SONG, doc); console.log( `${SONG}/${REL}: ${Number(dur).toFixed(2)}s, ${plans.length} plan(s), ${assets.length} asset(s)` + ` -> ${buildFile(SONG)}`, );