@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,831 +0,0 @@
1
- // fallow-ignore-file code-duplication complexity
2
- /**
3
- * Chunk Encoder Service
4
- *
5
- * Encodes captured frames into video using FFmpeg.
6
- * Supports CPU (libx264) and GPU encoding.
7
- */
8
-
9
- import { spawn } from "child_process";
10
- import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
11
- import { join, dirname, extname } from "path";
12
- import { trackChildProcess } from "../utils/processTracker.js";
13
- import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
14
- import {
15
- type GpuEncoder,
16
- getCachedGpuEncoder,
17
- getGpuEncoderName,
18
- mapPresetForGpuEncoder,
19
- } from "../utils/gpuEncoder.js";
20
- import { type HdrTransfer, getHdrEncoderColorParams } from "../utils/hdr.js";
21
- import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
22
- import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
23
- import { extractAudioMetadata } from "../utils/ffprobe.js";
24
- import { type Fps, fpsToFfmpegArg } from "@hyperframes/core";
25
- import type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
26
- import { appendVp9CpuUsedArg } from "./vp9Options.js";
27
-
28
- export type { EncoderOptions, EncodeResult, MuxResult } from "./chunkEncoder.types.js";
29
-
30
- export const ENCODER_PRESETS = {
31
- draft: { preset: "ultrafast", quality: 28, codec: "h264" as const },
32
- standard: { preset: "medium", quality: 18, codec: "h264" as const },
33
- high: { preset: "slow", quality: 15, codec: "h264" as const },
34
- };
35
-
36
- export interface EncoderPreset {
37
- preset: string;
38
- quality: number;
39
- codec: "h264" | "h265" | "vp9" | "prores";
40
- pixelFormat: string;
41
- hdr?: { transfer: HdrTransfer };
42
- }
43
-
44
- function appendEncodeTimeoutMessage(error: string, timedOut: boolean, timeoutMs: number): string {
45
- if (!timedOut) return error;
46
- return `${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms)`;
47
- }
48
-
49
- function isAacSidecar(audioPath: string): boolean {
50
- return extname(audioPath).toLowerCase() === ".aac";
51
- }
52
-
53
- const KNOWN_NON_AAC_AUDIO_EXTENSIONS = new Set([
54
- ".flac",
55
- ".mp3",
56
- ".oga",
57
- ".ogg",
58
- ".opus",
59
- ".wav",
60
- ".webm",
61
- ]);
62
-
63
- export interface MuxVideoWithAudioOptions extends Partial<
64
- Pick<EngineConfig, "ffmpegProcessTimeout">
65
- > {
66
- /**
67
- * Codec of the sidecar audio when the caller already knows it. HyperFrames
68
- * render paths pass the mixed AAC sidecar by contract, so muxing should not
69
- * depend on the file extension alone.
70
- */
71
- audioCodec?: "aac";
72
- }
73
-
74
- async function shouldCopyAacSidecar(
75
- audioPath: string,
76
- options: MuxVideoWithAudioOptions | undefined,
77
- ) {
78
- if (options?.audioCodec === "aac" || isAacSidecar(audioPath)) return true;
79
-
80
- const audioExtension = extname(audioPath).toLowerCase();
81
- if (KNOWN_NON_AAC_AUDIO_EXTENSIONS.has(audioExtension)) return false;
82
-
83
- try {
84
- const metadata = await extractAudioMetadata(audioPath);
85
- return metadata.audioCodec === "aac";
86
- } catch {
87
- // Preserve the pre-existing fallback for invalid or unprobeable sidecars:
88
- // let the final ffmpeg transcode path surface the actionable mux error.
89
- return false;
90
- }
91
- }
92
-
93
- /**
94
- * Get encoder preset for a given quality and output format.
95
- * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
96
- * MOV uses ProRes 4444 with alpha for editor-compatible transparency.
97
- */
98
- export function getEncoderPreset(
99
- quality: "draft" | "standard" | "high",
100
- format: "mp4" | "webm" | "mov" = "mp4",
101
- hdr?: { transfer: HdrTransfer },
102
- ): EncoderPreset {
103
- const base = ENCODER_PRESETS[quality];
104
- if (format === "webm") {
105
- return {
106
- preset: base.preset === "ultrafast" ? "realtime" : "good",
107
- quality: base.quality,
108
- codec: "vp9",
109
- pixelFormat: "yuva420p",
110
- };
111
- }
112
- if (format === "mov") {
113
- return {
114
- preset: "4444",
115
- quality: base.quality,
116
- codec: "prores",
117
- pixelFormat: "yuva444p10le",
118
- };
119
- }
120
- if (hdr) {
121
- return {
122
- preset: base.preset === "ultrafast" ? "fast" : base.preset,
123
- quality: base.quality,
124
- codec: "h265",
125
- pixelFormat: "yuv420p10le",
126
- hdr,
127
- };
128
- }
129
- return { ...base, pixelFormat: "yuv420p" };
130
- }
131
-
132
- // Re-export GPU utilities so existing consumers that import from chunkEncoder still work.
133
- export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
134
-
135
- export function buildEncoderArgs(
136
- options: EncoderOptions,
137
- inputArgs: string[],
138
- outputPath: string,
139
- gpuEncoder: GpuEncoder = null,
140
- ): string[] {
141
- const {
142
- fps,
143
- codec = "h264",
144
- preset = "medium",
145
- quality = 23,
146
- bitrate,
147
- pixelFormat = "yuv420p",
148
- vp9CpuUsed,
149
- useGpu = false,
150
- } = options;
151
-
152
- // libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd
153
- // produce a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block
154
- // inside the bitstream) which confuses HDR-aware players. Strip hdr and
155
- // log a warning so the caller picks h265 (the SDR-tagged output is honest).
156
- if (options.hdr && codec === "h264") {
157
- console.warn(
158
- "[chunkEncoder] HDR is not supported with codec=h264 (libx264 has no HDR support). " +
159
- "Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.",
160
- );
161
- options = { ...options, hdr: undefined };
162
- }
163
-
164
- const args: string[] = [...inputArgs, "-r", fpsToFfmpegArg(fps)];
165
- const shouldUseGpu = useGpu && gpuEncoder !== null;
166
-
167
- if (codec === "h264" || codec === "h265") {
168
- if (shouldUseGpu) {
169
- const encoderName = getGpuEncoderName(gpuEncoder, codec);
170
- args.push("-c:v", encoderName);
171
-
172
- switch (gpuEncoder) {
173
- case "nvenc":
174
- args.push("-preset", mapPresetForGpuEncoder("nvenc", preset));
175
- if (bitrate) args.push("-b:v", bitrate);
176
- else args.push("-cq", String(quality));
177
- break;
178
- case "videotoolbox":
179
- if (bitrate) args.push("-b:v", bitrate);
180
- else {
181
- const vtQuality = Math.max(0, Math.min(100, 100 - quality * 2));
182
- args.push("-q:v", String(vtQuality));
183
- }
184
- args.push("-allow_sw", "1");
185
- break;
186
- case "vaapi":
187
- args.unshift("-vaapi_device", "/dev/dri/renderD128");
188
- args.push("-vf", "format=nv12,hwupload");
189
- if (bitrate) args.push("-b:v", bitrate);
190
- else args.push("-qp", String(quality));
191
- break;
192
- case "qsv":
193
- args.push("-preset", mapPresetForGpuEncoder("qsv", preset));
194
- if (bitrate) args.push("-b:v", bitrate);
195
- else args.push("-global_quality", String(quality));
196
- break;
197
- case "amf":
198
- if (bitrate) args.push("-b:v", bitrate);
199
- else args.push("-rc", "cqp", "-qp_i", String(quality), "-qp_p", String(quality));
200
- break;
201
- }
202
-
203
- // Same B-frame story as the SW branch below — nvenc/amf emit B-frames
204
- // by default (qsv via b_strategy, vaapi too), and the negative-DTS
205
- // freeze hits the same downstream players. The unconditional
206
- // `-avoid_negative_ts make_zero` near the bottom of this function
207
- // covers the mux level, but we belt-and-suspenders the encoder too
208
- // so even tools that consume the chunk file directly (without going
209
- // through our mux step) play correctly. videotoolbox doesn't accept
210
- // `-bf` so it's skipped — videotoolbox h264 also doesn't emit
211
- // negative DTS in practice on macOS Sonoma+.
212
- if (
213
- codec === "h264" &&
214
- (gpuEncoder === "nvenc" ||
215
- gpuEncoder === "qsv" ||
216
- gpuEncoder === "vaapi" ||
217
- gpuEncoder === "amf")
218
- ) {
219
- args.push("-bf", "0");
220
- if (gpuEncoder === "qsv") {
221
- args.push("-b_strategy", "0");
222
- }
223
- }
224
- } else {
225
- const encoderName = codec === "h264" ? "libx264" : "libx265";
226
- args.push("-c:v", encoderName, "-preset", preset);
227
- if (bitrate) args.push("-b:v", bitrate);
228
- else args.push("-crf", String(quality));
229
-
230
- // Closed-GOP / forced-keyframe args so an external orchestrator can
231
- // ffmpeg-concat chunk files with `-c copy`. Without these, libx264 /
232
- // libx265 emit open-GOP frames with mid-chunk scenecut keyframes; the
233
- // first frame of each chunk isn't an independently-decodable IDR and
234
- // concat-copy playback freezes at chunk seams on some decoders.
235
- const lockGop = options.lockGopForChunkConcat === true;
236
- let gop = 0;
237
- if (lockGop) {
238
- if (
239
- typeof options.gopSize !== "number" ||
240
- !Number.isFinite(options.gopSize) ||
241
- options.gopSize <= 0
242
- ) {
243
- throw new Error(
244
- `[chunkEncoder] lockGopForChunkConcat=true requires a positive integer gopSize (received ${String(options.gopSize)})`,
245
- );
246
- }
247
- gop = Math.floor(options.gopSize);
248
- args.push(
249
- "-g",
250
- String(gop),
251
- "-keyint_min",
252
- String(gop),
253
- "-sc_threshold",
254
- "0",
255
- "-force_key_frames",
256
- `expr:eq(mod(n,${gop}),0)`,
257
- );
258
- }
259
-
260
- // Disable B-frames. Standard h264 with B-frames produces negative DTS
261
- // at the start of the stream (the first B-frame's decode order is
262
- // "before" the first I-frame's presentation time). VS Code's video
263
- // preview, several browser <video> pipelines, and some HW decoders
264
- // freeze on the first frame when DTS is negative, so audio plays alone.
265
- // -bf 0 makes PTS == DTS at every frame, eliminating the issue at the
266
- // source. Quality cost is ~5–10% larger files at the same CRF — a
267
- // worthwhile trade for "the file plays everywhere".
268
- //
269
- // Also emit `-bf 0` for h265 when closed-GOP is locked: chunked
270
- // concat-copy of h265 with B-frames hits the same negative-DTS hazard
271
- // at every chunk boundary, even though single-stream h265 normally
272
- // tolerates B-frames fine.
273
- if (codec === "h264" || (codec === "h265" && lockGop)) {
274
- args.push("-bf", "0");
275
- }
276
-
277
- // Encoder-specific params: anti-banding + color space tagging.
278
- // aq-mode=3 redistributes bits to dark flat areas (gradients).
279
- // For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
280
- // mastering metadata via x265-params; libx264 only carries BT.709 tags
281
- // since HDR through H.264 is not supported by this encoder path.
282
- //
283
- // When closed-GOP is locked we additionally bake the keyint/scenecut
284
- // controls into the codec param string so libx264's slice-type decisions
285
- // and libx265's rate-control respect the IDR cadence end-to-end (without
286
- // these, ffmpeg's `-force_key_frames` is honored but the underlying
287
- // encoder may still insert mini-GOPs with open-GOP references that
288
- // break concat-copy on some decoders). `repeat-headers=1` writes SPS/PPS
289
- // at every keyframe so each chunk file is self-contained.
290
- const xParamsFlag = codec === "h264" ? "-x264-params" : "-x265-params";
291
- const colorParams =
292
- codec === "h265" && options.hdr
293
- ? getHdrEncoderColorParams(options.hdr.transfer).x265ColorParams
294
- : "colorprim=bt709:transfer=bt709:colormatrix=bt709";
295
- let gopParams = "";
296
- if (lockGop) {
297
- const shared = "scenecut=0:open-gop=0:repeat-headers=1";
298
- gopParams = codec === "h264" ? shared : `keyint=${gop}:min-keyint=${gop}:${shared}`;
299
- }
300
- const joinParams = (...parts: string[]): string =>
301
- parts.filter((p) => p.length > 0).join(":");
302
- if (preset === "ultrafast") {
303
- args.push(xParamsFlag, joinParams("aq-mode=3", colorParams, gopParams));
304
- } else {
305
- args.push(
306
- xParamsFlag,
307
- joinParams("aq-mode=3", "aq-strength=0.8", "deblock=1,1", colorParams, gopParams),
308
- );
309
- }
310
- }
311
- // Apple devices require hvc1 tag for HEVC playback (default hev1 won't open in QuickTime)
312
- if (codec === "h265") {
313
- args.push("-tag:v", "hvc1");
314
- }
315
- } else if (codec === "vp9") {
316
- args.push("-c:v", "libvpx-vp9", "-b:v", bitrate || "0", "-crf", String(quality));
317
- args.push("-deadline", preset === "ultrafast" ? "realtime" : "good");
318
- args.push("-row-mt", "1");
319
- appendVp9CpuUsedArg(args, vp9CpuUsed);
320
-
321
- // `-auto-alt-ref 0` is mandatory for chunk concat-copy: libvpx-vp9's
322
- // alt-ref frames can reference frames in either direction inside a
323
- // GOP, so a chunk-boundary frame is not guaranteed to be the first
324
- // displayable reference when alt-ref is on. The shared `vp9CpuUsed`
325
- // option pins speed/quality against libvpx-vp9 default drift across
326
- // versions for both chunked and streaming WebM encodes.
327
- const lockGopVp9 = options.lockGopForChunkConcat === true;
328
- if (lockGopVp9) {
329
- if (
330
- typeof options.gopSize !== "number" ||
331
- !Number.isFinite(options.gopSize) ||
332
- options.gopSize <= 0
333
- ) {
334
- throw new Error(
335
- `[chunkEncoder] lockGopForChunkConcat=true requires a positive integer gopSize (received ${String(options.gopSize)})`,
336
- );
337
- }
338
- const gop = Math.floor(options.gopSize);
339
- args.push("-g", String(gop), "-keyint_min", String(gop), "-auto-alt-ref", "0");
340
- }
341
- if (pixelFormat === "yuva420p") {
342
- // Alpha + alt-ref is unsupported by libvpx-vp9. The closed-GOP
343
- // branch above already emits `-auto-alt-ref 0`, so skip the
344
- // duplicate push.
345
- if (!lockGopVp9) {
346
- args.push("-auto-alt-ref", "0");
347
- }
348
- args.push("-metadata:s:v:0", "alpha_mode=1");
349
- }
350
- } else if (codec === "prores") {
351
- args.push("-c:v", "prores_ks", "-profile:v", preset, "-vendor", "apl0");
352
- args.push("-pix_fmt", pixelFormat);
353
- return [...args, "-y", outputPath];
354
- }
355
-
356
- // Color space metadata — tags the output so players interpret colors correctly.
357
- //
358
- // Default (no options.hdr): Chrome screenshots are sRGB/bt709 pixels and
359
- // we tag them truthfully as bt709. Tagging as bt2020 when pixels are bt709
360
- // causes browsers to apply the wrong color transform, producing visible
361
- // orange/warm shifts.
362
- //
363
- // HDR (options.hdr provided): the caller asserts the input pixels are
364
- // already in the BT.2020 color space (e.g. extracted HDR video frames or a
365
- // pre-tagged source). We tag the output as BT.2020 + the corresponding
366
- // transfer (smpte2084 for PQ, arib-std-b67 for HLG). HDR static mastering
367
- // metadata (master-display, max-cll) is embedded only in the SW libx265
368
- // path above; GPU H.265 + HDR carries the color tags but not the static
369
- // metadata, which is acceptable for previews but not for HDR-aware delivery.
370
- if (codec === "h264" || codec === "h265") {
371
- if (options.hdr) {
372
- const transferTag = options.hdr.transfer === "pq" ? "smpte2084" : "arib-std-b67";
373
- args.push(
374
- "-colorspace:v",
375
- "bt2020nc",
376
- "-color_primaries:v",
377
- "bt2020",
378
- "-color_trc:v",
379
- transferTag,
380
- "-color_range",
381
- "tv",
382
- );
383
- } else {
384
- args.push(
385
- "-colorspace:v",
386
- "bt709",
387
- "-color_primaries:v",
388
- "bt709",
389
- "-color_trc:v",
390
- "bt709",
391
- "-color_range",
392
- "tv",
393
- );
394
- }
395
-
396
- // Range conversion: Chrome's full-range RGB → limited/TV range.
397
- if (gpuEncoder === "vaapi") {
398
- const vfIdx = args.indexOf("-vf");
399
- if (vfIdx !== -1) {
400
- args[vfIdx + 1] = `scale=in_range=pc:out_range=tv,${args[vfIdx + 1]}`;
401
- }
402
- } else if (!shouldUseGpu) {
403
- // Range conversion: Chrome screenshots are full-range RGB.
404
- // The scale filter handles both 8-bit and 10-bit correctly.
405
- args.push("-vf", "scale=in_range=pc:out_range=tv");
406
- }
407
-
408
- // Fixed timescale for consistent A/V timing across platforms.
409
- args.push("-video_track_timescale", "90000");
410
- }
411
-
412
- if (gpuEncoder !== "vaapi") {
413
- args.push("-pix_fmt", pixelFormat);
414
- }
415
-
416
- args.push("-avoid_negative_ts", "make_zero");
417
-
418
- args.push("-y", outputPath);
419
- return args;
420
- }
421
-
422
- export async function encodeFramesFromDir(
423
- framesDir: string,
424
- framePattern: string,
425
- outputPath: string,
426
- options: EncoderOptions,
427
- signal?: AbortSignal,
428
- config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
429
- ): Promise<EncodeResult> {
430
- const startTime = Date.now();
431
-
432
- const outputDir = dirname(outputPath);
433
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
434
-
435
- const files = readdirSync(framesDir).filter((f) => f.match(/\.(jpg|jpeg|png)$/i));
436
- const frameCount = files.length;
437
-
438
- if (frameCount === 0) {
439
- return {
440
- success: false,
441
- outputPath,
442
- durationMs: Date.now() - startTime,
443
- framesEncoded: 0,
444
- fileSize: 0,
445
- error: "[FFmpeg] No frame files found in directory",
446
- };
447
- }
448
-
449
- let gpuEncoder: GpuEncoder = null;
450
- if (options.useGpu) {
451
- gpuEncoder = await getCachedGpuEncoder();
452
- }
453
-
454
- const inputPath = join(framesDir, framePattern);
455
- const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
456
- const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
457
-
458
- return new Promise((resolve) => {
459
- const ffmpeg = spawn(getFfmpegBinary(), args);
460
- trackChildProcess(ffmpeg);
461
- let stderr = "";
462
- const onAbort = () => {
463
- ffmpeg.kill("SIGTERM");
464
- };
465
- if (signal) {
466
- if (signal.aborted) {
467
- ffmpeg.kill("SIGTERM");
468
- } else {
469
- signal.addEventListener("abort", onAbort, { once: true });
470
- }
471
- }
472
-
473
- const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
474
- let timedOut = false;
475
- const timer = setTimeout(() => {
476
- timedOut = true;
477
- ffmpeg.kill("SIGTERM");
478
- }, encodeTimeout);
479
-
480
- ffmpeg.stderr.on("data", (data) => {
481
- stderr += data.toString();
482
- });
483
-
484
- ffmpeg.on("close", (code) => {
485
- clearTimeout(timer);
486
- if (signal) signal.removeEventListener("abort", onAbort);
487
- const durationMs = Date.now() - startTime;
488
- if (signal?.aborted && !timedOut) {
489
- resolve({
490
- success: false,
491
- outputPath,
492
- durationMs,
493
- framesEncoded: 0,
494
- fileSize: 0,
495
- error: "FFmpeg encode cancelled",
496
- });
497
- return;
498
- }
499
-
500
- if (code !== 0 || timedOut) {
501
- resolve({
502
- success: false,
503
- outputPath,
504
- durationMs,
505
- framesEncoded: 0,
506
- fileSize: 0,
507
- error: appendEncodeTimeoutMessage(
508
- formatFfmpegError(code, stderr),
509
- timedOut,
510
- encodeTimeout,
511
- ),
512
- });
513
- return;
514
- }
515
-
516
- const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
517
- resolve({ success: true, outputPath, durationMs, framesEncoded: frameCount, fileSize });
518
- });
519
-
520
- ffmpeg.on("error", (err) => {
521
- clearTimeout(timer);
522
- if (signal) signal.removeEventListener("abort", onAbort);
523
- resolve({
524
- success: false,
525
- outputPath,
526
- durationMs: Date.now() - startTime,
527
- framesEncoded: 0,
528
- fileSize: 0,
529
- error: appendEncodeTimeoutMessage(`[FFmpeg] ${err.message}`, timedOut, encodeTimeout),
530
- });
531
- });
532
- });
533
- }
534
-
535
- export async function encodeFramesChunkedConcat(
536
- framesDir: string,
537
- framePattern: string,
538
- outputPath: string,
539
- options: EncoderOptions,
540
- chunkSizeFrames: number,
541
- signal?: AbortSignal,
542
- config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>,
543
- ): Promise<EncodeResult> {
544
- const start = Date.now();
545
- const files = readdirSync(framesDir)
546
- .filter((f) => f.match(/\.(jpg|jpeg|png)$/i))
547
- .sort();
548
- if (files.length === 0) {
549
- return {
550
- success: false,
551
- outputPath,
552
- durationMs: Date.now() - start,
553
- framesEncoded: 0,
554
- fileSize: 0,
555
- error: "[FFmpeg] No frame files found in directory",
556
- };
557
- }
558
- const chunkSize = Math.max(30, Math.floor(chunkSizeFrames));
559
- const chunkCount = Math.ceil(files.length / chunkSize);
560
- const chunkDir = join(dirname(outputPath), "chunk-encode");
561
- if (!existsSync(chunkDir)) mkdirSync(chunkDir, { recursive: true });
562
- const chunkPaths: string[] = [];
563
-
564
- for (let i = 0; i < chunkCount; i++) {
565
- if (signal?.aborted) {
566
- return {
567
- success: false,
568
- outputPath,
569
- durationMs: Date.now() - start,
570
- framesEncoded: 0,
571
- fileSize: 0,
572
- error: "Chunked encode cancelled",
573
- };
574
- }
575
- const startNumber = i * chunkSize;
576
- const framesInChunk = Math.min(chunkSize, files.length - startNumber);
577
- const ext = outputPath.endsWith(".webm")
578
- ? ".webm"
579
- : outputPath.endsWith(".mov")
580
- ? ".mov"
581
- : ".mp4";
582
- const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`);
583
- const inputPath = join(framesDir, framePattern);
584
- const inputArgs = [
585
- "-framerate",
586
- fpsToFfmpegArg(options.fps),
587
- "-start_number",
588
- String(startNumber),
589
- "-i",
590
- inputPath,
591
- "-frames:v",
592
- String(framesInChunk),
593
- ];
594
- let gpuEncoder: GpuEncoder = null;
595
- if (options.useGpu) gpuEncoder = await getCachedGpuEncoder();
596
- const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
597
- const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
598
- const ffmpeg = spawn(getFfmpegBinary(), args);
599
- trackChildProcess(ffmpeg);
600
- let stderr = "";
601
- const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
602
- let timedOut = false;
603
- const timer = setTimeout(() => {
604
- timedOut = true;
605
- ffmpeg.kill("SIGTERM");
606
- }, encodeTimeout);
607
- ffmpeg.stderr.on("data", (d) => {
608
- stderr += d.toString();
609
- });
610
- ffmpeg.on("close", (code) => {
611
- clearTimeout(timer);
612
- if (code === 0 && !timedOut) resolve({ success: true });
613
- else {
614
- resolve({
615
- success: false,
616
- error: appendEncodeTimeoutMessage(
617
- `Chunk ${i} encode failed: ${stderr.slice(-400)}`,
618
- timedOut,
619
- encodeTimeout,
620
- ),
621
- });
622
- }
623
- });
624
- ffmpeg.on("error", (err) => {
625
- clearTimeout(timer);
626
- resolve({
627
- success: false,
628
- error: appendEncodeTimeoutMessage(
629
- `Chunk ${i} encode error: ${err.message}`,
630
- timedOut,
631
- encodeTimeout,
632
- ),
633
- });
634
- });
635
- });
636
- if (!chunkResult.success) {
637
- return {
638
- success: false,
639
- outputPath,
640
- durationMs: Date.now() - start,
641
- framesEncoded: 0,
642
- fileSize: 0,
643
- error: chunkResult.error,
644
- };
645
- }
646
- chunkPaths.push(chunkPath);
647
- }
648
-
649
- const concatListPath = join(chunkDir, "concat-list.txt");
650
- const concatInput = chunkPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join("\n");
651
- writeFileSync(concatListPath, concatInput, "utf-8");
652
-
653
- const concatArgs = [
654
- "-f",
655
- "concat",
656
- "-safe",
657
- "0",
658
- "-i",
659
- concatListPath,
660
- "-c",
661
- "copy",
662
- "-y",
663
- outputPath,
664
- ];
665
- const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
666
- const ffmpeg = spawn(getFfmpegBinary(), concatArgs);
667
- trackChildProcess(ffmpeg);
668
- let stderr = "";
669
- const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
670
- let timedOut = false;
671
- const timer = setTimeout(() => {
672
- timedOut = true;
673
- ffmpeg.kill("SIGTERM");
674
- }, encodeTimeout);
675
- ffmpeg.stderr.on("data", (d) => {
676
- stderr += d.toString();
677
- });
678
- ffmpeg.on("close", (code) => {
679
- clearTimeout(timer);
680
- if (code === 0 && !timedOut) resolve({ success: true });
681
- else {
682
- resolve({
683
- success: false,
684
- error: appendEncodeTimeoutMessage(
685
- `Chunk concat failed: ${stderr.slice(-400)}`,
686
- timedOut,
687
- encodeTimeout,
688
- ),
689
- });
690
- }
691
- });
692
- ffmpeg.on("error", (err) => {
693
- clearTimeout(timer);
694
- resolve({
695
- success: false,
696
- error: appendEncodeTimeoutMessage(
697
- `Chunk concat error: ${err.message}`,
698
- timedOut,
699
- encodeTimeout,
700
- ),
701
- });
702
- });
703
- });
704
-
705
- if (!concatResult.success) {
706
- return {
707
- success: false,
708
- outputPath,
709
- durationMs: Date.now() - start,
710
- framesEncoded: 0,
711
- fileSize: 0,
712
- error: concatResult.error,
713
- };
714
- }
715
-
716
- const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
717
- return {
718
- success: true,
719
- outputPath,
720
- durationMs: Date.now() - start,
721
- framesEncoded: files.length,
722
- fileSize,
723
- };
724
- }
725
-
726
- export async function muxVideoWithAudio(
727
- videoPath: string,
728
- audioPath: string,
729
- outputPath: string,
730
- signal?: AbortSignal,
731
- config?: MuxVideoWithAudioOptions,
732
- fps?: Fps,
733
- ): Promise<MuxResult> {
734
- const outputDir = dirname(outputPath);
735
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
736
-
737
- const isWebm = outputPath.endsWith(".webm");
738
- const isMov = outputPath.endsWith(".mov");
739
- const shouldCopyAudio = isWebm ? false : await shouldCopyAacSidecar(audioPath, config);
740
- const args = ["-i", videoPath, "-i", audioPath, "-c:v", "copy"];
741
-
742
- if (isWebm) {
743
- args.push("-c:a", "libopus", "-b:a", "128k");
744
- } else if (isMov) {
745
- if (shouldCopyAudio) {
746
- args.push("-c:a", "copy");
747
- } else {
748
- args.push("-c:a", "aac", "-b:a", "192k");
749
- }
750
- } else {
751
- // processCompositionAudio (audioMixer.ts) performs the AAC encode and
752
- // owns the single encoder-priming interval. Copying that sidecar into
753
- // MP4 preserves the correct priming metadata; re-encoding it during mux
754
- // creates another priming interval that ffmpeg writes as an empty leading
755
- // video edit list, which QuickTime/Safari render as a black first frame.
756
- if (shouldCopyAudio) {
757
- args.push("-c:a", "copy", "-movflags", "+faststart");
758
- } else {
759
- args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
760
- }
761
- }
762
- // PTS bases can diverge during mux and reintroduce negative DTS. See
763
- // buildEncoderArgs for the full reasoning on why that breaks playback.
764
- args.push("-avoid_negative_ts", "make_zero");
765
- if (fps !== undefined) {
766
- // Set the exact output framerate so the muxer doesn't PTS-average a
767
- // fractional rational like `360000/12001` instead of `30/1` into the
768
- // output container metadata. `-c:v copy` is retained; no re-encode.
769
- args.push("-r", fpsToFfmpegArg(fps));
770
- }
771
- args.push("-shortest", "-y", outputPath);
772
-
773
- const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
774
- const result = await runFfmpeg(args, { signal, timeout: processTimeout });
775
-
776
- if (signal?.aborted) {
777
- return {
778
- success: false,
779
- outputPath,
780
- durationMs: result.durationMs,
781
- error: "FFmpeg mux cancelled",
782
- };
783
- }
784
- return {
785
- success: result.success,
786
- outputPath,
787
- durationMs: result.durationMs,
788
- error: !result.success ? formatFfmpegError(result.exitCode, result.stderr) : undefined,
789
- };
790
- }
791
-
792
- export async function applyFaststart(
793
- inputPath: string,
794
- outputPath: string,
795
- signal?: AbortSignal,
796
- config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
797
- fps?: Fps,
798
- ): Promise<MuxResult> {
799
- // faststart is MP4-only (moves moov atom to file start for streaming).
800
- // WebM and MOV don't need it — skip the re-mux.
801
- if (outputPath.endsWith(".webm") || outputPath.endsWith(".mov")) {
802
- if (inputPath !== outputPath) copyFileSync(inputPath, outputPath);
803
- return { success: true, outputPath, durationMs: 0 };
804
- }
805
- const args = ["-i", inputPath, "-c", "copy", "-movflags", "+faststart"];
806
- if (fps !== undefined) {
807
- // Set the exact output framerate so the final remux doesn't PTS-average
808
- // a fractional rational like `360000/12001` instead of `30/1` into the
809
- // output container metadata. `-c copy` is retained; no re-encode.
810
- args.push("-r", fpsToFfmpegArg(fps));
811
- }
812
- args.push("-y", outputPath);
813
-
814
- const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
815
- const result = await runFfmpeg(args, { signal, timeout: processTimeout });
816
-
817
- if (signal?.aborted) {
818
- return {
819
- success: false,
820
- outputPath,
821
- durationMs: result.durationMs,
822
- error: "FFmpeg faststart cancelled",
823
- };
824
- }
825
- return {
826
- success: result.success,
827
- outputPath,
828
- durationMs: result.durationMs,
829
- error: !result.success ? formatFfmpegError(result.exitCode, result.stderr) : undefined,
830
- };
831
- }