@hyperframes/engine 0.6.119 → 0.6.121

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 (73) hide show
  1. package/package.json +24 -7
  2. package/scripts/generate-lut-reference.py +0 -168
  3. package/scripts/test-fitTextFontSize-browser.ts +0 -135
  4. package/src/cdp-headless-experimental.d.ts +0 -54
  5. package/src/config.test.ts +0 -213
  6. package/src/config.ts +0 -417
  7. package/src/index.ts +0 -273
  8. package/src/services/audioMixer.test.ts +0 -326
  9. package/src/services/audioMixer.ts +0 -604
  10. package/src/services/audioMixer.types.ts +0 -35
  11. package/src/services/audioVolumeEnvelope.test.ts +0 -176
  12. package/src/services/audioVolumeEnvelope.ts +0 -138
  13. package/src/services/browserManager.test.ts +0 -330
  14. package/src/services/browserManager.ts +0 -670
  15. package/src/services/chunkEncoder.test.ts +0 -1415
  16. package/src/services/chunkEncoder.ts +0 -831
  17. package/src/services/chunkEncoder.types.ts +0 -60
  18. package/src/services/extractionCache.test.ts +0 -199
  19. package/src/services/extractionCache.ts +0 -216
  20. package/src/services/fileServer.ts +0 -110
  21. package/src/services/frameCapture-discardWarmup.test.ts +0 -183
  22. package/src/services/frameCapture-namePolyfill.test.ts +0 -78
  23. package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
  24. package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
  25. package/src/services/frameCapture-warmupTicks.test.ts +0 -174
  26. package/src/services/frameCapture.test.ts +0 -192
  27. package/src/services/frameCapture.ts +0 -1934
  28. package/src/services/hdrCapture.test.ts +0 -159
  29. package/src/services/hdrCapture.ts +0 -315
  30. package/src/services/parallelCoordinator.test.ts +0 -139
  31. package/src/services/parallelCoordinator.ts +0 -437
  32. package/src/services/screenshotService.test.ts +0 -510
  33. package/src/services/screenshotService.ts +0 -615
  34. package/src/services/streamingEncoder.test.ts +0 -832
  35. package/src/services/streamingEncoder.ts +0 -594
  36. package/src/services/systemMemory.test.ts +0 -324
  37. package/src/services/systemMemory.ts +0 -180
  38. package/src/services/videoFrameExtractor.test.ts +0 -1062
  39. package/src/services/videoFrameExtractor.ts +0 -1139
  40. package/src/services/videoFrameInjector.test.ts +0 -300
  41. package/src/services/videoFrameInjector.ts +0 -687
  42. package/src/services/vp9Options.ts +0 -13
  43. package/src/types.ts +0 -191
  44. package/src/utils/alphaBlit.test.ts +0 -1349
  45. package/src/utils/alphaBlit.ts +0 -1015
  46. package/src/utils/assertSwiftShader.test.ts +0 -130
  47. package/src/utils/assertSwiftShader.ts +0 -126
  48. package/src/utils/ffmpegBinaries.test.ts +0 -43
  49. package/src/utils/ffmpegBinaries.ts +0 -63
  50. package/src/utils/ffprobe.test.ts +0 -342
  51. package/src/utils/ffprobe.ts +0 -457
  52. package/src/utils/gpuEncoder.test.ts +0 -140
  53. package/src/utils/gpuEncoder.ts +0 -268
  54. package/src/utils/hdr.test.ts +0 -191
  55. package/src/utils/hdr.ts +0 -137
  56. package/src/utils/hdrCompositing.test.ts +0 -130
  57. package/src/utils/htmlTemplate.test.ts +0 -42
  58. package/src/utils/htmlTemplate.ts +0 -42
  59. package/src/utils/layerCompositor.test.ts +0 -150
  60. package/src/utils/layerCompositor.ts +0 -58
  61. package/src/utils/parityContract.ts +0 -1
  62. package/src/utils/processTracker.test.ts +0 -74
  63. package/src/utils/processTracker.ts +0 -41
  64. package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
  65. package/src/utils/runFfmpeg.test.ts +0 -102
  66. package/src/utils/runFfmpeg.ts +0 -136
  67. package/src/utils/shaderTransitions.test.ts +0 -738
  68. package/src/utils/shaderTransitions.ts +0 -1130
  69. package/src/utils/uint16-alignment-audit.test.ts +0 -125
  70. package/src/utils/urlDownloader.test.ts +0 -65
  71. package/src/utils/urlDownloader.ts +0 -143
  72. package/tsconfig.json +0 -19
  73. package/vitest.config.ts +0 -7
