@hyperframes/engine 0.6.94 → 0.6.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,112 @@
7
7
  * They all need the same "how much memory does this box have" reading, so
8
8
  * it lives here once instead of being re-derived inline.
9
9
  */
10
+ import { readFileSync } from "fs";
10
11
  import { totalmem } from "os";
12
+ const BYTES_PER_MIB = 1024 * 1024;
13
+ const BYTES_PER_MIB_BIGINT = BigInt(BYTES_PER_MIB);
14
+ // These are the paths as seen from INSIDE a container, where the runtime
15
+ // mounts the container's own cgroup at the namespace root — the case this
16
+ // probe exists for. They are deliberately not resolved via /proc/self/cgroup:
17
+ // on a bare host under systemd the process's real limit may live in a nested
18
+ // slice (e.g. /sys/fs/cgroup/user.slice/.../memory.max) that these root paths
19
+ // don't see, and that's acceptable — bare hosts are covered by total-RAM
20
+ // detection, and chasing nested slices adds fragility for no container gain.
21
+ const CGROUP_V2_MEMORY_MAX_PATH = "/sys/fs/cgroup/memory.max";
22
+ const CGROUP_V1_MEMORY_LIMIT_PATH = "/sys/fs/cgroup/memory/memory.limit_in_bytes";
23
+ // Kernel no-limit sentinel is page-rounded 2^63-1 (~9223372036854771712); >= 2^60 is implausible as a real limit.
24
+ const CGROUP_V1_NO_LIMIT_CUTOFF_BYTES = 2n ** 60n;
25
+ let _cachedCgroupLimitMb;
26
+ let _warnedCgroupReadFailure = false;
27
+ /** Parse cgroup v2/v1 memory limits from sysfs file contents into MiB. */
28
+ export function parseCgroupLimitMb(v2Content, v1Content) {
29
+ if (v2Content !== null) {
30
+ return parseCgroupV2LimitMb(v2Content);
31
+ }
32
+ return parseCgroupV1LimitMb(v1Content);
33
+ }
34
+ function parseCgroupV2LimitMb(content) {
35
+ const trimmed = content.trim();
36
+ if (trimmed === "max") {
37
+ return null;
38
+ }
39
+ return parsePositiveByteLimitMb(trimmed);
40
+ }
41
+ function parseCgroupV1LimitMb(content) {
42
+ if (content === null) {
43
+ return null;
44
+ }
45
+ return parsePositiveByteLimitMb(content.trim(), CGROUP_V1_NO_LIMIT_CUTOFF_BYTES);
46
+ }
47
+ function parsePositiveByteLimitMb(content, noLimitCutoffBytes) {
48
+ if (!/^\d+$/.test(content)) {
49
+ return null;
50
+ }
51
+ const bytes = BigInt(content);
52
+ if (bytes <= 0n) {
53
+ return null;
54
+ }
55
+ if (noLimitCutoffBytes !== undefined && bytes >= noLimitCutoffBytes) {
56
+ return null;
57
+ }
58
+ return Number(bytes / BYTES_PER_MIB_BIGINT);
59
+ }
60
+ /** Test-only: reset the cached cgroup memory probe. */
61
+ export function _resetCgroupLimitCacheForTests() {
62
+ _cachedCgroupLimitMb = undefined;
63
+ _warnedCgroupReadFailure = false;
64
+ }
65
+ function getCgroupLimitMb() {
66
+ if (_cachedCgroupLimitMb !== undefined)
67
+ return _cachedCgroupLimitMb;
68
+ if (process.platform !== "linux") {
69
+ _cachedCgroupLimitMb = null;
70
+ return null;
71
+ }
72
+ const v2Content = readCgroupFile(CGROUP_V2_MEMORY_MAX_PATH);
73
+ const v1Content = v2Content === null ? readCgroupFile(CGROUP_V1_MEMORY_LIMIT_PATH) : null;
74
+ _cachedCgroupLimitMb = parseCgroupLimitMb(v2Content, v1Content);
75
+ if (_cachedCgroupLimitMb !== null) {
76
+ console.info(`[SystemMemory] cgroup memory limit detected: ${_cachedCgroupLimitMb} MiB — ` +
77
+ `it governs memory-adaptive render behaviour instead of host RAM.`);
78
+ }
79
+ return _cachedCgroupLimitMb;
80
+ }
81
+ function readCgroupFile(path) {
82
+ try {
83
+ return readFileSync(path, "utf8");
84
+ }
85
+ catch (error) {
86
+ const code = getErrorCode(error);
87
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
88
+ warnCgroupReadFailure(path, error);
89
+ }
90
+ return null;
91
+ }
92
+ }
93
+ function getErrorCode(error) {
94
+ if (typeof error !== "object" || error === null || !("code" in error)) {
95
+ return undefined;
96
+ }
97
+ return typeof error.code === "string" ? error.code : undefined;
98
+ }
99
+ function formatCgroupReadError(error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ const code = getErrorCode(error);
102
+ return code ? `${code}: ${message}` : message;
103
+ }
104
+ function warnCgroupReadFailure(path, error) {
105
+ if (_warnedCgroupReadFailure)
106
+ return;
107
+ _warnedCgroupReadFailure = true;
108
+ console.warn(`[SystemMemory] Unable to read cgroup memory limit at ${path} ` +
109
+ `(${formatCgroupReadError(error)}); falling back to host RAM.`);
110
+ }
11
111
  /** Total physical RAM in MiB. */
