mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(modality)!: SnapOtter 2.0 phase 3 modality framework: media/doc engines, pool routing, display modes (#218)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
let ffmpegPath: string | null | undefined;
|
||||
let ffprobePath: string | null | undefined;
|
||||
|
||||
function which(bin: string): string | null {
|
||||
const res = spawnSync(process.platform === "win32" ? "where" : "which", [bin], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (res.status === 0 && res.stdout.trim()) return res.stdout.trim().split("\n")[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** FFMPEG_PATH env override, else $PATH. Null when unavailable. Cached. */
|
||||
export function resolveFfmpeg(): string | null {
|
||||
if (ffmpegPath === undefined) ffmpegPath = process.env.FFMPEG_PATH || which("ffmpeg");
|
||||
return ffmpegPath;
|
||||
}
|
||||
|
||||
export function resolveFfprobe(): string | null {
|
||||
if (ffprobePath === undefined) ffprobePath = process.env.FFPROBE_PATH || which("ffprobe");
|
||||
return ffprobePath;
|
||||
}
|
||||
|
||||
export function ffmpegAvailable(): boolean {
|
||||
return resolveFfmpeg() !== null && resolveFfprobe() !== null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type EncoderTarget = "h264" | "hevc" | "av1" | "vp9" | "aac" | "opus" | "mp3";
|
||||
|
||||
const SOFTWARE: Record<EncoderTarget, string> = {
|
||||
h264: "libx264",
|
||||
hevc: "libx265",
|
||||
av1: "libsvtav1",
|
||||
vp9: "libvpx-vp9",
|
||||
aac: "aac",
|
||||
opus: "libopus",
|
||||
mp3: "libmp3lame",
|
||||
};
|
||||
|
||||
const NVENC: Partial<Record<EncoderTarget, string>> = {
|
||||
h264: "h264_nvenc",
|
||||
hevc: "hevc_nvenc",
|
||||
av1: "av1_nvenc",
|
||||
};
|
||||
|
||||
const VAAPI: Partial<Record<EncoderTarget, string>> = {
|
||||
h264: "h264_vaapi",
|
||||
hevc: "hevc_vaapi",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hardware-acceleration seam (spec 4.5): SNAPOTTER_HW_ACCEL selects an
|
||||
* encoder family; a CUDA/NVENC deployment is a Dockerfile change, not a
|
||||
* code change. Unknown values fall back to software.
|
||||
*/
|
||||
export function resolveEncoder(target: EncoderTarget): string {
|
||||
const accel = (process.env.SNAPOTTER_HW_ACCEL ?? "").toLowerCase();
|
||||
if (accel === "nvenc") return NVENC[target] ?? SOFTWARE[target];
|
||||
if (accel === "vaapi") return VAAPI[target] ?? SOFTWARE[target];
|
||||
return SOFTWARE[target];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveFfmpeg } from "./binaries.js";
|
||||
import { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||
|
||||
export interface RunFfmpegOptions {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
onProgress?: (p: FfmpegProgress) => void;
|
||||
}
|
||||
|
||||
const STDERR_RING_MAX = 16 * 1024;
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with `-progress pipe:1` appended, parsing progress blocks from
|
||||
* stdout. Rejects with the tail of stderr on non-zero exit, timeout or abort.
|
||||
* Output must go to a FILE path in args (no stdout piping of media data).
|
||||
*/
|
||||
export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise<void> {
|
||||
const bin = resolveFfmpeg();
|
||||
if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)");
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(bin, ["-hide_banner", "-nostdin", "-y", ...args, "-progress", "pipe:1"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderrTail = "";
|
||||
let settled = false;
|
||||
let buffer = "";
|
||||
const timeoutMs = opts.timeoutMs;
|
||||
const timer = timeoutMs
|
||||
? setTimeout(() => {
|
||||
fail(new Error(`ffmpeg timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
const onAbort = () => fail(new Error("Canceled"));
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) onAbort();
|
||||
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
function fail(err: Error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.kill("SIGKILL");
|
||||
reject(err);
|
||||
}
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
// Blocks end at the line that starts with "progress="
|
||||
let idx = buffer.indexOf("progress=");
|
||||
while (idx !== -1) {
|
||||
const lineEnd = buffer.indexOf("\n", idx);
|
||||
if (lineEnd === -1) break;
|
||||
const block = buffer.slice(0, lineEnd);
|
||||
buffer = buffer.slice(lineEnd + 1);
|
||||
try {
|
||||
opts.onProgress?.(parseProgressBlock(block));
|
||||
} catch (cbErr) {
|
||||
fail(cbErr instanceof Error ? cbErr : new Error(String(cbErr)));
|
||||
return;
|
||||
}
|
||||
idx = buffer.indexOf("progress=");
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString("utf8")).slice(-STDERR_RING_MAX);
|
||||
});
|
||||
child.on("error", (err) => fail(err));
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolveFfprobe } from "./binaries.js";
|
||||
|
||||
export interface MediaStreamInfo {
|
||||
type: "video" | "audio" | "other";
|
||||
codec: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface MediaInfo {
|
||||
container: string;
|
||||
durationS: number | null;
|
||||
bitrateKbps: number | null;
|
||||
streams: MediaStreamInfo[];
|
||||
}
|
||||
|
||||
export interface ProbeOptions {
|
||||
timeoutMs?: number; // default 15s
|
||||
}
|
||||
|
||||
/** Capped, time-limited ffprobe of a file path (spec 4.7). */
|
||||
export async function probeMedia(filePath: string, opts: ProbeOptions = {}): Promise<MediaInfo> {
|
||||
const bin = resolveFfprobe();
|
||||
if (!bin) throw new Error("ffprobe binary not found (set FFPROBE_PATH or install ffmpeg)");
|
||||
const args = [
|
||||
"-v",
|
||||
"error",
|
||||
"-analyzeduration",
|
||||
"10M",
|
||||
"-probesize",
|
||||
"25M",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
];
|
||||
const timeoutMs = opts.timeoutMs ?? 15_000;
|
||||
const stdout = await new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
let err = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`ffprobe timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
child.stdout.on("data", (c: Buffer) => {
|
||||
out += c.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (c: Buffer) => {
|
||||
err = (err + c.toString("utf8")).slice(-4096);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise(out);
|
||||
else reject(new Error(`ffprobe exited ${code ?? signal}: ${err.slice(-1000)}`));
|
||||
});
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
format?: { format_name?: string; duration?: string; bit_rate?: string };
|
||||
streams?: Array<{ codec_type?: string; codec_name?: string; width?: number; height?: number }>;
|
||||
};
|
||||
const duration = parsed.format?.duration ? Number(parsed.format.duration) : null;
|
||||
const bitRate = parsed.format?.bit_rate ? Number(parsed.format.bit_rate) : null;
|
||||
return {
|
||||
container: parsed.format?.format_name ?? "unknown",
|
||||
durationS: Number.isFinite(duration as number) ? (duration as number) : null,
|
||||
bitrateKbps: Number.isFinite(bitRate as number) ? Math.round((bitRate as number) / 1000) : null,
|
||||
streams: (parsed.streams ?? []).map((s) => ({
|
||||
type: s.codec_type === "video" ? "video" : s.codec_type === "audio" ? "audio" : "other",
|
||||
codec: s.codec_name ?? "unknown",
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ffmpegAvailable, resolveFfmpeg, resolveFfprobe } from "./binaries.js";
|
||||
export { type EncoderTarget, resolveEncoder } from "./encoders.js";
|
||||
export { type RunFfmpegOptions, runFfmpeg } from "./ffmpeg.js";
|
||||
export { type MediaInfo, type MediaStreamInfo, type ProbeOptions, probeMedia } from "./ffprobe.js";
|
||||
export { type FfmpegProgress, parseProgressBlock } from "./progress.js";
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface FfmpegProgress {
|
||||
outTimeMs: number | null;
|
||||
done: boolean;
|
||||
raw: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Parses one `-progress pipe:1` block (key=value lines ending in progress=...). */
|
||||
export function parseProgressBlock(block: string): FfmpegProgress {
|
||||
const raw: Record<string, string> = {};
|
||||
for (const line of block.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx > 0) raw[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
||||
}
|
||||
let outTimeMs: number | null = null;
|
||||
if (raw.out_time_us !== undefined) {
|
||||
const us = Number(raw.out_time_us);
|
||||
if (Number.isFinite(us)) outTimeMs = Math.round(us / 1000);
|
||||
} else if (raw.out_time_ms !== undefined) {
|
||||
// ffmpeg's out_time_ms is historically MICROseconds despite the name.
|
||||
const us = Number(raw.out_time_ms);
|
||||
if (Number.isFinite(us)) outTimeMs = Math.round(us / 1000);
|
||||
}
|
||||
return { outTimeMs, done: raw.progress === "end", raw };
|
||||
}
|
||||
Reference in New Issue
Block a user