@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +6 -1
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +55 -16
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js +4 -3
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts +9 -2
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +55 -16
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/systemMemory.d.ts +11 -8
- package/dist/services/systemMemory.d.ts.map +1 -1
- package/dist/services/systemMemory.js +110 -9
- package/dist/services/systemMemory.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +3 -1
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/utils/ffmpegBinaries.d.ts +6 -0
- package/dist/utils/ffmpegBinaries.d.ts.map +1 -0
- package/dist/utils/ffmpegBinaries.js +55 -0
- package/dist/utils/ffmpegBinaries.js.map +1 -0
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +8 -2
- package/dist/utils/ffprobe.js.map +1 -1
- package/dist/utils/gpuEncoder.d.ts.map +1 -1
- package/dist/utils/gpuEncoder.js +4 -2
- package/dist/utils/gpuEncoder.js.map +1 -1
- package/dist/utils/runFfmpeg.d.ts.map +1 -1
- package/dist/utils/runFfmpeg.js +20 -1
- package/dist/utils/runFfmpeg.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/services/browserManager.test.ts +18 -0
- package/src/services/browserManager.ts +8 -1
- package/src/services/chunkEncoder.test.ts +330 -1
- package/src/services/chunkEncoder.ts +75 -13
- package/src/services/parallelCoordinator.ts +4 -3
- package/src/services/streamingEncoder.test.ts +156 -10
- package/src/services/streamingEncoder.ts +73 -17
- package/src/services/systemMemory.test.ts +303 -2
- package/src/services/systemMemory.ts +137 -9
- package/src/services/videoFrameExtractor.ts +3 -1
- package/src/utils/ffmpegBinaries.test.ts +43 -0
- package/src/utils/ffmpegBinaries.ts +63 -0
- package/src/utils/ffprobe.test.ts +27 -0
- package/src/utils/ffprobe.ts +12 -2
- package/src/utils/gpuEncoder.ts +4 -2
- package/src/utils/runFfmpeg.test.ts +57 -1
- 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(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
461
|
-
// Reset inactivity timer ONLY on `accepted === true`. `true`
|
|
462
|
-
// write went through to the kernel pipe without buffering in
|
|
463
|
-
// proof FFmpeg is actually consuming. `false` means Node's writable
|
|
464
|
-
// stream had to buffer (FFmpeg hasn't drained the pipe yet); we
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
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> => {
|