12
112
  export function getSystemTotalMb() {
13
- return Math.floor(totalmem() / (1024 * 1024));
113
+ const hostTotalMb = Math.floor(totalmem() / BYTES_PER_MIB);
114
+ const cgroupLimitMb = getCgroupLimitMb();
115
+ return cgroupLimitMb === null ? hostTotalMb : Math.min(hostTotalMb, cgroupLimitMb);
14
116
  }
15
117
  /**
16
118
  * Total-RAM ceiling (MiB) at or below which the host is treated as
@@ -34,14 +136,13 @@ export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
34
136
  * survive". Accepts an explicit `totalMb` so callers (and tests) can pass
35
137
  * a known value instead of re-probing.
36
138
  *
37
- * Caveat: `os.totalmem()` reports the *host's* physical RAM, not a
38
- * cgroup/container memory limit. A 4 GB container on a 32 GB host will not
39
- * auto-flag as low-memory, and an 8 GB container on a 64 GB host won't
40
- * either. Containerised and serverless callers (Docker `--docker` renders,
41
- * Lambda) that want a specific profile should set `PRODUCER_LOW_MEMORY_MODE`
42
- * explicitly rather than relying on auto-detection. Hosts whose *total* RAM
43
- * is genuinely <= the threshold (laptops, small VMs, small Lambda tiers) are
44
- * detected correctly regardless of container nesting.
139
+ * Caveat: Linux cgroup v1/v2 memory limits are consulted when readable, so
140
+ * Docker and serverless runtimes, including Lambda tiers with readable cgroup
141
+ * ceilings, inherit the tighter container limit instead of the host's physical
142
+ * RAM. Environments that hide cgroup files should set
143
+ * `PRODUCER_LOW_MEMORY_MODE` explicitly rather than relying on auto-detection.
144
+ * Hosts whose *effective* total RAM is genuinely <= the threshold (laptops,
145
+ * small VMs, small Lambda tiers, small containers) are detected correctly.
45
146
  */
