mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
83 lines
2.9 KiB
TypeScript
83 lines
2.9 KiB
TypeScript
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.
|
|
* Resolves with the captured stderr tail (filters like silencedetect report
|
|
* there). Output must go to a FILE path in args (no stdout piping of media
|
|
* data).
|
|
*/
|
|
export async function runFfmpeg(args: string[], opts: RunFfmpegOptions = {}): Promise<string> {
|
|
const bin = resolveFfmpeg();
|
|
if (!bin) throw new Error("ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)");
|
|
return new Promise<string>((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(stderrTail);
|
|
else reject(new Error(`ffmpeg exited ${code ?? signal}: ${stderrTail.slice(-2000)}`));
|
|
});
|
|
});
|
|
}
|