@hyperframes/engine 0.6.93 → 0.6.95

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.
Files changed (57) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/browserManager.d.ts.map +1 -1
  6. package/dist/services/browserManager.js +6 -1
  7. package/dist/services/browserManager.js.map +1 -1
  8. package/dist/services/chunkEncoder.d.ts +1 -1
  9. package/dist/services/chunkEncoder.d.ts.map +1 -1
  10. package/dist/services/chunkEncoder.js +55 -16
  11. package/dist/services/chunkEncoder.js.map +1 -1
  12. package/dist/services/parallelCoordinator.d.ts.map +1 -1
  13. package/dist/services/parallelCoordinator.js +4 -3
  14. package/dist/services/parallelCoordinator.js.map +1 -1
  15. package/dist/services/streamingEncoder.d.ts +9 -2
  16. package/dist/services/streamingEncoder.d.ts.map +1 -1
  17. package/dist/services/streamingEncoder.js +55 -16
  18. package/dist/services/streamingEncoder.js.map +1 -1
  19. package/dist/services/systemMemory.d.ts +11 -8
  20. package/dist/services/systemMemory.d.ts.map +1 -1
  21. package/dist/services/systemMemory.js +110 -9
  22. package/dist/services/systemMemory.js.map +1 -1
  23. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  24. package/dist/services/videoFrameExtractor.js +3 -1
  25. package/dist/services/videoFrameExtractor.js.map +1 -1
  26. package/dist/utils/ffmpegBinaries.d.ts +6 -0
  27. package/dist/utils/ffmpegBinaries.d.ts.map +1 -0
  28. package/dist/utils/ffmpegBinaries.js +55 -0
  29. package/dist/utils/ffmpegBinaries.js.map +1 -0
  30. package/dist/utils/ffprobe.d.ts.map +1 -1
  31. package/dist/utils/ffprobe.js +8 -2
  32. package/dist/utils/ffprobe.js.map +1 -1
  33. package/dist/utils/gpuEncoder.d.ts.map +1 -1
  34. package/dist/utils/gpuEncoder.js +4 -2
  35. package/dist/utils/gpuEncoder.js.map +1 -1
  36. package/dist/utils/runFfmpeg.d.ts.map +1 -1
  37. package/dist/utils/runFfmpeg.js +20 -1
  38. package/dist/utils/runFfmpeg.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/index.ts +7 -0
  41. package/src/services/browserManager.test.ts +18 -0
  42. package/src/services/browserManager.ts +8 -1
  43. package/src/services/chunkEncoder.test.ts +330 -1
  44. package/src/services/chunkEncoder.ts +75 -13
  45. package/src/services/parallelCoordinator.ts +4 -3
  46. package/src/services/streamingEncoder.test.ts +156 -10
  47. package/src/services/streamingEncoder.ts +73 -17
  48. package/src/services/systemMemory.test.ts +303 -2
  49. package/src/services/systemMemory.ts +137 -9
  50. package/src/services/videoFrameExtractor.ts +3 -1
  51. package/src/utils/ffmpegBinaries.test.ts +43 -0
  52. package/src/utils/ffmpegBinaries.ts +63 -0
  53. package/src/utils/ffprobe.test.ts +27 -0
  54. package/src/utils/ffprobe.ts +12 -2
  55. package/src/utils/gpuEncoder.ts +4 -2
  56. package/src/utils/runFfmpeg.test.ts +57 -1
  57. package/src/utils/runFfmpeg.ts +24 -1
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication complexity
1
2
  /**
2
3
  * Chunk Encoder Service
3
4
  *
@@ -18,6 +19,7 @@ import {
18
19
  } from "../utils/gpuEncoder.js";
19
20
  import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
20
21
  import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
22
+ import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
21
23
  import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
22
24
  import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
23
25
 
@@ -37,6 +39,11 @@ export interface EncoderPreset {
37
39
  hdr?: { transfer: HdrTransfer };
38
40
  }
39
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
+
40
47
  /**
41
48
  * Get encoder preset for a given quality and output format.
42
49
  * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
@@ -411,7 +418,7 @@ export async function encodeFramesFromDir(
411
418
  const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
412
419
 
413
420
  return new Promise((resolve) => {
414
- const ffmpeg = spawn("ffmpeg", args);
421
+ const ffmpeg = spawn(getFfmpegBinary(), args);
415
422
  trackChildProcess(ffmpeg);
416
423
  let stderr = "";
417
424
  const onAbort = () => {
@@ -426,7 +433,9 @@ export async function encodeFramesFromDir(
426
433
  }
427
434
 
428
435
  const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
436
+ let timedOut = false;
429
437
  const timer = setTimeout(() => {
438
+ timedOut = true;
430
439
  ffmpeg.kill("SIGTERM");
431
440
  }, encodeTimeout);
432
441
 
@@ -438,7 +447,7 @@ export async function encodeFramesFromDir(
438
447
  clearTimeout(timer);
439
448
  if (signal) signal.removeEventListener("abort", onAbort);
440
449
  const durationMs = Date.now() - startTime;
441
- if (signal?.aborted) {
450
+ if (signal?.aborted && !timedOut) {
442
451
  resolve({
443
452
  success: false,
444
453
  outputPath,
@@ -450,14 +459,18 @@ export async function encodeFramesFromDir(
450
459
  return;
451
460
  }
452
461
 
453
- if (code !== 0) {
462
+ if (code !== 0 || timedOut) {
454
463
  resolve({
455
464
  success: false,
456
465
  outputPath,
457
466
  durationMs,
458
467
  framesEncoded: 0,
459
468
  fileSize: 0,
460
- error: formatFfmpegError(code, stderr),
469
+ error: appendEncodeTimeoutMessage(
470
+ formatFfmpegError(code, stderr),
471
+ timedOut,
472
+ encodeTimeout,
473
+ ),
461
474
  });
462
475
  return;
463
476
  }
@@ -475,7 +488,7 @@ export async function encodeFramesFromDir(
475
488
  durationMs: Date.now() - startTime,
476
489
  framesEncoded: 0,
477
490
  fileSize: 0,
478
- error: `[FFmpeg] ${err.message}`,
491
+ error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
479
492
  });
480
493
  });
481
494
  });
@@ -488,6 +501,7 @@ export async function encodeFramesChunkedConcat(
488
501
  options: EncoderOptions,
489
502
  chunkSizeFrames: number,
490
503
  signal?: AbortSignal,
504
+ config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
491
505
  ): Promise<EncodeResult> {
492
506
  const start = Date.now();
493
507
  const files = readdirSync(framesDir)
@@ -543,18 +557,42 @@ export async function encodeFramesChunkedConcat(
543
557
  if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
544
558
  const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
545
559
  const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
546
- const ffmpeg = spawn("ffmpeg", args);
560
+ const ffmpeg = spawn(getFfmpegBinary(), args);
547
561
  trackChildProcess(ffmpeg);
548
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);
549
569
  ffmpeg.stderr.on("data", (d) => {
550
570
  stderr += d.toString();
551
571
  });
552
572
  ffmpeg.on("close", (code) => {
553
- if (code === 0) resolve({ success: true });
554
- 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
+ }
555
585
  });
556
586
  ffmpeg.on("error", (err) => {
557
- 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
+ });
558
596
  });
559
597
  });
560
598
  if (!chunkResult.success) {
@@ -587,18 +625,42 @@ export async function encodeFramesChunkedConcat(
587
625
  outputPath,
588
626
  ];
589
627
  const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
590
- const ffmpeg = spawn("ffmpeg", concatArgs);
628
+ const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
591
629
  trackChildProcess(ffmpeg);
592
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);
593
637
  ffmpeg.stderr.on("data", (d) => {
594
638
  stderr += d.toString();
595
639
  });
596
640
  ffmpeg.on("close", (code) => {
597
- if (code === 0) resolve({ success: true });
598
- 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
+ }
599
653
  });
600
654
  ffmpeg.on("error", (err) => {
601
- 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
+ });
602
664
  });
603
665
  });
604
666
 
@@ -5,7 +5,7 @@
5
5
  * Auto-detects optimal worker count based on CPU/memory.
6
6
  */
