evOo-local/packages/adapters/src/media-ingest-proxy.ts
Claude 55e129cb7e
wip(media): w2-ingest partial work (handover, unverified)
Stopped mid-implementation for the handover to the project thread.
Not typechecked or tested; do not merge as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:24:47 +00:00

215 lines
7.2 KiB
TypeScript

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<boolean> | null = null;
/** Whether this ffmpeg build has zscale and tonemap (cached per process). */
function hasTonemapFilters(ffmpeg: string): Promise<boolean> {
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<typeof parseFfprobe>[0]),
hdr: HDR_TRANSFERS.has(video?.color_transfer ?? ""),
};
}
async function ffmpegTo(
target: string,
ext: string,
build: (out: string) => string[],
tools: MediaToolPaths,
signal?: AbortSignal,
): Promise<void> {
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<ProxyOutputs> {
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<void> {
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,
);
}