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
215 lines
7.2 KiB
TypeScript
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,
|
|
);
|
|
}
|