@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.
- package/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- 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
|
-
}
|