@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,1415 +0,0 @@
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";
6
- import { ENCODER_PRESETS, getEncoderPreset, buildEncoderArgs } from "./chunkEncoder.js";
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.doUnmock("../utils/ffprobe.js");
22
- vi.useRealTimers();
23
- });
24
-
25
- function createFrameFixture(): { root: string; framesDir: string } {
26
- const root = mkdtempSync(join(tmpdir(), "hf-chunk-encoder-"));
27
- tempDirs.push(root);
28
- const framesDir = join(root, "frames");
29
- mkdirSync(framesDir);
30
- for (let i = 1; i <= 2; i++) {
31
- writeFileSync(join(framesDir, `frame_${String(i).padStart(6, "0")}.png`), TINY_PNG);
32
- }
33
- return { root, framesDir };
34
- }
35
-
36
- const tinyEncodeOptions = {
37
- fps: { num: 30, den: 1 },
38
- width: 2,
39
- height: 2,
40
- codec: "h264" as const,
41
- preset: "ultrafast",
42
- quality: 28,
43
- pixelFormat: "yuv420p",
44
- useGpu: false,
45
- };
46
-
47
- function encodeTimeoutMessage(timeoutMs: number): string {
48
- return `FFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
49
- }
50
-
51
- type FakeProc = EventEmitter & {
52
- stderr: EventEmitter;
53
- kill: ReturnType<typeof vi.fn>;
54
- killed: boolean;
55
- };
56
-
57
- type SpawnCall = {
58
- command: string;
59
- args: readonly string[];
60
- proc: FakeProc;
61
- };
62
-
63
- function createFakeProc(): FakeProc {
64
- const proc = new EventEmitter() as FakeProc;
65
- proc.stderr = new EventEmitter();
66
- proc.kill = vi.fn(() => {
67
- proc.killed = true;
68
- return true;
69
- });
70
- proc.killed = false;
71
- return proc;
72
- }
73
-
74
- function createSpawnSpy(): {
75
- spawn: (command: string, args: readonly string[]) => FakeProc;
76
- calls: SpawnCall[];
77
- } {
78
- const calls: SpawnCall[] = [];
79
- const spawn = (command: string, args: readonly string[]): FakeProc => {
80
- const proc = createFakeProc();
81
- calls.push({ command, args, proc });
82
- return proc;
83
- };
84
- return { spawn, calls };
85
- }
86
-
87
- function emitClose(proc: FakeProc, code: number): void {
88
- proc.emit("exit", code);
89
- proc.emit("close", code);
90
- }
91
-
92
- async function flushMuxCodecResolution(): Promise<void> {
93
- await Promise.resolve();
94
- await Promise.resolve();
95
- }
96
-
97
- describe("ENCODER_PRESETS", () => {
98
- it("has draft, standard, and high presets", () => {
99
- expect(ENCODER_PRESETS).toHaveProperty("draft");
100
- expect(ENCODER_PRESETS).toHaveProperty("standard");
101
- expect(ENCODER_PRESETS).toHaveProperty("high");
102
- });
103
-
104
- it("draft uses ultrafast preset with high CRF", () => {
105
- expect(ENCODER_PRESETS.draft.preset).toBe("ultrafast");
106
- expect(ENCODER_PRESETS.draft.quality).toBeGreaterThan(ENCODER_PRESETS.standard.quality);
107
- expect(ENCODER_PRESETS.draft.codec).toBe("h264");
108
- });
109
-
110
- it("high uses slow preset with low CRF for better quality", () => {
111
- expect(ENCODER_PRESETS.high.preset).toBe("slow");
112
- expect(ENCODER_PRESETS.high.quality).toBeLessThan(ENCODER_PRESETS.standard.quality);
113
- expect(ENCODER_PRESETS.high.codec).toBe("h264");
114
- });
115
-
116
- it("standard sits between draft and high in quality", () => {
117
- expect(ENCODER_PRESETS.standard.quality).toBeGreaterThan(ENCODER_PRESETS.high.quality);
118
- expect(ENCODER_PRESETS.standard.quality).toBeLessThan(ENCODER_PRESETS.draft.quality);
119
- });
120
- });
121
-
122
- describe("encodeFramesFromDir ffmpegEncodeTimeout", () => {
123
- it("kills ffmpeg when config timeout elapses", async () => {
124
- vi.useFakeTimers();
125
- const { spawn, calls } = createSpawnSpy();
126
- vi.resetModules();
127
- vi.doMock("child_process", () => ({ spawn }));
128
-
129
- const { encodeFramesFromDir } = await import("./chunkEncoder.js");
130
- const { root, framesDir } = createFrameFixture();
131
-
132
- const encodePromise = encodeFramesFromDir(
133
- framesDir,
134
- "frame_%06d.png",
135
- join(root, "timeout.mp4"),
136
- tinyEncodeOptions,
137
- undefined,
138
- { ffmpegEncodeTimeout: 1000 },
139
- );
140
-
141
- expect(calls).toHaveLength(1);
142
- const proc = calls[0]!.proc;
143
- vi.advanceTimersByTime(999);
144
- expect(proc.kill).not.toHaveBeenCalled();
145
-
146
- vi.advanceTimersByTime(1);
147
- expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
148
-
149
- proc.stderr.emit("data", Buffer.from("terminated by timeout\n"));
150
- emitClose(proc, 143);
151
-
152
- const result = await encodePromise;
153
- expect(result.success).toBe(false);
154
- expect(result.error).toContain("FFmpeg exited with code 143");
155
- expect(result.error).toContain("terminated by timeout");
156
- expect(result.error).toContain(encodeTimeoutMessage(1000));
157
- });
158
-
159
- it("keeps non-timeout ffmpeg failures unchanged", async () => {
160
- vi.useFakeTimers();
161
- const { spawn, calls } = createSpawnSpy();
162
- vi.resetModules();
163
- vi.doMock("child_process", () => ({ spawn }));
164
-
165
- const { encodeFramesFromDir } = await import("./chunkEncoder.js");
166
- const { root, framesDir } = createFrameFixture();
167
-
168
- const encodePromise = encodeFramesFromDir(
169
- framesDir,
170
- "frame_%06d.png",
171
- join(root, "failure.mp4"),
172
- tinyEncodeOptions,
173
- undefined,
174
- { ffmpegEncodeTimeout: 1000 },
175
- );
176
-
177
- expect(calls).toHaveLength(1);
178
- const proc = calls[0]!.proc;
179
- proc.stderr.emit("data", Buffer.from("encoder failed\n"));
180
- emitClose(proc, 1);
181
-
182
- const result = await encodePromise;
183
- expect(result.success).toBe(false);
184
- expect(result.error).toContain("FFmpeg exited with code 1");
185
- expect(result.error).toContain("encoder failed");
186
- expect(result.error).not.toContain("ffmpegEncodeTimeout");
187
- });
188
-
189
- it("uses the default timeout when config is omitted", async () => {
190
- vi.useFakeTimers();
191
- const { spawn, calls } = createSpawnSpy();
192
- vi.resetModules();
193
- vi.doMock("child_process", () => ({ spawn }));
194
-
195
- const { encodeFramesFromDir } = await import("./chunkEncoder.js");
196
- const { root, framesDir } = createFrameFixture();
197
-
198
- const encodePromise = encodeFramesFromDir(
199
- framesDir,
200
- "frame_%06d.png",
201
- join(root, "default.mp4"),
202
- tinyEncodeOptions,
203
- );
204
-
205
- expect(calls).toHaveLength(1);
206
- const proc = calls[0]!.proc;
207
- vi.advanceTimersByTime(599_999);
208
- expect(proc.kill).not.toHaveBeenCalled();
209
-
210
- emitClose(proc, 0);
211
-
212
- const result = await encodePromise;
213
- expect(result.success).toBe(true);
214
- expect(result.framesEncoded).toBe(2);
215
- expect(result.fileSize).toBe(0);
216
- });
217
- });
218
-
219
- describe("encodeFramesChunkedConcat ffmpegEncodeTimeout", () => {
220
- it("passes config timeout to per-chunk encodes", async () => {
221
- vi.useFakeTimers();
222
- const { spawn, calls } = createSpawnSpy();
223
- vi.resetModules();
224
- vi.doMock("child_process", () => ({ spawn }));
225
-
226
- const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
227
- const { root, framesDir } = createFrameFixture();
228
-
229
- const encodePromise = encodeFramesChunkedConcat(
230
- framesDir,
231
- "frame_%06d.png",
232
- join(root, "chunked.mp4"),
233
- tinyEncodeOptions,
234
- 30,
235
- undefined,
236
- { ffmpegEncodeTimeout: 1000 },
237
- );
238
-
239
- expect(calls).toHaveLength(1);
240
- const proc = calls[0]!.proc;
241
- vi.advanceTimersByTime(999);
242
- expect(proc.kill).not.toHaveBeenCalled();
243
-
244
- vi.advanceTimersByTime(1);
245
- expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
246
-
247
- proc.stderr.emit("data", Buffer.from("chunk timeout\n"));
248
- emitClose(proc, 143);
249
-
250
- const result = await encodePromise;
251
- expect(result.success).toBe(false);
252
- expect(result.error).toContain("Chunk 0 encode failed");
253
- expect(result.error).toContain("chunk timeout");
254
- expect(result.error).toContain(encodeTimeoutMessage(1000));
255
- });
256
-
257
- it("keeps non-timeout chunk failures unchanged", async () => {
258
- vi.useFakeTimers();
259
- const { spawn, calls } = createSpawnSpy();
260
- vi.resetModules();
261
- vi.doMock("child_process", () => ({ spawn }));
262
-
263
- const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
264
- const { root, framesDir } = createFrameFixture();
265
-
266
- const encodePromise = encodeFramesChunkedConcat(
267
- framesDir,
268
- "frame_%06d.png",
269
- join(root, "chunked-failure.mp4"),
270
- tinyEncodeOptions,
271
- 30,
272
- undefined,
273
- { ffmpegEncodeTimeout: 1000 },
274
- );
275
-
276
- expect(calls).toHaveLength(1);
277
- const proc = calls[0]!.proc;
278
- proc.stderr.emit("data", Buffer.from("chunk failed\n"));
279
- emitClose(proc, 1);
280
-
281
- const result = await encodePromise;
282
- expect(result.success).toBe(false);
283
- expect(result.error).toBe("Chunk 0 encode failed: chunk failed\n");
284
- expect(result.error).not.toContain("ffmpegEncodeTimeout");
285
- });
286
-
287
- it("kills concat ffmpeg when config timeout elapses", async () => {
288
- vi.useFakeTimers();
289
- const { spawn, calls } = createSpawnSpy();
290
- vi.resetModules();
291
- vi.doMock("child_process", () => ({ spawn }));
292
-
293
- const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
294
- const { root, framesDir } = createFrameFixture();
295
-
296
- const encodePromise = encodeFramesChunkedConcat(
297
- framesDir,
298
- "frame_%06d.png",
299
- join(root, "concat-timeout.mp4"),
300
- tinyEncodeOptions,
301
- 30,
302
- undefined,
303
- { ffmpegEncodeTimeout: 1000 },
304
- );
305
-
306
- expect(calls).toHaveLength(1);
307
- emitClose(calls[0]!.proc, 0);
308
- await Promise.resolve();
309
-
310
- expect(calls).toHaveLength(2);
311
- const concatProc = calls[1]!.proc;
312
- vi.advanceTimersByTime(999);
313
- expect(concatProc.kill).not.toHaveBeenCalled();
314
-
315
- vi.advanceTimersByTime(1);
316
- expect(concatProc.kill).toHaveBeenCalledWith("SIGTERM");
317
-
318
- concatProc.stderr.emit("data", Buffer.from("concat timeout\n"));
319
- emitClose(concatProc, 143);
320
-
321
- const result = await encodePromise;
322
- expect(result.success).toBe(false);
323
- expect(result.error).toContain("Chunk concat failed");
324
- expect(result.error).toContain("concat timeout");
325
- expect(result.error).toContain(encodeTimeoutMessage(1000));
326
- });
327
-
328
- it("uses the default timeout for per-chunk encodes when config is omitted", async () => {
329
- vi.useFakeTimers();
330
- const { spawn, calls } = createSpawnSpy();
331
- vi.resetModules();
332
- vi.doMock("child_process", () => ({ spawn }));
333
-
334
- const { encodeFramesChunkedConcat } = await import("./chunkEncoder.js");
335
- const { root, framesDir } = createFrameFixture();
336
-
337
- const encodePromise = encodeFramesChunkedConcat(
338
- framesDir,
339
- "frame_%06d.png",
340
- join(root, "chunked-default.mp4"),
341
- tinyEncodeOptions,
342
- 30,
343
- );
344
-
345
- expect(calls).toHaveLength(1);
346
- const chunkProc = calls[0]!.proc;
347
- vi.advanceTimersByTime(599_999);
348
- expect(chunkProc.kill).not.toHaveBeenCalled();
349
-
350
- emitClose(chunkProc, 0);
351
- await Promise.resolve();
352
-
353
- expect(calls).toHaveLength(2);
354
- const concatProc = calls[1]!.proc;
355
- emitClose(concatProc, 0);
356
-
357
- const result = await encodePromise;
358
- expect(result.success).toBe(true);
359
- expect(result.framesEncoded).toBe(2);
360
- expect(result.fileSize).toBe(0);
361
- });
362
- });
363
-
364
- describe("muxVideoWithAudio audio codec handling", () => {
365
- it("copies HyperFrames AAC sidecars into MP4 instead of re-encoding", async () => {
366
- const { spawn, calls } = createSpawnSpy();
367
- vi.resetModules();
368
- vi.doMock("child_process", () => ({ spawn }));
369
-
370
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
371
- const muxPromise = muxVideoWithAudio(
372
- "/tmp/video-only.mp4",
373
- "/tmp/audio.aac",
374
- "/tmp/output.mp4",
375
- undefined,
376
- undefined,
377
- { num: 30, den: 1 },
378
- );
379
-
380
- await flushMuxCodecResolution();
381
- expect(calls).toHaveLength(1);
382
- expect(calls[0]!.args).toEqual([
383
- "-i",
384
- "/tmp/video-only.mp4",
385
- "-i",
386
- "/tmp/audio.aac",
387
- "-c:v",
388
- "copy",
389
- "-c:a",
390
- "copy",
391
- "-movflags",
392
- "+faststart",
393
- "-avoid_negative_ts",
394
- "make_zero",
395
- "-r",
396
- "30",
397
- "-shortest",
398
- "-y",
399
- "/tmp/output.mp4",
400
- ]);
401
- expect(calls[0]!.args).not.toContain("-use_editlist");
402
-
403
- emitClose(calls[0]!.proc, 0);
404
- await expect(muxPromise).resolves.toMatchObject({
405
- success: true,
406
- outputPath: "/tmp/output.mp4",
407
- });
408
- });
409
-
410
- it("uses the caller-provided AAC codec contract instead of the sidecar extension", async () => {
411
- const { spawn, calls } = createSpawnSpy();
412
- vi.resetModules();
413
- vi.doMock("child_process", () => ({ spawn }));
414
-
415
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
416
- const muxPromise = muxVideoWithAudio(
417
- "/tmp/video-only.mp4",
418
- "/tmp/audio-sidecar",
419
- "/tmp/output.mp4",
420
- undefined,
421
- { audioCodec: "aac" },
422
- { num: 30, den: 1 },
423
- );
424
-
425
- await flushMuxCodecResolution();
426
- expect(calls).toHaveLength(1);
427
- expect(calls[0]!.args).toContain("-c:a");
428
- expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("copy");
429
- expect(calls[0]!.args).not.toContain("-b:a");
430
- expect(calls[0]!.args).toContain("+faststart");
431
-
432
- emitClose(calls[0]!.proc, 0);
433
- await expect(muxPromise).resolves.toMatchObject({
434
- success: true,
435
- outputPath: "/tmp/output.mp4",
436
- });
437
- });
438
-
439
- it("probes unknown-extension AAC sidecars before choosing the MP4 copy path", async () => {
440
- const { spawn, calls } = createSpawnSpy();
441
- const extractAudioMetadata = vi.fn(async () => ({
442
- durationSeconds: 1,
443
- sampleRate: 48000,
444
- channels: 2,
445
- audioCodec: "aac",
446
- }));
447
- vi.resetModules();
448
- vi.doMock("child_process", () => ({ spawn }));
449
- vi.doMock("../utils/ffprobe.js", () => ({ extractAudioMetadata }));
450
-
451
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
452
- const muxPromise = muxVideoWithAudio(
453
- "/tmp/video-only.mp4",
454
- "/tmp/audio-sidecar",
455
- "/tmp/output.mp4",
456
- );
457
-
458
- await flushMuxCodecResolution();
459
- expect(extractAudioMetadata).toHaveBeenCalledWith("/tmp/audio-sidecar");
460
- expect(calls).toHaveLength(1);
461
- expect(calls[0]!.args).toContain("-c:a");
462
- expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("copy");
463
- expect(calls[0]!.args).not.toContain("-b:a");
464
-
465
- emitClose(calls[0]!.proc, 0);
466
- await expect(muxPromise).resolves.toMatchObject({
467
- success: true,
468
- outputPath: "/tmp/output.mp4",
469
- });
470
- });
471
-
472
- it("keeps probed non-AAC unknown-extension sidecars on the MP4 transcode path", async () => {
473
- const { spawn, calls } = createSpawnSpy();
474
- const extractAudioMetadata = vi.fn(async () => ({
475
- durationSeconds: 1,
476
- sampleRate: 48000,
477
- channels: 2,
478
- audioCodec: "mp3",
479
- }));
480
- vi.resetModules();
481
- vi.doMock("child_process", () => ({ spawn }));
482
- vi.doMock("../utils/ffprobe.js", () => ({ extractAudioMetadata }));
483
-
484
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
485
- const muxPromise = muxVideoWithAudio(
486
- "/tmp/video-only.mp4",
487
- "/tmp/audio-sidecar",
488
- "/tmp/output.mp4",
489
- );
490
-
491
- await flushMuxCodecResolution();
492
- expect(extractAudioMetadata).toHaveBeenCalledWith("/tmp/audio-sidecar");
493
- expect(calls).toHaveLength(1);
494
- expect(calls[0]!.args).toContain("-c:a");
495
- expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("aac");
496
- expect(calls[0]!.args).toContain("-b:a");
497
-
498
- emitClose(calls[0]!.proc, 0);
499
- await expect(muxPromise).resolves.toMatchObject({ success: true });
500
- });
501
-
502
- it("still transcodes non-AAC audio when muxing MP4", async () => {
503
- const { spawn, calls } = createSpawnSpy();
504
- vi.resetModules();
505
- vi.doMock("child_process", () => ({ spawn }));
506
-
507
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
508
- const muxPromise = muxVideoWithAudio(
509
- "/tmp/video-only.mp4",
510
- "/tmp/audio.wav",
511
- "/tmp/output.mp4",
512
- );
513
-
514
- await flushMuxCodecResolution();
515
- expect(calls).toHaveLength(1);
516
- expect(calls[0]!.args).toContain("-c:a");
517
- expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("aac");
518
- expect(calls[0]!.args).toContain("-b:a");
519
- expect(calls[0]!.args).toContain("+faststart");
520
-
521
- emitClose(calls[0]!.proc, 0);
522
- await expect(muxPromise).resolves.toMatchObject({ success: true });
523
- });
524
-
525
- it("copies HyperFrames AAC sidecars into MOV containers without MP4 faststart flags", async () => {
526
- const { spawn, calls } = createSpawnSpy();
527
- vi.resetModules();
528
- vi.doMock("child_process", () => ({ spawn }));
529
-
530
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
531
- const muxPromise = muxVideoWithAudio(
532
- "/tmp/video-only.mov",
533
- "/tmp/audio.aac",
534
- "/tmp/output.mov",
535
- );
536
-
537
- await flushMuxCodecResolution();
538
- expect(calls).toHaveLength(1);
539
- expect(calls[0]!.args).toContain("-c:a");
540
- expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("copy");
541
- expect(calls[0]!.args).not.toContain("-b:a");
542
- expect(calls[0]!.args).not.toContain("+faststart");
543
-
544
- emitClose(calls[0]!.proc, 0);
545
- await expect(muxPromise).resolves.toMatchObject({ success: true });
546
- });
547
-
548
- it("keeps WebM audio on the Opus transcode path", async () => {
549
- const { spawn, calls } = createSpawnSpy();
550
- vi.resetModules();
551
- vi.doMock("child_process", () => ({ spawn }));
552
-
553
- const { muxVideoWithAudio } = await import("./chunkEncoder.js");
554
- const muxPromise = muxVideoWithAudio(
555
- "/tmp/video-only.webm",
556
- "/tmp/audio.aac",
557
- "/tmp/output.webm",
558
- );
559
-
560
- expect(calls).toHaveLength(1);
561
- expect(calls[0]!.args).toContain("-c:a");
562
- expect(calls[0]!.args[calls[0]!.args.indexOf("-c:a") + 1]).toBe("libopus");
563
- expect(calls[0]!.args).not.toContain("+faststart");
564
-
565
- emitClose(calls[0]!.proc, 0);
566
- await expect(muxPromise).resolves.toMatchObject({ success: true });
567
- });
568
- });
569
-
570
- describe("getEncoderPreset", () => {
571
- it("returns h264 with yuv420p for mp4 format", () => {
572
- const preset = getEncoderPreset("standard", "mp4");
573
- expect(preset.codec).toBe("h264");
574
- expect(preset.pixelFormat).toBe("yuv420p");
575
- });
576
-
577
- it("returns vp9 with yuva420p for webm format", () => {
578
- const preset = getEncoderPreset("standard", "webm");
579
- expect(preset.codec).toBe("vp9");
580
- expect(preset.pixelFormat).toBe("yuva420p");
581
- });
582
-
583
- it("maps draft ultrafast to vp9 realtime deadline", () => {
584
- const preset = getEncoderPreset("draft", "webm");
585
- expect(preset.preset).toBe("realtime");
586
- expect(preset.codec).toBe("vp9");
587
- });
588
-
589
- it("maps standard/high to vp9 good deadline", () => {
590
- expect(getEncoderPreset("standard", "webm").preset).toBe("good");
591
- expect(getEncoderPreset("high", "webm").preset).toBe("good");
592
- });
593
-
594
- it("preserves quality values across formats", () => {
595
- for (const q of ["draft", "standard", "high"] as const) {
596
- expect(getEncoderPreset(q, "webm").quality).toBe(ENCODER_PRESETS[q].quality);
597
- }
598
- });
599
-
600
- it("returns prores 4444 with yuva444p10le for mov format", () => {
601
- const preset = getEncoderPreset("standard", "mov");
602
- expect(preset.codec).toBe("prores");
603
- expect(preset.preset).toBe("4444");
604
- expect(preset.pixelFormat).toBe("yuva444p10le");
605
- });
606
-
607
- it("uses prores 4444 for all mov quality levels", () => {
608
- for (const q of ["draft", "standard", "high"] as const) {
609
- const preset = getEncoderPreset(q, "mov");
610
- expect(preset.codec).toBe("prores");
611
- expect(preset.preset).toBe("4444");
612
- }
613
- });
614
-
615
- it("defaults to mp4 when format is omitted", () => {
616
- const preset = getEncoderPreset("standard");
617
- expect(preset.codec).toBe("h264");
618
- expect(preset.pixelFormat).toBe("yuv420p");
619
- });
620
- });
621
-
622
- describe("buildEncoderArgs anti-banding", () => {
623
- const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
624
-
625
- it("adds aq-mode=3 x264-params for h264 CPU encoding", () => {
626
- const args = buildEncoderArgs(
627
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
628
- ["-framerate", "30", "-i", "frames/%04d.png"],
629
- "out.mp4",
630
- );
631
- const paramIdx = args.indexOf("-x264-params");
632
- expect(paramIdx).toBeGreaterThan(-1);
633
- expect(args[paramIdx + 1]).toContain("aq-mode=3");
634
- });
635
-
636
- it("adds aq-mode=3 x265-params for h265 CPU encoding", () => {
637
- const args = buildEncoderArgs(
638
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
639
- ["-framerate", "30", "-i", "frames/%04d.png"],
640
- "out.mp4",
641
- );
642
- const paramIdx = args.indexOf("-x265-params");
643
- expect(paramIdx).toBeGreaterThan(-1);
644
- expect(args[paramIdx + 1]).toContain("aq-mode=3");
645
- });
646
-
647
- it("includes deblock for non-ultrafast presets", () => {
648
- for (const preset of ["medium", "slow"]) {
649
- const args = buildEncoderArgs(
650
- { ...baseOptions, codec: "h264", preset, quality: 23 },
651
- ["-framerate", "30", "-i", "frames/%04d.png"],
652
- "out.mp4",
653
- );
654
- const paramIdx = args.indexOf("-x264-params");
655
- expect(args[paramIdx + 1]).toContain("deblock=1,1");
656
- }
657
- });
658
-
659
- it("omits deblock for ultrafast (draft) preset", () => {
660
- const args = buildEncoderArgs(
661
- { ...baseOptions, codec: "h264", preset: "ultrafast", quality: 28 },
662
- ["-framerate", "30", "-i", "frames/%04d.png"],
663
- "out.mp4",
664
- );
665
- const paramIdx = args.indexOf("-x264-params");
666
- expect(paramIdx).toBeGreaterThan(-1);
667
- expect(args[paramIdx + 1]).toContain("aq-mode=3");
668
- expect(args[paramIdx + 1]).not.toContain("deblock");
669
- });
670
-
671
- it("does not add x264-params for GPU encoding", () => {
672
- const args = buildEncoderArgs(
673
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
674
- ["-framerate", "30", "-i", "frames/%04d.png"],
675
- "out.mp4",
676
- "nvenc",
677
- );
678
- expect(args.indexOf("-x264-params")).toBe(-1);
679
- });
680
-
681
- it("does not add x264-params for VP9 encoding", () => {
682
- const args = buildEncoderArgs(
683
- { ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
684
- ["-framerate", "30", "-i", "frames/%04d.png"],
685
- "out.webm",
686
- );
687
- expect(args.indexOf("-x264-params")).toBe(-1);
688
- expect(args.indexOf("-x265-params")).toBe(-1);
689
- });
690
- });
691
-
692
- describe("buildEncoderArgs fps rational forwarding", () => {
693
- // Regression for the fps fraction-syntax feature: rational fps must reach
694
- // ffmpeg's `-r` flag verbatim (e.g. "30000/1001") so NTSC stays exact end-
695
- // to-end rather than being rounded to 29.97 decimal at the encoder boundary.
696
- it("emits integer -r for { num: 30, den: 1 }", () => {
697
- const args = buildEncoderArgs(
698
- { fps: { num: 30, den: 1 }, width: 1920, height: 1080, codec: "h264" },
699
- ["-framerate", "30", "-i", "frames/%04d.png"],
700
- "out.mp4",
701
- );
702
- const rIdx = args.indexOf("-r");
703
- expect(rIdx).toBeGreaterThan(-1);
704
- expect(args[rIdx + 1]).toBe("30");
705
- });
706
-
707
- it("emits rational -r for NTSC { num: 30000, den: 1001 }", () => {
708
- const args = buildEncoderArgs(
709
- { fps: { num: 30000, den: 1001 }, width: 1920, height: 1080, codec: "h264" },
710
- ["-framerate", "30000/1001", "-i", "frames/%04d.png"],
711
- "out.mp4",
712
- );
713
- const rIdx = args.indexOf("-r");
714
- expect(rIdx).toBeGreaterThan(-1);
715
- expect(args[rIdx + 1]).toBe("30000/1001");
716
- });
717
- });
718
-
719
- describe("buildEncoderArgs GPU preset mapping", () => {
720
- const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
721
- const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
722
-
723
- function presetArg(args: string[]): string | undefined {
724
- const idx = args.indexOf("-preset");
725
- return idx === -1 ? undefined : args[idx + 1];
726
- }
727
-
728
- // Regression for the "draft quality + --gpu fails with code -22" bug:
729
- // NVENC rejects the libx264 preset name `ultrafast` with AVERROR(EINVAL),
730
- // so the `draft` quality tier must not forward that value to h264_nvenc.
731
- it("translates the draft ultrafast preset to NVENC p1", () => {
732
- const args = buildEncoderArgs(
733
- { ...baseOptions, codec: "h264", preset: "ultrafast", quality: 28, useGpu: true },
734
- inputArgs,
735
- "out.mp4",
736
- "nvenc",
737
- );
738
- expect(presetArg(args)).toBe("p1");
739
- });
740
-
741
- it("translates the standard medium preset to NVENC p4", () => {
742
- const args = buildEncoderArgs(
743
- { ...baseOptions, codec: "h264", preset: "medium", quality: 18, useGpu: true },
744
- inputArgs,
745
- "out.mp4",
746
- "nvenc",
747
- );
748
- expect(presetArg(args)).toBe("p4");
749
- });
750
-
751
- it("translates the high slow preset to NVENC p5", () => {
752
- const args = buildEncoderArgs(
753
- { ...baseOptions, codec: "h264", preset: "slow", quality: 15, useGpu: true },
754
- inputArgs,
755
- "out.mp4",
756
- "nvenc",
757
- );
758
- expect(presetArg(args)).toBe("p5");
759
- });
760
-
761
- // hevc_nvenc uses the same p1..p7 preset vocabulary as h264_nvenc, so the
762
- // mapping must apply to both codecs. Locks in "H.264 and H.265 NVENC share
763
- // the preset mapping" against a future refactor that might split the path.
764
- it("translates libx264 preset names to NVENC p1..p7 for h265 as well", () => {
765
- for (const [libx264, nvencPreset] of [
766
- ["ultrafast", "p1"],
767
- ["medium", "p4"],
768
- ["veryslow", "p7"],
769
- ] as const) {
770
- const args = buildEncoderArgs(
771
- { ...baseOptions, codec: "h265", preset: libx264, quality: 23, useGpu: true },
772
- inputArgs,
773
- "out.mp4",
774
- "nvenc",
775
- );
776
- expect(args[args.indexOf("-c:v") + 1]).toBe("hevc_nvenc");
777
- expect(presetArg(args)).toBe(nvencPreset);
778
- }
779
- });
780
-
781
- it("rewrites QSV's unsupported ultrafast preset to veryfast", () => {
782
- const args = buildEncoderArgs(
783
- { ...baseOptions, codec: "h264", preset: "ultrafast", quality: 28, useGpu: true },
784
- inputArgs,
785
- "out.mp4",
786
- "qsv",
787
- );
788
- expect(presetArg(args)).toBe("veryfast");
789
- });
790
-
791
- it("passes QSV-supported preset names through unchanged", () => {
792
- const args = buildEncoderArgs(
793
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
794
- inputArgs,
795
- "out.mp4",
796
- "qsv",
797
- );
798
- expect(presetArg(args)).toBe("medium");
799
- });
800
-
801
- it("uses AMD AMF encoder names and quality flags when selected", () => {
802
- const h264Args = buildEncoderArgs(
803
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
804
- inputArgs,
805
- "out.mp4",
806
- "amf",
807
- );
808
- expect(h264Args[h264Args.indexOf("-c:v") + 1]).toBe("h264_amf");
809
- expect(h264Args[h264Args.indexOf("-qp_i") + 1]).toBe("23");
810
- expect(h264Args).toContain("-bf");
811
- expect(h264Args[h264Args.indexOf("-bf") + 1]).toBe("0");
812
-
813
- const h265Args = buildEncoderArgs(
814
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23, useGpu: true },
815
- inputArgs,
816
- "out.mp4",
817
- "amf",
818
- );
819
- expect(h265Args[h265Args.indexOf("-c:v") + 1]).toBe("hevc_amf");
820
- expect(h265Args[h265Args.indexOf("-qp_i") + 1]).toBe("23");
821
- });
822
- });
823
-
824
- describe("buildEncoderArgs color space", () => {
825
- const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
826
- const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
827
-
828
- it("adds bt709 color space metadata for h264 CPU encoding", () => {
829
- const args = buildEncoderArgs(
830
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
831
- inputArgs,
832
- "out.mp4",
833
- );
834
- // FFmpeg-level metadata tags
835
- expect(args).toContain("-colorspace:v");
836
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
837
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
838
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
839
- expect(args[args.indexOf("-color_range") + 1]).toBe("tv");
840
- // x264-params VUI embedding
841
- const paramIdx = args.indexOf("-x264-params");
842
- expect(args[paramIdx + 1]).toContain("colorprim=bt709");
843
- expect(args[paramIdx + 1]).toContain("transfer=bt709");
844
- expect(args[paramIdx + 1]).toContain("colormatrix=bt709");
845
- });
846
-
847
- it("adds bt709 color space metadata for h265 CPU encoding", () => {
848
- const args = buildEncoderArgs(
849
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
850
- inputArgs,
851
- "out.mp4",
852
- );
853
- expect(args).toContain("-colorspace:v");
854
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
855
- // x265-params VUI embedding
856
- const paramIdx = args.indexOf("-x265-params");
857
- expect(args[paramIdx + 1]).toContain("colorprim=bt709");
858
- });
859
-
860
- it("adds range conversion filter for CPU h264 encoding", () => {
861
- const args = buildEncoderArgs(
862
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
863
- inputArgs,
864
- "out.mp4",
865
- );
866
- const vfIdx = args.indexOf("-vf");
867
- expect(vfIdx).toBeGreaterThan(-1);
868
- expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
869
- });
870
-
871
- it("prepends range conversion to VAAPI filter chain", () => {
872
- const args = buildEncoderArgs(
873
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
874
- inputArgs,
875
- "out.mp4",
876
- "vaapi",
877
- );
878
- const vfIdx = args.indexOf("-vf");
879
- expect(vfIdx).toBeGreaterThan(-1);
880
- expect(args[vfIdx + 1]).toBe("scale=in_range=pc:out_range=tv,format=nv12,hwupload");
881
- });
882
-
883
- it("skips range conversion filter for non-VAAPI GPU encoding", () => {
884
- const args = buildEncoderArgs(
885
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23, useGpu: true },
886
- inputArgs,
887
- "out.mp4",
888
- "nvenc",
889
- );
890
- expect(args.indexOf("-vf")).toBe(-1);
891
- // but still has color metadata
892
- expect(args).toContain("-colorspace:v");
893
- });
894
-
895
- it("does not add color metadata for VP9", () => {
896
- const args = buildEncoderArgs(
897
- { ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
898
- inputArgs,
899
- "out.webm",
900
- );
901
- expect(args).not.toContain("-colorspace:v");
902
- });
903
-
904
- it("adds video_track_timescale for h264", () => {
905
- const args = buildEncoderArgs(
906
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
907
- inputArgs,
908
- "out.mp4",
909
- );
910
- expect(args).toContain("-video_track_timescale");
911
- expect(args[args.indexOf("-video_track_timescale") + 1]).toBe("90000");
912
- });
913
-
914
- it("does not add timescale for VP9", () => {
915
- const args = buildEncoderArgs(
916
- { ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
917
- inputArgs,
918
- "out.webm",
919
- );
920
- expect(args).not.toContain("-video_track_timescale");
921
- });
922
- });
923
-
924
- describe("getEncoderPreset HDR", () => {
925
- it("returns h265 with 10-bit for HDR HLG", () => {
926
- const preset = getEncoderPreset("standard", "mp4", { transfer: "hlg" });
927
- expect(preset.codec).toBe("h265");
928
- expect(preset.pixelFormat).toBe("yuv420p10le");
929
- expect(preset.hdr).toEqual({ transfer: "hlg" });
930
- });
931
-
932
- it("returns h265 with 10-bit for HDR PQ", () => {
933
- const preset = getEncoderPreset("high", "mp4", { transfer: "pq" });
934
- expect(preset.codec).toBe("h265");
935
- expect(preset.pixelFormat).toBe("yuv420p10le");
936
- expect(preset.hdr).toEqual({ transfer: "pq" });
937
- });
938
-
939
- it("avoids ultrafast preset for HDR (upgrades to fast)", () => {
940
- const preset = getEncoderPreset("draft", "mp4", { transfer: "hlg" });
941
- expect(preset.preset).toBe("fast");
942
- });
943
-
944
- it("ignores HDR for webm format", () => {
945
- const preset = getEncoderPreset("standard", "webm", { transfer: "hlg" });
946
- expect(preset.codec).toBe("vp9");
947
- expect(preset.hdr).toBeUndefined();
948
- });
949
-
950
- it("ignores HDR for mov format", () => {
951
- const preset = getEncoderPreset("standard", "mov", { transfer: "pq" });
952
- expect(preset.codec).toBe("prores");
953
- expect(preset.hdr).toBeUndefined();
954
- });
955
- });
956
-
957
- describe("buildEncoderArgs lockGopForChunkConcat", () => {
958
- const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
959
- const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
960
-
961
- // Default path must emit zero closed-GOP args — in-process renders rely on
962
- // libx264/libx265 defaults to stay byte-identical with their PSNR baselines.
963
- it("default (false) omits closed-GOP args for libx264", () => {
964
- const args = buildEncoderArgs(
965
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
966
- inputArgs,
967
- "out.mp4",
968
- );
969
- expect(args).not.toContain("-g");
970
- expect(args).not.toContain("-keyint_min");
971
- expect(args).not.toContain("-force_key_frames");
972
- expect(args).not.toContain("-sc_threshold");
973
- const paramIdx = args.indexOf("-x264-params");
974
- expect(args[paramIdx + 1]).not.toContain("scenecut=0");
975
- expect(args[paramIdx + 1]).not.toContain("open-gop=0");
976
- expect(args[paramIdx + 1]).not.toContain("repeat-headers=1");
977
- });
978
-
979
- it("default (false) omits closed-GOP args for libx265", () => {
980
- const args = buildEncoderArgs(
981
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
982
- inputArgs,
983
- "out.mp4",
984
- );
985
- expect(args).not.toContain("-g");
986
- expect(args).not.toContain("-keyint_min");
987
- expect(args).not.toContain("-force_key_frames");
988
- expect(args).not.toContain("-sc_threshold");
989
- const paramIdx = args.indexOf("-x265-params");
990
- expect(args[paramIdx + 1]).not.toContain("scenecut=0");
991
- expect(args[paramIdx + 1]).not.toContain("keyint=");
992
- expect(args[paramIdx + 1]).not.toContain("open-gop=0");
993
- expect(args[paramIdx + 1]).not.toContain("repeat-headers=1");
994
- });
995
-
996
- it("true appends closed-GOP ffmpeg flags and x264-params for libx264", () => {
997
- const args = buildEncoderArgs(
998
- {
999
- ...baseOptions,
1000
- codec: "h264",
1001
- preset: "medium",
1002
- quality: 23,
1003
- lockGopForChunkConcat: true,
1004
- gopSize: 240,
1005
- },
1006
- inputArgs,
1007
- "out.mp4",
1008
- );
1009
- expect(args[args.indexOf("-g") + 1]).toBe("240");
1010
- expect(args[args.indexOf("-keyint_min") + 1]).toBe("240");
1011
- expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
1012
- expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,240),0)");
1013
- const paramIdx = args.indexOf("-x264-params");
1014
- expect(args[paramIdx + 1]).toContain("scenecut=0");
1015
- expect(args[paramIdx + 1]).toContain("open-gop=0");
1016
- expect(args[paramIdx + 1]).toContain("repeat-headers=1");
1017
- // -bf 0 was already present for h264; closed-GOP doesn't change that.
1018
- expect(args).toContain("-bf");
1019
- expect(args[args.indexOf("-bf") + 1]).toBe("0");
1020
- // 90000 timescale is required for clean concat-copy — already enforced for h264/h265.
1021
- expect(args[args.indexOf("-video_track_timescale") + 1]).toBe("90000");
1022
- });
1023
-
1024
- it("true appends closed-GOP x265-params keyint controls for libx265", () => {
1025
- const args = buildEncoderArgs(
1026
- {
1027
- ...baseOptions,
1028
- codec: "h265",
1029
- preset: "medium",
1030
- quality: 23,
1031
- lockGopForChunkConcat: true,
1032
- gopSize: 360,
1033
- },
1034
- inputArgs,
1035
- "out.mp4",
1036
- );
1037
- expect(args[args.indexOf("-g") + 1]).toBe("360");
1038
- expect(args[args.indexOf("-keyint_min") + 1]).toBe("360");
1039
- expect(args[args.indexOf("-sc_threshold") + 1]).toBe("0");
1040
- expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:eq(mod(n,360),0)");
1041
- const paramIdx = args.indexOf("-x265-params");
1042
- expect(args[paramIdx + 1]).toContain("keyint=360");
1043
- expect(args[paramIdx + 1]).toContain("min-keyint=360");
1044
- expect(args[paramIdx + 1]).toContain("scenecut=0");
1045
- expect(args[paramIdx + 1]).toContain("open-gop=0");
1046
- expect(args[paramIdx + 1]).toContain("repeat-headers=1");
1047
- // h265 normally tolerates B-frames; closed-GOP concat-copy doesn't.
1048
- expect(args[args.indexOf("-bf") + 1]).toBe("0");
1049
- });
1050
-
1051
- it("true preserves the x264-params anti-banding controls", () => {
1052
- // The closed-GOP params join onto the existing aq-mode/deblock string —
1053
- // make sure we didn't accidentally drop the anti-banding tuning.
1054
- const args = buildEncoderArgs(
1055
- {
1056
- ...baseOptions,
1057
- codec: "h264",
1058
- preset: "medium",
1059
- quality: 23,
1060
- lockGopForChunkConcat: true,
1061
- gopSize: 240,
1062
- },
1063
- inputArgs,
1064
- "out.mp4",
1065
- );
1066
- const paramIdx = args.indexOf("-x264-params");
1067
- expect(args[paramIdx + 1]).toContain("aq-mode=3");
1068
- expect(args[paramIdx + 1]).toContain("aq-strength=0.8");
1069
- expect(args[paramIdx + 1]).toContain("deblock=1,1");
1070
- expect(args[paramIdx + 1]).toContain("colorprim=bt709");
1071
- });
1072
-
1073
- it("true with ultrafast preset still emits closed-GOP params and skips deblock", () => {
1074
- const args = buildEncoderArgs(
1075
- {
1076
- ...baseOptions,
1077
- codec: "h264",
1078
- preset: "ultrafast",
1079
- quality: 28,
1080
- lockGopForChunkConcat: true,
1081
- gopSize: 240,
1082
- },
1083
- inputArgs,
1084
- "out.mp4",
1085
- );
1086
- expect(args[args.indexOf("-g") + 1]).toBe("240");
1087
- const paramIdx = args.indexOf("-x264-params");
1088
- expect(args[paramIdx + 1]).toContain("aq-mode=3");
1089
- expect(args[paramIdx + 1]).toContain("scenecut=0");
1090
- expect(args[paramIdx + 1]).not.toContain("deblock");
1091
- });
1092
-
1093
- it("true is a no-op on GPU encoders", () => {
1094
- // GPU encoders take a separate code path; lockGopForChunkConcat does not
1095
- // wire `-g` / `-keyint_min` into nvenc/amf/qsv/vaapi.
1096
- const args = buildEncoderArgs(
1097
- {
1098
- ...baseOptions,
1099
- codec: "h264",
1100
- preset: "medium",
1101
- quality: 23,
1102
- useGpu: true,
1103
- lockGopForChunkConcat: true,
1104
- gopSize: 240,
1105
- },
1106
- inputArgs,
1107
- "out.mp4",
1108
- "nvenc",
1109
- );
1110
- expect(args).not.toContain("-g");
1111
- expect(args).not.toContain("-keyint_min");
1112
- expect(args).not.toContain("-force_key_frames");
1113
- expect(args).not.toContain("-sc_threshold");
1114
- expect(args.indexOf("-x264-params")).toBe(-1);
1115
- });
1116
-
1117
- it("true appends closed-GOP args for libvpx-vp9", () => {
1118
- const args = buildEncoderArgs(
1119
- {
1120
- ...baseOptions,
1121
- codec: "vp9",
1122
- preset: "good",
1123
- quality: 23,
1124
- lockGopForChunkConcat: true,
1125
- gopSize: 240,
1126
- },
1127
- inputArgs,
1128
- "out.webm",
1129
- );
1130
- expect(args[args.indexOf("-g") + 1]).toBe("240");
1131
- expect(args[args.indexOf("-keyint_min") + 1]).toBe("240");
1132
- expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0");
1133
- expect(args[args.indexOf("-cpu-used") + 1]).toBe("4");
1134
- expect(args[args.indexOf("-deadline") + 1]).toBe("good");
1135
- expect(args.indexOf("-x264-params")).toBe(-1);
1136
- expect(args.indexOf("-x265-params")).toBe(-1);
1137
- expect(args.indexOf("-sc_threshold")).toBe(-1);
1138
- expect(args.indexOf("-force_key_frames")).toBe(-1);
1139
- });
1140
-
1141
- it("default (false) omits closed-GOP args for libvpx-vp9", () => {
1142
- const args = buildEncoderArgs(
1143
- { ...baseOptions, codec: "vp9", preset: "good", quality: 23 },
1144
- inputArgs,
1145
- "out.webm",
1146
- );
1147
- expect(args).not.toContain("-g");
1148
- expect(args).not.toContain("-keyint_min");
1149
- expect(args[args.indexOf("-cpu-used") + 1]).toBe("4");
1150
- // The non-locked, non-alpha VP9 path leaves `-auto-alt-ref` at the
1151
- // libvpx default. Alpha branches still emit `-auto-alt-ref 0` for an
1152
- // unrelated reason (alpha + alt-ref is unsupported), but that's a
1153
- // separate test below.
1154
- expect(args).not.toContain("-auto-alt-ref");
1155
- });
1156
-
1157
- it("honors the resolved engine VP9 cpu-used override", () => {
1158
- const args = buildEncoderArgs(
1159
- { ...baseOptions, codec: "vp9", preset: "good", quality: 23, vp9CpuUsed: 6 },
1160
- inputArgs,
1161
- "out.webm",
1162
- );
1163
-
1164
- expect(args[args.indexOf("-cpu-used") + 1]).toBe("6");
1165
- });
1166
-
1167
- it("true with alpha pixel format keeps alpha metadata and emits -auto-alt-ref once", () => {
1168
- // Regression: alpha + closed-GOP must NOT double-push `-auto-alt-ref 0`.
1169
- // Both paths want it disabled; the encoder branch emits it exactly once.
1170
- const args = buildEncoderArgs(
1171
- {
1172
- ...baseOptions,
1173
- codec: "vp9",
1174
- preset: "good",
1175
- quality: 23,
1176
- pixelFormat: "yuva420p",
1177
- lockGopForChunkConcat: true,
1178
- gopSize: 240,
1179
- },
1180
- inputArgs,
1181
- "out.webm",
1182
- );
1183
- const autoAltRefIndices = args.reduce<number[]>((acc, a, i) => {
1184
- if (a === "-auto-alt-ref") acc.push(i);
1185
- return acc;
1186
- }, []);
1187
- expect(autoAltRefIndices.length).toBe(1);
1188
- expect(args[autoAltRefIndices[0] + 1]).toBe("0");
1189
- expect(args[args.indexOf("-metadata:s:v:0") + 1]).toBe("alpha_mode=1");
1190
- expect(args[args.indexOf("-g") + 1]).toBe("240");
1191
- });
1192
-
1193
- it("vp9 + lockGopForChunkConcat=true throws on missing gopSize", () => {
1194
- // Mirrors the libx264/libx265 branch: closed-GOP without a GOP size
1195
- // makes no sense — surface the caller error eagerly.
1196
- expect(() =>
1197
- buildEncoderArgs(
1198
- {
1199
- ...baseOptions,
1200
- codec: "vp9",
1201
- preset: "good",
1202
- quality: 23,
1203
- lockGopForChunkConcat: true,
1204
- },
1205
- inputArgs,
1206
- "out.webm",
1207
- ),
1208
- ).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
1209
- });
1210
-
1211
- it("true is a no-op on ProRes (intra-only — no GOP forcing needed)", () => {
1212
- const args = buildEncoderArgs(
1213
- {
1214
- ...baseOptions,
1215
- codec: "prores",
1216
- preset: "4444",
1217
- quality: 23,
1218
- lockGopForChunkConcat: true,
1219
- gopSize: 240,
1220
- },
1221
- inputArgs,
1222
- "out.mov",
1223
- );
1224
- expect(args).not.toContain("-g");
1225
- expect(args).not.toContain("-keyint_min");
1226
- expect(args).not.toContain("-force_key_frames");
1227
- });
1228
-
1229
- it("true with missing or invalid gopSize throws", () => {
1230
- for (const bad of [undefined, 0, -10, NaN, Infinity]) {
1231
- expect(() =>
1232
- buildEncoderArgs(
1233
- {
1234
- ...baseOptions,
1235
- codec: "h264",
1236
- preset: "medium",
1237
- quality: 23,
1238
- lockGopForChunkConcat: true,
1239
- gopSize: bad as number | undefined,
1240
- },
1241
- inputArgs,
1242
- "out.mp4",
1243
- ),
1244
- ).toThrow(/lockGopForChunkConcat=true requires a positive integer gopSize/);
1245
- }
1246
- });
1247
-
1248
- it("HDR + closed-GOP keeps HDR mastering metadata in x265-params", () => {
1249
- const args = buildEncoderArgs(
1250
- {
1251
- ...baseOptions,
1252
- codec: "h265",
1253
- preset: "medium",
1254
- quality: 23,
1255
- hdr: { transfer: "pq" },
1256
- lockGopForChunkConcat: true,
1257
- gopSize: 240,
1258
- },
1259
- inputArgs,
1260
- "out.mp4",
1261
- );
1262
- const paramIdx = args.indexOf("-x265-params");
1263
- expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
1264
- expect(args[paramIdx + 1]).toContain("transfer=smpte2084");
1265
- expect(args[paramIdx + 1]).toContain("master-display=");
1266
- expect(args[paramIdx + 1]).toContain("max-cll=");
1267
- expect(args[paramIdx + 1]).toContain("keyint=240");
1268
- expect(args[paramIdx + 1]).toContain("scenecut=0");
1269
- });
1270
- });
1271
-
1272
- describe("buildEncoderArgs HDR color space", () => {
1273
- const baseOptions = { fps: { num: 30, den: 1 }, width: 1920, height: 1080 };
1274
- const inputArgs = ["-framerate", "30", "-i", "frames/%04d.png"];
1275
-
1276
- it("emits BT.2020 + arib-std-b67 tags for HDR HLG (h265 SW)", () => {
1277
- // When options.hdr is set, the caller asserts the input pixels are
1278
- // already in the BT.2020 color space — tag the output truthfully so
1279
- // HDR-aware players apply the right transform.
1280
- const args = buildEncoderArgs(
1281
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
1282
- inputArgs,
1283
- "out.mp4",
1284
- );
1285
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
1286
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
1287
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("arib-std-b67");
1288
- const paramIdx = args.indexOf("-x265-params");
1289
- expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
1290
- expect(args[paramIdx + 1]).toContain("transfer=arib-std-b67");
1291
- expect(args[paramIdx + 1]).toContain("colormatrix=bt2020nc");
1292
- });
1293
-
1294
- it("emits BT.2020 + smpte2084 tags for HDR PQ (h265 SW)", () => {
1295
- const args = buildEncoderArgs(
1296
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "pq" } },
1297
- inputArgs,
1298
- "out.mp4",
1299
- );
1300
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
1301
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
1302
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
1303
- const paramIdx = args.indexOf("-x265-params");
1304
- expect(args[paramIdx + 1]).toContain("colorprim=bt2020");
1305
- expect(args[paramIdx + 1]).toContain("transfer=smpte2084");
1306
- expect(args[paramIdx + 1]).toContain("colormatrix=bt2020nc");
1307
- });
1308
-
1309
- it("embeds HDR static mastering metadata in x265-params when HDR is set", () => {
1310
- // master-display + max-cll SEI messages are required so HDR-aware
1311
- // players (Apple QuickTime, YouTube, HDR TVs) treat the stream as
1312
- // HDR10 instead of falling back to SDR BT.2020 tone-mapping.
1313
- const args = buildEncoderArgs(
1314
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "pq" } },
1315
- inputArgs,
1316
- "out.mp4",
1317
- );
1318
- const paramIdx = args.indexOf("-x265-params");
1319
- expect(args[paramIdx + 1]).toContain("master-display=");
1320
- expect(args[paramIdx + 1]).toContain("max-cll=");
1321
- });
1322
-
1323
- it("uses bt709 when HDR is not set (SDR Chrome captures)", () => {
1324
- const args = buildEncoderArgs(
1325
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
1326
- inputArgs,
1327
- "out.mp4",
1328
- );
1329
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
1330
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
1331
- const paramIdx = args.indexOf("-x265-params");
1332
- expect(args[paramIdx + 1]).toContain("colorprim=bt709");
1333
- expect(args[paramIdx + 1]).not.toContain("master-display");
1334
- });
1335
-
1336
- it("does not embed HDR mastering metadata when HDR is not set", () => {
1337
- const args = buildEncoderArgs(
1338
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23 },
1339
- inputArgs,
1340
- "out.mp4",
1341
- );
1342
- const paramIdx = args.indexOf("-x265-params");
1343
- expect(args[paramIdx + 1]).not.toContain("master-display");
1344
- expect(args[paramIdx + 1]).not.toContain("max-cll");
1345
- });
1346
-
1347
- it("strips HDR and tags as SDR/BT.709 when codec=h264 (libx264 has no HDR support)", () => {
1348
- // libx264 cannot encode HDR. Rather than emit a "half-HDR" file (BT.2020
1349
- // container tags + BT.709 VUI inside the bitstream — confusing to HDR-aware
1350
- // players), we strip hdr and tag the whole output as SDR/BT.709. The caller
1351
- // gets a warning telling them to use codec=h265 for real HDR output.
1352
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1353
- const args = buildEncoderArgs(
1354
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23, hdr: { transfer: "pq" } },
1355
- inputArgs,
1356
- "out.mp4",
1357
- );
1358
- const paramIdx = args.indexOf("-x264-params");
1359
- expect(args[paramIdx + 1]).toContain("colorprim=bt709");
1360
- expect(args[paramIdx + 1]).not.toContain("master-display");
1361
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt709");
1362
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt709");
1363
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("bt709");
1364
- expect(warnSpy).toHaveBeenCalledWith(
1365
- expect.stringContaining("HDR is not supported with codec=h264"),
1366
- );
1367
- warnSpy.mockRestore();
1368
- });
1369
-
1370
- it("uses range conversion for HDR CPU encoding", () => {
1371
- const args = buildEncoderArgs(
1372
- { ...baseOptions, codec: "h265", preset: "medium", quality: 23, hdr: { transfer: "hlg" } },
1373
- inputArgs,
1374
- "out.mp4",
1375
- );
1376
- const vfIdx = args.indexOf("-vf");
1377
- expect(vfIdx).toBeGreaterThan(-1);
1378
- expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
1379
- });
1380
-
1381
- it("uses same range conversion for SDR CPU encoding", () => {
1382
- const args = buildEncoderArgs(
1383
- { ...baseOptions, codec: "h264", preset: "medium", quality: 23 },
1384
- inputArgs,
1385
- "out.mp4",
1386
- );
1387
- const vfIdx = args.indexOf("-vf");
1388
- expect(args[vfIdx + 1]).toContain("scale=in_range=pc:out_range=tv");
1389
- });
1390
-
1391
- it("tags BT.2020 + transfer for HDR GPU H.265 (no mastering metadata via -x265-params)", () => {
1392
- // GPU encoders (nvenc, videotoolbox, amf, qsv, vaapi) still emit the BT.2020
1393
- // color tags via the codec-level -colorspace/-color_primaries/-color_trc
1394
- // flags, but cannot accept x265-params, so HDR static mastering metadata
1395
- // (master-display, max-cll) is not embedded. Acceptable for previews,
1396
- // not for HDR-aware delivery.
1397
- const args = buildEncoderArgs(
1398
- {
1399
- ...baseOptions,
1400
- codec: "h265",
1401
- preset: "medium",
1402
- quality: 23,
1403
- useGpu: true,
1404
- hdr: { transfer: "pq" },
1405
- },
1406
- inputArgs,
1407
- "out.mp4",
1408
- "nvenc",
1409
- );
1410
- expect(args[args.indexOf("-colorspace:v") + 1]).toBe("bt2020nc");
1411
- expect(args[args.indexOf("-color_primaries:v") + 1]).toBe("bt2020");
1412
- expect(args[args.indexOf("-color_trc:v") + 1]).toBe("smpte2084");
1413
- expect(args.indexOf("-x265-params")).toBe(-1);
1414
- });
1415
- });