Skip to content

Commit 5917c03

Browse files
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
1 parent a8090ca commit 5917c03

5 files changed

Lines changed: 581 additions & 15 deletions

File tree

packages/engine/src/services/chunkEncoder.test.ts

Lines changed: 330 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,93 @@
1-
import { describe, it, expect, vi } from "vitest";
1+
import { EventEmitter } from "node:events";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { afterEach, describe, it, expect, vi } from "vitest";
26
import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
37

8+
const TINY_PNG = Buffer.from(
9+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAEElEQVR4nGP8wwACLGCSAQANBAECv1AVswAAAABJRU5ErkJggg==",
10+
"base64",
11+
);
12+
13+
const tempDirs: string[] = [];
14+
15+
afterEach(() => {
16+
for (const dir of tempDirs.splice(0)) {
17+
rmSync(dir, { recursive: true, force: true });
18+
}
19+
vi.resetModules();
20+
vi.doUnmock("child_process");
21+
vi.useRealTimers();
22+
});
23+
24+
function createFrameFixture(): { root: string; framesDir: string } {
25+
const root = mkdtempSync(join(tmpdir(), "hf-chunk-encoder-"));
26+
tempDirs.push(root);
27+
const framesDir = join(root, "frames");
28+
mkdirSync(framesDir);
29+
for (let i = 1; i <= 2; i++) {
30+
writeFileSync(join(framesDir, `frame_${String(i).padStart(6, "0")}.png`), TINY_PNG);
31+
}
32+
return { root, framesDir };
33+
}
34+
35+
const tinyEncodeOptions = {
36+
fps: { num: 30, den: 1 },
37+
width: 2,
38+
height: 2,
39+
codec: "h264" as const,
40+
preset: "ultrafast",
41+
quality: 28,
42+
pixelFormat: "yuv420p",
43+
useGpu: false,
44+
};
45+
46+
function encodeTimeoutMessage(timeoutMs: number): string {
47+
return `FFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
48+
}
49+
50+
type FakeProc = EventEmitter & {
51+
stderr: EventEmitter;
52+
kill: ReturnType<typeof vi.fn>;
53+
killed: boolean;
54+
};
55+
56+
type SpawnCall = {
57+
command: string;
58+
args: readonly string[];
59+
proc: FakeProc;
60+
};
61+
62+
function createFakeProc(): FakeProc {
63+
const proc = new EventEmitter() as FakeProc;
64+
proc.stderr = new EventEmitter();
65+
proc.kill = vi.fn(() => {
66+
proc.killed = true;
67+
return true;
68+
});
69+
proc.killed = false;
70+
return proc;
71+
}
72+
73+
function createSpawnSpy(): {
74+
spawn: (command: string, args: readonly string[]) => FakeProc;
75+
calls: SpawnCall[];
76+
} {
77+
const calls: SpawnCall[] = [];
78+
const spawn = (command: string, args: readonly string[]): FakeProc => {
79+
const proc = createFakeProc();
80+
calls.push({ command, args, proc });
81+
return proc;
82+
};
83+
return { spawn, calls };
84+
}
85+
86+
function emitClose(proc: FakeProc, code: number): void {
87+
proc.emit("exit", code);
88+
proc.emit("close", code);
89+
}
90+
491
describe("ENCODER_PRESETS", () => {
592
it("has draft, standard, and high presets", () => {
693
expect(ENCODER_PRESETS).toHaveProperty("draft");
@@ -26,6 +113,248 @@ describe("ENCODER_PRESETS", () => {
26113
});
27114
});
28115

116+
describe("encodeFramesFromDir ffmpegEncodeTimeout", () => {
117+
it("kills ffmpeg when config timeout elapses", async () => {
118+
vi.useFakeTimers();
119+
const { spawn, calls } = createSpawnSpy();
120+
vi.resetModules();
121+
vi.doMock("child_process", () => ({ spawn }));
122+
123+
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
124+
const { root, framesDir } = createFrameFixture();
125+
126+
const encodePromise = encodeFramesFromDir(
127+
framesDir,
128+
"frame_%06d.png",
129+
join(root, "timeout.mp4"),
130+
tinyEncodeOptions,
131+
undefined,
132+
{ ffmpegEncodeTimeout: 1000 },
133+
);
134+
135+
expect(calls).toHaveLength(1);
136+
const proc = calls[0]!.proc;
137+
vi.advanceTimersByTime(999);
138+
expect(proc.kill).not.toHaveBeenCalled();
139+
140+
vi.advanceTimersByTime(1);
141+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
142+
143+
proc.stderr.emit("data", Buffer.from("terminated by timeout\n"));
144+
emitClose(proc, 143);
145+
146+
const result = await encodePromise;
147+
expect(result.success).toBe(false);
148+
expect(result.error).toContain("FFmpeg exited with code 143");
149+
expect(result.error).toContain("terminated by timeout");
150+
expect(result.error).toContain(encodeTimeoutMessage(1000));
151+
});
152+
153+
it("keeps non-timeout ffmpeg failures unchanged", async () => {
154+
vi.useFakeTimers();
155+
const { spawn, calls } = createSpawnSpy();
156+
vi.resetModules();
157+
vi.doMock("child_process", () => ({ spawn }));
158+
159+
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
160+
const { root, framesDir } = createFrameFixture();
161+
162+
const encodePromise = encodeFramesFromDir(
163+
framesDir,
164+
"frame_%06d.png",
165+
join(root, "failure.mp4"),
166+
tinyEncodeOptions,
167+
undefined,
168+
{ ffmpegEncodeTimeout: 1000 },
169+
);
170+
171+
expect(calls).toHaveLength(1);
172+
const proc = calls[0]!.proc;
173+
proc.stderr.emit("data", Buffer.from("encoder failed\n"));
174+
emitClose(proc, 1);
175+
176+
const result = await encodePromise;
177+
expect(result.success).toBe(false);
178+
expect(result.error).toContain("FFmpeg exited with code 1");
179+
expect(result.error).toContain("encoder failed");
180+
expect(result.error).not.toContain("ffmpegEncodeTimeout");
181+
});
182+
183+
it("uses the default timeout when config is omitted", async () => {
184+
vi.useFakeTimers();
185+
const { spawn, calls } = createSpawnSpy();
186+
vi.resetModules();
187+
vi.doMock("child_process", () => ({ spawn }));
188+
189+
const { encodeFramesFromDir } = await import("./chunkEncoder.js");
190+
const { root, framesDir } = createFrameFixture();
191+
192+
const encodePromise = encodeFramesFromDir(
193+
framesDir,
194+
"frame_%06d.png",
195+
join(root, "default.mp4"),
196+
tinyEncodeOptions,
197+
);
198+
199+
expect(calls).toHaveLength(1);
200+
const proc = calls[0]!.proc;
201+
vi.advanceTimersByTime(599_999);
202+
expect(proc.kill).not.toHaveBeenCalled();
203+
204+
emitClose(proc, 0);
205+
206+
const result = await encodePromise;
207+
expect(result.success).toBe(true);
208+
expect(result.framesEncoded).toBe(2);
209+
expect(result.fileSize).toBe(0);
210+
});
211+
});
212+
213+
describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
214+
it("passes config timeout to per-chunk encodes", async () => {
215+
vi.useFakeTimers();
216+
const { spawn, calls } = createSpawnSpy();
217+
vi.resetModules();
218+
vi.doMock("child_process", () => ({ spawn }));
219+
220+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
221+
const { root, framesDir } = createFrameFixture();
222+
223+
const encodePromise = encodeFramesChunkedConcat(
224+
framesDir,
225+
"frame_%06d.png",
226+
join(root, "chunked.mp4"),
227+
tinyEncodeOptions,
228+
30,
229+
undefined,
230+
{ ffmpegEncodeTimeout: 1000 },
231+
);
232+
233+
expect(calls).toHaveLength(1);
234+
const proc = calls[0]!.proc;
235+
vi.advanceTimersByTime(999);
236+
expect(proc.kill).not.toHaveBeenCalled();
237+
238+
vi.advanceTimersByTime(1);
239+
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
240+
241+
proc.stderr.emit("data", Buffer.from("chunk timeout\n"));
242+
emitClose(proc, 143);
243+
244+
const result = await encodePromise;
245+
expect(result.success).toBe(false);
246+
expect(result.error).toContain("Chunk 0 encode failed");
247+
expect(result.error).toContain("chunk timeout");
248+
expect(result.error).toContain(encodeTimeoutMessage(1000));
249+
});
250+
251+
it("keeps non-timeout chunk failures unchanged", async () => {
252+
vi.useFakeTimers();
253+
const { spawn, calls } = createSpawnSpy();
254+
vi.resetModules();
255+
vi.doMock("child_process", () => ({ spawn }));
256+
257+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
258+
const { root, framesDir } = createFrameFixture();
259+
260+
const encodePromise = encodeFramesChunkedConcat(
261+
framesDir,
262+
"frame_%06d.png",
263+
join(root, "chunked-failure.mp4"),
264+
tinyEncodeOptions,
265+
30,
266+
undefined,
267+
{ ffmpegEncodeTimeout: 1000 },
268+
);
269+
270+
expect(calls).toHaveLength(1);
271+
const proc = calls[0]!.proc;
272+
proc.stderr.emit("data", Buffer.from("chunk failed\n"));
273+
emitClose(proc, 1);
274+
275+
const result = await encodePromise;
276+
expect(result.success).toBe(false);
277+
expect(result.error).toBe("Chunk 0 encode failed: chunk failed\n");
278+
expect(result.error).not.toContain("ffmpegEncodeTimeout");
279+
});
280+
281+
it("kills concat ffmpeg when config timeout elapses", async () => {
282+
vi.useFakeTimers();
283+
const { spawn, calls } = createSpawnSpy();
284+
vi.resetModules();
285+
vi.doMock("child_process", () => ({ spawn }));
286+
287+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
288+
const { root, framesDir } = createFrameFixture();
289+
290+
const encodePromise = encodeFramesChunkedConcat(
291+
framesDir,
292+
"frame_%06d.png",
293+
join(root, "concat-timeout.mp4"),
294+
tinyEncodeOptions,
295+
30,
296+
undefined,
297+
{ ffmpegEncodeTimeout: 1000 },
298+
);
299+
300+
expect(calls).toHaveLength(1);
301+
emitClose(calls[0]!.proc, 0);
302+
await Promise.resolve();
303+
304+
expect(calls).toHaveLength(2);
305+
const concatProc = calls[1]!.proc;
306+
vi.advanceTimersByTime(999);
307+
expect(concatProc.kill).not.toHaveBeenCalled();
308+
309+
vi.advanceTimersByTime(1);
310+
expect(concatProc.kill).toHaveBeenCalledWith("SIGTERM");
311+
312+
concatProc.stderr.emit("data", Buffer.from("concat timeout\n"));
313+
emitClose(concatProc, 143);
314+
315+
const result = await encodePromise;
316+
expect(result.success).toBe(false);
317+
expect(result.error).toContain("Chunk concat failed");
318+
expect(result.error).toContain("concat timeout");
319+
expect(result.error).toContain(encodeTimeoutMessage(1000));
320+
});
321+
322+
it("uses the default timeout for per-chunk encodes when config is omitted", async () => {
323+
vi.useFakeTimers();
324+
const { spawn, calls } = createSpawnSpy();
325+
vi.resetModules();
326+
vi.doMock("child_process", () => ({ spawn }));
327+
328+
const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
329+
const { root, framesDir } = createFrameFixture();
330+
331+
const encodePromise = encodeFramesChunkedConcat(
332+
framesDir,
333+
"frame_%06d.png",
334+
join(root, "chunked-default.mp4"),
335+
tinyEncodeOptions,
336+
30,
337+
);
338+
339+
expect(calls).toHaveLength(1);
340+
const chunkProc = calls[0]!.proc;
341+
vi.advanceTimersByTime(599_999);
342+
expect(chunkProc.kill).not.toHaveBeenCalled();
343+
344+
emitClose(chunkProc, 0);
345+
await Promise.resolve();
346+
347+
expect(calls).toHaveLength(2);
348+
const concatProc = calls[1]!.proc;
349+
emitClose(concatProc, 0);
350+
351+
const result = await encodePromise;
352+
expect(result.success).toBe(true);
353+
expect(result.framesEncoded).toBe(2);
354+
expect(result.fileSize).toBe(0);
355+
});
356+
});
357+
29358
describe("getEncoderPreset", () => {
30359
it("returns h264 with yuv420p for mp4 format", () => {
31360
const preset = getEncoderPreset("standard", "mp4");

0 commit comments

Comments
 (0)