46
147
  export function isLowMemorySystem(totalMb = getSystemTotalMb()) {
47
148
  return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
@@ -1 +1 @@
1
- {"version":3,"file":"systemMemory.js","sourceRoot":"","sources":["../../src/services/systemMemory.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,IAAI,CAAC;AAE9B,iCAAiC;AACjC,MAAM,UAAU,gBAAgB;IAC9B,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC;AAChD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,IAAI,CAAC;AAElD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,iBAAiB,CAAC,UAAkB,gBAAgB,EAAE;IACpE,OAAO,OAAO,IAAI,6BAA6B,CAAC;AAClD,CAAC"}
1
+ {"version":3,"file":"systemMemory.js","sourceRoot":"","sources":["../../src/services/systemMemory.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.6.94",
3
+ "version": "0.6.96",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "linkedom": "^0.18.12",
22
22
  "puppeteer": "^24.0.0",
23
23
  "puppeteer-core": "^24.39.1",
24
- "@hyperframes/core": "^0.6.94"
24
+ "@hyperframes/core": "^0.6.96"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^25.0.10",
@@ -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";
2
6
  import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
3
7
 
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
+
4
91
  describe("ENCODER_PRESETS", () => {
5
92
  it("has draft, standard, and high presets", () => {
6
93
  expect(ENCODER_PRESETS).toHaveProperty("draft");
@@ -26,6 +113,248 @@ describe("ENCODER_PRESETS", () => {
26
113
  });
27
114
  });
28
115
 
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
+
29
358
  describe("getEncoderPreset", () => {
30
359
  it("returns h264 with yuv420p for mp4 format", () => {
31
360
  const preset = getEncoderPreset("standard", "mp4");
@@ -39,6 +39,11 @@ export interface EncoderPreset {
39
39
  hdr?: { transfer: HdrTransfer };
40
40
  }
41
41
 
42
+ function appendEncodeTimeoutMessage(error: string, timedOut: boolean, timeoutMs: number): string {
43
+ if (!timedOut) return error;
44
+ return `${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
45
+ }
46
+
42
47
  /**
43
48
  * Get encoder preset for a given quality and output format.
44
49
  * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
@@ -428,7 +433,9 @@ export async function encodeFramesFromDir(
428
433
  }
429
434
 
430
435
  const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
436
+ let timedOut = false;
431
437
  const timer = setTimeout(() => {
438
+ timedOut = true;
432
439
  ffmpeg.kill("SIGTERM");
433
440
  }, encodeTimeout);
434
441
 
@@ -440,7 +447,7 @@ export async function encodeFramesFromDir(
440
447
  clearTimeout(timer);
441
448
  if (signal) signal.removeEventListener("abort", onAbort);
442
449
  const durationMs = Date.now() - startTime;
443
- if (signal?.aborted) {
450
+ if (signal?.aborted && !timedOut) {
444
451
  resolve({
445
452
  success: false,
446
453
  outputPath,
@@ -452,14 +459,18 @@ export async function encodeFramesFromDir(
452
459
  return;
453
460
  }
454
461
 
455
- if (code !== 0) {
462
+ if (code !== 0 || timedOut) {
456
463
  resolve({
457
464
  success: false,
458
465
  outputPath,
459
466
  durationMs,
460
467
  framesEncoded: 0,
461
468
  fileSize: 0,
462
- error: formatFfmpegError(code, stderr),
469
+ error: appendEncodeTimeoutMessage(
470
+ formatFfmpegError(code, stderr),
471
+ timedOut,
472
+ encodeTimeout,
473
+ ),
463
474
  });
464
475
  return;
465
476
  }
@@ -477,7 +488,7 @@ export async function encodeFramesFromDir(
477
488
  durationMs: Date.now() - startTime,
478
489
  framesEncoded: 0,
479
490
  fileSize: 0,
480
- error: `[FFmpeg] ${err.message}`,
491
+ error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
481
492
  });
482
493
  });
483
494
  });
@@ -490,6 +501,7 @@ export async function encodeFramesChunkedConcat(
490
501
  options: EncoderOptions,
491
502
  chunkSizeFrames: number,
492
503
  signal?: AbortSignal,
504
+ config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
493
505
  ): Promise<EncodeResult> {
494
506
  const start = Date.now();
495
507
  const files = readdirSync(framesDir)
@@ -548,15 +560,39 @@ export async function encodeFramesChunkedConcat(
548
560
  const ffmpeg = spawn(getFfmpegBinary(), args);
549
561
  trackChildProcess(ffmpeg);
550
562
  let stderr = "";
563
+ const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
564
+ let timedOut = false;
565
+ const timer = setTimeout(() => {
566
+ timedOut = true;
567
+ ffmpeg.kill("SIGTERM");
568
+ }, encodeTimeout);
551
569
  ffmpeg.stderr.on("data", (d) => {
552
570
  stderr += d.toString();
553
571
  });
554
572
  ffmpeg.on("close", (code) => {
555
- if (code === 0) resolve({ success: true });
556
- else resolve({ success: false, error: `Chunk ${i} encode failed: ${stderr.slice(-400)}` });
573
+ clearTimeout(timer);
574
+ if (code === 0 && !timedOut) resolve({ success: true });
575
+ else {
576
+ resolve({
577
+ success: false,
578
+ error: appendEncodeTimeoutMessage(
579
+ `Chunk ${i} encode failed: ${stderr.slice(-400)}`,
580
+ timedOut,
581
+ encodeTimeout,
582
+ ),
583
+ });
584
+ }
557
585
  });
558
586
  ffmpeg.on("error", (err) => {
559
- resolve({ success: false, error: `Chunk ${i} encode error: ${err.message}` });
587
+ clearTimeout(timer);
588
+ resolve({
589
+ success: false,
590
+ error: appendEncodeTimeoutMessage(
591
+ `Chunk ${i} encode error: ${err.message}`,
592
+ timedOut,
593
+ encodeTimeout,
594
+ ),
595
+ });
560
596
  });
561
597
  });
562
598
  if (!chunkResult.success) {
@@ -592,15 +628,39 @@ export async function encodeFramesChunkedConcat(
592
628
  const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
593
629
  trackChildProcess(ffmpeg);
594
630
  let stderr = "";
631
+ const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
632
+ let timedOut = false;
633
+ const timer = setTimeout(() => {
634
+ timedOut = true;
635
+ ffmpeg.kill("SIGTERM");
636
+ }, encodeTimeout);
595
637
  ffmpeg.stderr.on("data", (d) => {
596
638
  stderr += d.toString();
597
639
  });
598
640
  ffmpeg.on("close", (code) => {
599
- if (code === 0) resolve({ success: true });
600
- else resolve({ success: false, error: `Chunk concat failed: ${stderr.slice(-400)}` });
641
+ clearTimeout(timer);
642
+ if (code === 0 && !timedOut) resolve({ success: true });
643
+ else {
644
+ resolve({
645
+ success: false,
646
+ error: appendEncodeTimeoutMessage(
647
+ `Chunk concat failed: ${stderr.slice(-400)}`,
648
+ timedOut,
649
+ encodeTimeout,
650
+ ),
651
+ });
652
+ }
601
653
  });
602
654
  ffmpeg.on("error", (err) => {
603
- resolve({ success: false, error: `Chunk concat error: ${err.message}` });
655
+ clearTimeout(timer);
656
+ resolve({
657
+ success: false,
658
+ error: appendEncodeTimeoutMessage(
659
+ `Chunk concat error: ${err.message}`,
660
+ timedOut,
661
+ encodeTimeout,
662
+ ),
663
+ });
604
664
  });
605
665
  });
606
666