import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { mkdir, rename, stat } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { CACHE_DIR } from "./paths"; import { probeCached } from "./browse"; const run = promisify(execFile); // --------------------------------------------------------------------------- // A frame to look at, for a cut or a variant. // // thumbs/ covers the SONG level (see lib/browse.ts) and nothing else -- those // are hand-composed 1280x720 covers, not per-cut posters. So every cut and // every variant gets a frame pulled out of the file itself. // // The frames go in CACHE_DIR, NOT next to the mp4s. `videos//` is a // directory the user drags into a player expecting to see the current cuts and // nothing else; a litter of .poster.jpg files would break that, and it is the // kind of thing nobody notices until it is annoying. CACHE_DIR is already // documented as derived output, safe to delete at any time, and it sits inside // SONG_DATA -- hence inside MEDIA_ROOTS -- so the frames are servable. // --------------------------------------------------------------------------- export const POSTER_CACHE = path.join(CACHE_DIR, "browse"); /** * Where into the file to grab. * * A frame at 0s is the intro card on every Mortal Kombat cut -- four posters of * the same Sub-Zero bio screen, which tells you nothing about which cut you are * looking at. A quarter of the way in is past every intro in the tree. */ const POSTER_AT = 0.25; const DEFAULT_WIDTH = 480; const keyFor = (abs: string, mtimeMs: number, size: number, width: number) => createHash("sha1") .update(`${abs}|${Math.round(mtimeMs)}|${size}|${width}|v1`) .digest("hex") .slice(0, 16); export type PosterResult = { file: string; cached: boolean }; /** Extract (or reuse) a poster frame. Returns null if the file will not decode. */ export async function posterFrame(abs: string, width = DEFAULT_WIDTH): Promise { let st; try { st = await stat(abs); } catch { return null; } const file = path.join(POSTER_CACHE, `${keyFor(abs, st.mtimeMs, st.size, width)}.jpg`); if (existsSync(file)) return { file, cached: true }; const info = await probeCached(abs); if (!info || !info.hasVideo) return null; const at = info.duration > 0 ? +(info.duration * POSTER_AT).toFixed(3) : 0; await mkdir(POSTER_CACHE, { recursive: true }); const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}.jpg`; try { // -ss BEFORE -i is the fast seek: it jumps in the container rather than // decoding up to the mark, which on a 66 MB render is the difference // between a poster in 200ms and one in several seconds. await run( "ffmpeg", [ "-nostdin", "-v", "error", "-y", "-ss", String(at), "-i", abs, "-frames:v", "1", "-vf", `scale=${width}:-2`, "-q:v", "4", tmp, ], { maxBuffer: 1 << 20 }, ); } catch { return null; } await rename(tmp, file).catch(() => {}); return existsSync(file) ? { file, cached: false } : null; }