feat(studio-server): add media processing routes

This commit is contained in:
ukimsanov
2026-07-06 13:25:04 -07:00
parent 2757d32092
commit a3bf7eb995
9 changed files with 938 additions and 4 deletions
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { classifyMediaColor, probeMediaMetadata } from "./mediaMetadata.js";
describe("classifyMediaColor", () => {
it("detects HDR PQ from BT.2020 + smpte2084 metadata", () => {
expect(
classifyMediaColor({
codec_type: "video",
codec_name: "hevc",
profile: "Main 10",
pix_fmt: "yuv420p10le",
color_space: "bt2020nc",
color_transfer: "smpte2084",
color_primaries: "bt2020",
}),
).toMatchObject({
dynamicRange: "hdr",
hdrTransfer: "pq",
label: "HDR PQ",
isHdr: true,
});
});
it("detects HDR HLG from arib-std-b67 metadata", () => {
expect(
classifyMediaColor({
codec_type: "video",
color_space: "bt2020nc",
color_transfer: "arib-std-b67",
color_primaries: "bt2020",
}),
).toMatchObject({
dynamicRange: "hdr",
hdrTransfer: "hlg",
label: "HDR HLG",
isHdr: true,
});
});
it("labels BT.709 media as SDR Rec.709", () => {
expect(
classifyMediaColor({
codec_type: "video",
color_space: "bt709",
color_transfer: "bt709",
color_primaries: "bt709",
}),
).toMatchObject({
dynamicRange: "sdr",
hdrTransfer: null,
label: "SDR Rec.709",
isHdr: false,
});
});
});
describe("probeMediaMetadata", () => {
it("reads the first video stream from ffprobe JSON", () => {
const metadata = probeMediaMetadata("/tmp/clip.mp4", () => ({
status: 0,
stdout: JSON.stringify({
streams: [
{ codec_type: "audio", codec_name: "aac" },
{
codec_type: "video",
codec_name: "hevc",
pix_fmt: "yuv420p10le",
color_space: "bt2020nc",
color_transfer: "smpte2084",
color_primaries: "bt2020",
},
],
}),
stderr: "",
}));
expect(metadata).toMatchObject({
kind: "video",
color: { isHdr: true, label: "HDR PQ" },
});
});
it("returns unknown metadata when ffprobe is unavailable", () => {
expect(
probeMediaMetadata("/tmp/clip.mp4", () => ({
status: null,
stdout: "",
stderr: "",
error: { code: "ENOENT" } as NodeJS.ErrnoException,
})),
).toMatchObject({
kind: "video",
color: { dynamicRange: "unknown", isHdr: false },
probeError: "ffprobe unavailable",
});
});
});
@@ -0,0 +1,160 @@
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
import { extname } from "node:path";
type FfprobeRunner = (
command: string,
args: string[],
options?: SpawnSyncOptions,
) => {
status: number | null;
stdout: string | Buffer;
stderr: string | Buffer;
error?: NodeJS.ErrnoException;
};
export type MediaDynamicRange = "hdr" | "sdr" | "unknown";
export type MediaHdrTransfer = "pq" | "hlg" | "unknown";
export interface MediaColorMetadata {
dynamicRange: MediaDynamicRange;
hdrTransfer: MediaHdrTransfer | null;
label: string;
isHdr: boolean;
codecName?: string;
profile?: string;
pixelFormat?: string;
colorSpace?: string;
colorTransfer?: string;
colorPrimaries?: string;
bitsPerRawSample?: string;
}
export interface MediaMetadata {
kind: "video" | "image" | "audio" | "unknown";
color: MediaColorMetadata;
probeError?: string;
}
interface FfprobeStream {
codec_type?: string;
codec_name?: string;
profile?: string;
pix_fmt?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
bits_per_raw_sample?: string;
}
const VIDEO_EXT = new Set([".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"]);
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
function lower(value: string | undefined): string {
return value?.toLowerCase() ?? "";
}
function inferKindFromPath(path: string): MediaMetadata["kind"] {
const ext = extname(path).toLowerCase();
if (VIDEO_EXT.has(ext)) return "video";
if (IMAGE_EXT.has(ext)) return "image";
if (AUDIO_EXT.has(ext)) return "audio";
return "unknown";
}
function colorLabel(input: {
isHdr: boolean;
hdrTransfer: MediaHdrTransfer | null;
colorPrimaries: string;
colorSpace: string;
colorTransfer: string;
}): string {
if (input.isHdr) {
if (input.hdrTransfer === "pq") return "HDR PQ";
if (input.hdrTransfer === "hlg") return "HDR HLG";
return "HDR";
}
if (
input.colorPrimaries.includes("bt709") ||
input.colorSpace.includes("bt709") ||
input.colorTransfer.includes("bt709")
) {
return "SDR Rec.709";
}
return "SDR/unknown";
}
export function classifyMediaColor(stream: FfprobeStream | null | undefined): MediaColorMetadata {
const colorPrimaries = lower(stream?.color_primaries);
const colorSpace = lower(stream?.color_space);
const colorTransfer = lower(stream?.color_transfer);
const isHdr =
colorPrimaries.includes("bt2020") ||
colorSpace.includes("bt2020") ||
colorTransfer === "smpte2084" ||
colorTransfer === "arib-std-b67";
const hdrTransfer: MediaHdrTransfer | null = isHdr
? colorTransfer === "smpte2084"
? "pq"
: colorTransfer === "arib-std-b67"
? "hlg"
: "unknown"
: null;
return {
dynamicRange: stream ? (isHdr ? "hdr" : "sdr") : "unknown",
hdrTransfer,
label: stream
? colorLabel({ isHdr, hdrTransfer, colorPrimaries, colorSpace, colorTransfer })
: "Unknown",
isHdr,
codecName: stream?.codec_name,
profile: stream?.profile,
pixelFormat: stream?.pix_fmt,
colorSpace: stream?.color_space,
colorTransfer: stream?.color_transfer,
colorPrimaries: stream?.color_primaries,
bitsPerRawSample: stream?.bits_per_raw_sample,
};
}
export function probeMediaMetadata(
filePath: string,
runner: FfprobeRunner = spawnSync as unknown as FfprobeRunner,
): MediaMetadata {
const kind = inferKindFromPath(filePath);
if (kind === "audio" || kind === "unknown") {
return { kind, color: classifyMediaColor(null) };
}
const result = runner(
"ffprobe",
[
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample",
"-of",
"json",
filePath,
],
{ timeout: 15_000, maxBuffer: 1024 * 1024 },
);
if (result.error?.code === "ENOENT") {
return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
}
if (result.status !== 0) {
return { kind, color: classifyMediaColor(null), probeError: "ffprobe failed" };
}
try {
const parsed = JSON.parse(String(result.stdout || "{}")) as { streams?: FfprobeStream[] };
const stream = parsed.streams?.find((item) =>
kind === "image" ? item.codec_type === "video" : item.codec_type === kind,
);
return { kind, color: classifyMediaColor(stream) };
} catch {
return { kind, color: classifyMediaColor(null), probeError: "ffprobe returned invalid json" };
}
}