7d12241ccb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
export interface ProbeStream {
|
|
streamIndex: number;
|
|
type: "Video" | "Audio" | "Subtitle" | "Data" | "EmbeddedImage";
|
|
codec: string | null;
|
|
profile: string | null;
|
|
language: string | null;
|
|
title: string | null;
|
|
isDefault: number;
|
|
isForced: number;
|
|
isHearingImpaired: number;
|
|
channels: number | null;
|
|
channelLayout: string | null;
|
|
bitRate: number | null;
|
|
sampleRate: number | null;
|
|
bitDepth: number | null;
|
|
}
|
|
|
|
export interface ProbeResult {
|
|
fileSize: number | null;
|
|
durationSeconds: number | null;
|
|
container: string | null;
|
|
streams: ProbeStream[];
|
|
}
|
|
|
|
function mapCodecType(codecType: string): ProbeStream["type"] {
|
|
switch (codecType) {
|
|
case "video":
|
|
return "Video";
|
|
case "audio":
|
|
return "Audio";
|
|
case "subtitle":
|
|
return "Subtitle";
|
|
case "data":
|
|
return "Data";
|
|
case "attachment":
|
|
return "EmbeddedImage";
|
|
default:
|
|
return "Data";
|
|
}
|
|
}
|
|
|
|
function parseIntOrNull(value: string | number | null | undefined): number | null {
|
|
if (value === null || value === undefined) return null;
|
|
const n = typeof value === "number" ? value : parseInt(value, 10);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function parseFloatOrNull(value: string | number | null | undefined): number | null {
|
|
if (value === null || value === undefined) return null;
|
|
const n = typeof value === "number" ? value : parseFloat(value);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
/** Parse ffprobe JSON output. Exported for unit testing. */
|
|
export function parseProbeOutput(json: string): ProbeResult {
|
|
const raw = JSON.parse(json);
|
|
const fmt = raw.format ?? {};
|
|
|
|
const fileSize = parseIntOrNull(fmt.size ?? null);
|
|
const durationSeconds = parseFloatOrNull(fmt.duration ?? null);
|
|
const container = fmt.format_name
|
|
? (fmt.format_name as string).split(",")[0] || null
|
|
: null;
|
|
|
|
const streams: ProbeStream[] = (raw.streams ?? []).map(
|
|
// biome-ignore lint/suspicious/noExplicitAny: raw ffprobe JSON
|
|
(s: any): ProbeStream => {
|
|
const tags = s.tags ?? {};
|
|
const disp = s.disposition ?? {};
|
|
return {
|
|
streamIndex: s.index as number,
|
|
type: mapCodecType(s.codec_type ?? ""),
|
|
codec: (s.codec_name as string | undefined) ?? null,
|
|
profile: (s.profile as string | undefined) ?? null,
|
|
language: (tags.language ?? tags.LANGUAGE ?? null) as string | null,
|
|
title: (tags.title ?? tags.TITLE ?? null) as string | null,
|
|
isDefault: (disp.default as number | undefined) ?? 0,
|
|
isForced: (disp.forced as number | undefined) ?? 0,
|
|
isHearingImpaired: (disp.hearing_impaired as number | undefined) ?? 0,
|
|
channels: parseIntOrNull(s.channels ?? null),
|
|
channelLayout: (s.channel_layout as string | undefined) ?? null,
|
|
bitRate: parseIntOrNull(s.bit_rate ?? null),
|
|
sampleRate: parseIntOrNull(s.sample_rate ?? null),
|
|
bitDepth: parseIntOrNull(s.bits_per_raw_sample ?? null),
|
|
};
|
|
},
|
|
);
|
|
|
|
return { fileSize, durationSeconds, container, streams };
|
|
}
|
|
|
|
/** Run ffprobe on a local file. */
|
|
export async function probeFile(filePath: string): Promise<ProbeResult> {
|
|
const proc = Bun.spawn(
|
|
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
|
{ stdout: "pipe", stderr: "pipe" },
|
|
);
|
|
|
|
const [stdout, exitCode] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
proc.exited,
|
|
]);
|
|
|
|
if (exitCode !== 0) {
|
|
const stderr = await new Response(proc.stderr).text();
|
|
throw new Error(`ffprobe exited with code ${exitCode}: ${stderr.trim()}`);
|
|
}
|
|
|
|
return parseProbeOutput(stdout);
|
|
}
|