mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
refactor(engine): manage child process lifecycles (#2160)
* refactor(engine): manage child process lifecycles * fix(engine): preserve child reaping after runtime errors * fix(engine): untrack child processes on exit
This commit is contained in:
@@ -242,6 +242,12 @@ export {
|
||||
type RunFfmpegOptions,
|
||||
type RunFfmpegResult,
|
||||
} from "./utils/runFfmpeg.js";
|
||||
export {
|
||||
ManagedChildProcess,
|
||||
type ManagedChildProcessOptions,
|
||||
type ManagedChildProcessOutcome,
|
||||
type ManagedProcessTerminationReason,
|
||||
} from "./utils/managedChildProcess.js";
|
||||
export {
|
||||
assertConfiguredFfmpegBinariesExist,
|
||||
getFfmpegBinary,
|
||||
|
||||
@@ -94,6 +94,12 @@ async function flushMuxCodecResolution(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async function flushManagedProcessResolution(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("ENCODER_PRESETS", () => {
|
||||
it("has draft, standard, and high presets", () => {
|
||||
expect(ENCODER_PRESETS).toHaveProperty("draft");
|
||||
@@ -310,7 +316,7 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
emitClose(calls[0]!.proc, 0);
|
||||
await Promise.resolve();
|
||||
await flushManagedProcessResolution();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
const concatProc = calls[1]!.proc;
|
||||
@@ -353,7 +359,7 @@ describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
|
||||
expect(chunkProc.kill).not.toHaveBeenCalled();
|
||||
|
||||
emitClose(chunkProc, 0);
|
||||
await Promise.resolve();
|
||||
await flushManagedProcessResolution();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
const concatProc = calls[1]!.proc;
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
* Supports CPU (libx264) and GPU encoding.
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
|
||||
import { join, dirname, extname } from "path";
|
||||
import { trackChildProcess } from "../utils/processTracker.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import {
|
||||
type GpuEncoder,
|
||||
@@ -20,7 +18,6 @@ import {
|
||||
import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
|
||||
import { withEvenDimensionPad } from "../utils/evenDimensions.js";
|
||||
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
|
||||
import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
|
||||
@@ -484,82 +481,40 @@ export async function encodeFramesFromDir(
|
||||
const inputPath = join(framesDir, framePattern);
|
||||
const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
|
||||
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
const onAbort = () => {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
||||
const result = await runFfmpeg(args, { signal, timeout: encodeTimeout });
|
||||
if (result.terminationReason === "abort") {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: "FFmpeg encode cancelled",
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}, encodeTimeout);
|
||||
|
||||
ffmpeg.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
const durationMs = Date.now() - startTime;
|
||||
if (signal?.aborted && !timedOut) {
|
||||
resolve({
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: "FFmpeg encode cancelled",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0 || timedOut) {
|
||||
resolve({
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: appendEncodeTimeoutMessage(
|
||||
formatFfmpegError(code, stderr),
|
||||
timedOut,
|
||||
encodeTimeout,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
|
||||
resolve({ success: true, outputPath, durationMs, framesEncoded: frameCount, fileSize });
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTime,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: appendEncodeTimeoutMessage(
|
||||
formatFfmpegError(result.exitCode, result.stderr),
|
||||
result.terminationReason === "deadline",
|
||||
encodeTimeout,
|
||||
),
|
||||
};
|
||||
}
|
||||
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
|
||||
return {
|
||||
success: true,
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTime,
|
||||
framesEncoded: frameCount,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
export async function encodeFramesChunkedConcat(
|
||||
@@ -624,45 +579,18 @@ export async function encodeFramesChunkedConcat(
|
||||
let gpuEncoder: GpuEncoder = null;
|
||||
if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
|
||||
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
|
||||
const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}, encodeTimeout);
|
||||
ffmpeg.stderr.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
ffmpeg.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0 && !timedOut) resolve({ success: true });
|
||||
else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: appendEncodeTimeoutMessage(
|
||||
`Chunk ${i} encode failed: ${stderr.slice(-400)}`,
|
||||
timedOut,
|
||||
encodeTimeout,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
ffmpeg.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
success: false,
|
||||
error: appendEncodeTimeoutMessage(
|
||||
`Chunk ${i} encode error: ${err.message}`,
|
||||
timedOut,
|
||||
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
||||
const processResult = await runFfmpeg(args, { signal, timeout: encodeTimeout });
|
||||
const chunkResult = {
|
||||
success: processResult.success,
|
||||
error: processResult.success
|
||||
? undefined
|
||||
: appendEncodeTimeoutMessage(
|
||||
`Chunk ${i} encode failed: ${processResult.stderr.slice(-400)}`,
|
||||
processResult.terminationReason === "deadline",
|
||||
encodeTimeout,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
if (!chunkResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -692,45 +620,18 @@ export async function encodeFramesChunkedConcat(
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}, encodeTimeout);
|
||||
ffmpeg.stderr.on("data", (d) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
ffmpeg.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0 && !timedOut) resolve({ success: true });
|
||||
else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: appendEncodeTimeoutMessage(
|
||||
`Chunk concat failed: ${stderr.slice(-400)}`,
|
||||
timedOut,
|
||||
encodeTimeout,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
ffmpeg.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
success: false,
|
||||
error: appendEncodeTimeoutMessage(
|
||||
`Chunk concat error: ${err.message}`,
|
||||
timedOut,
|
||||
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
||||
const concatProcessResult = await runFfmpeg(concatArgs, { signal, timeout: encodeTimeout });
|
||||
const concatResult = {
|
||||
success: concatProcessResult.success,
|
||||
error: concatProcessResult.success
|
||||
? undefined
|
||||
: appendEncodeTimeoutMessage(
|
||||
`Chunk concat failed: ${concatProcessResult.stderr.slice(-400)}`,
|
||||
concatProcessResult.terminationReason === "deadline",
|
||||
encodeTimeout,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (!concatResult.success) {
|
||||
return {
|
||||
|
||||
@@ -819,7 +819,7 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
|
||||
await expect(resolveWithin(writePromise)).resolves.toBe(false);
|
||||
expect(encoder.getExitStatus()).toBe("error");
|
||||
expect(proc.stdin.listenerCount("drain")).toBe(0);
|
||||
expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
|
||||
expect(proc.listenerCount("close")).toBeLessThanOrEqual(baselineCloseListeners);
|
||||
|
||||
const result = await encoder.close();
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import { once } from "events";
|
||||
import { trackChildProcess } from "../utils/processTracker.js";
|
||||
import {
|
||||
ManagedChildProcess,
|
||||
type ManagedProcessTerminationReason,
|
||||
} from "../utils/managedChildProcess.js";
|
||||
import { existsSync, mkdirSync, statSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
@@ -447,7 +451,6 @@ export async function spawnStreamingEncoder(
|
||||
|
||||
const args = buildStreamingArgs(options, outputPath, gpuEncoder);
|
||||
|
||||
const startTime = Date.now();
|
||||
const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
@@ -456,43 +459,11 @@ export async function spawnStreamingEncoder(
|
||||
let exitStatus: "running" | "success" | "error" = "running";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
let exitPromiseResolve: ((value: void) => void) | null = null;
|
||||
const exitPromise = new Promise<void>((resolve) => (exitPromiseResolve = resolve));
|
||||
|
||||
// Track stderr for progress and error messages
|
||||
ffmpeg.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.on("close", (code: number | null) => {
|
||||
exitCode = code;
|
||||
exitStatus = code === 0 ? "success" : "error";
|
||||
exitPromiseResolve?.();
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err: Error) => {
|
||||
exitStatus = "error";
|
||||
stderr += `\nProcess error: ${err.message}`;
|
||||
exitPromiseResolve?.();
|
||||
});
|
||||
let terminationReason: ManagedProcessTerminationReason = "exit";
|
||||
|
||||
ffmpeg.stdin?.on("error", () => {});
|
||||
ffmpeg.stdout?.on("error", () => {});
|
||||
|
||||
// Handle abort signal
|
||||
const onAbort = () => {
|
||||
if (exitStatus === "running") {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Inactivity timeout: fires only when no frame has been written for
|
||||
// `ffmpegStreamingTimeout` ms. A slow-but-progressing capture (e.g. a CI
|
||||
// runner under load) keeps resetting the timer on each writeFrame, so total
|
||||
@@ -503,16 +474,17 @@ export async function spawnStreamingEncoder(
|
||||
// libx264 printed its summary and exited 255, observable as
|
||||
// "Streaming encode failed: FFmpeg exited with code 255" with audio:0kB).
|
||||
const streamingTimeout = config?.ffmpegStreamingTimeout ?? DEFAULT_CONFIG.ffmpegStreamingTimeout;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const resetTimer = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (exitStatus === "running") {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}
|
||||
}, streamingTimeout);
|
||||
};
|
||||
resetTimer();
|
||||
const managed = new ManagedChildProcess(ffmpeg, {
|
||||
signal,
|
||||
inactivityTimeoutMs: streamingTimeout,
|
||||
});
|
||||
const exitPromise = managed.wait().then((outcome) => {
|
||||
exitCode = outcome.exitCode;
|
||||
stderr = outcome.stderr;
|
||||
terminationReason = outcome.reason;
|
||||
exitStatus = outcome.reason === "exit" && outcome.exitCode === 0 ? "success" : "error";
|
||||
return outcome;
|
||||
});
|
||||
|
||||
const waitForDrainOrExit = async (
|
||||
stdin: NonNullable<ChildProcess["stdin"]>,
|
||||
@@ -539,7 +511,7 @@ export async function spawnStreamingEncoder(
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (exitStatus !== "running") {
|
||||
if (managed.isSettled || exitStatus !== "running") {
|
||||
return "exit";
|
||||
}
|
||||
|
||||
@@ -573,7 +545,7 @@ export async function spawnStreamingEncoder(
|
||||
// before draining, waitForDrainOrExit returns "exit", removes its
|
||||
// one-shot listeners, and callers see `false` instead of hanging.
|
||||
if (accepted) {
|
||||
resetTimer();
|
||||
managed.markActivity();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -581,7 +553,7 @@ export async function spawnStreamingEncoder(
|
||||
if (drainResult !== "drain" || exitStatus !== "running") {
|
||||
return false;
|
||||
}
|
||||
resetTimer();
|
||||
managed.markActivity();
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -590,9 +562,6 @@ export async function spawnStreamingEncoder(
|
||||
// path tracks an `encoderClosed` flag and may still re-call close() in
|
||||
// the outer finally if the inner cleanup raised before the flag flipped.
|
||||
// Each step here must be safe to repeat:
|
||||
// - clearTimeout: safe to call on an already-cleared/fired timer
|
||||
// - removeEventListener: no-op if the listener was already removed
|
||||
// (and {once: true} would have removed it on the first abort anyway)
|
||||
// - stdin.end gated on !destroyed: skipped on the second call
|
||||
// - exitPromise: a single shared Promise; awaiting an already-resolved
|
||||
// Promise resolves immediately with the same captured exitCode
|
||||
@@ -600,12 +569,6 @@ export async function spawnStreamingEncoder(
|
||||
// repeated calls. If you change this method, preserve idempotency or
|
||||
// a regression here will silently double-close ffmpeg and produce
|
||||
// harder-to-trace errors at the orchestrator layer.
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
|
||||
const stdin = ffmpeg.stdin;
|
||||
if (stdin && !stdin.destroyed) {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -613,11 +576,10 @@ export async function spawnStreamingEncoder(
|
||||
});
|
||||
}
|
||||
|
||||
await exitPromise;
|
||||
const outcome = await exitPromise;
|
||||
const durationMs = outcome.durationMs;
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (signal?.aborted) {
|
||||
if (terminationReason === "abort") {
|
||||
return {
|
||||
success: false,
|
||||
durationMs,
|
||||
@@ -627,11 +589,15 @@ export async function spawnStreamingEncoder(
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const inactivitySuffix =
|
||||
terminationReason === "inactivity"
|
||||
? `\nFFmpeg stopped after ${streamingTimeout} ms without consuming a frame.`
|
||||
: "";
|
||||
return {
|
||||
success: false,
|
||||
durationMs,
|
||||
fileSize: 0,
|
||||
error: formatFfmpegError(exitCode, stderr),
|
||||
error: `${formatFfmpegError(exitCode, stderr)}${inactivitySuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
* Videos are replaced with <img> elements during capture.
|
||||
*/
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync } from "fs";
|
||||
import { isAbsolute, join, posix, resolve, sep } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { decodeUrlPathVariants, MEDIA_DURATION_CLAMP_EPSILON_SECONDS } from "@hyperframes/core";
|
||||
import { trackChildProcess } from "../utils/processTracker.js";
|
||||
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js";
|
||||
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
|
||||
import {
|
||||
@@ -20,7 +18,7 @@ import {
|
||||
type HdrTransfer,
|
||||
} from "../utils/hdr.js";
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
import {
|
||||
@@ -328,78 +326,51 @@ export async function extractVideoFramesRange(
|
||||
if (format === "png") args.push("-compression_level", "1");
|
||||
args.push("-y", outputPattern);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
const onAbort = () => {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
const processResult = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
if (processResult.terminationReason === "abort") {
|
||||
throw new Error("Video frame extraction cancelled");
|
||||
}
|
||||
if (processResult.terminationReason === "spawn_error") {
|
||||
if ((processResult.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
throw new Error("[FFmpeg] ffmpeg not found");
|
||||
}
|
||||
throw processResult.error ?? new Error(processResult.stderr);
|
||||
}
|
||||
if (!processResult.success) {
|
||||
// With the SDR-to-HDR remap folded into this pass, a filter failure
|
||||
// (e.g. an ffmpeg built without the colorspace filter) would otherwise
|
||||
// surface as a generic extract error and the operator has to grep the
|
||||
// filter chain to learn it was the HDR conversion. Attribute it.
|
||||
const hdrPrefix = options.sdrToHdrTransfer
|
||||
? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
|
||||
: "";
|
||||
const timeoutSuffix =
|
||||
processResult.terminationReason === "deadline"
|
||||
? ` (timed out after ${ffmpegProcessTimeout} ms)`
|
||||
: "";
|
||||
throw new Error(
|
||||
`${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ${processResult.stderr.slice(-500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}, ffmpegProcessTimeout);
|
||||
|
||||
ffmpeg.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Video frame extraction cancelled"));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
// With the SDR-to-HDR remap folded into this pass, a filter failure
|
||||
// (e.g. an ffmpeg built without the colorspace filter) would otherwise
|
||||
// surface as a generic extract error and the operator has to grep the
|
||||
// filter chain to learn it was the HDR conversion. Attribute it.
|
||||
const hdrPrefix = options.sdrToHdrTransfer
|
||||
? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
|
||||
: "";
|
||||
reject(new Error(`${hdrPrefix}FFmpeg exited with code ${code}: ${stderr.slice(-500)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const framePaths = new Map<number, string>();
|
||||
const files = readdirSync(videoOutputDir)
|
||||
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(`.${format}`))
|
||||
.sort();
|
||||
files.forEach((file, index) => {
|
||||
framePaths.set(index, join(videoOutputDir, file));
|
||||
});
|
||||
|
||||
resolve({
|
||||
videoId,
|
||||
srcPath: videoPath,
|
||||
outputDir: videoOutputDir,
|
||||
framePattern,
|
||||
fps,
|
||||
totalFrames: framePaths.size,
|
||||
metadata,
|
||||
framePaths,
|
||||
});
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[FFmpeg] ffmpeg not found"));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
const framePaths = new Map<number, string>();
|
||||
const files = readdirSync(videoOutputDir)
|
||||
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(`.${format}`))
|
||||
.sort();
|
||||
files.forEach((file, index) => {
|
||||
framePaths.set(index, join(videoOutputDir, file));
|
||||
});
|
||||
|
||||
return {
|
||||
videoId,
|
||||
srcPath: videoPath,
|
||||
outputDir: videoOutputDir,
|
||||
framePattern,
|
||||
fps,
|
||||
totalFrames: framePaths.size,
|
||||
metadata,
|
||||
framePaths,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,42 +3,40 @@ import { spawn } from "child_process";
|
||||
import { readFileSync } from "fs";
|
||||
import { extname } from "path";
|
||||
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
|
||||
import { ManagedChildProcess } from "./managedChildProcess.js";
|
||||
import { trackChildProcess } from "./processTracker.js";
|
||||
|
||||
/** Spawn ffprobe with given args, return stdout. Throws on non-zero exit or missing binary. */
|
||||
function runFfprobe(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const command = getFfprobeBinary();
|
||||
const proc = spawn(command, args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||
reject(
|
||||
new Error(
|
||||
configured
|
||||
? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
|
||||
: "[FFmpeg] ffprobe not found. Please install FFmpeg.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
async function runFfprobe(args: string[], signal?: AbortSignal): Promise<string> {
|
||||
const command = getFfprobeBinary();
|
||||
const proc = spawn(command, args);
|
||||
trackChildProcess(proc);
|
||||
let stdout = "";
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
const managed = new ManagedChildProcess(proc, {
|
||||
signal,
|
||||
deadlineAtMs: Date.now() + 30_000,
|
||||
});
|
||||
const outcome = await managed.wait();
|
||||
if (outcome.reason === "spawn_error") {
|
||||
if ((outcome.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
|
||||
throw new Error(
|
||||
configured
|
||||
? `[FFmpeg] ffprobe not found at ${FFPROBE_PATH_ENV}="${configured}". Please install FFmpeg.`
|
||||
: "[FFmpeg] ffprobe not found. Please install FFmpeg.",
|
||||
);
|
||||
}
|
||||
throw outcome.error ?? new Error(outcome.stderr);
|
||||
}
|
||||
if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`[FFmpeg] ffprobe ${outcome.reason} with code ${outcome.exitCode}: ${outcome.stderr}`,
|
||||
);
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function parseProbeJson(stdout: string): FFProbeOutput {
|
||||
@@ -351,20 +349,21 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
|
||||
*/
|
||||
export const extractVideoMetadata = extractMediaMetadata;
|
||||
|
||||
export async function extractAudioMetadata(filePath: string): Promise<AudioMetadata> {
|
||||
const cached = audioMetadataCache.get(filePath);
|
||||
export async function extractAudioMetadata(
|
||||
filePath: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<AudioMetadata> {
|
||||
// A caller-owned abort signal cannot safely share a cached in-flight probe:
|
||||
// cancelling one consumer would also cancel unrelated consumers. Signal-bound
|
||||
// probes therefore bypass the process-promise cache.
|
||||
const cached = options?.signal ? undefined : audioMetadataCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
|
||||
const probePromise = (async (): Promise<AudioMetadata> => {
|
||||
const stdout = await runFfprobe([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
]);
|
||||
const stdout = await runFfprobe(
|
||||
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
|
||||
options?.signal,
|
||||
);
|
||||
const output = parseProbeJson(stdout);
|
||||
const audioStream = output.streams.find((s) => s.codec_type === "audio");
|
||||
if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
|
||||
@@ -403,6 +402,7 @@ export async function extractAudioMetadata(filePath: string): Promise<AudioMetad
|
||||
};
|
||||
})();
|
||||
|
||||
if (options?.signal) return probePromise;
|
||||
audioMetadataCache.set(filePath, probePromise);
|
||||
probePromise.catch(() => {
|
||||
if (audioMetadataCache.get(filePath) === probePromise) {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import { getFfmpegBinary } from "./ffmpegBinaries.js";
|
||||
import { ManagedChildProcess } from "./managedChildProcess.js";
|
||||
import { trackChildProcess } from "./processTracker.js";
|
||||
|
||||
export type ConcreteGpuEncoder = "nvenc" | "videotoolbox" | "vaapi" | "qsv" | "amf";
|
||||
export type GpuEncoder = ConcreteGpuEncoder | null;
|
||||
@@ -60,25 +62,20 @@ export async function selectUsableGpuEncoder(
|
||||
}
|
||||
|
||||
export async function detectGpuEncoder(): Promise<GpuEncoder> {
|
||||
return new Promise((resolve) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
|
||||
ffmpeg.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.on("close", () => {
|
||||
const candidates = getCompiledGpuEncoders(stdout);
|
||||
void selectUsableGpuEncoder(candidates, canUseGpuEncoder)
|
||||
.then(resolve)
|
||||
.catch(() => resolve(null));
|
||||
});
|
||||
|
||||
ffmpeg.on("error", () => resolve(null));
|
||||
const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
trackChildProcess(ffmpeg);
|
||||
let stdout = "";
|
||||
ffmpeg.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
const outcome = await new ManagedChildProcess(ffmpeg, {
|
||||
deadlineAtMs: Date.now() + 30_000,
|
||||
}).wait();
|
||||
if (outcome.reason !== "exit" || outcome.exitCode !== 0) return null;
|
||||
const candidates = getCompiledGpuEncoders(stdout);
|
||||
return selectUsableGpuEncoder(candidates, canUseGpuEncoder).catch(() => null);
|
||||
}
|
||||
|
||||
let cachedGpuEncoder: GpuEncoder | undefined = undefined;
|
||||
@@ -147,46 +144,23 @@ export function getProbeArgs(encoder: ConcreteGpuEncoder): string[] {
|
||||
}
|
||||
|
||||
async function canUseGpuEncoder(encoder: ConcreteGpuEncoder): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stderr = "";
|
||||
const finish = (usable: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (killTimer) clearTimeout(killTimer);
|
||||
resolve(usable);
|
||||
};
|
||||
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
ffmpeg.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
ffmpeg.kill("SIGTERM");
|
||||
killTimer = setTimeout(() => {
|
||||
ffmpeg.kill("SIGKILL");
|
||||
finish(false);
|
||||
}, GPU_PROBE_KILL_GRACE_MS);
|
||||
}, GPU_PROBE_TIMEOUT_MS);
|
||||
|
||||
ffmpeg.on("close", (code, signal) => {
|
||||
const usable = code === 0;
|
||||
logGpuProbeFailure(encoder, { code, signal, stderr, timedOut });
|
||||
finish(usable);
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (error) => {
|
||||
logGpuProbeFailure(encoder, { error, timedOut });
|
||||
finish(false);
|
||||
});
|
||||
const ffmpeg = spawn(getFfmpegBinary(), getProbeArgs(encoder), {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
trackChildProcess(ffmpeg);
|
||||
const outcome = await new ManagedChildProcess(ffmpeg, {
|
||||
deadlineAtMs: Date.now() + GPU_PROBE_TIMEOUT_MS,
|
||||
terminationGraceMs: GPU_PROBE_KILL_GRACE_MS,
|
||||
}).wait();
|
||||
const usable = outcome.reason === "exit" && outcome.exitCode === 0;
|
||||
logGpuProbeFailure(encoder, {
|
||||
code: outcome.exitCode,
|
||||
signal: outcome.signal,
|
||||
stderr: outcome.stderr,
|
||||
error: outcome.error,
|
||||
timedOut: outcome.reason === "deadline",
|
||||
});
|
||||
return usable;
|
||||
}
|
||||
|
||||
function logGpuProbeFailure(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ManagedChildProcess } from "./managedChildProcess.js";
|
||||
|
||||
function childProcess() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn().mockReturnValue(true);
|
||||
return child as unknown as ChildProcess & {
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("ManagedChildProcess", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns a typed natural exit and bounded stderr tail", async () => {
|
||||
const child = childProcess();
|
||||
const managed = new ManagedChildProcess(child, { stderrMaxBytes: 5 });
|
||||
child.stderr.emit("data", Buffer.from("123456789"));
|
||||
child.emit("close", 0, null);
|
||||
|
||||
await expect(managed.wait()).resolves.toMatchObject({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
stderr: "56789",
|
||||
});
|
||||
});
|
||||
|
||||
it("escalates abort from SIGTERM to SIGKILL and resolves only after close", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = childProcess();
|
||||
const controller = new AbortController();
|
||||
const managed = new ManagedChildProcess(child, {
|
||||
signal: controller.signal,
|
||||
terminationGraceMs: 50,
|
||||
});
|
||||
|
||||
controller.abort();
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
|
||||
let reaped = false;
|
||||
void managed.wait().then(() => {
|
||||
reaped = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(reaped).toBe(false);
|
||||
child.emit("close", null, "SIGKILL");
|
||||
await expect(managed.wait()).resolves.toMatchObject({ reason: "abort", signal: "SIGKILL" });
|
||||
});
|
||||
|
||||
it("keeps escalation and reaping active after a post-spawn error", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = childProcess();
|
||||
const controller = new AbortController();
|
||||
const managed = new ManagedChildProcess(child, {
|
||||
signal: controller.signal,
|
||||
terminationGraceMs: 50,
|
||||
});
|
||||
child.emit("spawn");
|
||||
|
||||
controller.abort();
|
||||
child.emit("error", new Error("kill EPERM"));
|
||||
|
||||
let reaped = false;
|
||||
void managed.wait().then(() => {
|
||||
reaped = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(reaped).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
||||
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
||||
child.emit("error", new Error("kill EPERM"));
|
||||
expect(reaped).toBe(false);
|
||||
|
||||
child.emit("close", null, "SIGKILL");
|
||||
await expect(managed.wait()).resolves.toMatchObject({ reason: "abort", signal: "SIGKILL" });
|
||||
});
|
||||
|
||||
it("distinguishes deadline from inactivity and refreshes activity", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deadlineChild = childProcess();
|
||||
const deadline = new ManagedChildProcess(deadlineChild, {
|
||||
deadlineAtMs: Date.now() + 100,
|
||||
terminationGraceMs: 1_000,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(deadlineChild.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
deadlineChild.emit("close", null, "SIGTERM");
|
||||
await expect(deadline.wait()).resolves.toMatchObject({ reason: "deadline" });
|
||||
|
||||
const inactiveChild = childProcess();
|
||||
const inactive = new ManagedChildProcess(inactiveChild, {
|
||||
inactivityTimeoutMs: 100,
|
||||
terminationGraceMs: 1_000,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(75);
|
||||
inactive.markActivity();
|
||||
await vi.advanceTimersByTimeAsync(75);
|
||||
expect(inactiveChild.kill).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(inactiveChild.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
inactiveChild.emit("close", null, "SIGTERM");
|
||||
await expect(inactive.wait()).resolves.toMatchObject({ reason: "inactivity" });
|
||||
});
|
||||
|
||||
it("settles a spawn failure and removes cancellation listeners", async () => {
|
||||
const child = childProcess();
|
||||
const controller = new AbortController();
|
||||
const managed = new ManagedChildProcess(child, { signal: controller.signal });
|
||||
child.emit("error", new Error("spawn ENOENT"));
|
||||
controller.abort();
|
||||
|
||||
await expect(managed.wait()).resolves.toMatchObject({
|
||||
reason: "spawn_error",
|
||||
exitCode: null,
|
||||
stderr: "spawn ENOENT",
|
||||
});
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
export type ManagedProcessTerminationReason =
|
||||
| "exit"
|
||||
| "abort"
|
||||
| "deadline"
|
||||
| "inactivity"
|
||||
| "spawn_error";
|
||||
|
||||
export interface ManagedChildProcessOutcome {
|
||||
reason: ManagedProcessTerminationReason;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface ManagedChildProcessOptions {
|
||||
signal?: AbortSignal;
|
||||
deadlineAtMs?: number;
|
||||
inactivityTimeoutMs?: number;
|
||||
terminationGraceMs?: number;
|
||||
stderrMaxBytes?: number;
|
||||
onStderr?: (chunk: string) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINATION_GRACE_MS = 2_000;
|
||||
const DEFAULT_STDERR_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** Owns cancellation, escalation, stderr and reaping for one child process. */
|
||||
export class ManagedChildProcess {
|
||||
private readonly startedAtMs: number;
|
||||
private readonly now: () => number;
|
||||
private readonly outcomePromise: Promise<ManagedChildProcessOutcome>;
|
||||
private resolveOutcome!: (outcome: ManagedChildProcessOutcome) => void;
|
||||
private requestedReason: Exclude<ManagedProcessTerminationReason, "exit" | "spawn_error"> | null =
|
||||
null;
|
||||
private stderrTail = Buffer.alloc(0);
|
||||
private spawned = false;
|
||||
private settled = false;
|
||||
private deadlineTimer: NodeJS.Timeout | null = null;
|
||||
private inactivityTimer: NodeJS.Timeout | null = null;
|
||||
private escalationTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
readonly child: ChildProcess,
|
||||
private readonly options: ManagedChildProcessOptions = {},
|
||||
) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.startedAtMs = this.now();
|
||||
this.outcomePromise = new Promise((resolve) => {
|
||||
this.resolveOutcome = resolve;
|
||||
});
|
||||
|
||||
child.stderr?.on("data", this.onStderr);
|
||||
child.once("spawn", this.onSpawn);
|
||||
child.once("close", this.onClose);
|
||||
child.on("error", this.onError);
|
||||
this.installAbort();
|
||||
this.installDeadline();
|
||||
this.markActivity();
|
||||
}
|
||||
|
||||
wait(): Promise<ManagedChildProcessOutcome> {
|
||||
return this.outcomePromise;
|
||||
}
|
||||
|
||||
get isSettled(): boolean {
|
||||
return this.settled;
|
||||
}
|
||||
|
||||
markActivity(): void {
|
||||
if (this.settled || this.options.inactivityTimeoutMs === undefined) return;
|
||||
if (this.inactivityTimer) clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = setTimeout(
|
||||
() => this.requestTermination("inactivity"),
|
||||
Math.max(0, this.options.inactivityTimeoutMs),
|
||||
);
|
||||
this.inactivityTimer.unref?.();
|
||||
}
|
||||
|
||||
private readonly onStderr = (data: Buffer | string): void => {
|
||||
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
const maxBytes = this.options.stderrMaxBytes ?? DEFAULT_STDERR_MAX_BYTES;
|
||||
this.stderrTail = Buffer.concat([this.stderrTail, chunk]);
|
||||
if (this.stderrTail.byteLength > maxBytes) {
|
||||
this.stderrTail = this.stderrTail.subarray(this.stderrTail.byteLength - maxBytes);
|
||||
}
|
||||
this.options.onStderr?.(chunk.toString());
|
||||
};
|
||||
|
||||
private readonly onSpawn = (): void => {
|
||||
this.spawned = true;
|
||||
};
|
||||
|
||||
private readonly onClose = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
||||
this.settle({
|
||||
reason: this.requestedReason ?? "exit",
|
||||
exitCode,
|
||||
signal,
|
||||
stderr: this.stderrTail.toString(),
|
||||
durationMs: this.now() - this.startedAtMs,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly onError = (error: Error): void => {
|
||||
if (this.spawned) return;
|
||||
this.settle({
|
||||
reason: "spawn_error",
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
stderr: this.stderrTail.length > 0 ? this.stderrTail.toString() : error.message,
|
||||
durationMs: this.now() - this.startedAtMs,
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
private installAbort(): void {
|
||||
const signal = this.options.signal;
|
||||
if (!signal) return;
|
||||
if (signal.aborted) {
|
||||
this.requestTermination("abort");
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", this.onAbort, { once: true });
|
||||
}
|
||||
|
||||
private readonly onAbort = (): void => {
|
||||
this.requestTermination("abort");
|
||||
};
|
||||
|
||||
private installDeadline(): void {
|
||||
if (this.options.deadlineAtMs === undefined) return;
|
||||
const remainingMs = Math.max(0, this.options.deadlineAtMs - this.now());
|
||||
this.deadlineTimer = setTimeout(() => this.requestTermination("deadline"), remainingMs);
|
||||
this.deadlineTimer.unref?.();
|
||||
}
|
||||
|
||||
private requestTermination(
|
||||
reason: Exclude<ManagedProcessTerminationReason, "exit" | "spawn_error">,
|
||||
): void {
|
||||
if (this.settled || this.requestedReason) return;
|
||||
this.requestedReason = reason;
|
||||
try {
|
||||
this.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// A close/error event owns settlement; escalation remains the backstop.
|
||||
}
|
||||
const graceMs = this.options.terminationGraceMs ?? DEFAULT_TERMINATION_GRACE_MS;
|
||||
this.escalationTimer = setTimeout(
|
||||
() => {
|
||||
if (this.settled) return;
|
||||
try {
|
||||
this.child.kill("SIGKILL");
|
||||
} catch {
|
||||
// The child may have exited between the settled check and kill.
|
||||
}
|
||||
},
|
||||
Math.max(0, graceMs),
|
||||
);
|
||||
this.escalationTimer.unref?.();
|
||||
}
|
||||
|
||||
private settle(outcome: ManagedChildProcessOutcome): void {
|
||||
if (this.settled) return;
|
||||
this.settled = true;
|
||||
this.clearTimers();
|
||||
this.options.signal?.removeEventListener("abort", this.onAbort);
|
||||
this.child.stderr?.off("data", this.onStderr);
|
||||
this.child.off("spawn", this.onSpawn);
|
||||
this.child.off("close", this.onClose);
|
||||
this.child.off("error", this.onError);
|
||||
this.resolveOutcome(outcome);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.deadlineTimer) clearTimeout(this.deadlineTimer);
|
||||
if (this.inactivityTimer) clearTimeout(this.inactivityTimer);
|
||||
if (this.escalationTimer) clearTimeout(this.escalationTimer);
|
||||
this.deadlineTimer = null;
|
||||
this.inactivityTimer = null;
|
||||
this.escalationTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { trackChildProcess, killTrackedProcesses } from "./processTracker.js";
|
||||
|
||||
@@ -18,14 +18,45 @@ describe("trackChildProcess", () => {
|
||||
killTrackedProcesses();
|
||||
});
|
||||
|
||||
it("removes the process on spawn error", async () => {
|
||||
const proc = spawn("/nonexistent-binary-that-does-not-exist", { stdio: "ignore" });
|
||||
it("removes an exited process before its stdio closes", async () => {
|
||||
const proc = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
const closePromise = new Promise<void>((resolve) => proc.on("close", resolve));
|
||||
const kill = vi.spyOn(proc, "kill");
|
||||
trackChildProcess(proc);
|
||||
|
||||
await new Promise<void>((resolve) => proc.on("error", () => resolve()));
|
||||
try {
|
||||
proc.emit("exit", 0, null);
|
||||
killTrackedProcesses();
|
||||
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
proc.kill("SIGKILL");
|
||||
await closePromise;
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the process on spawn error", async () => {
|
||||
const proc = spawn("/nonexistent-binary-that-does-not-exist", { stdio: "ignore" });
|
||||
proc.on("error", () => undefined);
|
||||
trackChildProcess(proc);
|
||||
|
||||
await new Promise<void>((resolve) => proc.on("close", () => resolve()));
|
||||
|
||||
killTrackedProcesses();
|
||||
});
|
||||
|
||||
it("keeps a process tracked after a post-spawn error", () => {
|
||||
const proc = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
const kill = vi.spyOn(proc, "kill");
|
||||
proc.on("error", () => undefined);
|
||||
trackChildProcess(proc);
|
||||
|
||||
proc.emit("error", new Error("kill EPERM"));
|
||||
killTrackedProcesses();
|
||||
|
||||
expect(kill).toHaveBeenCalledWith("SIGTERM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("killTrackedProcesses", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ export function trackChildProcess(proc: ChildProcess): void {
|
||||
tracked.add(proc);
|
||||
const remove = () => tracked.delete(proc);
|
||||
proc.once("exit", remove);
|
||||
proc.once("error", remove);
|
||||
proc.once("close", remove);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
import { spawn } from "child_process";
|
||||
import { getFfmpegBinary } from "./ffmpegBinaries.js";
|
||||
import { trackChildProcess } from "./processTracker.js";
|
||||
import {
|
||||
ManagedChildProcess,
|
||||
type ManagedProcessTerminationReason,
|
||||
} from "./managedChildProcess.js";
|
||||
|
||||
export interface RunFfmpegOptions {
|
||||
signal?: AbortSignal;
|
||||
@@ -21,6 +25,8 @@ export interface RunFfmpegResult {
|
||||
exitCode: number | null;
|
||||
stderr: string;
|
||||
durationMs: number;
|
||||
terminationReason: ManagedProcessTerminationReason;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 300_000;
|
||||
@@ -85,60 +91,21 @@ export function formatFfmpegError(
|
||||
}
|
||||
|
||||
export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult> {
|
||||
const startMs = Date.now();
|
||||
const signal = opts?.signal;
|
||||
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
|
||||
const onStderr = opts?.onStderr;
|
||||
|
||||
return new Promise<RunFfmpegResult>((resolve) => {
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
let stderr = "";
|
||||
|
||||
const onAbort = () => {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ffmpeg.kill("SIGTERM");
|
||||
}, timeout);
|
||||
|
||||
ffmpeg.stderr.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stderr += chunk;
|
||||
if (onStderr) {
|
||||
onStderr(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
success: !signal?.aborted && code === 0,
|
||||
exitCode: code,
|
||||
stderr,
|
||||
durationMs: Date.now() - startMs,
|
||||
});
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stderr: err.message,
|
||||
durationMs: Date.now() - startMs,
|
||||
});
|
||||
});
|
||||
const ffmpeg = spawn(getFfmpegBinary(), args);
|
||||
trackChildProcess(ffmpeg);
|
||||
const managed = new ManagedChildProcess(ffmpeg, {
|
||||
signal: opts?.signal,
|
||||
deadlineAtMs: Date.now() + timeout,
|
||||
onStderr: opts?.onStderr,
|
||||
});
|
||||
const outcome = await managed.wait();
|
||||
return {
|
||||
success: outcome.reason === "exit" && outcome.exitCode === 0,
|
||||
exitCode: outcome.exitCode,
|
||||
stderr: outcome.stderr,
|
||||
durationMs: outcome.durationMs,
|
||||
terminationReason: outcome.reason,
|
||||
error: outcome.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core";
|
||||
import { DEFAULT_VP9_CPU_USED, getFfmpegBinary } from "@hyperframes/engine";
|
||||
import { DEFAULT_VP9_CPU_USED, runFfmpeg } from "@hyperframes/engine";
|
||||
import { isHttpUrl } from "../utils/urlDownloader.js";
|
||||
|
||||
const PREPARED_GIF_SUBDIR = "_animated_gif";
|
||||
@@ -247,30 +246,16 @@ export function buildAnimatedGifTranscodeArgs(input: {
|
||||
}
|
||||
|
||||
async function runAnimatedGifTranscode(request: AnimatedGifTranscodeRequest): Promise<void> {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const proc = spawn(getFfmpegBinary(), request.args);
|
||||
let stderr = "";
|
||||
const timeout = request.timeoutMs ?? 300_000;
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill("SIGTERM");
|
||||
reject(new Error(`Animated GIF transcode timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Animated GIF transcode failed (${code}): ${stderr.slice(-500)}`));
|
||||
});
|
||||
proc.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
const timeout = request.timeoutMs ?? 300_000;
|
||||
const result = await runFfmpeg(request.args, { timeout });
|
||||
if (result.success) return;
|
||||
if (result.terminationReason === "deadline") {
|
||||
throw new Error(`Animated GIF transcode timed out after ${timeout}ms`);
|
||||
}
|
||||
throw (
|
||||
result.error ??
|
||||
new Error(`Animated GIF transcode failed (${result.exitCode}): ${result.stderr.slice(-500)}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function ensurePreparedWebm(input: {
|
||||
|
||||
@@ -296,6 +296,7 @@ export async function assemble(
|
||||
videoPath: postConcatPath,
|
||||
audioPath,
|
||||
outputPath: paddedAudioPath,
|
||||
signal: abortSignal,
|
||||
});
|
||||
if (!padTrimResult.success) {
|
||||
throw new Error(`[assemble] audio pad/trim failed: ${padTrimResult.error}`);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* No real ffmpeg/ffprobe runs in these tests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { describe, expect, it, mock } from "bun:test";
|
||||
import {
|
||||
buildPadTrimAudioArgs,
|
||||
buildPadTrimAudioPlan,
|
||||
@@ -198,6 +198,24 @@ describe("padOrTrimAudioToVideoFrameCount", () => {
|
||||
return { input, captured };
|
||||
}
|
||||
|
||||
it("passes the render abort signal to the audio metadata probe", async () => {
|
||||
const controller = new AbortController();
|
||||
const probeVideoFrameInfo = mock(async () => ({ frameCount: 30, fpsNum: 30, fpsDen: 1 }));
|
||||
const probeAudioInfo = mock(async () => ({ durationSeconds: 1 }));
|
||||
|
||||
await padOrTrimAudioToVideoFrameCount({
|
||||
videoPath: "/tmp/v.mp4",
|
||||
audioPath: "/tmp/a.aac",
|
||||
outputPath: "/tmp/o.aac",
|
||||
signal: controller.signal,
|
||||
probeVideoFrameInfo,
|
||||
probeAudioInfo,
|
||||
runFfmpeg: mock(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
expect(probeAudioInfo).toHaveBeenCalledWith("/tmp/a.aac", controller.signal);
|
||||
});
|
||||
|
||||
it("pads a video of N=180 frames at 30/1 fps with shorter audio", async () => {
|
||||
const { input, captured } = harness({
|
||||
video: { frameCount: 180, fpsNum: 30, fpsDen: 1 },
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
extractAudioMetadata,
|
||||
formatFfmpegError,
|
||||
getFfprobeBinary,
|
||||
ManagedChildProcess,
|
||||
runFfmpeg,
|
||||
trackChildProcess,
|
||||
type AudioMetadata,
|
||||
} from "@hyperframes/engine";
|
||||
|
||||
@@ -62,12 +64,13 @@ export interface PadTrimAudioInput {
|
||||
audioPath: string;
|
||||
/** Path the helper writes the duration-corrected audio to. */
|
||||
outputPath: string;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Optional injectables for unit tests. Production callers omit them and
|
||||
* get the real `ffprobe`/`ffmpeg`-backed implementations.
|
||||
*/
|
||||
probeVideoFrameInfo?: (videoPath: string) => Promise<ProbeVideoFrameInfo>;
|
||||
probeAudioInfo?: (audioPath: string) => Promise<AudioProbeInfo>;
|
||||
probeAudioInfo?: (audioPath: string, signal?: AbortSignal) => Promise<AudioProbeInfo>;
|
||||
runFfmpeg?: (args: string[]) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
@@ -265,15 +268,17 @@ function concatFileLine(path: string): string {
|
||||
export async function padOrTrimAudioToVideoFrameCount(
|
||||
input: PadTrimAudioInput,
|
||||
): Promise<PadTrimAudioResult> {
|
||||
const probeVideo = input.probeVideoFrameInfo ?? defaultProbeVideoFrameInfo;
|
||||
const probeVideo =
|
||||
input.probeVideoFrameInfo ??
|
||||
((videoPath: string) => defaultProbeVideoFrameInfo(videoPath, input.signal));
|
||||
const probeAudio = input.probeAudioInfo ?? defaultProbeAudioInfo;
|
||||
const runner = input.runFfmpeg ?? defaultRunFfmpeg;
|
||||
const runner = input.runFfmpeg ?? ((args: string[]) => defaultRunFfmpeg(args, input.signal));
|
||||
|
||||
// Probe video and audio in parallel — the two ffprobe invocations are
|
||||
// independent and account for most of this function's wall-clock time.
|
||||
const [videoResult, audioResult] = await Promise.allSettled([
|
||||
probeVideo(input.videoPath),
|
||||
probeAudio(input.audioPath),
|
||||
probeAudio(input.audioPath, input.signal),
|
||||
]);
|
||||
|
||||
if (videoResult.status === "rejected") {
|
||||
@@ -393,39 +398,48 @@ interface FfprobeOutput {
|
||||
streams?: FfprobeStreamInfo[];
|
||||
}
|
||||
|
||||
async function defaultProbeVideoFrameInfo(videoPath: string): Promise<ProbeVideoFrameInfo> {
|
||||
async function defaultProbeVideoFrameInfo(
|
||||
videoPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProbeVideoFrameInfo> {
|
||||
// Try the container header (`nb_frames`) first — single moov atom read,
|
||||
// no decode. Closed-GOP, B-frame-free streams (the only ones we'll ever
|
||||
// ask to pad/trim) reliably set it. Fall back to `-count_packets` which
|
||||
// walks the packet stream when the header doesn't carry the count.
|
||||
const fastInfo = await runFfprobeJson<FfprobeOutput>([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=nb_frames,r_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
videoPath,
|
||||
]);
|
||||
const fastInfo = await runFfprobeJson<FfprobeOutput>(
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=nb_frames,r_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
videoPath,
|
||||
],
|
||||
signal,
|
||||
);
|
||||
let stream = fastInfo.streams?.[0];
|
||||
const fastCount = Number(stream?.nb_frames);
|
||||
if (stream && Number.isFinite(fastCount) && fastCount > 0) {
|
||||
return { frameCount: fastCount, ...parseFrameRate(stream.r_frame_rate ?? "") };
|
||||
}
|
||||
const slowInfo = await runFfprobeJson<FfprobeOutput>([
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_packets",
|
||||
"-show_entries",
|
||||
"stream=nb_read_packets,r_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
videoPath,
|
||||
]);
|
||||
const slowInfo = await runFfprobeJson<FfprobeOutput>(
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-count_packets",
|
||||
"-show_entries",
|
||||
"stream=nb_read_packets,r_frame_rate",
|
||||
"-of",
|
||||
"json",
|
||||
videoPath,
|
||||
],
|
||||
signal,
|
||||
);
|
||||
stream = slowInfo.streams?.[0];
|
||||
if (!stream) throw new Error(`ffprobe found no video stream in ${videoPath}`);
|
||||
const slowCount = Number(stream.nb_read_packets);
|
||||
@@ -445,10 +459,13 @@ function parseFrameRate(rate: string): { fpsNum: number; fpsDen: number } {
|
||||
return { fpsNum, fpsDen };
|
||||
}
|
||||
|
||||
async function defaultProbeAudioInfo(audioPath: string): Promise<AudioProbeInfo> {
|
||||
async function defaultProbeAudioInfo(
|
||||
audioPath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AudioProbeInfo> {
|
||||
// The shared ffprobe wrapper derives AAC-LC duration from packet count so
|
||||
// every consumer sees the same VBR-safe metadata.
|
||||
const metadata: AudioMetadata = await extractAudioMetadata(audioPath);
|
||||
// every consumer sees the same VBR-safe metadata while preserving cancellation.
|
||||
const metadata: AudioMetadata = await extractAudioMetadata(audioPath, { signal });
|
||||
return {
|
||||
durationSeconds: metadata.durationSeconds,
|
||||
sampleRate: metadata.sampleRate,
|
||||
@@ -457,8 +474,11 @@ async function defaultProbeAudioInfo(audioPath: string): Promise<AudioProbeInfo>
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultRunFfmpeg(args: string[]): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await runFfmpeg(args);
|
||||
async function defaultRunFfmpeg(
|
||||
args: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await runFfmpeg(args, { signal });
|
||||
if (result.success) return { success: true };
|
||||
return {
|
||||
success: false,
|
||||
@@ -468,34 +488,30 @@ async function defaultRunFfmpeg(args: string[]): Promise<{ success: boolean; err
|
||||
|
||||
// ── ffprobe JSON runner (shared between fast/slow video probe paths) ─────
|
||||
|
||||
function runFfprobeJson<T>(args: string[]): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(getFfprobeBinary(), args);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
reject(new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg."));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffprobe exited ${code}: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout) as T);
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse ffprobe output: ${(err as Error).message}`));
|
||||
}
|
||||
});
|
||||
async function runFfprobeJson<T>(args: string[], signal?: AbortSignal): Promise<T> {
|
||||
const proc = spawn(getFfprobeBinary(), args);
|
||||
trackChildProcess(proc);
|
||||
let stdout = "";
|
||||
proc.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
const managed = new ManagedChildProcess(proc, {
|
||||
signal,
|
||||
deadlineAtMs: Date.now() + 30_000,
|
||||
});
|
||||
const outcome = await managed.wait();
|
||||
if (outcome.reason === "spawn_error") {
|
||||
if ((outcome.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||
throw new Error("[audioPadTrim] ffprobe not found. Please install FFmpeg.");
|
||||
}
|
||||
throw outcome.error ?? new Error(outcome.stderr);
|
||||
}
|
||||
if (outcome.reason !== "exit" || outcome.exitCode !== 0) {
|
||||
throw new Error(`ffprobe ${outcome.reason}: ${outcome.stderr}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse ffprobe output: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user