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 { CACHE_DIR, cacheFile } from "@/lib/paths"; import { sourceVideo } from "@/lib/clips"; const run = promisify(execFile); export const dynamic = "force-dynamic"; // The PICTURE for the window being judged. // // Judging by waveform and ear alone throws away the one signal that settles the // hardest question this tool asks: is this Jer, or the other person in the room? // The pitch gate exists precisely because interviews carry a guest, and a guest // is obvious on sight and ambiguous by ear. The face is also how "is this even a // filler" gets answered quickly -- a laugh, a cough and an um look nothing alike. // // Served for the SAME window as the audio route so the two share one time base: // the player can be positioned with (playFrom - view.from) and needs no second // mapping. Cached on the same key scheme, and small on purpose -- this is a // reference thumbnail, not a viewing copy. export async function GET(request: Request, ctx: { params: Promise<{ key: string }> }) { const { key } = await ctx.params; const url = new URL(request.url); const from = Number(url.searchParams.get("from")); const to = Number(url.searchParams.get("to")); if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) { return new Response("bad window", { status: 400 }); } const decoded = decodeURIComponent(key); const at = decoded.lastIndexOf("@"); const video = at > 0 ? decoded.slice(0, at) : decoded; const file = sourceVideo(video); // Not every source has media/ -- it is bulk data and re-derivable. A missing // file is a normal state, not an error: the client simply shows no player. if (!existsSync(file)) return new Response("no video", { status: 404 }); const stamp = createHash("sha1") .update(`v1|${video}|${from.toFixed(3)}|${to.toFixed(3)}`) .digest("hex") .slice(0, 16); // Through cacheFile, never a join here: see its comment in lib/paths.mjs. const cached = cacheFile(`${stamp}.mp4`); if (existsSync(cached)) { return new Response(new Uint8Array(await readFile(cached)), { headers: { "content-type": "video/mp4", "cache-control": "no-store" }, }); } await mkdir(CACHE_DIR, { recursive: true }); const tmp = cacheFile(`${stamp}.mp4.tmp.mp4`); // -ss BEFORE -i for the fast seek, then -t for the length. Re-encoded rather // than copied because a stream copy starts at the previous keyframe, which // would slide the picture against the audio by up to several seconds. await run("ffmpeg", [ "-nostdin", "-v", "error", "-y", "-ss", from.toFixed(3), "-t", (to - from).toFixed(3), "-i", file, "-an", "-vf", "scale=360:-2,fps=25", "-c:v", "libx264", "-preset", "veryfast", "-crf", "30", "-pix_fmt", "yuv420p", "-movflags", "+faststart", tmp, ]); await rename(tmp, cached).catch(() => {}); return new Response(new Uint8Array(await readFile(cached)), { headers: { "content-type": "video/mp4", "cache-control": "no-store" }, }); }