feat(modality)!: SnapOtter 2.0 phase 3 modality framework: media/doc engines, pool routing, display modes (#218)

This commit is contained in:
SnapOtter
2026-06-13 10:18:39 +08:00
parent c451b939c7
commit d647d8ed19
99 changed files with 4380 additions and 1813 deletions
+27
View File
@@ -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;
}
+34
View File
@@ -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];
}
+80
View File
@@ -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)}`));
});
});
}
+88
View File
@@ -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,
})),
};
}
+5
View File
@@ -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";
+24
View File
@@ -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 };
}