avbridge 1.0.0

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 (103) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/dist/avi-M5B4SHRM.cjs +164 -0
  5. package/dist/avi-M5B4SHRM.cjs.map +1 -0
  6. package/dist/avi-POCGZ4JX.js +162 -0
  7. package/dist/avi-POCGZ4JX.js.map +1 -0
  8. package/dist/chunk-5ISVAODK.js +80 -0
  9. package/dist/chunk-5ISVAODK.js.map +1 -0
  10. package/dist/chunk-F7YS2XOA.cjs +2966 -0
  11. package/dist/chunk-F7YS2XOA.cjs.map +1 -0
  12. package/dist/chunk-FKM7QBZU.js +2957 -0
  13. package/dist/chunk-FKM7QBZU.js.map +1 -0
  14. package/dist/chunk-J5MCMN3S.js +27 -0
  15. package/dist/chunk-J5MCMN3S.js.map +1 -0
  16. package/dist/chunk-L4NPOJ36.cjs +180 -0
  17. package/dist/chunk-L4NPOJ36.cjs.map +1 -0
  18. package/dist/chunk-NZU7W256.cjs +29 -0
  19. package/dist/chunk-NZU7W256.cjs.map +1 -0
  20. package/dist/chunk-PQTZS7OA.js +147 -0
  21. package/dist/chunk-PQTZS7OA.js.map +1 -0
  22. package/dist/chunk-WD2ZNQA7.js +177 -0
  23. package/dist/chunk-WD2ZNQA7.js.map +1 -0
  24. package/dist/chunk-Y5FYF5KG.cjs +153 -0
  25. package/dist/chunk-Y5FYF5KG.cjs.map +1 -0
  26. package/dist/chunk-Z2FJ5TJC.cjs +82 -0
  27. package/dist/chunk-Z2FJ5TJC.cjs.map +1 -0
  28. package/dist/element.cjs +433 -0
  29. package/dist/element.cjs.map +1 -0
  30. package/dist/element.d.cts +158 -0
  31. package/dist/element.d.ts +158 -0
  32. package/dist/element.js +431 -0
  33. package/dist/element.js.map +1 -0
  34. package/dist/index.cjs +576 -0
  35. package/dist/index.cjs.map +1 -0
  36. package/dist/index.d.cts +80 -0
  37. package/dist/index.d.ts +80 -0
  38. package/dist/index.js +554 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/libav-http-reader-FPYDBMYK.cjs +16 -0
  41. package/dist/libav-http-reader-FPYDBMYK.cjs.map +1 -0
  42. package/dist/libav-http-reader-NQJVY273.js +3 -0
  43. package/dist/libav-http-reader-NQJVY273.js.map +1 -0
  44. package/dist/libav-import-2JURFHEW.js +8 -0
  45. package/dist/libav-import-2JURFHEW.js.map +1 -0
  46. package/dist/libav-import-GST2AMPL.cjs +30 -0
  47. package/dist/libav-import-GST2AMPL.cjs.map +1 -0
  48. package/dist/libav-loader-KA2MAWLM.js +3 -0
  49. package/dist/libav-loader-KA2MAWLM.js.map +1 -0
  50. package/dist/libav-loader-ZHOERPHW.cjs +12 -0
  51. package/dist/libav-loader-ZHOERPHW.cjs.map +1 -0
  52. package/dist/player-BBwbCkdL.d.cts +365 -0
  53. package/dist/player-BBwbCkdL.d.ts +365 -0
  54. package/dist/source-SC6ZEQYR.cjs +28 -0
  55. package/dist/source-SC6ZEQYR.cjs.map +1 -0
  56. package/dist/source-ZFS4H7J3.js +3 -0
  57. package/dist/source-ZFS4H7J3.js.map +1 -0
  58. package/dist/variant-routing-GOHB2RZN.cjs +12 -0
  59. package/dist/variant-routing-GOHB2RZN.cjs.map +1 -0
  60. package/dist/variant-routing-JOBWXYKD.js +3 -0
  61. package/dist/variant-routing-JOBWXYKD.js.map +1 -0
  62. package/package.json +95 -0
  63. package/src/classify/index.ts +1 -0
  64. package/src/classify/rules.ts +214 -0
  65. package/src/convert/index.ts +2 -0
  66. package/src/convert/remux.ts +522 -0
  67. package/src/convert/transcode.ts +329 -0
  68. package/src/diagnostics.ts +99 -0
  69. package/src/element/avbridge-player.ts +576 -0
  70. package/src/element.ts +19 -0
  71. package/src/events.ts +71 -0
  72. package/src/index.ts +42 -0
  73. package/src/libav-stubs.d.ts +24 -0
  74. package/src/player.ts +455 -0
  75. package/src/plugins/builtin.ts +37 -0
  76. package/src/plugins/registry.ts +32 -0
  77. package/src/probe/avi.ts +242 -0
  78. package/src/probe/index.ts +59 -0
  79. package/src/probe/mediabunny.ts +194 -0
  80. package/src/strategies/fallback/audio-output.ts +293 -0
  81. package/src/strategies/fallback/clock.ts +7 -0
  82. package/src/strategies/fallback/decoder.ts +660 -0
  83. package/src/strategies/fallback/index.ts +170 -0
  84. package/src/strategies/fallback/libav-import.ts +27 -0
  85. package/src/strategies/fallback/libav-loader.ts +190 -0
  86. package/src/strategies/fallback/variant-routing.ts +43 -0
  87. package/src/strategies/fallback/video-renderer.ts +216 -0
  88. package/src/strategies/hybrid/decoder.ts +641 -0
  89. package/src/strategies/hybrid/index.ts +139 -0
  90. package/src/strategies/native.ts +107 -0
  91. package/src/strategies/remux/annexb.ts +112 -0
  92. package/src/strategies/remux/index.ts +79 -0
  93. package/src/strategies/remux/mse.ts +234 -0
  94. package/src/strategies/remux/pipeline.ts +254 -0
  95. package/src/subtitles/index.ts +91 -0
  96. package/src/subtitles/render.ts +62 -0
  97. package/src/subtitles/srt.ts +62 -0
  98. package/src/subtitles/vtt.ts +5 -0
  99. package/src/types-shim.d.ts +3 -0
  100. package/src/types.ts +360 -0
  101. package/src/util/codec-strings.ts +86 -0
  102. package/src/util/libav-http-reader.ts +315 -0
  103. package/src/util/source.ts +274 -0
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Standalone remux function: repackage media into a modern container without
3
+ * re-encoding. Input can be any format avbridge can probe; output is a
4
+ * finalized downloadable file (MP4, WebM, or MKV).
5
+ *
6
+ * Two internal paths:
7
+ * - **Path A** (mediabunny-readable containers): wraps mediabunny's Conversion
8
+ * class for MP4/MKV/WebM/OGG/MOV/WAV/MP3/FLAC/ADTS sources.
9
+ * - **Path B** (AVI/ASF/FLV): libav.js demux + mediabunny mux via manual
10
+ * packet pump. Lazy-loads libav.js — zero cost if unused.
11
+ */
12
+
13
+ import { probe } from "../probe/index.js";
14
+ import {
15
+ avbridgeVideoToMediabunny,
16
+ avbridgeAudioToMediabunny,
17
+ buildMediabunnySourceFromInput,
18
+ } from "../probe/mediabunny.js";
19
+ import { normalizeSource } from "../util/source.js";
20
+ import { prepareLibavInput, type LibavInputHandle } from "../util/libav-http-reader.js";
21
+ import type {
22
+ MediaInput,
23
+ MediaContext,
24
+ ConvertOptions,
25
+ ConvertResult,
26
+ OutputFormat,
27
+ } from "../types.js";
28
+
29
+ /** Containers mediabunny can read (and therefore use Conversion for). */
30
+ const MEDIABUNNY_CONTAINERS = new Set([
31
+ "mp4", "mov", "mkv", "webm", "ogg", "wav", "mp3", "flac", "adts",
32
+ ]);
33
+
34
+ /**
35
+ * Remux a media source into a modern container format without re-encoding.
36
+ *
37
+ * @throws When the source codecs cannot be remuxed (e.g. WMV3 — use `transcode()` instead).
38
+ * @throws When an AVI/ASF/FLV source is provided but libav.js is not installed.
39
+ */
40
+ export async function remux(
41
+ source: MediaInput,
42
+ options: ConvertOptions = {},
43
+ ): Promise<ConvertResult> {
44
+ const outputFormat = options.outputFormat ?? "mp4";
45
+ options.signal?.throwIfAborted();
46
+
47
+ // Probe the source
48
+ const ctx = await probe(source);
49
+ options.signal?.throwIfAborted();
50
+
51
+ // Validate remux eligibility: all codecs must map to mediabunny output codecs
52
+ validateRemuxEligibility(ctx, options.strict ?? false);
53
+
54
+ // Route to the appropriate path
55
+ if (MEDIABUNNY_CONTAINERS.has(ctx.container)) {
56
+ return remuxViaMediAbunny(ctx, outputFormat, options);
57
+ }
58
+ return remuxViaLibav(ctx, outputFormat, options);
59
+ }
60
+
61
+ // ── Eligibility validation ──────────────────────────────────────────────────
62
+
63
+ /** @internal Exported for testing. */
64
+ export function validateRemuxEligibility(ctx: MediaContext, strict: boolean): void {
65
+ const video = ctx.videoTracks[0];
66
+ const audio = ctx.audioTracks[0];
67
+
68
+ if (video) {
69
+ const mbCodec = avbridgeVideoToMediabunny(video.codec);
70
+ if (!mbCodec) {
71
+ throw new Error(
72
+ `Cannot remux: video codec "${video.codec}" is not supported for remuxing. ` +
73
+ `Use transcode() to re-encode to a modern codec.`,
74
+ );
75
+ }
76
+ }
77
+
78
+ if (audio) {
79
+ const mbCodec = avbridgeAudioToMediabunny(audio.codec);
80
+ if (!mbCodec) {
81
+ throw new Error(
82
+ `Cannot remux: audio codec "${audio.codec}" is not supported for remuxing. ` +
83
+ `Use transcode() to re-encode to a modern codec.`,
84
+ );
85
+ }
86
+ }
87
+
88
+ if (strict && video?.codec === "h264" && audio?.codec === "mp3") {
89
+ throw new Error(
90
+ `Cannot remux in strict mode: H.264 + MP3 is a best-effort combination ` +
91
+ `that may produce playback issues in some browsers. ` +
92
+ `Set strict: false to allow, or use transcode() to re-encode audio to AAC.`,
93
+ );
94
+ }
95
+
96
+ if (!video && !audio) {
97
+ throw new Error("Cannot remux: source has no video or audio tracks.");
98
+ }
99
+ }
100
+
101
+ // ── Path A: mediabunny Conversion ───────────────────────────────────────────
102
+
103
+ async function remuxViaMediAbunny(
104
+ ctx: MediaContext,
105
+ outputFormat: OutputFormat,
106
+ options: ConvertOptions,
107
+ ): Promise<ConvertResult> {
108
+ const mb = await import("mediabunny");
109
+
110
+ const input = new mb.Input({
111
+ source: await buildMediabunnySourceFromInput(mb, ctx.source),
112
+ formats: mb.ALL_FORMATS,
113
+ });
114
+
115
+ const target = new mb.BufferTarget();
116
+ const output = new mb.Output({
117
+ format: createOutputFormat(mb, outputFormat),
118
+ target,
119
+ });
120
+
121
+ const conversion = await mb.Conversion.init({
122
+ input,
123
+ output,
124
+ showWarnings: false,
125
+ });
126
+
127
+ if (!conversion.isValid) {
128
+ const reasons = conversion.discardedTracks
129
+ .map((d) => `${d.track.type} track discarded: ${d.reason}`)
130
+ .join("; ");
131
+ throw new Error(`Cannot remux: mediabunny rejected the conversion. ${reasons}`);
132
+ }
133
+
134
+ // Wire progress
135
+ if (options.onProgress) {
136
+ const onProgress = options.onProgress;
137
+ conversion.onProgress = (p) => {
138
+ onProgress({ percent: p * 100, bytesWritten: 0 });
139
+ };
140
+ }
141
+
142
+ // Wire cancellation
143
+ let abortHandler: (() => void) | undefined;
144
+ if (options.signal) {
145
+ options.signal.throwIfAborted();
146
+ abortHandler = () => void conversion.cancel();
147
+ options.signal.addEventListener("abort", abortHandler, { once: true });
148
+ }
149
+
150
+ try {
151
+ await conversion.execute();
152
+ } finally {
153
+ if (abortHandler && options.signal) {
154
+ options.signal.removeEventListener("abort", abortHandler);
155
+ }
156
+ }
157
+
158
+ if (!target.buffer) {
159
+ throw new Error("Remux failed: mediabunny produced no output buffer.");
160
+ }
161
+
162
+ const mimeType = mimeForFormat(outputFormat);
163
+ const blob = new Blob([target.buffer], { type: mimeType });
164
+ const filename = generateFilename(ctx.name, outputFormat);
165
+
166
+ options.onProgress?.({ percent: 100, bytesWritten: blob.size });
167
+
168
+ return {
169
+ blob,
170
+ mimeType,
171
+ container: outputFormat,
172
+ videoCodec: ctx.videoTracks[0]?.codec,
173
+ audioCodec: ctx.audioTracks[0]?.codec,
174
+ duration: ctx.duration,
175
+ filename,
176
+ };
177
+ }
178
+
179
+ // ── Path B: libav.js demux + mediabunny mux (AVI/ASF/FLV) ──────────────────
180
+
181
+ async function remuxViaLibav(
182
+ ctx: MediaContext,
183
+ outputFormat: OutputFormat,
184
+ options: ConvertOptions,
185
+ ): Promise<ConvertResult> {
186
+ // Lazy-load libav
187
+ let loadLibav: typeof import("../strategies/fallback/libav-loader.js").loadLibav;
188
+ let pickLibavVariant: typeof import("../strategies/fallback/variant-routing.js").pickLibavVariant;
189
+ try {
190
+ const loader = await import("../strategies/fallback/libav-loader.js");
191
+ const routing = await import("../strategies/fallback/variant-routing.js");
192
+ loadLibav = loader.loadLibav;
193
+ pickLibavVariant = routing.pickLibavVariant;
194
+ } catch {
195
+ throw new Error(
196
+ `Cannot remux ${ctx.container.toUpperCase()} source: libav.js is not available. ` +
197
+ `Install @libav.js/variant-webcodecs and libavjs-webcodecs-bridge, ` +
198
+ `or build the custom avbridge variant with scripts/build-libav.sh.`,
199
+ );
200
+ }
201
+
202
+ const variant = pickLibavVariant(ctx);
203
+ const libav = await loadLibav(variant) as unknown as LibavRuntime;
204
+
205
+ // For Blob/File inputs, libav reads from an in-memory readahead file.
206
+ // For URL inputs, libav demuxes via HTTP Range requests through the
207
+ // block reader — no full download.
208
+ const normalized = await normalizeSource(ctx.source);
209
+ const filename = ctx.name ?? `remux-input-${Date.now()}`;
210
+ const handle: LibavInputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], filename, normalized);
211
+
212
+ try {
213
+ return await doLibavRemux(libav, filename, ctx, outputFormat, options);
214
+ } finally {
215
+ await handle.detach().catch(() => {});
216
+ }
217
+ }
218
+
219
+ async function doLibavRemux(
220
+ libav: LibavRuntime,
221
+ filename: string,
222
+ ctx: MediaContext,
223
+ outputFormat: OutputFormat,
224
+ options: ConvertOptions,
225
+ ): Promise<ConvertResult> {
226
+ const mb = await import("mediabunny");
227
+
228
+ const readPkt = await libav.av_packet_alloc();
229
+ const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(filename);
230
+ const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
231
+ const audioStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
232
+
233
+ // Map codecs to mediabunny output
234
+ const videoTrackInfo = ctx.videoTracks[0];
235
+ const audioTrackInfo = ctx.audioTracks[0];
236
+ const mbVideoCodec = videoTrackInfo ? avbridgeVideoToMediabunny(videoTrackInfo.codec) : null;
237
+ const mbAudioCodec = audioTrackInfo ? avbridgeAudioToMediabunny(audioTrackInfo.codec) : null;
238
+
239
+ // Set up mediabunny output with BufferTarget
240
+ const target = new mb.BufferTarget();
241
+ const output = new mb.Output({
242
+ format: createOutputFormat(mb, outputFormat),
243
+ target,
244
+ });
245
+
246
+ let videoSource: InstanceType<typeof mb.EncodedVideoPacketSource> | null = null;
247
+ let audioSource: InstanceType<typeof mb.EncodedAudioPacketSource> | null = null;
248
+
249
+ if (mbVideoCodec && videoStream) {
250
+ videoSource = new mb.EncodedVideoPacketSource(mbVideoCodec);
251
+ output.addVideoTrack(videoSource);
252
+ }
253
+ if (mbAudioCodec && audioStream) {
254
+ type AudioCodecArg = ConstructorParameters<typeof mb.EncodedAudioPacketSource>[0];
255
+ audioSource = new mb.EncodedAudioPacketSource(mbAudioCodec as AudioCodecArg);
256
+ output.addAudioTrack(audioSource);
257
+ }
258
+
259
+ await output.start();
260
+
261
+ // Timestamp tracking for synthetic timestamps
262
+ const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
263
+ const videoFrameStepUs = Math.max(1, Math.round(1_000_000 / videoFps));
264
+ let syntheticVideoUs = 0;
265
+ let syntheticAudioUs = 0;
266
+
267
+ const videoTimeBase: [number, number] | undefined =
268
+ videoStream?.time_base_num && videoStream?.time_base_den
269
+ ? [videoStream.time_base_num, videoStream.time_base_den]
270
+ : undefined;
271
+ const audioTimeBase: [number, number] | undefined =
272
+ audioStream?.time_base_num && audioStream?.time_base_den
273
+ ? [audioStream.time_base_num, audioStream.time_base_den]
274
+ : undefined;
275
+
276
+ let totalPackets = 0;
277
+ const durationUs = ctx.duration ? ctx.duration * 1_000_000 : 0;
278
+ let firstVideoMeta = true;
279
+ let firstAudioMeta = true;
280
+
281
+ // Pump loop: read packets from libav, feed to mediabunny output
282
+ while (true) {
283
+ options.signal?.throwIfAborted();
284
+
285
+ let readErr: number;
286
+ let packets: Record<number, LibavPacket[]>;
287
+ try {
288
+ [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
289
+ limit: 64 * 1024,
290
+ });
291
+ } catch (err) {
292
+ throw new Error(`libav demux failed: ${(err as Error).message}`);
293
+ }
294
+
295
+ const videoPackets = videoStream ? packets[videoStream.index] ?? [] : [];
296
+ const audioPackets = audioStream ? packets[audioStream.index] ?? [] : [];
297
+
298
+ // Feed video packets
299
+ if (videoSource) {
300
+ for (const pkt of videoPackets) {
301
+ sanitizePacketTimestamp(pkt, () => {
302
+ const ts = syntheticVideoUs;
303
+ syntheticVideoUs += videoFrameStepUs;
304
+ return ts;
305
+ }, videoTimeBase);
306
+
307
+ const mbPacket = libavPacketToMediAbunny(mb, pkt);
308
+ await videoSource.add(
309
+ mbPacket,
310
+ firstVideoMeta ? { decoderConfig: buildVideoDecoderConfig(videoTrackInfo!) } : undefined,
311
+ );
312
+ firstVideoMeta = false;
313
+ }
314
+ }
315
+
316
+ // Feed audio packets
317
+ if (audioSource) {
318
+ for (const pkt of audioPackets) {
319
+ sanitizePacketTimestamp(pkt, () => {
320
+ const ts = syntheticAudioUs;
321
+ const sampleRate = audioTrackInfo?.sampleRate ?? 44100;
322
+ syntheticAudioUs += Math.round(1024 * 1_000_000 / sampleRate);
323
+ return ts;
324
+ }, audioTimeBase);
325
+
326
+ const mbPacket = libavPacketToMediAbunny(mb, pkt);
327
+ await audioSource.add(
328
+ mbPacket,
329
+ firstAudioMeta ? { decoderConfig: buildAudioDecoderConfig(audioTrackInfo!) } : undefined,
330
+ );
331
+ firstAudioMeta = false;
332
+ }
333
+ }
334
+
335
+ totalPackets += videoPackets.length + audioPackets.length;
336
+
337
+ // Report progress
338
+ if (options.onProgress && durationUs > 0) {
339
+ const lastVideoTs = videoPackets.length > 0 ? videoPackets[videoPackets.length - 1].pts ?? 0 : 0;
340
+ const lastAudioTs = audioPackets.length > 0 ? audioPackets[audioPackets.length - 1].pts ?? 0 : 0;
341
+ const currentUs = Math.max(lastVideoTs, lastAudioTs);
342
+ const percent = Math.min(99, (currentUs / durationUs) * 100);
343
+ options.onProgress({ percent, bytesWritten: 0 });
344
+ }
345
+
346
+ if (readErr === libav.AVERROR_EOF) break;
347
+ if (readErr && readErr !== 0 && readErr !== -libav.EAGAIN) {
348
+ console.warn("[avbridge] remux: ff_read_frame_multi returned", readErr);
349
+ break;
350
+ }
351
+ }
352
+
353
+ await output.finalize();
354
+
355
+ // Cleanup libav resources
356
+ try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
357
+ try { await libav.avformat_close_input_js(fmt_ctx); } catch { /* ignore */ }
358
+
359
+ if (!target.buffer) {
360
+ throw new Error("Remux failed: mediabunny produced no output buffer.");
361
+ }
362
+
363
+ const mimeType = mimeForFormat(outputFormat);
364
+ const blob = new Blob([target.buffer], { type: mimeType });
365
+ const outputFilename = generateFilename(ctx.name, outputFormat);
366
+
367
+ options.onProgress?.({ percent: 100, bytesWritten: blob.size });
368
+
369
+ return {
370
+ blob,
371
+ mimeType,
372
+ container: outputFormat,
373
+ videoCodec: videoTrackInfo?.codec,
374
+ audioCodec: audioTrackInfo?.codec,
375
+ duration: ctx.duration,
376
+ filename: outputFilename,
377
+ };
378
+ }
379
+
380
+ // ── Packet timestamp sanitizer (from hybrid/decoder.ts) ─────────────────────
381
+
382
+ function sanitizePacketTimestamp(
383
+ pkt: LibavPacket,
384
+ nextUs: () => number,
385
+ fallbackTimeBase?: [number, number],
386
+ ): void {
387
+ const lo = pkt.pts ?? 0;
388
+ const hi = pkt.ptshi ?? 0;
389
+ const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
390
+ if (isInvalid) {
391
+ const us = nextUs();
392
+ pkt.pts = us;
393
+ pkt.ptshi = 0;
394
+ pkt.time_base_num = 1;
395
+ pkt.time_base_den = 1_000_000;
396
+ return;
397
+ }
398
+ const tb = fallbackTimeBase ?? [1, 1_000_000];
399
+ const pts64 = hi * 0x100000000 + lo;
400
+ const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
401
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
402
+ pkt.pts = us;
403
+ pkt.ptshi = us < 0 ? -1 : 0;
404
+ pkt.time_base_num = 1;
405
+ pkt.time_base_den = 1_000_000;
406
+ return;
407
+ }
408
+ const fallback = nextUs();
409
+ pkt.pts = fallback;
410
+ pkt.ptshi = 0;
411
+ pkt.time_base_num = 1;
412
+ pkt.time_base_den = 1_000_000;
413
+ }
414
+
415
+ // ── Helpers ─────────────────────────────────────────────────────────────────
416
+
417
+ /** @internal Exported for use by transcode(). */
418
+ export function createOutputFormat(
419
+ mb: typeof import("mediabunny"),
420
+ format: OutputFormat,
421
+ ) {
422
+ switch (format) {
423
+ case "mp4": return new mb.Mp4OutputFormat({ fastStart: "in-memory" });
424
+ case "webm": return new mb.WebMOutputFormat();
425
+ case "mkv": return new mb.MkvOutputFormat();
426
+ default: return new mb.Mp4OutputFormat({ fastStart: "in-memory" });
427
+ }
428
+ }
429
+
430
+ /** @internal Exported for testing. */
431
+ export function mimeForFormat(format: OutputFormat): string {
432
+ switch (format) {
433
+ case "mp4": return "video/mp4";
434
+ case "webm": return "video/webm";
435
+ case "mkv": return "video/x-matroska";
436
+ default: return "application/octet-stream";
437
+ }
438
+ }
439
+
440
+ /** @internal Exported for testing. */
441
+ export function generateFilename(originalName: string | undefined, format: OutputFormat): string {
442
+ const ext = format === "mkv" ? "mkv" : format;
443
+ if (!originalName) return `output.${ext}`;
444
+ const base = originalName.replace(/\.[^.]+$/, "");
445
+ return `${base}.${ext}`;
446
+ }
447
+
448
+ /** Sequence counter for decode-order numbering in mediabunny packets. */
449
+ let _seqCounter = 0;
450
+
451
+ /**
452
+ * Convert a libav packet to a mediabunny EncodedPacket.
453
+ * Timestamps from libav are in microseconds (after sanitization); mediabunny wants seconds.
454
+ */
455
+ function libavPacketToMediAbunny(
456
+ mb: typeof import("mediabunny"),
457
+ pkt: LibavPacket,
458
+ ): InstanceType<typeof mb.EncodedPacket> {
459
+ const KEY_FRAME_FLAG = 0x0001;
460
+ const timestampSec = (pkt.pts ?? 0) / 1_000_000;
461
+ const durationSec = (pkt.duration ?? 0) / 1_000_000;
462
+ const type = (pkt.flags & KEY_FRAME_FLAG) ? "key" as const : "delta" as const;
463
+ return new mb.EncodedPacket(pkt.data, type, timestampSec, durationSec, _seqCounter++);
464
+ }
465
+
466
+ function buildVideoDecoderConfig(track: { codec: string; width: number; height: number; codecString?: string }) {
467
+ return {
468
+ codec: track.codecString ?? track.codec,
469
+ codedWidth: track.width,
470
+ codedHeight: track.height,
471
+ };
472
+ }
473
+
474
+ function buildAudioDecoderConfig(track: { codec: string; channels: number; sampleRate: number; codecString?: string }) {
475
+ return {
476
+ codec: track.codecString ?? track.codec,
477
+ numberOfChannels: track.channels,
478
+ sampleRate: track.sampleRate,
479
+ };
480
+ }
481
+
482
+ // ── Structural types ────────────────────────────────────────────────────────
483
+
484
+ interface LibavPacket {
485
+ data: Uint8Array;
486
+ pts: number;
487
+ ptshi?: number;
488
+ duration?: number;
489
+ durationhi?: number;
490
+ flags: number;
491
+ stream_index: number;
492
+ time_base_num?: number;
493
+ time_base_den?: number;
494
+ }
495
+
496
+ interface LibavStream {
497
+ index: number;
498
+ codec_type: number;
499
+ codec_id: number;
500
+ codecpar: number;
501
+ time_base_num?: number;
502
+ time_base_den?: number;
503
+ }
504
+
505
+ interface LibavRuntime {
506
+ AVMEDIA_TYPE_VIDEO: number;
507
+ AVMEDIA_TYPE_AUDIO: number;
508
+ AVERROR_EOF: number;
509
+ EAGAIN: number;
510
+
511
+ mkreadaheadfile(name: string, blob: Blob): Promise<void>;
512
+ unlinkreadaheadfile(name: string): Promise<void>;
513
+ ff_init_demuxer_file(name: string): Promise<[number, LibavStream[]]>;
514
+ ff_read_frame_multi(
515
+ fmt_ctx: number,
516
+ pkt: number,
517
+ opts?: { limit?: number },
518
+ ): Promise<[number, Record<number, LibavPacket[]>]>;
519
+ av_packet_alloc(): Promise<number>;
520
+ av_packet_free?(pkt: number): Promise<void>;
521
+ avformat_close_input_js(ctx: number): Promise<void>;
522
+ }