Files

131 lines
3.8 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface NativeVideoMetadataProbe {
width: number;
height: number;
duration: number;
mediaStartTime?: number;
streamStartTime?: number;
streamDuration?: number;
frameRate: number;
codec: string;
hasAudio: boolean;
audioCodec?: string;
audioSampleRate?: number;
}
export function parseFfmpegDurationSeconds(value: string): number | null {
const parts = value.trim().split(":");
if (parts.length !== 3) {
return null;
}
const [hours, minutes, seconds] = parts.map(Number);
if (![hours, minutes, seconds].every(Number.isFinite)) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
export function parseFfmpegFrameRate(line: string): number | null {
const fpsMatch = line.match(/,\s*([0-9]+(?:\.[0-9]+)?)\s*fps\b/i);
if (fpsMatch) {
const frameRate = Number(fpsMatch[1]);
return Number.isFinite(frameRate) && frameRate > 0 ? frameRate : null;
}
const tbrMatch = line.match(/,\s*([0-9]+(?:\.[0-9]+)?)\s*tbr\b/i);
if (tbrMatch) {
const frameRate = Number(tbrMatch[1]);
return Number.isFinite(frameRate) && frameRate > 0 ? frameRate : null;
}
return null;
}
export function parseNativeVideoMetadataProbeOutput(
output: string,
): NativeVideoMetadataProbe | null {
const durationMatch = output.match(
/Duration:\s*([0-9:.]+),\s*start:\s*(-?[0-9]+(?:\.[0-9]+)?)/i,
);
const duration = durationMatch ? parseFfmpegDurationSeconds(durationMatch[1]) : null;
if (!duration || duration <= 0) {
return null;
}
const mediaStartTime = durationMatch ? Number(durationMatch[2]) : 0;
const lines = output.split(/\r?\n/);
const videoLine = lines.find((line) => /\bVideo:\s*/i.test(line));
if (!videoLine) {
return null;
}
const dimensionsMatch = videoLine.match(/,\s*([0-9]{2,5})x([0-9]{2,5})(?:[,\s]|$)/);
if (!dimensionsMatch) {
return null;
}
const width = Number(dimensionsMatch[1]);
const height = Number(dimensionsMatch[2]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
const videoCodecMatch = videoLine.match(/Video:\s*([^,\r\n]+)/i);
const videoStartMatch = videoLine.match(/\bstart:\s*(-?[0-9]+(?:\.[0-9]+)?)/i);
const frameRate = parseFfmpegFrameRate(videoLine) ?? 60;
const audioLine = lines.find((line) => /\bAudio:\s*/i.test(line));
const audioCodecMatch = audioLine?.match(/Audio:\s*([^,\r\n]+)/i);
const audioSampleRateMatch = audioLine?.match(/,\s*([0-9]+)\s*Hz\b/i);
return {
width,
height,
duration,
mediaStartTime: Number.isFinite(mediaStartTime) ? mediaStartTime : 0,
streamStartTime: videoStartMatch ? Number(videoStartMatch[1]) : mediaStartTime,
streamDuration: duration,
frameRate,
codec: videoCodecMatch?.[1]?.trim() || "unknown",
hasAudio: Boolean(audioLine),
audioCodec: audioCodecMatch?.[1]?.trim(),
audioSampleRate: audioSampleRateMatch ? Number(audioSampleRateMatch[1]) : undefined,
};
}
export async function probeNativeVideoMetadata(
ffmpegPath: string,
inputPath: string,
signal?: AbortSignal,
): Promise<NativeVideoMetadataProbe> {
let output = "";
try {
const result = await execFileAsync(ffmpegPath, ["-hide_banner", "-i", inputPath], {
signal,
timeout: 30_000,
maxBuffer: 4 * 1024 * 1024,
});
output = `${result.stdout}\n${result.stderr}`;
} catch (error) {
signal?.throwIfAborted();
const processOutput = error as { stdout?: unknown; stderr?: unknown };
output = [processOutput.stdout, processOutput.stderr]
.filter((value): value is string => typeof value === "string")
.join("\n");
if (!output) {
throw error;
}
}
const metadata = parseNativeVideoMetadataProbeOutput(output);
if (!metadata) {
throw new Error("Unable to parse native video metadata from FFmpeg output");
}
return metadata;
}