fix(producer): pass resolved engine config through every encode path (#1371)

encodeFramesFromDir was called with 5 of its 6 args, dropping the config
param — the encode timeout always fell back to the hardcoded 600s default
and FFMPEG_ENCODE_TIMEOUT_MS was silently ignored, so any encode over
600s wall time was deterministically SIGTERM-killed. Resolve the engine
config once in the encode stage and pass it to the non-chunked, chunked,
and GIF paths. The chunked per-chunk encodes previously had no timeout at
all; they now honor the same config value.

Review follow-ups: the chunked path's final concat spawn gains the same
config-driven timeout (it previously had none); every encode-timeout
kill now appends 'FFmpeg killed after exceeding ffmpegEncodeTimeout
(N ms)' to the failure instead of surfacing a bare exit-255; and the
orchestrator threads its already-resolved config into the encode stage
via an optional EncodeStageInput.engineConfig field (direct callers and
distributed chunks keep the producerConfig ?? resolveConfig() fallback).
The encode-timeout tests run against a mocked child_process spawn with
fake timers, removing the real-ffmpeg dependency that made the previous
default-timeout test environment-fragile in CI.

Fixes #1348
This commit is contained in:
Miguel Ángel
2026-06-12 12:21:35 -04:00
committed by GitHub
parent a8090ca895
commit 5917c0382d
5 changed files with 581 additions and 15 deletions
@@ -1,6 +1,93 @@
import { describe, it, expect, vi } from "vitest"; import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, it, expect, vi } from "vitest";
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js"; import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAEElEQVR4nGP8wwACLGCSAQANBAECv1AVswAAAABJRU5ErkJggg==",
"base64",
);
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
vi.resetModules();
vi.doUnmock("child_process");
vi.useRealTimers();
});
function createFrameFixture(): { root: string; framesDir: string } {
const root = mkdtempSync(join(tmpdir(), "hf-chunk-encoder-"));
tempDirs.push(root);
const framesDir = join(root, "frames");
mkdirSync(framesDir);
for (let i = 1; i <= 2; i++) {
writeFileSync(join(framesDir, `frame_${String(i).padStart(6, "0")}.png`), TINY_PNG);
}
return { root, framesDir };
}
const tinyEncodeOptions = {
fps: { num: 30, den: 1 },
width: 2,
height: 2,
codec: "h264" as const,
preset: "ultrafast",
quality: 28,
pixelFormat: "yuv420p",
useGpu: false,
};
function encodeTimeoutMessage(timeoutMs: number): string {
return `FFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
}
type FakeProc = EventEmitter & {
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
killed: boolean;
};
type SpawnCall = {
command: string;
args: readonly string[];
proc: FakeProc;
};
function createFakeProc(): FakeProc {
const proc = new EventEmitter() as FakeProc;
proc.stderr = new EventEmitter();
proc.kill = vi.fn(() => {
proc.killed = true;
return true;
});
proc.killed = false;
return proc;
}
function createSpawnSpy(): {
spawn: (command: string, args: readonly string[]) => FakeProc;
calls: SpawnCall[];
} {
const calls: SpawnCall[] = [];
const spawn = (command: string, args: readonly string[]): FakeProc => {
const proc = createFakeProc();
calls.push({ command, args, proc });
return proc;
};
return { spawn, calls };
}
function emitClose(proc: FakeProc, code: number): void {
proc.emit("exit", code);
proc.emit("close", code);
}
describe("ENCODER_PRESETS", () => { describe("ENCODER_PRESETS", () => {
it("has draft, standard, and high presets", () => { it("has draft, standard, and high presets", () => {
expect(ENCODER_PRESETS).toHaveProperty("draft"); expect(ENCODER_PRESETS).toHaveProperty("draft");
@@ -26,6 +113,248 @@ describe("ENCODER_PRESETS", () => {
}); });
}); });
describe("encodeFramesFromDir ffmpegEncodeTimeout", () => {
it("kills ffmpeg when config timeout elapses", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesFromDir(
framesDir,
"frame_%06d.png",
join(root, "timeout.mp4"),
tinyEncodeOptions,
undefined,
{ ffmpegEncodeTimeout: 1000 },
);
expect(calls).toHaveLength(1);
const proc = calls[0]!.proc;
vi.advanceTimersByTime(999);
expect(proc.kill).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
proc.stderr.emit("data", Buffer.from("terminated by timeout\n"));
emitClose(proc, 143);
const result = await encodePromise;
expect(result.success).toBe(false);
expect(result.error).toContain("FFmpeg exited with code 143");
expect(result.error).toContain("terminated by timeout");
expect(result.error).toContain(encodeTimeoutMessage(1000));
});
it("keeps non-timeout ffmpeg failures unchanged", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesFromDir(
framesDir,
"frame_%06d.png",
join(root, "failure.mp4"),
tinyEncodeOptions,
undefined,
{ ffmpegEncodeTimeout: 1000 },
);
expect(calls).toHaveLength(1);
const proc = calls[0]!.proc;
proc.stderr.emit("data", Buffer.from("encoder failed\n"));
emitClose(proc, 1);
const result = await encodePromise;
expect(result.success).toBe(false);
expect(result.error).toContain("FFmpeg exited with code 1");
expect(result.error).toContain("encoder failed");
expect(result.error).not.toContain("ffmpegEncodeTimeout");
});
it("uses the default timeout when config is omitted", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesFromDir(
framesDir,
"frame_%06d.png",
join(root, "default.mp4"),
tinyEncodeOptions,
);
expect(calls).toHaveLength(1);
const proc = calls[0]!.proc;
vi.advanceTimersByTime(599_999);
expect(proc.kill).not.toHaveBeenCalled();
emitClose(proc, 0);
const result = await encodePromise;
expect(result.success).toBe(true);
expect(result.framesEncoded).toBe(2);
expect(result.fileSize).toBe(0);
});
});
describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
it("passes config timeout to per-chunk encodes", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesChunkedConcat(
framesDir,
"frame_%06d.png",
join(root, "chunked.mp4"),
tinyEncodeOptions,
30,
undefined,
{ ffmpegEncodeTimeout: 1000 },
);
expect(calls).toHaveLength(1);
const proc = calls[0]!.proc;
vi.advanceTimersByTime(999);
expect(proc.kill).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
proc.stderr.emit("data", Buffer.from("chunk timeout\n"));
emitClose(proc, 143);
const result = await encodePromise;
expect(result.success).toBe(false);
expect(result.error).toContain("Chunk 0 encode failed");
expect(result.error).toContain("chunk timeout");
expect(result.error).toContain(encodeTimeoutMessage(1000));
});
it("keeps non-timeout chunk failures unchanged", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesChunkedConcat(
framesDir,
"frame_%06d.png",
join(root, "chunked-failure.mp4"),
tinyEncodeOptions,
30,
undefined,
{ ffmpegEncodeTimeout: 1000 },
);
expect(calls).toHaveLength(1);
const proc = calls[0]!.proc;
proc.stderr.emit("data", Buffer.from("chunk failed\n"));
emitClose(proc, 1);
const result = await encodePromise;
expect(result.success).toBe(false);
expect(result.error).toBe("Chunk 0 encode failed: chunk failed\n");
expect(result.error).not.toContain("ffmpegEncodeTimeout");
});
it("kills concat ffmpeg when config timeout elapses", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesChunkedConcat(
framesDir,
"frame_%06d.png",
join(root, "concat-timeout.mp4"),
tinyEncodeOptions,
30,
undefined,
{ ffmpegEncodeTimeout: 1000 },
);
expect(calls).toHaveLength(1);
emitClose(calls[0]!.proc, 0);
await Promise.resolve();
expect(calls).toHaveLength(2);
const concatProc = calls[1]!.proc;
vi.advanceTimersByTime(999);
expect(concatProc.kill).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(concatProc.kill).toHaveBeenCalledWith("SIGTERM");
concatProc.stderr.emit("data", Buffer.from("concat timeout\n"));
emitClose(concatProc, 143);
const result = await encodePromise;
expect(result.success).toBe(false);
expect(result.error).toContain("Chunk concat failed");
expect(result.error).toContain("concat timeout");
expect(result.error).toContain(encodeTimeoutMessage(1000));
});
it("uses the default timeout for per-chunk encodes when config is omitted", async () => {
vi.useFakeTimers();
const { spawn, calls } = createSpawnSpy();
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
const { root, framesDir } = createFrameFixture();
const encodePromise = encodeFramesChunkedConcat(
framesDir,
"frame_%06d.png",
join(root, "chunked-default.mp4"),
tinyEncodeOptions,
30,
);
expect(calls).toHaveLength(1);
const chunkProc = calls[0]!.proc;
vi.advanceTimersByTime(599_999);
expect(chunkProc.kill).not.toHaveBeenCalled();
emitClose(chunkProc, 0);
await Promise.resolve();
expect(calls).toHaveLength(2);
const concatProc = calls[1]!.proc;
emitClose(concatProc, 0);
const result = await encodePromise;
expect(result.success).toBe(true);
expect(result.framesEncoded).toBe(2);
expect(result.fileSize).toBe(0);
});
});
describe("getEncoderPreset", () => { describe("getEncoderPreset", () => {
it("returns h264 with yuv420p for mp4 format", () => { it("returns h264 with yuv420p for mp4 format", () => {
const preset = getEncoderPreset("standard", "mp4"); const preset = getEncoderPreset("standard", "mp4");
+70 -10
View File
@@ -39,6 +39,11 @@ export interface EncoderPreset {
hdr?: { transfer: HdrTransfer }; hdr?: { transfer: HdrTransfer };
} }
function appendEncodeTimeoutMessage(error: string, timedOut: boolean, timeoutMs: number): string {
if (!timedOut) return error;
return `${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
}
/** /**
* Get encoder preset for a given quality and output format. * Get encoder preset for a given quality and output format.
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR); * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
@@ -428,7 +433,9 @@ export async function encodeFramesFromDir(
} }
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout; const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
let timedOut = false;
const timer = setTimeout(() => { const timer = setTimeout(() => {
timedOut = true;
ffmpeg.kill("SIGTERM"); ffmpeg.kill("SIGTERM");
}, encodeTimeout); }, encodeTimeout);
@@ -440,7 +447,7 @@ export async function encodeFramesFromDir(
clearTimeout(timer); clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort); if (signal) signal.removeEventListener("abort", onAbort);
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
if (signal?.aborted) { if (signal?.aborted && !timedOut) {
resolve({ resolve({
success: false, success: false,
outputPath, outputPath,
@@ -452,14 +459,18 @@ export async function encodeFramesFromDir(
return; return;
} }
if (code !== 0) { if (code !== 0 || timedOut) {
resolve({ resolve({
success: false, success: false,
outputPath, outputPath,
durationMs, durationMs,
framesEncoded: 0, framesEncoded: 0,
fileSize: 0, fileSize: 0,
error: formatFfmpegError(code, stderr), error: appendEncodeTimeoutMessage(
formatFfmpegError(code, stderr),
timedOut,
encodeTimeout,
),
}); });
return; return;
} }
@@ -477,7 +488,7 @@ export async function encodeFramesFromDir(
durationMs: Date.now() - startTime, durationMs: Date.now() - startTime,
framesEncoded: 0, framesEncoded: 0,
fileSize: 0, fileSize: 0,
error: `[FFmpeg] ${err.message}`, error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
}); });
}); });
}); });
@@ -490,6 +501,7 @@ export async function encodeFramesChunkedConcat(
options: EncoderOptions, options: EncoderOptions,
chunkSizeFrames: number, chunkSizeFrames: number,
signal?: AbortSignal, signal?: AbortSignal,
config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
): Promise<EncodeResult> { ): Promise<EncodeResult> {
const start = Date.now(); const start = Date.now();
const files = readdirSync(framesDir) const files = readdirSync(framesDir)
@@ -548,15 +560,39 @@ export async function encodeFramesChunkedConcat(
const ffmpeg = spawn(getFfmpegBinary(), args); const ffmpeg = spawn(getFfmpegBinary(), args);
trackChildProcess(ffmpeg); trackChildProcess(ffmpeg);
let stderr = ""; 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) => { ffmpeg.stderr.on("data", (d) => {
stderr += d.toString(); stderr += d.toString();
}); });
ffmpeg.on("close", (code) => { ffmpeg.on("close", (code) => {
if (code === 0) resolve({ success: true }); clearTimeout(timer);
else resolve({ success: false, error: `Chunk ${i} encode failed: ${stderr.slice(-400)}` }); 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) => { ffmpeg.on("error", (err) => {
resolve({ success: false, error: `Chunk ${i} encode error: ${err.message}` }); clearTimeout(timer);
resolve({
success: false,
error: appendEncodeTimeoutMessage(
`Chunk ${i} encode error: ${err.message}`,
timedOut,
encodeTimeout,
),
});
}); });
}); });
if (!chunkResult.success) { if (!chunkResult.success) {
@@ -592,15 +628,39 @@ export async function encodeFramesChunkedConcat(
const ffmpeg = spawn(getFfmpegBinary(), concatArgs); const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
trackChildProcess(ffmpeg); trackChildProcess(ffmpeg);
let stderr = ""; 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) => { ffmpeg.stderr.on("data", (d) => {
stderr += d.toString(); stderr += d.toString();
}); });
ffmpeg.on("close", (code) => { ffmpeg.on("close", (code) => {
if (code === 0) resolve({ success: true }); clearTimeout(timer);
else resolve({ success: false, error: `Chunk concat failed: ${stderr.slice(-400)}` }); 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) => { ffmpeg.on("error", (err) => {
resolve({ success: false, error: `Chunk concat error: ${err.message}` }); clearTimeout(timer);
resolve({
success: false,
error: appendEncodeTimeoutMessage(
`Chunk concat error: ${err.message}`,
timedOut,
encodeTimeout,
),
});
}); });
}); });
@@ -1,5 +1,116 @@
import { describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, mock } from "bun:test";
import { buildGifPalettegenArgs, buildGifPaletteuseArgs } from "./gifEncodeArgs.js"; import { buildGifPalettegenArgs, buildGifPaletteuseArgs } from "./gifEncodeArgs.js";
import type { EncodeStageInput } from "./encodeStage.js";
const resolvedEngineConfig = { ffmpegEncodeTimeout: 12_345 };
const encodeFramesFromDirMock = mock(
async (_framesDir: string, _framePattern: string, outputPath: string) => ({
success: true,
outputPath,
durationMs: 1,
framesEncoded: 1,
fileSize: 1,
}),
);
const encodeFramesChunkedConcatMock = mock(
async (_framesDir: string, _framePattern: string, outputPath: string) => ({
success: true,
outputPath,
durationMs: 1,
framesEncoded: 1,
fileSize: 1,
}),
);
const runFfmpegMock = mock(async () => ({
success: true,
exitCode: 0,
stderr: "",
durationMs: 1,
}));
mock.module("@hyperframes/engine", () => ({
DEFAULT_CONFIG: { ffmpegEncodeTimeout: 600_000 },
encodeFramesChunkedConcat: encodeFramesChunkedConcatMock,
encodeFramesFromDir: encodeFramesFromDirMock,
formatFfmpegError: (code: number | null, stderr: string) => `${String(code)} ${stderr}`,
getEncoderPreset: () => ({
codec: "h264",
preset: "ultrafast",
quality: 28,
pixelFormat: "yuv420p",
}),
resolveConfig: () => resolvedEngineConfig,
runFfmpeg: runFfmpegMock,
}));
const tempDirs: string[] = [];
afterEach(() => {
encodeFramesFromDirMock.mockClear();
encodeFramesChunkedConcatMock.mockClear();
runFfmpegMock.mockClear();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function createFramesDir(ext: "jpg" | "png"): { root: string; framesDir: string } {
const root = mkdtempSync(join(tmpdir(), "hf-encode-stage-"));
tempDirs.push(root);
const framesDir = join(root, "frames");
mkdirSync(framesDir);
writeFileSync(join(framesDir, `frame_000001.${ext}`), "stub");
return { root, framesDir };
}
function makeInput(overrides: Partial<EncodeStageInput> = {}): EncodeStageInput {
const paths = createFramesDir("jpg");
return {
job: {
id: "encode-stage-config-test",
config: {
fps: { num: 30, den: 1 },
quality: "draft",
},
status: "queued",
progress: 0,
currentStage: "queued",
createdAt: new Date(0),
duration: 1,
},
log: {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
},
outputPath: join(paths.root, "out.mp4"),
framesDir: paths.framesDir,
videoOnlyPath: join(paths.root, "video-only.mp4"),
width: 2,
height: 2,
needsAlpha: false,
hasAudio: false,
isPngSequence: false,
isGif: false,
preset: {
codec: "h264",
preset: "ultrafast",
quality: 28,
pixelFormat: "yuv420p",
},
effectiveQuality: 28,
effectiveBitrate: undefined,
enableChunkedEncode: false,
chunkedEncodeSize: 30,
abortSignal: undefined,
assertNotAborted: () => {},
...overrides,
};
}
describe("gif encode args", () => { describe("gif encode args", () => {
const input = { const input = {
@@ -41,3 +152,55 @@ describe("gif encode args", () => {
]); ]);
}); });
}); });
describe("runEncodeStage config plumbing", () => {
it("prefers engine config supplied by the orchestrator", async () => {
const { runEncodeStage } = await import("./encodeStage.js");
const orchestratorEngineConfig = { ffmpegEncodeTimeout: 54_321 };
await runEncodeStage(makeInput({ engineConfig: orchestratorEngineConfig }));
expect(encodeFramesFromDirMock).toHaveBeenCalledTimes(1);
expect(encodeFramesFromDirMock.mock.calls[0]?.[5]).toBe(orchestratorEngineConfig);
});
it("passes resolved engine config to encodeFramesFromDir", async () => {
const { runEncodeStage } = await import("./encodeStage.js");
await runEncodeStage(makeInput());
expect(encodeFramesFromDirMock).toHaveBeenCalledTimes(1);
expect(encodeFramesFromDirMock.mock.calls[0]?.[5]).toBe(resolvedEngineConfig);
});
it("passes resolved engine config to encodeFramesChunkedConcat", async () => {
const { runEncodeStage } = await import("./encodeStage.js");
await runEncodeStage(makeInput({ enableChunkedEncode: true }));
expect(encodeFramesChunkedConcatMock).toHaveBeenCalledTimes(1);
expect(encodeFramesChunkedConcatMock.mock.calls[0]?.[6]).toBe(resolvedEngineConfig);
});
it("uses resolved engine config for GIF ffmpeg timeouts", async () => {
const { runEncodeStage } = await import("./encodeStage.js");
const paths = createFramesDir("jpg");
await runEncodeStage(
makeInput({
framesDir: paths.framesDir,
outputPath: join(paths.root, "out.gif"),
videoOnlyPath: join(paths.root, "video-only.mp4"),
isGif: true,
}),
);
expect(runFfmpegMock).toHaveBeenCalledTimes(2);
expect(runFfmpegMock.mock.calls[0]?.[1]?.timeout).toBe(
resolvedEngineConfig.ffmpegEncodeTimeout,
);
expect(runFfmpegMock.mock.calls[1]?.[1]?.timeout).toBe(
resolvedEngineConfig.ffmpegEncodeTimeout,
);
});
});
@@ -31,12 +31,13 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { import {
DEFAULT_CONFIG,
encodeFramesChunkedConcat, encodeFramesChunkedConcat,
encodeFramesFromDir, encodeFramesFromDir,
formatFfmpegError, formatFfmpegError,
getEncoderPreset, getEncoderPreset,
resolveConfig,
runFfmpeg, runFfmpeg,
type EngineConfig,
type EncodeResult, type EncodeResult,
} from "@hyperframes/engine"; } from "@hyperframes/engine";
import type { Fps } from "@hyperframes/core"; import type { Fps } from "@hyperframes/core";
@@ -83,6 +84,8 @@ export interface EncodeStageInput {
/** Producer config — enables the chunked-concat encoder when on. */ /** Producer config — enables the chunked-concat encoder when on. */
enableChunkedEncode: boolean; enableChunkedEncode: boolean;
chunkedEncodeSize: number; chunkedEncodeSize: number;
/** Already-resolved engine config from the orchestrator; direct callers fall back below. */
engineConfig?: Pick<EngineConfig, "ffmpegEncodeTimeout">;
abortSignal: AbortSignal | undefined; abortSignal: AbortSignal | undefined;
assertNotAborted: () => void; assertNotAborted: () => void;
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
@@ -241,6 +244,8 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
return { encodeMs: Date.now() - stage5Start }; return { encodeMs: Date.now() - stage5Start };
} }
const engineCfg = input.engineConfig ?? job.config.producerConfig ?? resolveConfig();
if (isGif) { if (isGif) {
// ── Stage 5 (gif): two-pass palette encode ─────────────────────── // ── Stage 5 (gif): two-pass palette encode ───────────────────────
updateJobStatus(job, "encoding", "Encoding GIF", 75, onProgress); updateJobStatus(job, "encoding", "Encoding GIF", 75, onProgress);
@@ -254,7 +259,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
loop, loop,
palettePath: join(dirname(videoOnlyPath), "gif-palette.png"), palettePath: join(dirname(videoOnlyPath), "gif-palette.png"),
signal: abortSignal, signal: abortSignal,
timeout: job.config.producerConfig?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout, timeout: engineCfg.ffmpegEncodeTimeout,
}); });
assertNotAborted(); assertNotAborted();
if (!encodeResult.success) { if (!encodeResult.success) {
@@ -293,8 +298,16 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
encoderOpts, encoderOpts,
chunkedEncodeSize, chunkedEncodeSize,
abortSignal, abortSignal,
engineCfg,
) )
: await encodeFramesFromDir(framesDir, framePattern, videoOnlyPath, encoderOpts, abortSignal); : await encodeFramesFromDir(
framesDir,
framePattern,
videoOnlyPath,
encoderOpts,
abortSignal,
engineCfg,
);
assertNotAborted(); assertNotAborted();
if (!encodeResult.success) { if (!encodeResult.success) {
@@ -2254,6 +2254,7 @@ export async function executeRenderJob(
effectiveBitrate, effectiveBitrate,
enableChunkedEncode, enableChunkedEncode,
chunkedEncodeSize, chunkedEncodeSize,
engineConfig: cfg,
abortSignal, abortSignal,
assertNotAborted, assertNotAborted,
onProgress, onProgress,