import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdir, readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { MEDIA_ROOT, MEDIA_ROOTS, MEDIA_TIERED, MIX_CACHE, REPORTS_ROOT, SONG_REPORTS, inside, labelFor } from "./paths"; import { brightnessCurve, brightnessSteps } from "../song/flatness.mjs"; const run = promisify(execFile); // --------------------------------------------------------------------------- // Reading rendered media, for the mix bench. // // Nothing here touches cand2/ or wav48/ -- this side of the app deals in // FINISHED files: a background cut, a body render, a deliverable. The unit is a // whole track rather than a 240ms window, so the caching strategy is the // opposite of the clip routes': analyse once per file, keep it, slice in memory. // --------------------------------------------------------------------------- const MEDIA_EXT = new Set([".mp4", ".mkv", ".webm", ".mov", ".m4v", ".wav", ".mp3", ".flac", ".ogg", ".aac", ".m4a"]); export type MediaInfo = { /** Absolute, always inside a root. */ path: string; /** Root-relative, and what the UI shows and sends back. */ label: string; duration: number; hasVideo: boolean; hasAudio: boolean; width: number; height: number; size: number; mtimeMs: number; }; export async function probeMedia(abs: string): Promise { const st = await stat(abs); const { stdout } = await run("ffprobe", [ "-v", "error", "-show_entries", "format=duration:stream=codec_type,width,height", "-of", "json", abs, ], { maxBuffer: 4 << 20 }); const j = JSON.parse(stdout) as { format?: { duration?: string }; streams?: { codec_type?: string; width?: number; height?: number }[]; }; const streams = j.streams ?? []; const v = streams.find((s) => s.codec_type === "video"); return { path: abs, label: labelFor(abs), duration: Number(j.format?.duration ?? 0), hasVideo: !!v, hasAudio: streams.some((s) => s.codec_type === "audio"), width: v?.width ?? 0, height: v?.height ?? 0, size: st.size, mtimeMs: st.mtimeMs, }; } // The render scratch, which must never be offered as something to load. // // SONG_SCRATCH is a working directory, not a library: one pass of render-poly // leaves a `polytmp--/` full of intermediate wavs and a // `poly-song-.wav` beside it. Unfiltered they outnumbered the actual // renders four to one in the picker, and they are all newest-first, so the // deliverables were pushed off the end of the list. // The four added names are a report video's INTERMEDIATES. Its out/ directory // holds one deliverable and 40-60 working files -- the fetched source windows, // the per-clip segments, the card PNGs, the QR codes. Reaching the deliverable // means walking one level deeper, and walking one level deeper without these // would put ~260 intermediates in a picker that is already saturated. const SCRATCH_DIR = /^(polytmp-|frames?\d*$|snap\d*$|qrtest$|facedet$|models$|vtest?$|verify$|tism$|fr$|vt$|segments$|clips-raw$|cards$|qr$)/; const SCRATCH_FILE = /^(poly-song-|polytmp-|seg_|i_|o_|ms\d?seg|out\.raw)/; /** Below this is a fragment, a probe or a one-note extraction, not a track. */ const MIN_INTERESTING = 256 * 1024; /** * A project's `out` linked to the media root (UMTOOL_MEDIA_DIR, release 17) is * walked like the directory it replaced, so a tiered deliverable stays in the * picker under its project. Only a link INTO the media root: every other link * stays unfollowed, as it always was (SONG_DATA's 39 GB are links). */ /** * The project directories that may be links to the media root: render scratch * (`out`), and a report's deliverables (`clips`, every `share-*`) once the * project's switch has moved them (release 17, slice U2). */ const MEDIA_LINKS = new Set(["out", "clips"]); const isMediaLinkName = (name: string) => MEDIA_LINKS.has(name) || name.startsWith("share-"); async function isMediaLink(e: { name: string; isSymbolicLink(): boolean }, abs: string): Promise { if (!MEDIA_TIERED || !e.isSymbolicLink() || !isMediaLinkName(e.name)) return false; const real = await realpath(abs).catch(() => null); if (!real || !inside(MEDIA_ROOT, real)) return false; return stat(real).then((s) => s.isDirectory(), () => false); } export type MediaRow = { path: string; label: string; size: number; mtimeMs: number }; /** * Every media file under one directory, newest first. * * Split out of listMedia() so a PROJECT can be enumerated directly. The picker * needs that: `listMedia` is newest-first over the whole tree with a cap, and * the cap is saturated -- measured, four of the six report deliverables fell off * the end of a 600-entry list. Asking each project for its own files instead * makes coverage a property of the enumeration rather than of the cap. */ export async function listMediaUnder(root: string, maxDepth = 2, limit = 200): Promise { const out: MediaRow[] = []; const seen = new Set(); 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() || (await isMediaLink(e, abs))) { if (depth > 0 && !SCRATCH_DIR.test(e.name)) await walk(abs, depth - 1); continue; } if (!MEDIA_EXT.has(path.extname(e.name).toLowerCase())) continue; if (SCRATCH_FILE.test(e.name)) continue; if (seen.has(abs)) continue; seen.add(abs); try { const st = await stat(abs); if (st.size < MIN_INTERESTING) continue; out.push({ path: abs, label: labelFor(abs), size: st.size, mtimeMs: st.mtimeMs }); } catch { /* vanished between readdir and stat */ } } }; await walk(root, maxDepth); out.sort((a, b) => b.mtimeMs - a.mtimeMs); return out.slice(0, limit); } /** Every media file under the roots, newest first. */ export async function listMedia(limit = 400): Promise { const out: MediaRow[] = []; const seen = new Set(); 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() || (await isMediaLink(e, abs))) { if (depth > 0 && !SCRATCH_DIR.test(e.name)) await walk(abs, depth - 1); continue; } if (!MEDIA_EXT.has(path.extname(e.name).toLowerCase())) continue; if (SCRATCH_FILE.test(e.name)) continue; if (seen.has(abs)) continue; seen.add(abs); try { const st = await stat(abs); if (st.size < MIN_INTERESTING) continue; out.push({ path: abs, label: labelFor(abs), size: st.size, mtimeMs: st.mtimeMs }); } catch { /* vanished between readdir and stat */ } } }; // Depth is PER ROOT, not one number. // // The project roots need two levels: a report video's deliverable is at // /out/.mp4, and a song's cut is at videos//.mp4. // Both were invisible here before -- six of seven projects had no entry at all. // // SONG_DATA and SONG_SCRATCH stay at one. They are the 39 GB corpus and the // render scratch; a second level there is thousands of stats of clip fragments // to find nothing anybody would load, which is the opposite of the problem // this is fixing. const depthFor = (r: string) => (r === REPORTS_ROOT || r === SONG_REPORTS ? 2 : 1); // The media root is reached through the projects' `out` links, under the // project's own path; walked as a root as well, every tiered deliverable // would be listed twice and the second copy would land in "other". for (const r of MEDIA_ROOTS) { if (MEDIA_TIERED && r === MEDIA_ROOT) continue; await walk(r, depthFor(r)); } out.sort((a, b) => b.mtimeMs - a.mtimeMs); return out.slice(0, limit); } // --------------------------------------------------------------------------- // The analysis pass. // // One decode per file gives everything the bench draws: // // peak a plain envelope, for the shape of the track // ratio high-band over low-band energy -- WHERE A CUE STARTS // firstSound the first sustained sound, matched to ffmpeg's own // silencedetect so the number on screen equals the number the // command line would print // // `ratio` is the one that earns its keep. The Pokemon background's route music // and its encounter alarm are within a decibel of each other, so an envelope // shows nothing at all -- but the alarm is a far higher figure, so the band // ratio steps from ~0.2 to ~0.9 and stays there. Reading that onset by eye off // a pitch contour put it 1.14s late, and the render handed over AFTER the // game's alarm instead of on top of it. On screen it is unmissable. // --------------------------------------------------------------------------- /** Analysis sample rate. 16 kHz resolves the whole band the ratio looks at. */ const ANALYSIS_SR = 16000; /** Envelope frames per second. */ export const ENV_RATE = 100; /** Brightness frames per second. */ export const RATIO_RATE = 50; /** -45 dBFS, held for 150ms: the same test `silencedetect=n=-45dB:d=0.15` makes. */ const SILENCE_FLOOR = 10 ** (-45 / 20); const SILENCE_HOLD = 0.15; /** Refuse to decode something absurd rather than sitting on it. */ const MAX_ANALYSIS_SECONDS = 3600; export type Analysis = { label: string; duration: number; envRate: number; peak: number[]; ratioRate: number; ratio: number[]; flux: number[]; /** First sustained sound, in seconds, or null for a wholly silent track. */ firstSound: number | null; /** Sustained brightness steps -- cue onsets -- in TIME order (see below). */ steps: { at: number; gain: number }[]; }; async function decodeMono(abs: string): Promise { const chunks: Buffer[] = []; await new Promise((resolve, reject) => { const ff = spawn("ffmpeg", [ "-nostdin", "-v", "error", "-i", abs, "-map", "a:0?", "-ac", "1", "-ar", String(ANALYSIS_SR), "-f", "f32le", "-", ]); let err = ""; ff.stdout.on("data", (b: Buffer) => chunks.push(b)); ff.stderr.on("data", (b: Buffer) => { err += b.toString(); }); ff.on("error", reject); ff.on("close", (code) => (code === 0 ? resolve() : reject(new Error(err.trim() || `ffmpeg exited ${code}`)))); }); const buf = Buffer.concat(chunks); // Buffer.concat may hand back a view into a larger pool, so honour the offset // -- reading from byteOffset 0 would silently analyse the wrong bytes. const usable = buf.length - (buf.length % 4); return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + usable)); } const cacheKey = (abs: string, mtimeMs: number, size: number) => createHash("sha1").update(`${abs}|${Math.round(mtimeMs)}|${size}|v3`).digest("hex").slice(0, 16); const memo = new Map(); /** Envelope + brightness for a whole track. Cached on disk, keyed by mtime. */ export async function analyseMedia(abs: string): Promise { const st = await stat(abs); const key = cacheKey(abs, st.mtimeMs, st.size); const hit = memo.get(key); if (hit) return hit; const file = path.join(MIX_CACHE, `${key}.json`); if (existsSync(file)) { try { const cached = JSON.parse(await readFile(file, "utf8")) as Analysis; memo.set(key, cached); return cached; } catch { /* rewrite it below */ } } const x = await decodeMono(abs); const duration = x.length / ANALYSIS_SR; if (duration > MAX_ANALYSIS_SECONDS) { throw new Error(`${labelFor(abs)} is ${Math.round(duration)}s; the bench analyses up to ${MAX_ANALYSIS_SECONDS}s`); } const envHop = Math.round(ANALYSIS_SR / ENV_RATE); const peak: number[] = []; for (let i = 0; i + envHop <= x.length; i += envHop) { let p = 0; for (let k = 0; k < envHop; k += 1) { const v = x[i + k] < 0 ? -x[i + k] : x[i + k]; if (v > p) p = v; } peak.push(+p.toFixed(5)); } const holdFrames = Math.max(1, Math.round(SILENCE_HOLD * ENV_RATE)); let firstSound: number | null = null; for (let i = 0; i + holdFrames <= peak.length; i += 1) { if (peak[i] < SILENCE_FLOOR) continue; let ok = true; for (let k = 0; k < holdFrames; k += 1) { if (peak[i + k] < SILENCE_FLOOR) { ok = false; break; } } if (ok) { firstSound = +(i / ENV_RATE).toFixed(3); break; } } const curve = brightnessCurve(x, ANALYSIS_SR, { N: 512, hop: Math.round(ANALYSIS_SR / RATIO_RATE) }); // Keep a GENEROUS list, in time order, and let the caller choose by locality. // // Ranking these globally by strength and keeping the top handful is wrong for // the job: over a 150s background the Pokemon encounter alarm at 3.96s is a // perfectly clear 3.6x step, and it still ranked below eight louder cues // elsewhere in the file -- so the one onset the whole exercise was about // never reached the screen. The strongest cue in a file is rarely the one you // are working next to. const steps = brightnessSteps(curve, { hold: 0.4, rise: 1.8 }) .map((s: { at: number; gain: number }) => ({ at: s.at, gain: s.gain })) .sort((a: { at: number }, b: { at: number }) => a.at - b.at) .slice(0, 60); const out: Analysis = { label: labelFor(abs), duration: +duration.toFixed(3), envRate: ENV_RATE, peak, ratioRate: RATIO_RATE, ratio: (curve.ratio as number[]).map((v: number) => +v.toFixed(3)), flux: (curve.flux as number[]).map((v: number) => +v.toFixed(2)), firstSound, steps, }; await mkdir(MIX_CACHE, { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; await writeFile(tmp, JSON.stringify(out)); await rename(tmp, file).catch(() => {}); memo.set(key, out); return out; }