import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, rename } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { sourceVideo } from "@/lib/clips"; import { CACHE_DIR, cacheFile } from "@/lib/paths"; const run = promisify(execFile); export const dynamic = "force-dynamic"; // ONE STILL FRAME, whole. // // A new media route, where the phrase console reused /api/clip/[key]/video // verbatim -- so it is worth saying why this one could not. // // The deck drags a crop box OVER the picture, which means it needs the WHOLE // frame in source proportions and it needs to read the pixels back out of a // canvas to guess at the webcam inset's border. /api/clip/[key]/video answers a // different question: it re-encodes a WINDOW of video, scaled to 360px wide, as // an mp4 -- the wrong shape, no addressable pixels, and about fifty times the // bytes for a picture nobody is going to play. // // Cached on a sha1 of (video, at, w), exactly as that route caches its window. // Decoding the same frame on every arrow key would be about 80ms of ffmpeg per // keystroke for a picture that cannot have changed. export async function GET(request: Request) { const url = new URL(request.url); const video = url.searchParams.get("video") ?? ""; const at = Number(url.searchParams.get("at")); // NO `w` MEANS NATIVE, and that is what the deck asks for. // // Serving the source's own pixels makes the canvas's coordinate system the // SOURCE coordinate system: a crop box dragged on screen is already in the // units facecrop.py cuts in, with no ratio in the middle to get backwards. // Every episode is 1280x720, so this is ~120 KB and decodes instantly. // // `w` is still honoured, and capped rather than trusted -- a client asking for // 40000 would ask ffmpeg to allocate accordingly. const raw = url.searchParams.get("w"); const w = raw === null ? null : Math.max(160, Math.min(1920, Number(raw) || 960)); if (!/^[A-Za-z0-9_-]+$/.test(video)) return new Response("bad video", { status: 400 }); if (!Number.isFinite(at) || at < 0) return new Response("bad time", { status: 400 }); if (raw !== null && !Number.isFinite(Number(raw))) return new Response("bad width", { status: 400 }); const file = sourceVideo(video); // media/ is bulk data and re-derivable, so a missing source is a normal state // rather than an error -- the deck simply says it cannot draw this one. if (!existsSync(file)) return new Response("no video", { status: 404 }); const stamp = createHash("sha1") .update(`face1|${video}|${at.toFixed(3)}|${w ?? "native"}`) .digest("hex") .slice(0, 16); // Through cacheFile, never a join here: see its comment in lib/paths.mjs. const cached = cacheFile(`${stamp}.jpg`); if (!existsSync(cached)) { await mkdir(CACHE_DIR, { recursive: true }); const tmp = cacheFile(`${stamp}.jpg.tmp.jpg`); // -ss BEFORE -i for the fast seek. When a width is asked for it is scaled to // an even one with the aspect preserved; when it is not, the frame comes out // at the source's own size and no mapping is needed at all. await run("ffmpeg", [ "-nostdin", "-v", "error", "-y", "-ss", at.toFixed(3), "-i", file, "-frames:v", "1", ...(w === null ? [] : ["-vf", `scale=${w}:-2`]), "-q:v", "3", tmp, ]); await rename(tmp, cached).catch(() => {}); } return new Response(new Uint8Array(await readFile(cached)), { headers: { "content-type": "image/jpeg", "cache-control": "no-store" }, }); }