@@ -1,832 +0,0 @@
1
- /**
2
- * buildStreamingArgs unit tests.
3
- *
4
- * These tests focus on the FFmpeg CLI shape rather than spawning the encoder
5
- * — they're the cheap regression net for the HDR static-metadata bug
6
- * (side_data=[none] in the encoded MP4) reproduced by
7
- * packages/producer/scripts/hdr-smoke.ts. Without these assertions, future
8
- * refactors of the x265-params string can silently strip
9
- * master-display / max-cll and ship as SDR BT.2020 again.
10
- */
11
-
12
- import { EventEmitter } from "events";
13
- import { mkdtempSync } from "fs";
14
- import { tmpdir } from "os";
15
- import { join } from "path";
16
- import { afterEach, describe, expect, it, vi } from "vitest";
17
-
18
- import {
19
- buildStreamingArgs,
20
- createFrameReorderBuffer,
21
- type StreamingEncoderOptions,
22
- } from "./streamingEncoder.js";
23
- import { DEFAULT_HDR10_MASTERING } from "../utils/hdr.js";
24
-
25
- const baseHdrPq: StreamingEncoderOptions = {
26
- fps: { num: 30, den: 1 },
27
- width: 1920,
28
- height: 1080,
29
- codec: "h265",
30
- preset: "medium",
31
- quality: 23,
32
- pixelFormat: "yuv420p10le",
33
- useGpu: false,
34
- rawInputFormat: "rgb48le",
35
- hdr: { transfer: "pq" },
36
- };
37
-
38
- const baseHdrHlg: StreamingEncoderOptions = {
39
- ...baseHdrPq,
40
- hdr: { transfer: "hlg" },
41
- };
42
-
43
- const baseSdr: StreamingEncoderOptions = {
44
- fps: { num: 30, den: 1 },
45
- width: 1920,
46
- height: 1080,
47
- codec: "h264",
48
- preset: "medium",
49
- quality: 23,
50
- useGpu: false,
51
- };
52
-
53
- const baseVp9 = {
54
- ...baseSdr,
55
- codec: "vp9" as const,
56
- preset: "good",
57
- quality: 18,
58
- pixelFormat: "yuva420p",
59
- imageFormat: "png" as const,
60
- };
61
-
62
- function getX265ParamsValue(args: string[]): string | undefined {
63
- const idx = args.indexOf("-x265-params");
64
- return idx === -1 ? undefined : args[idx + 1];
65
- }
66
-
67
- describe("buildStreamingArgs", () => {
68
- describe("HDR PQ (libx265)", () => {
69
- it("emits master-display and max-cll in -x265-params", () => {
70
- const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
71
- const x265 = getX265ParamsValue(args);
72
- expect(x265).toBeDefined();
73
- expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
74
- expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
75
- expect(x265).toContain("colorprim=bt2020");
76
- expect(x265).toContain("transfer=smpte2084");
77
- expect(x265).toContain("colormatrix=bt2020nc");
78
- });
79
-
80
- it("tags the output stream with bt2020 / smpte2084 / tv range", () => {
81
- const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
82
- expect(args).toContain("-colorspace:v");
83
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
84
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
85
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
86
- expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
87
- });
88
-
89
- it("uses libx265 with -tag:v hvc1 for QuickTime compatibility", () => {
90
- const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
91
- const cvIdx = args.indexOf("-c:v");
92
- expect(cvIdx).toBeGreaterThan(-1);
93
- expect(args[cvIdx + 1]).toBe("libx265");
94
- expect(args).toContain("-tag:v");
95
- expect(args[args.indexOf("-tag:v") + 1]).toBe("hvc1");
96
- });
97
-
98
- it("keeps the aq-mode prefix even with master-display present", () => {
99
- const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
100
- const x265 = getX265ParamsValue(args);
101
- expect(x265?.startsWith("aq-mode=3")).toBe(true);
102
- });
103
-
104
- it("uses the simpler aq-mode prefix on ultrafast preset", () => {
105
- const args = buildStreamingArgs({ ...baseHdrPq, preset: "ultrafast" }, "/tmp/out.mp4");
106
- const x265 = getX265ParamsValue(args);
107
- expect(x265?.startsWith("aq-mode=3:")).toBe(true);
108
- expect(x265).not.toContain("aq-strength");
109
- expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
110
- });
111
- });
112
-
113
- describe("HDR HLG (libx265)", () => {
114
- it("emits master-display, max-cll, and the HLG transfer", () => {
115
- const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
116
- const x265 = getX265ParamsValue(args);
117
- expect(x265).toContain("transfer=arib-std-b67");
118
- expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
119
- expect(x265).toContain(`max-cll=${DEFAULT_HDR10_MASTERING.maxCll}`);
120
- });
121
-
122
- it("tags the output stream with arib-std-b67", () => {
123
- const args = buildStreamingArgs(baseHdrHlg, "/tmp/out.mp4");
124
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
125
- });
126
- });
127
-
128
- describe("HDR raw input tagging", () => {
129
- it("tags the rawvideo input with the matching color metadata", () => {
130
- const args = buildStreamingArgs(baseHdrPq, "/tmp/out.mp4");
131
- const inputColorTrcIdx = args.indexOf("-color_trc");
132
- expect(inputColorTrcIdx).toBeGreaterThan(-1);
133
- expect(args[inputColorTrcIdx + 1]).toBe("smpte2084");
134
- const inputPrimariesIdx = args.indexOf("-color_primaries");
135
- expect(inputPrimariesIdx).toBeGreaterThan(-1);
136
- expect(args[inputPrimariesIdx + 1]).toBe("bt2020");
137
- // Pix_fmt of the raw input must match the buffer we hand FFmpeg.
138
- expect(args.indexOf("rgb48le")).toBeGreaterThan(-1);
139
- });
140
-
141
- it("does not strip the input color tags when bitrate is set instead of CRF", () => {
142
- const args = buildStreamingArgs({ ...baseHdrPq, bitrate: "20M" }, "/tmp/out.mp4");
143
- const x265 = getX265ParamsValue(args);
144
- expect(x265).toContain(`master-display=${DEFAULT_HDR10_MASTERING.masterDisplay}`);
145
- expect(args).toContain("-b:v");
146
- expect(args[args.indexOf("-b:v") + 1]).toBe("20M");
147
- });
148
- });
149
-
150
- describe("SDR fallback", () => {
151
- it("does NOT emit HDR mastering metadata for SDR encodes", () => {
152
- const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
153
- const x264 = args[args.indexOf("-x264-params") + 1];
154
- expect(x264).toContain("colorprim=bt709");
155
- expect(x264).toContain("transfer=bt709");
156
- expect(x264).toContain("colormatrix=bt709");
157
- expect(x264).not.toContain("master-display");
158
- expect(x264).not.toContain("max-cll");
159
- });
160
-
161
- it("tags SDR output with bt709 and tv range", () => {
162
- const args = buildStreamingArgs(baseSdr, "/tmp/out.mp4");
163
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
164
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
165
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
166
- expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
167
- });
168
- });
169
-
170
- describe("output path", () => {
171
- it("places the output path last after -y", () => {
172
- const args = buildStreamingArgs(baseHdrPq, "/tmp/some-output.mp4");
173
- expect(args[args.length - 2]).toBe("-y");
174
- expect(args[args.length - 1]).toBe("/tmp/some-output.mp4");
175
- });
176
- });
177
-
178
- describe("VP9 cpu-used", () => {
179
- it("emits the default speed/quality tradeoff for streaming WebM", () => {
180
- const args = buildStreamingArgs(baseVp9, "/tmp/out.webm");
181
-
182
- expect(args[args.indexOf("-c:v") + 1]).toBe("libvpx-vp9");
183
- expect(args[args.indexOf("-cpu-used") + 1]).toBe("4");
184
- });
185
-
186
- it("honors the resolved engine override for streaming WebM", () => {
187
- const args = buildStreamingArgs({ ...baseVp9, vp9CpuUsed: 2 }, "/tmp/out.webm");
188
-
189
- expect(args[args.indexOf("-cpu-used") + 1]).toBe("2");
190
- });
191
- });
192
-
193
- describe("fps rational forwarding", () => {
194
- // Regression for the fps fraction-syntax feature: both `-framerate`
195
- // (input timestamping) and `-r` (output framerate) must carry the
196
- // rational verbatim — collapsing to 29.97 decimal at this boundary
197
- // would defeat the whole point of supporting NTSC end-to-end.
198
- it("emits rational -framerate and -r for NTSC 30000/1001 (image2pipe)", () => {
199
- const sdrNtsc: StreamingEncoderOptions = {
200
- ...baseSdr,
201
- fps: { num: 30000, den: 1001 },
202
- };
203
- const args = buildStreamingArgs(sdrNtsc, "/tmp/ntsc.mp4");
204
- const framerateIdx = args.indexOf("-framerate");
205
- expect(framerateIdx).toBeGreaterThan(-1);
206
- expect(args[framerateIdx + 1]).toBe("30000/1001");
207
-
208
- const rIdx = args.indexOf("-r");
209
- expect(rIdx).toBeGreaterThan(-1);
210
- expect(args[rIdx + 1]).toBe("30000/1001");
211
- });
212
-
213
- it("emits rational -framerate and -r for NTSC 30000/1001 (rawvideo HDR)", () => {
214
- const hdrNtsc: StreamingEncoderOptions = {
215
- ...baseHdrPq,
216
- fps: { num: 30000, den: 1001 },
217
- };
218
- const args = buildStreamingArgs(hdrNtsc, "/tmp/ntsc-hdr.mp4");
219
- const framerateIdx = args.indexOf("-framerate");
220
- expect(framerateIdx).toBeGreaterThan(-1);
221
- expect(args[framerateIdx + 1]).toBe("30000/1001");
222
-
223
- const rIdx = args.indexOf("-r");
224
- expect(rIdx).toBeGreaterThan(-1);
225
- expect(args[rIdx + 1]).toBe("30000/1001");
226
- });
227
-
228
- it("emits bare integer -r for { num: 30, den: 1 }", () => {
229
- const args = buildStreamingArgs(baseSdr, "/tmp/30.mp4");
230
- const rIdx = args.indexOf("-r");
231
- expect(rIdx).toBeGreaterThan(-1);
232
- expect(args[rIdx + 1]).toBe("30");
233
- });
234
- });
235
-
236
- describe("GPU preset mapping", () => {
237
- const baseGpu: StreamingEncoderOptions = {
238
- fps: { num: 30, den: 1 },
239
- width: 1920,
240
- height: 1080,
241
- codec: "h264",
242
- preset: "ultrafast",
243
- quality: 28,
244
- useGpu: true,
245
- };
246
-
247
- function presetArg(args: string[]): string | undefined {
248
- const idx = args.indexOf("-preset");
249
- return idx === -1 ? undefined : args[idx + 1];
250
- }
251
-
252
- // Regression for the streaming-encode + --gpu failure: NVENC rejects
253
- // libx264 `ultrafast` with AVERROR(EINVAL), which previously surfaced
254
- // as a bare "FFmpeg exited with code -22".
255
- it("translates ultrafast to NVENC p1", () => {
256
- const args = buildStreamingArgs(baseGpu, "/tmp/out.mp4", "nvenc");
257
- expect(presetArg(args)).toBe("p1");
258
- });
259
-
260
- it("translates medium to NVENC p4", () => {
261
- const args = buildStreamingArgs({ ...baseGpu, preset: "medium" }, "/tmp/out.mp4", "nvenc");
262
- expect(presetArg(args)).toBe("p4");
263
- });
264
-
265
- // Same mapping applies to hevc_nvenc: NVENC's preset vocabulary is
266
- // codec-agnostic, so the helper must translate for H.265 too.
267
- it("translates libx264 preset names to NVENC pN for h265 as well", () => {
268
- for (const [libx264, nvencPreset] of [
269
- ["ultrafast", "p1"],
270
- ["medium", "p4"],
271
- ["veryslow", "p7"],
272
- ] as const) {
273
- const args = buildStreamingArgs(
274
- { ...baseGpu, codec: "h265", preset: libx264 },
275
- "/tmp/out.mp4",
276
- "nvenc",
277
- );
278
- expect(args[args.indexOf("-c:v") + 1]).toBe("hevc_nvenc");
279
- expect(presetArg(args)).toBe(nvencPreset);
280
- }
281
- });
282
-
283
- it("rewrites QSV's unsupported ultrafast preset to veryfast", () => {
284
- const args = buildStreamingArgs(baseGpu, "/tmp/out.mp4", "qsv");
285
- expect(presetArg(args)).toBe("veryfast");
286
- });
287
-
288
- it("passes QSV-supported preset names through unchanged", () => {
289
- const args = buildStreamingArgs({ ...baseGpu, preset: "medium" }, "/tmp/out.mp4", "qsv");
290
- expect(presetArg(args)).toBe("medium");
291
- });
292
-
293
- it("uses AMD AMF encoder names and quality flags when selected", () => {
294
- const h264Args = buildStreamingArgs(
295
- { ...baseGpu, preset: "medium", quality: 23 },
296
- "/tmp/out.mp4",
297
- "amf",
298
- );
299
- expect(h264Args[h264Args.indexOf("-c:v") + 1]).toBe("h264_amf");
300
- expect(h264Args[h264Args.indexOf("-qp_i") + 1]).toBe("23");
301
- expect(h264Args).toContain("-bf");
302
- expect(h264Args[h264Args.indexOf("-bf") + 1]).toBe("0");
303
-
304
- const h265Args = buildStreamingArgs(
305
- { ...baseGpu, codec: "h265", preset: "medium", quality: 23 },
306
- "/tmp/out.mp4",
307
- "amf",
308
- );
309
- expect(h265Args[h265Args.indexOf("-c:v") + 1]).toBe("hevc_amf");
310
- expect(h265Args[h265Args.indexOf("-qp_i") + 1]).toBe("23");
311
- });
312
- });
313
- });
314
-
315
- describe("createFrameReorderBuffer", () => {
316
- it("fast-paths waitForFrame(cursor) without queueing", async () => {
317
- const buf = createFrameReorderBuffer(0, 3);
318
- await buf.waitForFrame(0);
319
- });
320
-
321
- it("gates out-of-order writers into cursor order", async () => {
322
- const buf = createFrameReorderBuffer(0, 4);
323
- const writeOrder: number[] = [];
324
-
325
- const writer = async (frame: number) => {
326
- await buf.waitForFrame(frame);
327
- writeOrder.push(frame);
328
- buf.advanceTo(frame + 1);
329
- };
330
-
331
- const p3 = writer(3);
332
- const p1 = writer(1);
333
- const p2 = writer(2);
334
- const p0 = writer(0);
335
-
336
- await Promise.all([p0, p1, p2, p3]);
337
- expect(writeOrder).toEqual([0, 1, 2, 3]);
338
- });
339
-
340
- it("supports multiple waiters registered for the same frame", async () => {
341
- const buf = createFrameReorderBuffer(0, 2);
342
- const resolved: string[] = [];
343
-
344
- const a = buf.waitForFrame(1).then(() => resolved.push("a"));
345
- const b = buf.waitForFrame(1).then(() => resolved.push("b"));
346
-
347
- buf.advanceTo(0);
348
- await Promise.resolve();
349
- expect(resolved).toEqual([]);
350
-
351
- buf.advanceTo(1);
352
- await Promise.all([a, b]);
353
- expect(resolved.sort()).toEqual(["a", "b"]);
354
- });
355
-
356
- it("waitForAllDone resolves when cursor reaches endFrame", async () => {
357
- const buf = createFrameReorderBuffer(0, 3);
358
- let done = false;
359
- const allDone = buf.waitForAllDone().then(() => {
360
- done = true;
361
- });
362
-
363
- buf.advanceTo(1);
364
- await Promise.resolve();
365
- expect(done).toBe(false);
366
-
367
- buf.advanceTo(3);
368
- await allDone;
369
- expect(done).toBe(true);
370
- });
371
-
372
- it("waitForAllDone fast-paths when cursor already past endFrame", async () => {
373
- const buf = createFrameReorderBuffer(0, 3);
374
- buf.advanceTo(5);
375
- await buf.waitForAllDone();
376
- });
377
- });
378
-
379
- interface FakeStdin extends EventEmitter {
380
- destroyed: boolean;
381
- end: (cb?: () => void) => void;
382
- write: (chunk: Buffer) => boolean;
383
- }
384
-
385
- interface FakeProc extends EventEmitter {
386
- stdin: FakeStdin;
387
- stdout: EventEmitter;
388
- stderr: EventEmitter;
389
- kill: ReturnType<typeof vi.fn>;
390
- }
391
-
392
- interface SpawnCall {
393
- command: string;
394
- args: readonly string[];
395
- proc: FakeProc;
396
- }
397
-
398
- function createFakeStdin(): FakeStdin {
399
- const state = { destroyed: false };
400
- const stdin = new EventEmitter() as FakeStdin;
401
- Object.defineProperty(stdin, "destroyed", {
402
- get: () => state.destroyed,
403
- set: (v: boolean) => {
404
- state.destroyed = v;
405
- },
406
- });
407
- stdin.end = (cb?: () => void) => {
408
- state.destroyed = true;
409
- if (cb) process.nextTick(cb);
410
- };
411
- stdin.write = (_chunk: Buffer): boolean => !state.destroyed;
412
- return stdin;
413
- }
414
-
415
- function createFakeProc(): FakeProc {
416
- const proc = new EventEmitter() as FakeProc;
417
- proc.stdin = createFakeStdin();
418
- proc.stdout = new EventEmitter();
419
- proc.stderr = new EventEmitter();
420
- proc.kill = vi.fn();
421
- return proc;
422
- }
423
-
424
- function createSpawnSpy(): {
425
- spawn: (command: string, args: readonly string[]) => FakeProc;
426
- calls: SpawnCall[];
427
- } {
428
- const calls: SpawnCall[] = [];
429
- const spawn = (command: string, args: readonly string[]): FakeProc => {
430
- const proc = createFakeProc();
431
- calls.push({ command, args, proc });
432
- return proc;
433
- };
434
- return { spawn, calls };
435
- }
436
-
437
- const baseOptions: StreamingEncoderOptions = {
438
- fps: { num: 30, den: 1 },
439
- width: 100,
440
- height: 100,
441
- codec: "h264",
442
- useGpu: false,
443
- };
444
-
445
- async function resolveWithin<T>(promise: Promise<T>, ms = 100): Promise<T | "timeout"> {
446
- let timeout: ReturnType<typeof setTimeout> | undefined;
447
- try {
448
- return await Promise.race([
449
- promise,
450
- new Promise<"timeout">((resolve) => {
451
- timeout = setTimeout(() => resolve("timeout"), ms);
452
- }),
453
- ]);
454
- } finally {
455
- if (timeout) clearTimeout(timeout);
456
- }
457
- }
458
-
459
- describe("spawnStreamingEncoder lifecycle and cleanup", () => {
460
- afterEach(() => {
461
- vi.resetModules();
462
- vi.doUnmock("child_process");
463
- });
464
-
465
- it("returns a success result when ffmpeg exits cleanly after close()", async () => {
466
- const { spawn, calls } = createSpawnSpy();
467
- vi.resetModules();
468
- vi.doMock("child_process", () => ({ spawn }));
469
-
470
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
471
- const dir = mkdtempSync(join(tmpdir(), "se-success-"));
472
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
473
-
474
- expect(calls).toHaveLength(1);
475
- expect(calls[0]?.command).toBe("ffmpeg");
476
-
477
- const proc = calls[0]!.proc;
478
- const closePromise = encoder.close();
479
- process.nextTick(() => proc.emit("close", 0));
480
-
481
- const result = await closePromise;
482
- expect(result.success).toBe(true);
483
- expect(result.error).toBeUndefined();
484
- expect(result.fileSize).toBe(0); // No real ffmpeg, no file written
485
- });
486
-
487
- it("returns a failure result (does NOT throw) when ffmpeg exits non-zero before close()", async () => {
488
- const { spawn, calls } = createSpawnSpy();
489
- vi.resetModules();
490
- vi.doMock("child_process", () => ({ spawn }));
491
-
492
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
493
- const dir = mkdtempSync(join(tmpdir(), "se-fail-"));
494
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
495
-
496
- const proc = calls[0]!.proc;
497
- proc.stderr.emit("data", Buffer.from("Encoder error\n"));
498
- await new Promise<void>((resolve) => {
499
- process.nextTick(() => {
500
- proc.emit("close", 1);
501
- resolve();
502
- });
503
- });
504
-
505
- const result = await encoder.close();
506
- expect(result.success).toBe(false);
507
- expect(result.error).toContain("FFmpeg exited with code 1");
508
- expect(result.error).toContain("Encoder error");
509
- });
510
-
511
- it("returns a failure result (does NOT throw) when ffmpeg fails to spawn (ENOENT)", async () => {
512
- const { spawn, calls } = createSpawnSpy();
513
- vi.resetModules();
514
- vi.doMock("child_process", () => ({ spawn }));
515
-
516
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
517
- const dir = mkdtempSync(join(tmpdir(), "se-enoent-"));
518
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
519
-
520
- const proc = calls[0]!.proc;
521
- await new Promise<void>((resolve) => {
522
- process.nextTick(() => {
523
- const err = new Error("spawn ffmpeg ENOENT") as NodeJS.ErrnoException;
524
- err.code = "ENOENT";
525
- proc.emit("error", err);
526
- resolve();
527
- });
528
- });
529
-
530
- const result = await encoder.close();
531
- expect(result.success).toBe(false);
532
- expect(result.error).toMatch(/spawn ffmpeg ENOENT/);
533
- });
534
-
535
- it("returns a 'cancelled' result and SIGTERMs ffmpeg when the abort signal fires", async () => {
536
- const { spawn, calls } = createSpawnSpy();
537
- vi.resetModules();
538
- vi.doMock("child_process", () => ({ spawn }));
539
-
540
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
541
- const controller = new AbortController();
542
- const dir = mkdtempSync(join(tmpdir(), "se-abort-"));
543
- const encoder = await spawnStreamingEncoder(
544
- join(dir, "out.mp4"),
545
- baseOptions,
546
- controller.signal,
547
- );
548
-
549
- const proc = calls[0]!.proc;
550
- controller.abort();
551
- expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
552
-
553
- process.nextTick(() => proc.emit("close", null));
554
- const result = await encoder.close();
555
-
556
- expect(result.success).toBe(false);
557
- expect(result.error).toBe("Streaming encode cancelled");
558
- });
559
-
560
- it("close() is idempotent: a second call still resolves to a result and does not throw", async () => {
561
- const { spawn, calls } = createSpawnSpy();
562
- vi.resetModules();
563
- vi.doMock("child_process", () => ({ spawn }));
564
-
565
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
566
- const dir = mkdtempSync(join(tmpdir(), "se-idempotent-"));
567
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
568
-
569
- const proc = calls[0]!.proc;
570
- process.nextTick(() => proc.emit("close", 0));
571
-
572
- const first = await encoder.close();
573
- expect(first.success).toBe(true);
574
-
575
- // Defensive cleanup in renderOrchestrator may call close() again after the
576
- // explicit call. Verify the second call doesn't reject — it can return
577
- // either success (cached) or a benign failure result, but must not throw.
578
- let threw = false;
579
- try {
580
- const second = await encoder.close();
581
- expect(typeof second.success).toBe("boolean");
582
- } catch {
583
- threw = true;
584
- }
585
- expect(threw).toBe(false);
586
- });
587
-
588
- it("writeFrame returns false after ffmpeg has exited", async () => {
589
- const { spawn, calls } = createSpawnSpy();
590
- vi.resetModules();
591
- vi.doMock("child_process", () => ({ spawn }));
592
-
593
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
594
- const dir = mkdtempSync(join(tmpdir(), "se-writefail-"));
595
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
596
-
597
- expect(await encoder.writeFrame(Buffer.from([0]))).toBe(true);
598
-
599
- const proc = calls[0]!.proc;
600
- await new Promise<void>((resolve) => {
601
- process.nextTick(() => {
602
- proc.emit("close", 0);
603
- resolve();
604
- });
605
- });
606
-
607
- expect(await encoder.writeFrame(Buffer.from([0]))).toBe(false);
608
- });
609
-
610
- it("writeFrame waits for stdin drain when FFmpeg applies back-pressure", async () => {
611
- const { spawn, calls } = createSpawnSpy();
612
- vi.resetModules();
613
- vi.doMock("child_process", () => ({ spawn }));
614
-
615
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
616
- const dir = mkdtempSync(join(tmpdir(), "se-drain-"));
617
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
618
-
619
- const proc = calls[0]!.proc;
620
- proc.stdin.write = (_chunk: Buffer): boolean => false;
621
-
622
- const writeResult = encoder.writeFrame(Buffer.from([1])) as unknown;
623
- expect(writeResult).toBeInstanceOf(Promise);
624
-
625
- const writePromise = writeResult as Promise<boolean>;
626
- let settled = false;
627
- void writePromise.then(() => {
628
- settled = true;
629
- });
630
-
631
- await Promise.resolve();
632
- expect(settled).toBe(false);
633
- expect(proc.stdin.listenerCount("drain")).toBe(1);
634
-
635
- proc.stdin.emit("drain");
636
-
637
- await expect(writePromise).resolves.toBe(true);
638
- expect(settled).toBe(true);
639
- expect(proc.stdin.listenerCount("drain")).toBe(0);
640
-
641
- process.nextTick(() => proc.emit("close", 0));
642
- await encoder.close();
643
- });
644
-
645
- it("does not accumulate process close listeners across repeated back-pressured writes", async () => {
646
- const { spawn, calls } = createSpawnSpy();
647
- vi.resetModules();
648
- vi.doMock("child_process", () => ({ spawn }));
649
-
650
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
651
- const dir = mkdtempSync(join(tmpdir(), "se-drain-listeners-"));
652
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
653
-
654
- const proc = calls[0]!.proc;
655
- const baselineCloseListeners = proc.listenerCount("close");
656
- const baselineDrainListeners = proc.stdin.listenerCount("drain");
657
- proc.stdin.write = (_chunk: Buffer): boolean => false;
658
-
659
- for (let i = 0; i < 12; i++) {
660
- const writePromise = encoder.writeFrame(Buffer.from([i]));
661
-
662
- await Promise.resolve();
663
- expect(proc.stdin.listenerCount("drain")).toBe(baselineDrainListeners + 1);
664
- expect(proc.listenerCount("close")).toBe(baselineCloseListeners + 1);
665
-
666
- proc.stdin.emit("drain");
667
-
668
- await expect(writePromise).resolves.toBe(true);
669
- expect(proc.stdin.listenerCount("drain")).toBe(baselineDrainListeners);
670
- expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
671
- }
672
-
673
- process.nextTick(() => proc.emit("close", 0));
674
- await encoder.close();
675
- });
676
-
677
- it("writeFrame resolves false instead of hanging when FFmpeg exits before drain", async () => {
678
- const { spawn, calls } = createSpawnSpy();
679
- vi.resetModules();
680
- vi.doMock("child_process", () => ({ spawn }));
681
-
682
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
683
- const dir = mkdtempSync(join(tmpdir(), "se-drain-exit-"));
684
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
685
-
686
- const proc = calls[0]!.proc;
687
- proc.stdin.write = (_chunk: Buffer): boolean => false;
688
-
689
- const writeResult = encoder.writeFrame(Buffer.from([1])) as unknown;
690
- expect(writeResult).toBeInstanceOf(Promise);
691
-
692
- const writePromise = writeResult as Promise<boolean>;
693
- let settled = false;
694
- void writePromise.then(() => {
695
- settled = true;
696
- });
697
-
698
- await Promise.resolve();
699
- expect(settled).toBe(false);
700
- expect(proc.stdin.listenerCount("drain")).toBe(1);
701
-
702
- proc.emit("close", 1);
703
-
704
- await expect(writePromise).resolves.toBe(false);
705
- expect(settled).toBe(true);
706
- expect(proc.stdin.listenerCount("drain")).toBe(0);
707
-
708
- const result = await encoder.close();
709
- expect(result.success).toBe(false);
710
- });
711
-
712
- it("writeFrame resolves false when close fires after write returns false before await attaches listeners", async () => {
713
- const { spawn, calls } = createSpawnSpy();
714
- vi.resetModules();
715
- vi.doMock("child_process", () => ({ spawn }));
716
-
717
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
718
- const dir = mkdtempSync(join(tmpdir(), "se-drain-already-closed-"));
719
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions);
720
-
721
- const proc = calls[0]!.proc;
722
- const baselineCloseListeners = proc.listenerCount("close");
723
- proc.stdin.write = (_chunk: Buffer): boolean => {
724
- proc.emit("close", 1);
725
- return false;
726
- };
727
-
728
- const writePromise = encoder.writeFrame(Buffer.from([1]));
729
-
730
- await expect(resolveWithin(writePromise)).resolves.toBe(false);
731
- expect(encoder.getExitStatus()).toBe("error");
732
- expect(proc.stdin.listenerCount("drain")).toBe(0);
733
- expect(proc.listenerCount("close")).toBe(baselineCloseListeners);
734
-
735
- const result = await encoder.close();
736
- expect(result.success).toBe(false);
737
- });
738
-
739
- it("close() removes the abort listener so a post-close abort does not re-kill ffmpeg", async () => {
740
- const { spawn, calls } = createSpawnSpy();
741
- vi.resetModules();
742
- vi.doMock("child_process", () => ({ spawn }));
743
-
744
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
745
- const controller = new AbortController();
746
- const dir = mkdtempSync(join(tmpdir(), "se-detach-"));
747
- const encoder = await spawnStreamingEncoder(
748
- join(dir, "out.mp4"),
749
- baseOptions,
750
- controller.signal,
751
- );
752
-
753
- const proc = calls[0]!.proc;
754
- process.nextTick(() => proc.emit("close", 0));
755
- await encoder.close();
756
-
757
- expect(proc.kill).not.toHaveBeenCalled();
758
-
759
- controller.abort();
760
- expect(proc.kill).not.toHaveBeenCalled();
761
- });
762
-
763
- it("inactivity timeout fires only after a no-frame gap exceeds ffmpegStreamingTimeout", async () => {
764
- vi.useFakeTimers();
765
- try {
766
- const { spawn, calls } = createSpawnSpy();
767
- vi.resetModules();
768
- vi.doMock("child_process", () => ({ spawn }));
769
-
770
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
771
- const dir = mkdtempSync(join(tmpdir(), "se-heartbeat-"));
772
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions, undefined, {
773
- ffmpegStreamingTimeout: 1000,
774
- });
775
-
776
- const proc = calls[0]!.proc;
777
-
778
- // Frames every 900ms — under the 1000ms inactivity threshold — should
779
- // keep resetting the timer. After 9× 900ms = 8.1s of "slow but
780
- // progressing" capture the encoder must still be alive. The old total-
781
- // render timeout would have fired SIGTERM at ~1000ms.
782
- for (let i = 0; i < 9; i++) {
783
- await encoder.writeFrame(Buffer.from([i]));
784
- vi.advanceTimersByTime(900);
785
- }
786
- expect(proc.kill).not.toHaveBeenCalled();
787
-
788
- // Now stall — no writeFrame for longer than the threshold. SIGTERM fires.
789
- vi.advanceTimersByTime(1100);
790
- expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
791
- } finally {
792
- vi.useRealTimers();
793
- }
794
- });
795
-
796
- it("inactivity timeout still fires when stdin is backpressured (stalled ffmpeg, live producer)", async () => {
797
- vi.useFakeTimers();
798
- try {
799
- // Simulate the FFmpeg-hangs-but-Chrome-keeps-producing case: stdin.write
800
- // always returns false (Node has to buffer because ffmpeg isn't draining
801
- // the pipe). The heartbeat must NOT reset on those buffered writes —
802
- // otherwise a hung ffmpeg with a steady frame producer would never
803
- // SIGTERM and we'd grow Node's stdin buffer until OOM.
804
- const { spawn, calls } = createSpawnSpy();
805
- vi.resetModules();
806
- vi.doMock("child_process", () => ({ spawn }));
807
-
808
- const { spawnStreamingEncoder } = await import("./streamingEncoder.js");
809
- const dir = mkdtempSync(join(tmpdir(), "se-backpressure-"));
810
- const encoder = await spawnStreamingEncoder(join(dir, "out.mp4"), baseOptions, undefined, {
811
- ffmpegStreamingTimeout: 1000,
812
- });
813
-
814
- const proc = calls[0]!.proc;
815
- proc.stdin.write = (_chunk: Buffer) => false;
816
-
817
- // A buffered write should remain pending and must NOT reset the timer.
818
- // The 1000ms timer (last reset on spawn) therefore elapses while the
819
- // caller is correctly back-pressured on the first frame.
820
- const writePromise = encoder.writeFrame(Buffer.from([0]));
821
- await Promise.resolve();
822
-
823
- vi.advanceTimersByTime(1100);
824
- expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
825
-
826
- proc.emit("close", null);
827
- await expect(writePromise).resolves.toBe(false);
828
- } finally {
829
- vi.useRealTimers();
830
- }
831
- });
832
- });