@hyperframes/engine 0.6.118 → 0.6.120

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,594 +0,0 @@
1
- // fallow-ignore-file unused-type code-duplication complexity
2
- /**
3
- * Streaming Encoder Service
4
- *
5
- * Pipes frame screenshot buffers directly to FFmpeg's stdin via `-f image2pipe`
6
- * instead of writing them to disk and reading them back in a separate encode
7
- * stage. Inspired by Remotion's approach to browser-based video rendering.
8
- *
9
- * Two building blocks:
10
- * 1. Frame reorder buffer – ensures out-of-order parallel workers feed
11
- * frames to FFmpeg stdin in sequential order.
12
- * 2. Streaming FFmpeg encoder – spawns FFmpeg with `-f image2pipe` and
13
- * exposes an async `writeFrame(buffer)` + `close()` API.
14
- */
15
-
16
- import { spawn, type ChildProcess } from "child_process";
17
- import { once } from "events";
18
- import { trackChildProcess } from "../utils/processTracker.js";
19
- import { existsSync, mkdirSync, statSync } from "fs";
20
- import { dirname } from "path";
21
-
22
- import {
23
- type GpuEncoder,
24
- getCachedGpuEncoder,
25
- getGpuEncoderName,
26
- mapPresetForGpuEncoder,
27
- } from "../utils/gpuEncoder.js";
28
- import { formatFfmpegError } from "../utils/runFfmpeg.js";
29
- import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
30
- import { getHdrEncoderColorParams } from "../utils/hdr.js";
31
- import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
32
- import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
33
- import { appendVp9CpuUsedArg } from "./vp9Options.js";
34
-
35
- // Re-export EncoderOptions so callers can reference the type via this module.
36
- export type { EncoderOptions } from "./chunkEncoder.types.js";
37
-
38
- // ---------------------------------------------------------------------------
39
- // 1. Frame reorder buffer — ordered async barrier
40
- // ---------------------------------------------------------------------------
41
- //
42
- // Parallel workers produce frames out of order; FFmpeg's stdin expects them in
43
- // strict sequential order. Each worker calls `waitForFrame(n)` to block until
44
- // its turn, writes, then calls `advanceTo(n + 1)` to release the next waiter.
45
- //
46
- // `pending` holds an array per frame index (not a single resolver) so that
47
- // `waitForAllDone` can coexist with the writer still waiting on the final
48
- // frame without one clobbering the other.
49
-
50
- export interface FrameReorderBuffer {
51
- waitForFrame: (frame: number) => Promise<void>;
52
- advanceTo: (frame: number) => void;
53
- waitForAllDone: () => Promise<void>;
54
- }
55
-
56
- export function createFrameReorderBuffer(startFrame: number, endFrame: number): FrameReorderBuffer {
57
- let cursor = startFrame;
58
- const pending = new Map<number, Array<() => void>>();
59
-
60
- const enqueueAt = (frame: number, resolve: () => void): void => {
61
- const list = pending.get(frame);
62
- if (list === undefined) {
63
- pending.set(frame, [resolve]);
64
- } else {
65
- list.push(resolve);
66
- }
67
- };
68
-
69
- const flushAt = (frame: number): void => {
70
- const list = pending.get(frame);
71
- if (list === undefined) return;
72
- pending.delete(frame);
73
- for (const resolve of list) resolve();
74
- };
75
-
76
- const waitForFrame = (frame: number): Promise<void> =>
77
- new Promise<void>((resolve) => {
78
- if (frame === cursor) {
79
- resolve();
80
- return;
81
- }
82
- enqueueAt(frame, resolve);
83
- });
84
-
85
- const advanceTo = (frame: number): void => {
86
- cursor = frame;
87
- flushAt(frame);
88
- };
89
-
90
- const waitForAllDone = (): Promise<void> =>
91
- new Promise<void>((resolve) => {
92
- if (cursor >= endFrame) {
93
- resolve();
94
- return;
95
- }
96
- enqueueAt(endFrame, resolve);
97
- });
98
-
99
- return { waitForFrame, advanceTo, waitForAllDone };
100
- }
101
-
102
- // ---------------------------------------------------------------------------
103
- // 2. Streaming FFmpeg encoder
104
- // ---------------------------------------------------------------------------
105
-
106
- export interface StreamingEncoderOptions {
107
- /** Frame rate as an exact rational; see `Fps` in @hyperframes/core. */
108
- fps: Fps;
109
- width: number;
110
- height: number;
111
- codec?: "h264" | "h265" | "vp9" | "prores";
112
- preset?: string;
113
- quality?: number;
114
- bitrate?: string;
115
- pixelFormat?: string;
116
- /** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
117
- vp9CpuUsed?: number;
118
- useGpu?: boolean;
119
- imageFormat?: "jpeg" | "png";
120
- hdr?: { transfer: import("../utils/hdr.js").HdrTransfer };
121
- /** When set, use rawvideo input instead of image2pipe. For HDR PQ-encoded frames. */
122
- rawInputFormat?: "rgb48le";
123
- }
124
-
125
- export interface StreamingEncoderResult {
126
- success: boolean;
127
- durationMs: number;
128
- fileSize: number;
129
- error?: string;
130
- }
131
-
132
- export interface StreamingEncoder {
133
- /**
134
- * Write one frame to FFmpeg stdin, awaiting `drain` when the pipe is full
135
- * so back-pressure propagates to the caller. Resolves `false` when FFmpeg
136
- * is already gone. Callers must serialize calls — one in-flight writeFrame
137
- * per encoder (the frame reorder buffer provides this ordering); concurrent
138
- * calls would interleave frame bytes on the pipe and race the drain wait.
139
- */
140
- writeFrame: (buffer: Buffer) => Promise<boolean>;
141
- close: () => Promise<StreamingEncoderResult>;
142
- getExitStatus: () => "running" | "success" | "error";
143
- }
144
-
145
- /**
146
- * Build FFmpeg args for streaming (image2pipe) input.
147
- * Reuses the same codec/quality/GPU logic as chunkEncoder's buildEncoderArgs
148
- * but with `-f image2pipe` instead of `-i <pattern>`.
149
- *
150
- * Exported so unit tests can assert on the constructed CLI without spawning
151
- * FFmpeg — see streamingEncoder.test.ts.
152
- */
153
- export function buildStreamingArgs(
154
- options: StreamingEncoderOptions,
155
- outputPath: string,
156
- gpuEncoder: GpuEncoder = null,
157
- ): string[] {
158
- const {
159
- fps,
160
- codec = "h264",
161
- preset = "medium",
162
- quality = 23,
163
- bitrate,
164
- pixelFormat = "yuv420p",
165
- vp9CpuUsed,
166
- useGpu = false,
167
- imageFormat = "jpeg",
168
- } = options;
169
-
170
- // Input args: pipe from stdin
171
- const args: string[] = [];
172
- if (options.rawInputFormat) {
173
- // Raw pixel input (HLG/PQ-encoded rgb48le from FFmpeg extraction).
174
- // Tag the input with the correct color space so FFmpeg uses the right
175
- // YUV matrix when converting rgb48le → yuv420p10le for encoding.
176
- // Without these tags FFmpeg assumes bt709 and applies the wrong matrix.
177
- const hdrTransfer = options.hdr?.transfer;
178
- const inputColorTrc =
179
- hdrTransfer === "pq" ? "smpte2084" : hdrTransfer === "hlg" ? "arib-std-b67" : undefined;
180
- args.push(
181
- "-f",
182
- "rawvideo",
183
- "-pix_fmt",
184
- options.rawInputFormat,
185
- "-s",
186
- `${options.width}x${options.height}`,
187
- "-framerate",
188
- fpsToFfmpegArg(fps),
189
- );
190
- if (inputColorTrc) {
191
- args.push(
192
- "-color_primaries",
193
- "bt2020",
194
- "-color_trc",
195
- inputColorTrc,
196
- "-colorspace",
197
- "bt2020nc",
198
- );
199
- }
200
- args.push("-i", "-");
201
- } else {
202
- const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
203
- args.push(
204
- "-f",
205
- "image2pipe",
206
- "-vcodec",
207
- inputCodec,
208
- "-framerate",
209
- fpsToFfmpegArg(fps),
210
- "-i",
211
- "-",
212
- );
213
- }
214
- args.push("-r", fpsToFfmpegArg(fps));
215
-
216
- const shouldUseGpu = useGpu && gpuEncoder !== null;
217
-
218
- if (codec === "h264" || codec === "h265") {
219
- if (shouldUseGpu) {
220
- const encoderName = getGpuEncoderName(gpuEncoder, codec);
221
- args.push("-c:v", encoderName);
222
-
223
- switch (gpuEncoder) {
224
- case "nvenc":
225
- args.push("-preset", mapPresetForGpuEncoder("nvenc", preset));
226
- if (bitrate) args.push("-b:v", bitrate);
227
- else args.push("-cq", String(quality));
228
- break;
229
- case "videotoolbox":
230
- if (bitrate) args.push("-b:v", bitrate);
231
- else {
232
- const vtQuality = Math.max(0, Math.min(100, 100 - quality * 2));
233
- args.push("-q:v", String(vtQuality));
234
- }
235
- args.push("-allow_sw", "1");
236
- break;
237
- case "vaapi":
238
- args.unshift("-vaapi_device", "/dev/dri/renderD128");
239
- args.push("-vf", "format=nv12,hwupload");
240
- if (bitrate) args.push("-b:v", bitrate);
241
- else args.push("-qp", String(quality));
242
- break;
243
- case "qsv":
244
- args.push("-preset", mapPresetForGpuEncoder("qsv", preset));
245
- if (bitrate) args.push("-b:v", bitrate);
246
- else args.push("-global_quality", String(quality));
247
- break;
248
- case "amf":
249
- if (bitrate) args.push("-b:v", bitrate);
250
- else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
251
- break;
252
- }
253
-
254
- // Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, amf,
255
- // qsv, vaapi) and produce the same negative-DTS freeze for downstream players.
256
- // See chunkEncoder.buildEncoderArgs for the full explanation.
257
- if (
258
- codec === "h264" &&
259
- (gpuEncoder === "nvenc" ||
260
- gpuEncoder === "qsv" ||
261
- gpuEncoder === "vaapi" ||
262
- gpuEncoder === "amf")
263
- ) {
264
- args.push("-bf", "0");
265
- if (gpuEncoder === "qsv") {
266
- args.push("-b_strategy", "0");
267
- }
268
- }
269
- } else {
270
- const encoderName = codec === "h264" ? "libx264" : "libx265";
271
- args.push("-c:v", encoderName, "-preset", preset);
272
- if (bitrate) args.push("-b:v", bitrate);
273
- else args.push("-crf", String(quality));
274
-
275
- // Mirrors chunkEncoder: disable B-frames for h264 so PTS == DTS, no
276
- // negative DTS at stream start. Without this, files freeze on the
277
- // first frame in VS Code preview, several browsers, and some HW
278
- // decoders. See chunkEncoder.buildEncoderArgs for the full reasoning.
279
- if (codec === "h264") {
280
- args.push("-bf", "0");
281
- }
282
-
283
- // Encoder-specific params: anti-banding + color space tagging.
284
- // For HDR, getHdrEncoderColorParams also emits the SMPTE ST 2086
285
- // mastering-display and CTA-861.3 MaxCLL/MaxFALL SEI messages —
286
- // without them, players (Apple, YouTube, HDR TVs) treat the file
287
- // as SDR BT.2020 and tone-map incorrectly.
288
- const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
289
- const colorParams =
290
- options.rawInputFormat && options.hdr
291
- ? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
292
- : "colorprim=bt709:transfer=bt709:colormatrix=bt709";
293
- if (preset === "ultrafast") {
294
- args.push(xParamsFlag, `aq-mode=3:${colorParams}`);
295
- } else {
296
- args.push(xParamsFlag, `aq-mode=3:aq-strength=0.8:deblock=1,1:${colorParams}`);
297
- }
298
- // Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
299
- if (codec === "h265") {
300
- args.push("-tag:v", "hvc1");
301
- }
302
- }
303
- } else if (codec === "vp9") {
304
- args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
305
- args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
306
- args.push("-row-mt", "1");
307
- appendVp9CpuUsedArg(args, vp9CpuUsed);
308
- if (pixelFormat === "yuva420p") {
309
- args.push("-auto-alt-ref", "0");
310
- args.push("-metadata:s:v:0", "alpha_mode=1");
311
- }
312
- } else if (codec === "prores") {
313
- args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0");
314
- args.push("-pix_fmt", pixelFormat);
315
- return [...args, "-y", outputPath];
316
- }
317
-
318
- // Color space metadata.
319
- // When rawInputFormat is set, data comes from the WebGPU HDR pipeline
320
- // (PQ-encoded) — tag with bt2020/PQ truthfully.
321
- // Otherwise, Chrome captures sRGB — tag as bt709.
322
- if (codec === "h264" || codec === "h265") {
323
- if (options.rawInputFormat && options.hdr) {
324
- args.push(
325
- "-colorspace:v",
326
- "bt2020nc",
327
- "-color_primaries:v",
328
- "bt2020",
329
- "-color_trc:v",
330
- options.hdr.transfer === "pq" ? "smpte2084" : "arib-std-b67",
331
- "-color_range",
332
- "tv",
333
- );
334
- } else {
335
- args.push(
336
- "-colorspace:v",
337
- "bt709",
338
- "-color_primaries:v",
339
- "bt709",
340
- "-color_trc:v",
341
- "bt709",
342
- "-color_range",
343
- "tv",
344
- );
345
- }
346
-
347
- // Video filter for range/color conversion.
348
- // Raw HDR input (from WebGPU pipeline) is already PQ-encoded — no conversion needed.
349
- // Chrome screenshots need full→TV range conversion.
350
- if (options.rawInputFormat) {
351
- // No filter needed — PQ data goes straight to encoder
352
- } else if (gpuEncoder === "vaapi") {
353
- const vfIdx = args.indexOf("-vf");
354
- if (vfIdx !== -1) {
355
- args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
356
- }
357
- } else if (!shouldUseGpu) {
358
- // Range conversion: Chrome screenshots are full-range RGB.
359
- args.push("-vf", "scale=in_range=pc:out_range=tv");
360
- }
361
-
362
- // Fixed timescale for consistent A/V timing across platforms.
363
- args.push("-video_track_timescale", "90000");
364
- }
365
-
366
- if (gpuEncoder !== "vaapi") {
367
- args.push("-pix_fmt", pixelFormat);
368
- }
369
-
370
- // Belt-and-suspenders against negative DTS at stream start. See chunkEncoder
371
- // for the full explanation; same playback compatibility class.
372
- args.push("-avoid_negative_ts", "make_zero");
373
-
374
- args.push("-y", outputPath);
375
- return args;
376
- }
377
-
378
- /**
379
- * Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
380
- */
381
- export async function spawnStreamingEncoder(
382
- outputPath: string,
383
- options: StreamingEncoderOptions,
384
- signal?: AbortSignal,
385
- config?: Partial<Pick<EngineConfig, "ffmpegStreamingTimeout">>,
386
- ): Promise<StreamingEncoder> {
387
- const outputDir = dirname(outputPath);
388
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
389
-
390
- let gpuEncoder: GpuEncoder = null;
391
- if (options.useGpu) {
392
- gpuEncoder = await getCachedGpuEncoder();
393
- }
394
-
395
- const args = buildStreamingArgs(options, outputPath, gpuEncoder);
396
-
397
- const startTime = Date.now();
398
- const ffmpeg: ChildProcess = spawn(getFfmpegBinary(), args, {
399
- stdio: ["pipe", "pipe", "pipe"],
400
- });
401
- trackChildProcess(ffmpeg);
402
-
403
- let exitStatus: "running" | "success" | "error" = "running";
404
- let stderr = "";
405
- let exitCode: number | null = null;
406
- let exitPromiseResolve: ((value: void) => void) | null = null;
407
- const exitPromise = new Promise<void>((resolve) => (exitPromiseResolve = resolve));
408
-
409
- // Track stderr for progress and error messages
410
- ffmpeg.stderr?.on("data", (data: Buffer) => {
411
- stderr += data.toString();
412
- });
413
-
414
- ffmpeg.on("close", (code: number | null) => {
415
- exitCode = code;
416
- exitStatus = code === 0 ? "success" : "error";
417
- exitPromiseResolve?.();
418
- });
419
-
420
- ffmpeg.on("error", (err: Error) => {
421
- exitStatus = "error";
422
- stderr += `\nProcess error: ${err.message}`;
423
- exitPromiseResolve?.();
424
- });
425
-
426
- ffmpeg.stdin?.on("error", () => {});
427
- ffmpeg.stdout?.on("error", () => {});
428
-
429
- // Handle abort signal
430
- const onAbort = () => {
431
- if (exitStatus === "running") {
432
- ffmpeg.kill("SIGTERM");
433
- }
434
- };
435
- if (signal) {
436
- if (signal.aborted) {
437
- ffmpeg.kill("SIGTERM");
438
- } else {
439
- signal.addEventListener("abort", onAbort, { once: true });
440
- }
441
- }
442
-
443
- // Inactivity timeout: fires only when no frame has been written for
444
- // `ffmpegStreamingTimeout` ms. A slow-but-progressing capture (e.g. a CI
445
- // runner under load) keeps resetting the timer on each writeFrame, so total
446
- // wall-clock render time is unbounded — only a true hang (Chrome dead,
447
- // capture stuck, no frames arriving) trips SIGTERM. The 600s default was
448
- // previously a total-render cap, which intermittently killed legitimate
449
- // slow renders mid-encode (FFmpeg got SIGTERM after most frames were sent;
450
- // libx264 printed its summary and exited 255, observable as
451
- // "Streaming encode failed: FFmpeg exited with code 255" with audio:0kB).
452
- const streamingTimeout = config?.ffmpegStreamingTimeout ?? DEFAULT_CONFIG.ffmpegStreamingTimeout;
453
- let timer: NodeJS.Timeout | null = null;
454
- const resetTimer = () => {
455
- if (timer) clearTimeout(timer);
456
- timer = setTimeout(() => {
457
- if (exitStatus === "running") {
458
- ffmpeg.kill("SIGTERM");
459
- }
460
- }, streamingTimeout);
461
- };
462
- resetTimer();
463
-
464
- const waitForDrainOrExit = async (
465
- stdin: NonNullable<ChildProcess["stdin"]>,
466
- ): Promise<"drain" | "exit"> => {
467
- // Back-pressure can hit once per frame. Do not race `exitPromise.then(...)`
468
- // here: V8 retains `.then` reaction-list entries on an unsettled promise,
469
- // so a one-hour 30fps render under steady back-pressure can accumulate
470
- // ~108K closures + AbortControllers. Use one-shot listeners for this write
471
- // instead, then abort them in finally. `close` is the event that flips
472
- // `exitStatus`; re-check after listener attachment so a close emitted
473
- // between `stdin.write(false)` and this await cannot hang forever.
474
- const abortController = new AbortController();
475
- try {
476
- const drainPromise = once(stdin, "drain", { signal: abortController.signal }).then(
477
- () => "drain" as const,
478
- );
479
- const closePromise = once(ffmpeg, "close", { signal: abortController.signal }).then(
480
- () => "exit" as const,
481
- );
482
- const racePromise = Promise.race([drainPromise, closePromise]).catch((err: unknown) => {
483
- if (err instanceof Error && err.name === "AbortError") {
484
- return "exit" as const;
485
- }
486
- throw err;
487
- });
488
-
489
- if (exitStatus !== "running") {
490
- return "exit";
491
- }
492
-
493
- return await racePromise;
494
- } finally {
495
- abortController.abort();
496
- }
497
- };
498
-
499
- const encoder: StreamingEncoder = {
500
- writeFrame: async (buffer: Buffer): Promise<boolean> => {
501
- const stdin = ffmpeg.stdin;
502
- if (exitStatus !== "running" || !stdin || stdin.destroyed) {
503
- return false;
504
- }
505
- // Copy the buffer before writing — Node streams hold a reference to the
506
- // provided buffer and drain it asynchronously. The HDR path's compositor
507
- // reuses pre-allocated transOutput/normalCanvas buffers across frames,
508
- // so without this copy the pipe would read partially-overwritten data
509
- // and flicker.
510
- const copy = Buffer.from(buffer);
511
- const accepted = stdin.write(copy);
512
- // Reset inactivity timer immediately ONLY on `accepted === true`. `true`
513
- // means the write went through to the kernel pipe without buffering in
514
- // Node — proof FFmpeg is actually consuming. `false` means Node's writable
515
- // stream had to buffer (FFmpeg hasn't drained the pipe yet); we await
516
- // `drain` before letting callers produce the next frame, and only reset
517
- // after drain proves consumption. We deliberately don't reset before
518
- // drain so a hung FFmpeg with a still-producing Chrome can't keep us
519
- // alive forever while Node's stdin buffer grows to OOM. If FFmpeg exits
520
- // before draining, waitForDrainOrExit returns "exit", removes its
521
- // one-shot listeners, and callers see `false` instead of hanging.
522
- if (accepted) {
523
- resetTimer();
524
- return true;
525
- }
526
-
527
- const drainResult = await waitForDrainOrExit(stdin);
528
- if (drainResult !== "drain" || exitStatus !== "running") {
529
- return false;
530
- }
531
- resetTimer();
532
- return true;
533
- },
534
-
535
- close: async (): Promise<StreamingEncoderResult> => {
536
- // INVARIANT: close() is idempotent. The renderOrchestrator HDR cleanup
537
- // path tracks an `encoderClosed` flag and may still re-call close() in
538
- // the outer finally if the inner cleanup raised before the flag flipped.
539
- // Each step here must be safe to repeat:
540
- // - clearTimeout: safe to call on an already-cleared/fired timer
541
- // - removeEventListener: no-op if the listener was already removed
542
- // (and {once: true} would have removed it on the first abort anyway)
543
- // - stdin.end gated on !destroyed: skipped on the second call
544
- // - exitPromise: a single shared Promise; awaiting an already-resolved
545
- // Promise resolves immediately with the same captured exitCode
546
- // The returned StreamingEncoderResult is therefore consistent across
547
- // repeated calls. If you change this method, preserve idempotency or
548
- // a regression here will silently double-close ffmpeg and produce
549
- // harder-to-trace errors at the orchestrator layer.
550
- if (timer) {
551
- clearTimeout(timer);
552
- timer = null;
553
- }
554
- if (signal) signal.removeEventListener("abort", onAbort);
555
-
556
- const stdin = ffmpeg.stdin;
557
- if (stdin && !stdin.destroyed) {
558
- await new Promise<void>((resolve) => {
559
- stdin.end(() => resolve());
560
- });
561
- }
562
-
563
- await exitPromise;
564
-
565
- const durationMs = Date.now() - startTime;
566
-
567
- if (signal?.aborted) {
568
- return {
569
- success: false,
570
- durationMs,
571
- fileSize: 0,
572
- error: "Streaming encode cancelled",
573
- };
574
- }
575
-
576
- if (exitCode !== 0) {
577
- return {
578
- success: false,
579
- durationMs,
580
- fileSize: 0,
581
- error: formatFfmpegError(exitCode, stderr),
582
- };
583
- }
584
-
585
- const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
586
-
587
- return { success: true, durationMs, fileSize };
588
- },
589
-
590
- getExitStatus: () => exitStatus,
591
- };
592
-
593
- return encoder;
594
- }