@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,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
|
-
}
|