import { mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { getLogger } from "@rakazo/logging"; import { resolveInside } from "@rakazo/media/node"; import type { MediaToolPaths, ProbedMedia } from "./media-folder-source-probe.js"; import { parseFfprobe, runMediaTool } from "./media-folder-source-probe.js"; import { assetDir, tempSibling } from "./media-ingest-files.js"; /* * Proxy, poster and sprite generation with ffmpeg (argv only, never a shell). * * Videos: 720p (shorter side) H.264 with a keyframe every second, constant * frame rate (variable-rate sources are normalized for frame-accurate cuts), * stereo AAC, display rotation applied by ffmpeg's autorotate, HDR (PQ/HLG) * tone-mapped to SDR BT.709 when zscale and tonemap are available. Photos get * a resized JPEG proxy. Posters and sprites are JPEGs written with * `-map_metadata -1`, so no capture date, GPS, or device tags survive. * Every output is written to a temp name and renamed into place. */ const PROXY_SHORT_SIDE = 720; const THUMB_WIDTH = 480; const SPRITE_TILES = 10; const SPRITE_TILE_WIDTH = 160; const MAX_PROXY_FPS = 60; const HDR_TRANSFERS = new Set(["smpte2084", "arib-std-b67"]); export interface ProxyOutputs { proxyPath: string; thumbPath: string; spritePath: string | null; probe: ProbedMedia; hdr: boolean; tonemapped: boolean; } interface ProbeStream { codec_type?: string; color_transfer?: string; } /** Even-dimension scale so the shorter side is at most 720 px, never upscaled. */ const SCALE_720 = `scale=w='if(gte(iw,ih),-2,trunc(min(${PROXY_SHORT_SIDE},iw)/2)*2)'` + `:h='if(gte(iw,ih),trunc(min(${PROXY_SHORT_SIDE},ih)/2)*2,-2)'`; const TONEMAP = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," + "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv"; let tonemapSupport: Promise | null = null; /** Whether this ffmpeg build has zscale and tonemap (cached per process). */ function hasTonemapFilters(ffmpeg: string): Promise { tonemapSupport ??= runMediaTool(ffmpeg, ["-hide_banner", "-filters"]) .then(({ stdout }) => { const text = stdout.toString("utf8"); return /\szscale\s/.test(text) && /\stonemap\s/.test(text); }) .catch(() => false); return tonemapSupport; } export async function probeOriginal( file: string, tools: MediaToolPaths, signal?: AbortSignal, ): Promise<{ probe: ProbedMedia; hdr: boolean }> { const { stdout } = await runMediaTool( tools.ffprobePath ?? "ffprobe", ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", file], { signal }, ); const json = JSON.parse(stdout.toString("utf8")) as { streams?: ProbeStream[] }; const video = json.streams?.find((stream) => stream.codec_type === "video"); return { probe: parseFfprobe(json as Parameters[0]), hdr: HDR_TRANSFERS.has(video?.color_transfer ?? ""), }; } async function ffmpegTo( target: string, ext: string, build: (out: string) => string[], tools: MediaToolPaths, signal?: AbortSignal, ): Promise { await mkdir(path.dirname(target), { recursive: true }); const temp = tempSibling(target, ext); try { await runMediaTool(tools.ffmpegPath ?? "ffmpeg", build(temp), { signal }); if ((await stat(temp)).size === 0) throw new Error("ffmpeg produced an empty file"); await rename(temp, target); } finally { await rm(temp, { force: true }); } } function proxyFps(probe: ProbedMedia): number { const fps = probe.fps && probe.fps > 0 ? probe.fps : 30; return Math.min(MAX_PROXY_FPS, Math.max(1, Math.round(fps))); } /** Build proxy, poster, and sprite for one original. Returns media-root-relative paths. */ export async function buildProxy(input: { mediaRoot: string; spaceId: string; assetId: string; original: string; still: boolean; tools?: MediaToolPaths; signal?: AbortSignal; }): Promise { const tools = input.tools ?? {}; const signal = input.signal; const dir = assetDir(input.spaceId, input.assetId); const { probe, hdr } = await probeOriginal(input.original, tools, signal); const still = input.still || !probe.hasVideo || !probe.durationMs; const ffmpeg = tools.ffmpegPath ?? "ffmpeg"; let tonemapped = false; const filters: string[] = []; if (hdr) { if (await hasTonemapFilters(ffmpeg)) { filters.push(TONEMAP); tonemapped = true; } else { getLogger().warn("media proxy: HDR source kept without tone mapping", { reason: "ffmpeg lacks zscale or tonemap", }); } } filters.push(SCALE_720); const thumbRel = `${dir}/thumb.jpg`; const thumb = await resolveInside(input.mediaRoot, thumbRel); if (still) { const proxyRel = `${dir}/proxy.jpg`; const proxy = await resolveInside(input.mediaRoot, proxyRel); await ffmpegTo( proxy, ".jpg", (out) => [ ...["-v", "error", "-y", "-i", input.original, "-map", "0:v:0"], ...["-map_metadata", "-1", "-frames:v", "1", "-update", "1"], ...["-vf", [...filters, "format=yuvj420p"].join(","), "-q:v", "3", out], ], tools, signal, ); await writeThumb(proxy, thumb, 0, tools, signal); return { proxyPath: proxyRel, thumbPath: thumbRel, spritePath: null, probe, hdr, tonemapped }; } const proxyRel = `${dir}/proxy.mp4`; const proxy = await resolveInside(input.mediaRoot, proxyRel); filters.push(`fps=${proxyFps(probe)}`, "format=yuv420p"); await ffmpegTo( proxy, ".mp4", (out) => [ ...["-v", "error", "-y", "-i", input.original], ...["-map", "0:v:0", "-map", "0:a:0?", "-map_metadata", "-1", "-map_chapters", "-1"], ...["-vf", filters.join(",")], ...["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-profile:v", "high"], ...["-force_key_frames", "expr:gte(t,n_forced*1)", "-sc_threshold", "0"], ...["-c:a", "aac", "-b:a", "128k", "-ac", "2", "-ar", "48000"], ...["-movflags", "+faststart", "-f", "mp4", out], ], tools, signal, ); const durationMs = probe.durationMs ?? 1000; await writeThumb(proxy, thumb, Math.min(1000, Math.floor(durationMs / 2)), tools, signal); const spriteRel = `${dir}/sprite.jpg`; const sprite = await resolveInside(input.mediaRoot, spriteRel); const sampleFps = (SPRITE_TILES / Math.max(0.1, durationMs / 1000)).toFixed(6); await ffmpegTo( sprite, ".jpg", (out) => [ ...["-v", "error", "-y", "-i", proxy, "-map", "0:v:0", "-map_metadata", "-1"], ...["-vf", `fps=${sampleFps},scale=${SPRITE_TILE_WIDTH}:-2,tile=${SPRITE_TILES}x1`], ...["-frames:v", "1", "-update", "1", "-q:v", "4", out], ], tools, signal, ); return { proxyPath: proxyRel, thumbPath: thumbRel, spritePath: spriteRel, probe, hdr, tonemapped }; } async function writeThumb( source: string, target: string, atMs: number, tools: MediaToolPaths, signal?: AbortSignal, ): Promise { await ffmpegTo( target, ".jpg", (out) => [ ...["-v", "error", "-y", "-ss", (atMs / 1000).toFixed(3), "-i", source], ...["-map", "0:v:0", "-map_metadata", "-1", "-frames:v", "1", "-update", "1"], ...["-vf", `scale='min(${THUMB_WIDTH},iw)':-2`, "-q:v", "3", out], ], tools, signal, ); }