7
7
 
8
- import { cpus, freemem, totalmem } from "os";
8
+ import { cpus, freemem } from "os";
9
9
  import { existsSync, mkdirSync, readdirSync } from "fs";
10
10
  import { copyFile, rename } from "fs/promises";
11
11
  import { join } from "path";
@@ -26,6 +26,7 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
26
26
  import { assertSwiftShader } from "../utils/assertSwiftShader.js";
27
27
  import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
28
28
  import { resolveHeadlessShellPath } from "./browserManager.js";
29
+ import { getSystemTotalMb } from "./systemMemory.js";
29
30
 
30
31
  export interface WorkerTask {
31
32
  workerId: number;
@@ -153,7 +154,7 @@ export function calculateOptimalWorkers(
153
154
  // Use total memory instead of free memory — macOS reports misleadingly low
154
155
  // freemem() because it aggressively caches files in "inactive" memory that
155
156
  // is immediately reclaimable.
156
- const totalMemoryMB = Math.round(totalmem() / (1024 * 1024));
157
+ const totalMemoryMB = getSystemTotalMb();
157
158
  const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMB * 0.5) / MEMORY_PER_WORKER_MB));
158
159
 
159
160
  const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
@@ -429,7 +430,7 @@ export function getSystemResources(): {
429
430
  } {
430
431
  return {
431
432
  cpuCores: cpus().length,
432
- totalMemoryMB: Math.round(totalmem() / (1024 * 1024)),
433
+ totalMemoryMB: getSystemTotalMb(),
433
434
  freeMemoryMB: Math.round(freemem() / (1024 * 1024)),
434
435
  recommendedWorkers: calculateOptimalWorkers(1000),
435
436
  };
@@ -418,6 +418,20 @@ const baseOptions: StreamingEncoderOptions = {
418
418
  useGpu: false,
419
419
  };
420
420
 
421
+ async function resolveWithin<T>(promise: Promise<T>, ms = 100): Promise<T | "timeout"> {
422
+ let timeout: ReturnType<typeof setTimeout> | undefined;
423
+ try {
424
+ return await Promise.race([
425
+ promise,
426
+ new Promise<"timeout">((resolve) => {
427
+ timeout = setTimeout(() => resolve("timeout"), ms);
428
+ }),
429
+ ]);
430
+ } finally {
431
+ if (timeout) clearTimeout(timeout);
432
+ }
433
+ }
434
+
421
435
  describe("spawnStreamingEncoder lifecycle and cleanup", () => {
422
436
  afterEach(() => {
423
437
  vi.resetModules();
@@ -556,7 +570,7 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
556
570
  const dir = mkdtempSync(join(tmpdir(), "se-writefail-"));
557
571
  const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
558
572
 
559
- expect(encoder.writeFrame(Buffer.from([0]))).toBe(true);
573
+ expect(await encoder.writeFrame(Buffer.from([0]))).toBe(true);
560
574
 
561
575
  const proc = calls[0]!.proc;
562
576
  await new Promise<void>((resolve) => {
@@ -566,7 +580,136 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
566
580
  });
567
581
  });
568
582
 
569
- expect(encoder.writeFrame(Buffer.from([0]))).toBe(false);
583
+ expect(await encoder.writeFrame(Buffer.from([0]))).toBe(false);
584
+ });
585
+
586
+ it("writeFrame waits for stdin drain when FFmpeg applies back-pressure", async () => {
587
+ const { spawn, calls } = createSpawnSpy();
588
+ vi.resetModules();
589
+ vi.doMock("child_process", () => ({ spawn }));
590
+
591
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
592
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-"));
593
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
594
+
595
+ const proc = calls[0]!.proc;
596
+ proc.stdin.write = (_chunk: Buffer): boolean => false;
597
+
598
+ const writeResult = encoder.writeFrame(Buffer.from([1])) as unknown;
599
+ expect(writeResult).toBeInstanceOf(Promise);
600
+
601
+ const writePromise = writeResult as Promise<boolean>;
602
+ let settled = false;
603
+ void writePromise.then(() => {
604
+ settled = true;
605
+ });
606
+
607
+ await Promise.resolve();
608
+ expect(settled).toBe(false);
609
+ expect(proc.stdin.listenerCount("drain")).toBe(1);
610
+
611
+ proc.stdin.emit("drain");
612
+
613
+ await expect(writePromise).resolves.toBe(true);
614
+ expect(settled).toBe(true);
615
+ expect(proc.stdin.listenerCount("drain")).toBe(0);
616
+
617
+ process.nextTick(() => proc.emit("close", 0));
618
+ await encoder.close();
619
+ });
620
+
621
+ it("does not accumulate process close listeners across repeated back-pressured writes", async () => {
622
+ const { spawn, calls } = createSpawnSpy();
623
+ vi.resetModules();
624
+ vi.doMock("child_process", () => ({ spawn }));
625
+
626
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
627
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-listeners-"));
628
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
629
+
630
+ const proc = calls[0]!.proc;
631
+ const baselineCloseListeners = proc.listenerCount("close");
632
+ const baselineDrainListeners = proc.stdin.listenerCount("drain");
633
+ proc.stdin.write = (_chunk: Buffer): boolean => false;
634
+
635
+ for (let i = 0; i < 12; i++) {
636
+ const writePromise = encoder.writeFrame(Buffer.from([i]));
637
+
638
+ await Promise.resolve();
639
+ expect(proc.stdin.listenerCount("drain")).toBe(baselineDrainListeners + 1);
640
+ expect(proc.listenerCount("close")).toBe(baselineCloseListeners + 1);
641
+
642
+ proc.stdin.emit("drain");
643
+
644
+ await expect(writePromise).resolves.toBe(true);
645
+ expect(proc.stdin.listenerCount("drain")).toBe(baselineDrainListeners);
646
+ expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
647
+ }
648
+
649
+ process.nextTick(() => proc.emit("close", 0));
650
+ await encoder.close();
651
+ });
652
+
653
+ it("writeFrame resolves false instead of hanging when FFmpeg exits before drain", async () => {
654
+ const { spawn, calls } = createSpawnSpy();
655
+ vi.resetModules();
656
+ vi.doMock("child_process", () => ({ spawn }));
657
+
658
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
659
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-exit-"));
660
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
661
+
662
+ const proc = calls[0]!.proc;
663
+ proc.stdin.write = (_chunk: Buffer): boolean => false;
664
+
665
+ const writeResult = encoder.writeFrame(Buffer.from([1])) as unknown;
666
+ expect(writeResult).toBeInstanceOf(Promise);
667
+
668
+ const writePromise = writeResult as Promise<boolean>;
669
+ let settled = false;
670
+ void writePromise.then(() => {
671
+ settled = true;
672
+ });
673
+
674
+ await Promise.resolve();
675
+ expect(settled).toBe(false);
676
+ expect(proc.stdin.listenerCount("drain")).toBe(1);
677
+
678
+ proc.emit("close", 1);
679
+
680
+ await expect(writePromise).resolves.toBe(false);
681
+ expect(settled).toBe(true);
682
+ expect(proc.stdin.listenerCount("drain")).toBe(0);
683
+
684
+ const result = await encoder.close();
685
+ expect(result.success).toBe(false);
686
+ });
687
+
688
+ it("writeFrame resolves false when close fires after write returns false before await attaches listeners", async () => {
689
+ const { spawn, calls } = createSpawnSpy();
690
+ vi.resetModules();
691
+ vi.doMock("child_process", () => ({ spawn }));
692
+
693
+ const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
694
+ const dir = mkdtempSync(join(tmpdir(), "se-drain-already-closed-"));
695
+ const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
696
+
697
+ const proc = calls[0]!.proc;
698
+ const baselineCloseListeners = proc.listenerCount("close");
699
+ proc.stdin.write = (_chunk: Buffer): boolean => {
700
+ proc.emit("close", 1);
701
+ return false;
702
+ };
703
+
704
+ const writePromise = encoder.writeFrame(Buffer.from([1]));
705
+
706
+ await expect(resolveWithin(writePromise)).resolves.toBe(false);
707
+ expect(encoder.getExitStatus()).toBe("error");
708
+ expect(proc.stdin.listenerCount("drain")).toBe(0);
709
+ expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
710
+
711
+ const result = await encoder.close();
712
+ expect(result.success).toBe(false);
570
713
  });
571
714
 
572
715
  it("close() removes the abort listener so a post-close abort does not re-kill ffmpeg", async () => {
@@ -613,7 +756,7 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
613
756
  // progressing" capture the encoder must still be alive. The old total-
614
757
  // render timeout would have fired SIGTERM at ~1000ms.
615
758
  for (let i = 0; i < 9; i++) {
616
- encoder.writeFrame(Buffer.from([i]));
759
+ await encoder.writeFrame(Buffer.from([i]));
617
760
  vi.advanceTimersByTime(900);
618
761
  }
619
762
  expect(proc.kill).not.toHaveBeenCalled();
@@ -647,14 +790,17 @@ describe("spawnStreamingEncoder lifecycle and cleanup", () => {
647
790
  const proc = calls[0]!.proc;
648
791
  proc.stdin.write = (_chunk: Buffer) => false;
649
792
 
650
- // Pump 9 frames at 900ms intervals all returning false. The reset
651
- // should NOT fire (every write was buffered, not accepted), so the
652
- // 1000ms timer (last reset on spawn) elapses near the start.
653
- for (let i = 0; i < 9; i++) {
654
- encoder.writeFrame(Buffer.from([i]));
655
- vi.advanceTimersByTime(900);
656
- }
793
+ // A buffered write should remain pending and must NOT reset the timer.
794
+ // The 1000ms timer (last reset on spawn) therefore elapses while the
795
+ // caller is correctly back-pressured on the first frame.
796
+ const writePromise = encoder.writeFrame(Buffer.from([0]));
797
+ await Promise.resolve();
798
+
799
+ vi.advanceTimersByTime(1100);
657
800
  expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
801
+
802
+ proc.emit("close", null);
803
+ await expect(writePromise).resolves.toBe(false);
658
804
  } finally {
659
805
  vi.useRealTimers();
660
806
  }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file unused-type code-duplication complexity
1
2
  /**
2
3
  * Streaming Encoder Service
3
4
  *
@@ -9,10 +10,11 @@
9
10
  * 1. Frame reorder buffer – ensures out-of-order parallel workers feed
10
11
  * frames to FFmpeg stdin in sequential order.
11
12
  * 2. Streaming FFmpeg encoder – spawns FFmpeg with `-f image2pipe` and
12
- * exposes a `writeFrame(buffer)` + `close()` API.
13
+ * exposes an async `writeFrame(buffer)` + `close()` API.
13
14
  */
14
15
 
15
16
  import { spawn, type ChildProcess } from "child_process";
17
+ import { once } from "events";
16
18
  import { trackChildProcess } from "../utils/processTracker.js";
17
19
  import { existsSync, mkdirSync, statSync } from "fs";
18
20
  import { dirname } from "path";
@@ -24,6 +26,7 @@ import {
24
26
  mapPresetForGpuEncoder,
25
27
  } from "../utils/gpuEncoder.js";
26
28
  import { formatFfmpegError } from "../utils/runFfmpeg.js";
29
+ import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
27
30
  import { getHdrEncoderColorParams } from "../utils/hdr.js";
28
31
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
29
32
  import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
@@ -124,7 +127,14 @@ export interface StreamingEncoderResult {
124
127
  }
125
128
 
126
129
  export interface StreamingEncoder {
127
- writeFrame: (buffer: Buffer) => boolean;
130
+ /**
131
+ * Write one frame to FFmpeg stdin, awaiting `drain` when the pipe is full
132
+ * so back-pressure propagates to the caller. Resolves `false` when FFmpeg
133
+ * is already gone. Callers must serialize calls — one in-flight writeFrame
134
+ * per encoder (the frame reorder buffer provides this ordering); concurrent
135
+ * calls would interleave frame bytes on the pipe and race the drain wait.
136
+ */
137
+ writeFrame: (buffer: Buffer) => Promise<boolean>;
128
138
  close: () => Promise<StreamingEncoderResult>;
129
139
  getExitStatus: () => "running" | "success" | "error";
130
140
  }
@@ -380,7 +390,7 @@ export async function spawnStreamingEncoder(
380
390
  const args = buildStreamingArgs(options, outputPath, gpuEncoder);
381
391
 
382
392
  const startTime = Date.now();
383
- const ffmpeg: ChildProcess = spawn("ffmpeg", args, {
393
+ const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
384
394
  stdio: ["pipe", "pipe", "pipe"],
385
395
  });
386
396
  trackChildProcess(ffmpeg);
@@ -446,9 +456,45 @@ export async function spawnStreamingEncoder(
446
456
  };
447
457
  resetTimer();
448
458
 
459
+ const waitForDrainOrExit = async (
460
+ stdin: NonNullable<ChildProcess["stdin"]>,
461
+ ): Promise<"drain" | "exit"> => {
462
+ // Back-pressure can hit once per frame. Do not race `exitPromise.then(...)`
463
+ // here: V8 retains `.then` reaction-list entries on an unsettled promise,
464
+ // so a one-hour 30fps render under steady back-pressure can accumulate
465
+ // ~108K closures + AbortControllers. Use one-shot listeners for this write
466
+ // instead, then abort them in finally. `close` is the event that flips
467
+ // `exitStatus`; re-check after listener attachment so a close emitted
468
+ // between `stdin.write(false)` and this await cannot hang forever.
469
+ const abortController = new AbortController();
470
+ try {
471
+ const drainPromise = once(stdin, "drain", { signal: abortController.signal }).then(
472
+ () => "drain" as const,
473
+ );
474
+ const closePromise = once(ffmpeg, "close", { signal: abortController.signal }).then(
475
+ () => "exit" as const,
476
+ );
477
+ const racePromise = Promise.race([drainPromise, closePromise]).catch((err: unknown) => {
478
+ if (err instanceof Error && err.name === "AbortError") {
479
+ return "exit" as const;
480
+ }
481
+ throw err;
482
+ });
483
+
484
+ if (exitStatus !== "running") {
485
+ return "exit";
486
+ }
487
+
488
+ return await racePromise;
489
+ } finally {
490
+ abortController.abort();
491
+ }
492
+ };
493
+
449
494
  const encoder: StreamingEncoder = {
450
- writeFrame: (buffer: Buffer): boolean => {
451
- if (exitStatus !== "running" || !ffmpeg.stdin || ffmpeg.stdin.destroyed) {
495
+ writeFrame: async (buffer: Buffer): Promise<boolean> => {
496
+ const stdin = ffmpeg.stdin;
497
+ if (exitStatus !== "running" || !stdin || stdin.destroyed) {
452
498
  return false;
453
499
  }
454
500
  // Copy the buffer before writing — Node streams hold a reference to the
@@ -457,18 +503,28 @@ export async function spawnStreamingEncoder(
457
503
  // so without this copy the pipe would read partially-overwritten data
458
504
  // and flicker.
459
505
  const copy = Buffer.from(buffer);
460
- const accepted = ffmpeg.stdin.write(copy);
461
- // Reset inactivity timer ONLY on `accepted === true`. `true` means the
462
- // write went through to the kernel pipe without buffering in Node —
463
- // proof FFmpeg is actually consuming. `false` means Node's writable
464
- // stream had to buffer (FFmpeg hasn't drained the pipe yet); we deliberately
465
- // don't reset on `false` so a hung FFmpeg with a still-producing Chrome
466
- // can't keep us alive forever while Node's stdin buffer grows to OOM. In
467
- // steady state with a slower-but-alive FFmpeg, writes alternate between
468
- // true and false as the buffer drains and refills; the trues are enough
469
- // to keep the heartbeat ticking.
470
- if (accepted) resetTimer();
471
- return accepted;
506
+ const accepted = stdin.write(copy);
507
+ // Reset inactivity timer immediately ONLY on `accepted === true`. `true`
508
+ // means the write went through to the kernel pipe without buffering in
509
+ // Node — proof FFmpeg is actually consuming. `false` means Node's writable
510
+ // stream had to buffer (FFmpeg hasn't drained the pipe yet); we await
511
+ // `drain` before letting callers produce the next frame, and only reset
512
+ // after drain proves consumption. We deliberately don't reset before
513
+ // drain so a hung FFmpeg with a still-producing Chrome can't keep us
514
+ // alive forever while Node's stdin buffer grows to OOM. If FFmpeg exits
515
+ // before draining, waitForDrainOrExit returns "exit", removes its
516
+ // one-shot listeners, and callers see `false` instead of hanging.
517
+ if (accepted) {
518
+ resetTimer();
519
+ return true;
520
+ }
521
+
522
+ const drainResult = await waitForDrainOrExit(stdin);
523
+ if (drainResult !== "drain" || exitStatus !== "running") {
524
+ return false;
525
+ }
526
+ resetTimer();
527
+ return true;
472
528
  },
473
529
 
474
530
  close: async (): Promise<StreamingEncoderResult> => {