avbridge 2.2.1 → 2.5.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 (165) hide show
  1. package/CHANGELOG.md +153 -1
  2. package/NOTICE.md +2 -2
  3. package/README.md +2 -3
  4. package/THIRD_PARTY_LICENSES.md +2 -2
  5. package/dist/avi-2JPBSHGA.js +183 -0
  6. package/dist/avi-2JPBSHGA.js.map +1 -0
  7. package/dist/avi-F6WZJK5T.cjs +185 -0
  8. package/dist/avi-F6WZJK5T.cjs.map +1 -0
  9. package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
  10. package/dist/avi-NJXAXUXK.js.map +1 -0
  11. package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
  12. package/dist/avi-W6L3BTWU.cjs.map +1 -0
  13. package/dist/chunk-2IJ66NTD.cjs +212 -0
  14. package/dist/chunk-2IJ66NTD.cjs.map +1 -0
  15. package/dist/{chunk-ILKDNBSE.js → chunk-2XW2O3YI.cjs} +55 -10
  16. package/dist/chunk-2XW2O3YI.cjs.map +1 -0
  17. package/dist/chunk-5KVLE6YI.js +167 -0
  18. package/dist/chunk-5KVLE6YI.js.map +1 -0
  19. package/dist/chunk-5YAWWKA3.js +18 -0
  20. package/dist/chunk-5YAWWKA3.js.map +1 -0
  21. package/dist/chunk-CPJLFFCC.js +189 -0
  22. package/dist/chunk-CPJLFFCC.js.map +1 -0
  23. package/dist/chunk-CPZ7PXAM.cjs +240 -0
  24. package/dist/chunk-CPZ7PXAM.cjs.map +1 -0
  25. package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
  26. package/dist/chunk-DCSOQH2N.js.map +1 -0
  27. package/dist/{chunk-HZLQNKFN.cjs → chunk-E76AMWI4.js} +40 -15
  28. package/dist/chunk-E76AMWI4.js.map +1 -0
  29. package/dist/chunk-F3LQJKXK.cjs +20 -0
  30. package/dist/chunk-F3LQJKXK.cjs.map +1 -0
  31. package/dist/chunk-IAYKFGFG.js +200 -0
  32. package/dist/chunk-IAYKFGFG.js.map +1 -0
  33. package/dist/{chunk-DMWARSEF.js → chunk-KY2GPCT7.js} +788 -697
  34. package/dist/chunk-KY2GPCT7.js.map +1 -0
  35. package/dist/chunk-LUFA47FP.js +19 -0
  36. package/dist/chunk-LUFA47FP.js.map +1 -0
  37. package/dist/chunk-NNVOHKXJ.cjs +204 -0
  38. package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
  39. package/dist/chunk-Q2VUO52Z.cjs +374 -0
  40. package/dist/chunk-Q2VUO52Z.cjs.map +1 -0
  41. package/dist/chunk-QDJLQR53.cjs +22 -0
  42. package/dist/chunk-QDJLQR53.cjs.map +1 -0
  43. package/dist/chunk-S4WAZC2T.cjs +173 -0
  44. package/dist/chunk-S4WAZC2T.cjs.map +1 -0
  45. package/dist/chunk-SMH6IOP2.js +368 -0
  46. package/dist/chunk-SMH6IOP2.js.map +1 -0
  47. package/dist/chunk-SR3MPV4D.js +237 -0
  48. package/dist/chunk-SR3MPV4D.js.map +1 -0
  49. package/dist/{chunk-UF2N5L63.cjs → chunk-TBW26OPP.cjs} +800 -710
  50. package/dist/chunk-TBW26OPP.cjs.map +1 -0
  51. package/dist/chunk-X2K3GIWE.js +235 -0
  52. package/dist/chunk-X2K3GIWE.js.map +1 -0
  53. package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
  54. package/dist/chunk-Z33SBWL5.cjs.map +1 -0
  55. package/dist/chunk-ZCUXHW55.cjs +242 -0
  56. package/dist/chunk-ZCUXHW55.cjs.map +1 -0
  57. package/dist/element-browser.js +1282 -503
  58. package/dist/element-browser.js.map +1 -1
  59. package/dist/element.cjs +59 -5
  60. package/dist/element.cjs.map +1 -1
  61. package/dist/element.d.cts +39 -1
  62. package/dist/element.d.ts +39 -1
  63. package/dist/element.js +58 -4
  64. package/dist/element.js.map +1 -1
  65. package/dist/index.cjs +605 -327
  66. package/dist/index.cjs.map +1 -1
  67. package/dist/index.d.cts +48 -4
  68. package/dist/index.d.ts +48 -4
  69. package/dist/index.js +528 -319
  70. package/dist/index.js.map +1 -1
  71. package/dist/libav-demux-H2GS46GH.cjs +27 -0
  72. package/dist/{libav-http-reader-NQJVY273.js.map → libav-demux-H2GS46GH.cjs.map} +1 -1
  73. package/dist/libav-demux-OWZ4T2YW.js +6 -0
  74. package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-demux-OWZ4T2YW.js.map} +1 -1
  75. package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
  76. package/dist/libav-http-reader-AZLE7YFS.cjs.map +1 -0
  77. package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
  78. package/dist/libav-http-reader-WXG3Z7AI.js.map +1 -0
  79. package/dist/{libav-import-GST2AMPL.cjs → libav-import-2ZVKV2E7.cjs} +2 -2
  80. package/dist/{libav-import-GST2AMPL.cjs.map → libav-import-2ZVKV2E7.cjs.map} +1 -1
  81. package/dist/{libav-import-2JURFHEW.js → libav-import-6MGLCXVQ.js} +2 -2
  82. package/dist/{libav-import-2JURFHEW.js.map → libav-import-6MGLCXVQ.js.map} +1 -1
  83. package/dist/{player-U2NPmFvA.d.cts → player-B6WB74RD.d.cts} +62 -3
  84. package/dist/{player-U2NPmFvA.d.ts → player-B6WB74RD.d.ts} +62 -3
  85. package/dist/player.cjs +5631 -0
  86. package/dist/player.cjs.map +1 -0
  87. package/dist/player.d.cts +699 -0
  88. package/dist/player.d.ts +699 -0
  89. package/dist/player.js +5629 -0
  90. package/dist/player.js.map +1 -0
  91. package/dist/remux-OBSMIENG.cjs +35 -0
  92. package/dist/remux-OBSMIENG.cjs.map +1 -0
  93. package/dist/remux-WBYIZBBX.js +10 -0
  94. package/dist/remux-WBYIZBBX.js.map +1 -0
  95. package/dist/source-4TZ6KMNV.js +4 -0
  96. package/dist/{source-FFZ7TW2B.js.map → source-4TZ6KMNV.js.map} +1 -1
  97. package/dist/source-7YLO6E7X.cjs +29 -0
  98. package/dist/{source-CN43EI7Z.cjs.map → source-7YLO6E7X.cjs.map} +1 -1
  99. package/dist/source-MTX5ELUZ.js +4 -0
  100. package/dist/source-MTX5ELUZ.js.map +1 -0
  101. package/dist/source-VFLXLOCN.cjs +29 -0
  102. package/dist/source-VFLXLOCN.cjs.map +1 -0
  103. package/dist/subtitles-4T74JRGT.js +4 -0
  104. package/dist/subtitles-4T74JRGT.js.map +1 -0
  105. package/dist/subtitles-QUH4LPI4.cjs +29 -0
  106. package/dist/subtitles-QUH4LPI4.cjs.map +1 -0
  107. package/dist/variant-routing-434STYAB.js +3 -0
  108. package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
  109. package/dist/variant-routing-HONNAA6R.cjs +12 -0
  110. package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
  111. package/package.json +9 -1
  112. package/src/classify/rules.ts +27 -5
  113. package/src/convert/remux.ts +9 -35
  114. package/src/convert/transcode-libav.ts +691 -0
  115. package/src/convert/transcode.ts +53 -12
  116. package/src/element/avbridge-player.ts +861 -0
  117. package/src/element/avbridge-video.ts +54 -0
  118. package/src/element/player-icons.ts +25 -0
  119. package/src/element/player-styles.ts +472 -0
  120. package/src/errors.ts +53 -0
  121. package/src/index.ts +23 -0
  122. package/src/player-element.ts +18 -0
  123. package/src/player.ts +118 -27
  124. package/src/plugins/builtin.ts +2 -2
  125. package/src/probe/avi.ts +4 -0
  126. package/src/probe/index.ts +40 -10
  127. package/src/strategies/fallback/audio-output.ts +31 -0
  128. package/src/strategies/fallback/decoder.ts +179 -175
  129. package/src/strategies/fallback/index.ts +48 -6
  130. package/src/strategies/fallback/libav-import.ts +9 -1
  131. package/src/strategies/fallback/variant-routing.ts +7 -13
  132. package/src/strategies/fallback/video-renderer.ts +231 -32
  133. package/src/strategies/hybrid/decoder.ts +219 -200
  134. package/src/strategies/hybrid/index.ts +48 -7
  135. package/src/strategies/native.ts +6 -3
  136. package/src/strategies/remux/index.ts +14 -2
  137. package/src/strategies/remux/mse.ts +12 -2
  138. package/src/strategies/remux/pipeline.ts +72 -12
  139. package/src/subtitles/index.ts +7 -3
  140. package/src/subtitles/render.ts +8 -0
  141. package/src/types.ts +53 -1
  142. package/src/util/libav-demux.ts +405 -0
  143. package/src/util/libav-http-reader.ts +5 -1
  144. package/src/util/source.ts +28 -8
  145. package/src/util/transport.ts +26 -0
  146. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  147. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  148. package/dist/avi-6SJLWIWW.cjs.map +0 -1
  149. package/dist/avi-GCGM7OJI.js.map +0 -1
  150. package/dist/chunk-DMWARSEF.js.map +0 -1
  151. package/dist/chunk-HZLQNKFN.cjs.map +0 -1
  152. package/dist/chunk-ILKDNBSE.js.map +0 -1
  153. package/dist/chunk-J5MCMN3S.js +0 -27
  154. package/dist/chunk-J5MCMN3S.js.map +0 -1
  155. package/dist/chunk-L4NPOJ36.cjs.map +0 -1
  156. package/dist/chunk-NZU7W256.cjs +0 -29
  157. package/dist/chunk-NZU7W256.cjs.map +0 -1
  158. package/dist/chunk-UF2N5L63.cjs.map +0 -1
  159. package/dist/chunk-WD2ZNQA7.js.map +0 -1
  160. package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
  161. package/dist/libav-http-reader-NQJVY273.js +0 -3
  162. package/dist/source-CN43EI7Z.cjs +0 -28
  163. package/dist/source-FFZ7TW2B.js +0 -3
  164. package/dist/variant-routing-GOHB2RZN.cjs +0 -12
  165. package/dist/variant-routing-JOBWXYKD.js +0 -3
@@ -3,6 +3,8 @@
3
3
  * `SourceBuffer` with an append queue that respects `updateend` backpressure.
4
4
  */
5
5
 
6
+ import { AvbridgeError, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from "../../errors.js";
7
+
6
8
  export interface MseSinkOptions {
7
9
  mime: string;
8
10
  video: HTMLVideoElement;
@@ -23,10 +25,18 @@ export class MseSink {
23
25
 
24
26
  constructor(private readonly options: MseSinkOptions) {
25
27
  if (typeof MediaSource === "undefined") {
26
- throw new Error("MSE not supported in this environment");
28
+ throw new AvbridgeError(
29
+ ERR_MSE_NOT_SUPPORTED,
30
+ "MediaSource Extensions (MSE) are not supported in this environment.",
31
+ "MSE is required for the remux strategy. Use a browser that supports MSE, or try the fallback strategy.",
32
+ );
27
33
  }
28
34
  if (!MediaSource.isTypeSupported(options.mime)) {
29
- throw new Error(`MSE does not support MIME "${options.mime}" — cannot remux`);
35
+ throw new AvbridgeError(
36
+ ERR_MSE_CODEC_NOT_SUPPORTED,
37
+ `This browser's MSE does not support "${options.mime}".`,
38
+ "The codec combination can't be played via remux in this browser. The player will try the next strategy automatically.",
39
+ );
30
40
  }
31
41
 
32
42
  this.mediaSource = new MediaSource();
@@ -26,6 +26,11 @@ export interface RemuxPipeline {
26
26
  seek(time: number, autoPlay?: boolean): Promise<void>;
27
27
  /** Update the autoplay intent mid-flight — used when play() arrives after seek() but before the MseSink has been constructed. */
28
28
  setAutoPlay(autoPlay: boolean): void;
29
+ /**
30
+ * Switch the active audio track. Tears down the current Output, rebuilds
31
+ * with the new audio source, and resumes pumping at the given time.
32
+ */
33
+ setAudioTrack(trackId: number, timeSec: number, autoPlay: boolean): Promise<void>;
29
34
  destroy(): Promise<void>;
30
35
  stats(): Record<string, unknown>;
31
36
  }
@@ -37,7 +42,6 @@ export async function createRemuxPipeline(
37
42
  const mb = await import("mediabunny");
38
43
 
39
44
  const videoTrackInfo = ctx.videoTracks[0];
40
- const audioTrackInfo = ctx.audioTracks[0];
41
45
  if (!videoTrackInfo) throw new Error("remux: source has no video track");
42
46
 
43
47
  // Map avbridge codec names back to mediabunny's enum strings.
@@ -45,7 +49,6 @@ export async function createRemuxPipeline(
45
49
  if (!mbVideoCodec) {
46
50
  throw new Error(`remux: video codec "${videoTrackInfo.codec}" is not supported by mediabunny output`);
47
51
  }
48
- const mbAudioCodec = audioTrackInfo ? avbridgeAudioToMediabunny(audioTrackInfo.codec) : null;
49
52
 
50
53
  // Open the input. URL sources go through mediabunny's UrlSource so the
51
54
  // muxer streams via Range requests instead of buffering the whole file.
@@ -55,23 +58,52 @@ export async function createRemuxPipeline(
55
58
  });
56
59
  const allTracks = await input.getTracks();
57
60
  const inputVideo = allTracks.find((t) => t.id === videoTrackInfo.id && t.isVideoTrack());
58
- const inputAudio = audioTrackInfo
59
- ? allTracks.find((t) => t.id === audioTrackInfo.id && t.isAudioTrack())
60
- : null;
61
61
  if (!inputVideo || !inputVideo.isVideoTrack()) {
62
62
  throw new Error("remux: video track not found in input");
63
63
  }
64
- if (audioTrackInfo && (!inputAudio || !inputAudio.isAudioTrack())) {
65
- throw new Error("remux: audio track not found in input");
66
- }
67
64
 
68
- // Pull WebCodecs decoder configs once — used as `meta` on the first packet.
65
+ // Pull the video WebCodecs decoder config once — used as `meta` on the
66
+ // first packet after every Output rebuild.
69
67
  const videoConfig = await inputVideo.getDecoderConfig();
70
- const audioConfig = inputAudio && inputAudio.isAudioTrack() ? await inputAudio.getDecoderConfig() : null;
71
68
 
72
- // Packet sinks (input side) — reused across seeks.
69
+ // Packet sink for video — reused across seeks.
73
70
  const videoSink = new mb.EncodedPacketSink(inputVideo);
74
- const audioSink = inputAudio?.isAudioTrack() ? new mb.EncodedPacketSink(inputAudio) : null;
71
+
72
+ // Audio selection is mutable: setAudioTrack() can swap it. The selected
73
+ // audio derived state (input track, codec, sink, config) is rebuilt via
74
+ // rebuildAudio() whenever the id changes.
75
+ type InputAudioTrack = InstanceType<typeof mb.InputAudioTrack>;
76
+ type AudioDecCfg = Awaited<ReturnType<InputAudioTrack["getDecoderConfig"]>>;
77
+
78
+ let selectedAudioTrackId: number | null = ctx.audioTracks[0]?.id ?? null;
79
+ let inputAudio: InputAudioTrack | null = null;
80
+ let mbAudioCodec: ReturnType<typeof avbridgeAudioToMediabunny> | null = null;
81
+ let audioSink: InstanceType<typeof mb.EncodedPacketSink> | null = null;
82
+ let audioConfig: AudioDecCfg | null = null;
83
+
84
+ async function rebuildAudio(): Promise<void> {
85
+ if (selectedAudioTrackId == null) {
86
+ inputAudio = null;
87
+ mbAudioCodec = null;
88
+ audioSink = null;
89
+ audioConfig = null;
90
+ return;
91
+ }
92
+ const trackInfo = ctx.audioTracks.find((t) => t.id === selectedAudioTrackId);
93
+ if (!trackInfo) {
94
+ throw new Error(`remux: no audio track with id ${selectedAudioTrackId}`);
95
+ }
96
+ const newInput = allTracks.find((t) => t.id === trackInfo.id && t.isAudioTrack());
97
+ if (!newInput || !newInput.isAudioTrack()) {
98
+ throw new Error("remux: audio track not found in input");
99
+ }
100
+ inputAudio = newInput;
101
+ mbAudioCodec = avbridgeAudioToMediabunny(trackInfo.codec);
102
+ audioSink = new mb.EncodedPacketSink(newInput);
103
+ audioConfig = await newInput.getDecoderConfig();
104
+ }
105
+
106
+ await rebuildAudio();
75
107
 
76
108
  // MSE sink — created lazily on first output write, reused across seeks.
77
109
  let sink: MseSink | null = null;
@@ -254,6 +286,34 @@ export async function createRemuxPipeline(
254
286
  pendingAutoPlay = autoPlay;
255
287
  if (sink) sink.setPlayOnSeek(autoPlay);
256
288
  },
289
+ async setAudioTrack(trackId, time, autoPlay) {
290
+ if (selectedAudioTrackId === trackId) return;
291
+ if (!ctx.audioTracks.some((t) => t.id === trackId)) {
292
+ console.warn("[avbridge] remux: setAudioTrack — unknown track id", trackId);
293
+ return;
294
+ }
295
+ // Stop the current pump. The next pumpLoop() will build a fresh
296
+ // Output that uses the newly-selected audio source.
297
+ pumpToken++;
298
+ selectedAudioTrackId = trackId;
299
+ await rebuildAudio().catch((err) => {
300
+ console.warn("[avbridge] remux: rebuildAudio failed:", (err as Error).message);
301
+ });
302
+ // Tear down the existing MseSink — the audio codec may have changed,
303
+ // and the SourceBuffer's mime is fixed at construction time. The next
304
+ // createOutput will recompute `getMimeType()` and the write handler
305
+ // will lazily build a new sink.
306
+ if (sink) {
307
+ try { sink.destroy(); } catch { /* ignore */ }
308
+ sink = null;
309
+ }
310
+ pendingAutoPlay = autoPlay;
311
+ pendingStartTime = time;
312
+ pumpLoop(++pumpToken, time).catch((err) => {
313
+ // eslint-disable-next-line no-console
314
+ console.error("[avbridge] remux pipeline setAudioTrack pump failed:", err);
315
+ });
316
+ },
257
317
  async destroy() {
258
318
  destroyed = true;
259
319
  pumpToken++;
@@ -1,4 +1,5 @@
1
- import type { SubtitleTrackInfo } from "../types.js";
1
+ import type { SubtitleTrackInfo, TransportConfig } from "../types.js";
2
+ import { fetchWith } from "../util/transport.js";
2
3
  import { srtToVtt } from "./srt.js";
3
4
  import { isVtt } from "./vtt.js";
4
5
 
@@ -98,7 +99,10 @@ export async function attachSubtitleTracks(
98
99
  tracks: SubtitleTrackInfo[],
99
100
  bag?: SubtitleResourceBag,
100
101
  onError?: (err: Error, track: SubtitleTrackInfo) => void,
102
+ transport?: TransportConfig,
101
103
  ): Promise<void> {
104
+ const doFetch = fetchWith(transport);
105
+
102
106
  // Clear existing dynamically-attached tracks.
103
107
  for (const t of Array.from(video.querySelectorAll("track[data-avbridge]"))) {
104
108
  t.remove();
@@ -109,14 +113,14 @@ export async function attachSubtitleTracks(
109
113
  try {
110
114
  let url = t.sidecarUrl;
111
115
  if (t.format === "srt") {
112
- const res = await fetch(t.sidecarUrl);
116
+ const res = await doFetch(t.sidecarUrl, transport?.requestInit);
113
117
  const text = await res.text();
114
118
  const vtt = srtToVtt(text);
115
119
  const blob = new Blob([vtt], { type: "text/vtt" });
116
120
  url = bag ? bag.createObjectURL(blob) : URL.createObjectURL(blob);
117
121
  } else if (t.format === "vtt") {
118
122
  // Validate quickly so a malformed file fails loudly here.
119
- const res = await fetch(t.sidecarUrl);
123
+ const res = await doFetch(t.sidecarUrl, transport?.requestInit);
120
124
  const text = await res.text();
121
125
  if (!isVtt(text)) {
122
126
  // eslint-disable-next-line no-console
@@ -32,6 +32,14 @@ export class SubtitleOverlay {
32
32
  this.el.textContent = active?.text ?? "";
33
33
  }
34
34
 
35
+ /** Set the currently-displayed text directly (bypasses loadVtt/update). */
36
+ setText(text: string): void {
37
+ // Only touch the DOM if it actually changed — rAF tick runs 60Hz.
38
+ if (this.el.textContent !== text) {
39
+ this.el.textContent = text;
40
+ }
41
+ }
42
+
35
43
  destroy(): void {
36
44
  this.el.remove();
37
45
  this.cues = [];
package/src/types.ts CHANGED
@@ -69,6 +69,8 @@ export type AudioCodec =
69
69
  | "ra_288" // RealAudio 2.0 (28.8 kbps)
70
70
  | "sipr" // RealAudio Sipr (voice codec)
71
71
  | "atrac3" // Sony ATRAC3 (sometimes seen in .rm)
72
+ | "dts" // DTS (common in Blu-ray MKV rips)
73
+ | "truehd" // Dolby TrueHD (Blu-ray lossless)
72
74
  | (string & {});
73
75
 
74
76
  export interface VideoTrackInfo {
@@ -219,7 +221,7 @@ export interface Plugin {
219
221
  name: string;
220
222
  canHandle(context: MediaContext): boolean;
221
223
  /** Returns a session if it claims the context, otherwise throws. */
222
- execute(context: MediaContext, target: HTMLVideoElement): Promise<PlaybackSession>;
224
+ execute(context: MediaContext, target: HTMLVideoElement, transport?: TransportConfig): Promise<PlaybackSession>;
223
225
  }
224
226
 
225
227
  /** Player creation options. */
@@ -256,6 +258,38 @@ export interface CreatePlayerOptions {
256
258
  * strategy in the fallback chain on failure or stall.
257
259
  */
258
260
  autoEscalate?: boolean;
261
+ /**
262
+ * Behavior when the browser tab becomes hidden.
263
+ * - `"pause"` (default): auto-pause on hide, auto-resume on visible
264
+ * if the user had been playing. Matches YouTube, Netflix, and
265
+ * native media players. Prevents degraded playback from Chrome's
266
+ * background throttling of requestAnimationFrame and setTimeout.
267
+ * - `"continue"`: keep playing. Playback will degrade anyway due to
268
+ * browser throttling, but useful for consumers who want full
269
+ * control of visibility handling themselves.
270
+ */
271
+ backgroundBehavior?: "pause" | "continue";
272
+ /**
273
+ * Extra {@link RequestInit} merged into every HTTP request the player
274
+ * makes (probe Range requests, subtitle fetches, libav HTTP reader).
275
+ * Headers are merged, not overwritten — so you can add `Authorization`
276
+ * without losing the player's `Range` header.
277
+ */
278
+ requestInit?: RequestInit;
279
+ /**
280
+ * Custom fetch implementation. Defaults to `globalThis.fetch`. Useful
281
+ * for interceptors, logging, or environments without a global fetch.
282
+ */
283
+ fetchFn?: FetchFn;
284
+ }
285
+
286
+ /** Signature-compatible with `globalThis.fetch`. */
287
+ export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
288
+
289
+ /** Internal transport config bundle. Not part of the public API. */
290
+ export interface TransportConfig {
291
+ requestInit?: RequestInit;
292
+ fetchFn?: FetchFn;
259
293
  }
260
294
 
261
295
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
@@ -293,6 +327,24 @@ export interface ConvertOptions {
293
327
  onProgress?: (info: ProgressInfo) => void;
294
328
  /** When true, reject on any uncertain codec/container combo. Default: `false` (best-effort). */
295
329
  strict?: boolean;
330
+ /**
331
+ * Write output progressively to a `WritableStream` instead of accumulating
332
+ * in memory. Use with the File System Access API (`showSaveFilePicker()`) to
333
+ * transcode files larger than available memory.
334
+ *
335
+ * When set, the returned `ConvertResult.blob` will be an empty Blob (the
336
+ * real data went to the stream). The caller is responsible for closing the
337
+ * stream after the returned promise resolves.
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * const handle = await showSaveFilePicker({ suggestedName: "output.mp4" });
342
+ * const writable = await handle.createWritable();
343
+ * const result = await transcode(file, { outputStream: writable });
344
+ * await writable.close();
345
+ * ```
346
+ */
347
+ outputStream?: WritableStream;
296
348
  }
297
349
 
298
350
  /** Progress information passed to {@link ConvertOptions.onProgress}. */
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Shared libav demux session. Opens a libav demuxer over a NormalizedSource
3
+ * and provides a linear, cancellable packet pump.
4
+ *
5
+ * Phase 1 API: deliberately minimal. The first consumer is the AVI/ASF/FLV
6
+ * transcode path (src/convert/transcode-libav.ts), which is strictly linear.
7
+ * No seek, no track swapping — those were added to hybrid/fallback's
8
+ * private pumps for playback reasons. When those paths migrate here, the
9
+ * API will grow to cover their needs.
10
+ *
11
+ * The shared timestamp sanitizers (sanitizePacketTimestamp,
12
+ * sanitizeFrameTimestamp) also live here. They were previously duplicated
13
+ * in convert/remux.ts and strategies/hybrid/decoder.ts. The duplicates
14
+ * stay put in Phase 1 with TODO pointers; migration is a follow-up.
15
+ */
16
+
17
+ import { loadLibav, type LibavVariant } from "../strategies/fallback/libav-loader.js";
18
+ import { pickLibavVariant } from "../strategies/fallback/variant-routing.js";
19
+ import { prepareLibavInput } from "./libav-http-reader.js";
20
+ import type { MediaContext, TransportConfig } from "../types.js";
21
+ import type { NormalizedSource } from "./source.js";
22
+
23
+ // ─────────────────────────────────────────────────────────────────────────
24
+ // Structural types (mirror libav.js' shape without dragging in its types)
25
+ // ─────────────────────────────────────────────────────────────────────────
26
+
27
+ export interface LibavStream {
28
+ index: number;
29
+ codec_type: number;
30
+ codec_id: number;
31
+ codecpar: number;
32
+ time_base_num?: number;
33
+ time_base_den?: number;
34
+ }
35
+
36
+ export interface LibavPacket {
37
+ data: Uint8Array;
38
+ pts: number;
39
+ ptshi?: number;
40
+ duration?: number;
41
+ durationhi?: number;
42
+ flags: number;
43
+ stream_index: number;
44
+ time_base_num?: number;
45
+ time_base_den?: number;
46
+ }
47
+
48
+ export interface LibavFrame {
49
+ data: unknown;
50
+ format: number;
51
+ channels?: number;
52
+ ch_layout_nb_channels?: number;
53
+ sample_rate?: number;
54
+ nb_samples?: number;
55
+ pts?: number;
56
+ ptshi?: number;
57
+ width?: number;
58
+ height?: number;
59
+ }
60
+
61
+ interface LibavRuntime {
62
+ AVMEDIA_TYPE_VIDEO: number;
63
+ AVMEDIA_TYPE_AUDIO: number;
64
+ AVERROR_EOF: number;
65
+ EAGAIN: number;
66
+
67
+ mkreadaheadfile(name: string, blob: Blob): Promise<void>;
68
+ unlinkreadaheadfile(name: string): Promise<void>;
69
+ ff_init_demuxer_file(name: string): Promise<[number, LibavStream[]]>;
70
+ ff_read_frame_multi(
71
+ fmt_ctx: number,
72
+ pkt: number,
73
+ opts?: { limit?: number },
74
+ ): Promise<[number, Record<number, LibavPacket[]>]>;
75
+ av_packet_alloc(): Promise<number>;
76
+ av_packet_free?(pkt: number): Promise<void>;
77
+ avformat_close_input_js(ctx: number): Promise<void>;
78
+ f64toi64?(val: number): [number, number];
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────
82
+ // Session
83
+ // ─────────────────────────────────────────────────────────────────────────
84
+
85
+ export interface LibavDemuxSession {
86
+ readonly libav: LibavRuntime;
87
+ readonly fmtCtx: number;
88
+ readonly streams: LibavStream[];
89
+ readonly videoStream: LibavStream | null;
90
+ readonly audioStream: LibavStream | null;
91
+ /** True when the input is being streamed via HTTP Range requests. */
92
+ readonly transport: "http-range" | "blob";
93
+ /**
94
+ * Linear read-to-EOF pump. Invokes the callbacks for each
95
+ * ff_read_frame_multi batch (audio is handed over before video per
96
+ * batch, matching the audio-first ordering that the hybrid/fallback
97
+ * playback pumps use — see POSTMORTEMS.md entry 1).
98
+ *
99
+ * Honors the AbortSignal between batches. Invokes `onEof` once when
100
+ * the demuxer returns EOF. Does NOT handle seek.
101
+ */
102
+ pump(cb: {
103
+ onVideoPackets?: (pkts: LibavPacket[]) => Promise<void>;
104
+ onAudioPackets?: (pkts: LibavPacket[]) => Promise<void>;
105
+ onEof?: () => Promise<void>;
106
+ signal?: AbortSignal;
107
+ }): Promise<void>;
108
+ destroy(): Promise<void>;
109
+ }
110
+
111
+ export interface OpenLibavDemuxOptions {
112
+ source: NormalizedSource;
113
+ filename: string;
114
+ context: MediaContext;
115
+ transport?: TransportConfig;
116
+ /** Override automatic variant picking. Defaults to pickLibavVariant(context). */
117
+ variant?: LibavVariant;
118
+ }
119
+
120
+ export async function openLibavDemux(opts: OpenLibavDemuxOptions): Promise<LibavDemuxSession> {
121
+ const variant: LibavVariant = opts.variant ?? pickLibavVariant(opts.context);
122
+ const libav = (await loadLibav(variant)) as unknown as LibavRuntime;
123
+
124
+ const inputHandle = await prepareLibavInput(
125
+ libav as unknown as Parameters<typeof prepareLibavInput>[0],
126
+ opts.filename,
127
+ opts.source,
128
+ opts.transport,
129
+ );
130
+
131
+ const readPkt = await libav.av_packet_alloc();
132
+ const [fmtCtx, streams] = await libav.ff_init_demuxer_file(opts.filename);
133
+ const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
134
+ const audioStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_AUDIO) ?? null;
135
+
136
+ let destroyed = false;
137
+
138
+ async function pump(cb: Parameters<LibavDemuxSession["pump"]>[0]): Promise<void> {
139
+ while (!destroyed) {
140
+ if (cb.signal?.aborted) return;
141
+
142
+ let readErr: number;
143
+ let packets: Record<number, LibavPacket[]>;
144
+ try {
145
+ [readErr, packets] = await libav.ff_read_frame_multi(fmtCtx, readPkt, {
146
+ // 16 KB batch — chosen so each read produces a handful of
147
+ // packets, keeping downstream queues bounded. Same rationale
148
+ // as the hybrid/fallback pumps (see CLAUDE.md note).
149
+ limit: 16 * 1024,
150
+ });
151
+ } catch (err) {
152
+ throw new Error(`libav-demux: ff_read_frame_multi failed: ${(err as Error).message}`);
153
+ }
154
+
155
+ if (destroyed || cb.signal?.aborted) return;
156
+
157
+ const videoPackets = videoStream ? packets[videoStream.index] : undefined;
158
+ const audioPackets = audioStream ? packets[audioStream.index] : undefined;
159
+
160
+ // Audio-first ordering. Audio decode is cheap; video decode can
161
+ // be expensive. Feeding audio first ensures the audio consumer
162
+ // has samples to work with before any long video-decode block.
163
+ if (cb.onAudioPackets && audioPackets && audioPackets.length > 0) {
164
+ await cb.onAudioPackets(audioPackets);
165
+ }
166
+ if (destroyed || cb.signal?.aborted) return;
167
+ if (cb.onVideoPackets && videoPackets && videoPackets.length > 0) {
168
+ await cb.onVideoPackets(videoPackets);
169
+ }
170
+
171
+ if (readErr === libav.AVERROR_EOF) {
172
+ if (cb.onEof) await cb.onEof();
173
+ return;
174
+ }
175
+ if (readErr && readErr !== 0 && readErr !== -libav.EAGAIN) {
176
+ throw new Error(`libav-demux: ff_read_frame_multi returned ${readErr}`);
177
+ }
178
+ }
179
+ }
180
+
181
+ async function destroy(): Promise<void> {
182
+ destroyed = true;
183
+ try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
184
+ try { await libav.avformat_close_input_js(fmtCtx); } catch { /* ignore */ }
185
+ try { await inputHandle.detach(); } catch { /* ignore */ }
186
+ }
187
+
188
+ return {
189
+ libav,
190
+ fmtCtx,
191
+ streams,
192
+ videoStream,
193
+ audioStream,
194
+ transport: inputHandle.transport,
195
+ pump,
196
+ destroy,
197
+ };
198
+ }
199
+
200
+ // ─────────────────────────────────────────────────────────────────────────
201
+ // Timestamp sanitizers (extracted from convert/remux.ts + hybrid/decoder.ts)
202
+ //
203
+ // libav can hand us packets/frames with pts = AV_NOPTS_VALUE (encoded as
204
+ // ptshi = -2147483648, pts = 0) for inputs whose demuxer can't determine
205
+ // presentation times. AVI is the canonical example. Downstream consumers
206
+ // that treat pts as int64 overflow and throw.
207
+ //
208
+ // The sanitizer replaces invalid pts with a synthetic microsecond counter,
209
+ // and normalizes valid pts to a 1/1e6 time_base so consumers don't need
210
+ // to track the source time_base per packet.
211
+ // ─────────────────────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Sanitize a libav packet's timestamp. Mutates `pkt` in place.
215
+ * If the packet has AV_NOPTS_VALUE, replaces pts with `nextUs()`.
216
+ * Otherwise normalizes to µs with time_base = 1/1_000_000.
217
+ */
218
+ export function sanitizePacketTimestamp(
219
+ pkt: LibavPacket,
220
+ nextUs: () => number,
221
+ fallbackTimeBase?: [number, number],
222
+ ): void {
223
+ const lo = pkt.pts ?? 0;
224
+ const hi = pkt.ptshi ?? 0;
225
+ const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
226
+ if (isInvalid) {
227
+ const us = nextUs();
228
+ pkt.pts = us;
229
+ pkt.ptshi = 0;
230
+ pkt.time_base_num = 1;
231
+ pkt.time_base_den = 1_000_000;
232
+ return;
233
+ }
234
+ const tb = fallbackTimeBase ?? [1, 1_000_000];
235
+ const pts64 = hi * 0x100000000 + lo;
236
+ const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
237
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
238
+ pkt.pts = us;
239
+ pkt.ptshi = us < 0 ? -1 : 0;
240
+ pkt.time_base_num = 1;
241
+ pkt.time_base_den = 1_000_000;
242
+ return;
243
+ }
244
+ const fallback = nextUs();
245
+ pkt.pts = fallback;
246
+ pkt.ptshi = 0;
247
+ pkt.time_base_num = 1;
248
+ pkt.time_base_den = 1_000_000;
249
+ }
250
+
251
+ // ─────────────────────────────────────────────────────────────────────────
252
+ // Audio frame → interleaved Float32 (extracted from
253
+ // strategies/hybrid/decoder.ts + strategies/fallback/decoder.ts).
254
+ //
255
+ // libav hands us decoded audio frames in whichever sample format the codec
256
+ // uses (FLTP, S16P, etc.). Most downstream consumers (Web Audio, WebCodecs
257
+ // AudioEncoder) want interleaved Float32. This does the conversion without
258
+ // any dependencies.
259
+ // ─────────────────────────────────────────────────────────────────────────
260
+
261
+ const AV_SAMPLE_FMT_U8 = 0;
262
+ const AV_SAMPLE_FMT_S16 = 1;
263
+ const AV_SAMPLE_FMT_S32 = 2;
264
+ const AV_SAMPLE_FMT_FLT = 3;
265
+ const AV_SAMPLE_FMT_U8P = 5;
266
+ const AV_SAMPLE_FMT_S16P = 6;
267
+ const AV_SAMPLE_FMT_S32P = 7;
268
+ const AV_SAMPLE_FMT_FLTP = 8;
269
+
270
+ export interface InterleavedSamples {
271
+ data: Float32Array;
272
+ channels: number;
273
+ sampleRate: number;
274
+ }
275
+
276
+ export function libavFrameToInterleavedFloat32(frame: LibavFrame): InterleavedSamples | null {
277
+ const channels = frame.channels ?? frame.ch_layout_nb_channels ?? 1;
278
+ const sampleRate = frame.sample_rate ?? 44100;
279
+ const nbSamples = frame.nb_samples ?? 0;
280
+ if (nbSamples === 0) return null;
281
+
282
+ const out = new Float32Array(nbSamples * channels);
283
+
284
+ switch (frame.format) {
285
+ case AV_SAMPLE_FMT_FLTP: {
286
+ const planes = ensurePlanes(frame.data, channels);
287
+ for (let ch = 0; ch < channels; ch++) {
288
+ const plane = asFloat32(planes[ch]);
289
+ for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i];
290
+ }
291
+ return { data: out, channels, sampleRate };
292
+ }
293
+ case AV_SAMPLE_FMT_FLT: {
294
+ const flat = asFloat32(frame.data);
295
+ for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i];
296
+ return { data: out, channels, sampleRate };
297
+ }
298
+ case AV_SAMPLE_FMT_S16P: {
299
+ const planes = ensurePlanes(frame.data, channels);
300
+ for (let ch = 0; ch < channels; ch++) {
301
+ const plane = asInt16(planes[ch]);
302
+ for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 32768;
303
+ }
304
+ return { data: out, channels, sampleRate };
305
+ }
306
+ case AV_SAMPLE_FMT_S16: {
307
+ const flat = asInt16(frame.data);
308
+ for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 32768;
309
+ return { data: out, channels, sampleRate };
310
+ }
311
+ case AV_SAMPLE_FMT_S32P: {
312
+ const planes = ensurePlanes(frame.data, channels);
313
+ for (let ch = 0; ch < channels; ch++) {
314
+ const plane = asInt32(planes[ch]);
315
+ for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = plane[i] / 2147483648;
316
+ }
317
+ return { data: out, channels, sampleRate };
318
+ }
319
+ case AV_SAMPLE_FMT_S32: {
320
+ const flat = asInt32(frame.data);
321
+ for (let i = 0; i < nbSamples * channels; i++) out[i] = flat[i] / 2147483648;
322
+ return { data: out, channels, sampleRate };
323
+ }
324
+ case AV_SAMPLE_FMT_U8P: {
325
+ const planes = ensurePlanes(frame.data, channels);
326
+ for (let ch = 0; ch < channels; ch++) {
327
+ const plane = asUint8(planes[ch]);
328
+ for (let i = 0; i < nbSamples; i++) out[i * channels + ch] = (plane[i] - 128) / 128;
329
+ }
330
+ return { data: out, channels, sampleRate };
331
+ }
332
+ case AV_SAMPLE_FMT_U8: {
333
+ const flat = asUint8(frame.data);
334
+ for (let i = 0; i < nbSamples * channels; i++) out[i] = (flat[i] - 128) / 128;
335
+ return { data: out, channels, sampleRate };
336
+ }
337
+ default:
338
+ return null;
339
+ }
340
+ }
341
+
342
+ function ensurePlanes(data: unknown, channels: number): unknown[] {
343
+ if (Array.isArray(data)) return data;
344
+ const arr = data as { length: number; subarray?: (a: number, b: number) => unknown };
345
+ const len = arr.length;
346
+ const perChannel = Math.floor(len / channels);
347
+ const planes: unknown[] = [];
348
+ for (let ch = 0; ch < channels; ch++) {
349
+ planes.push(arr.subarray ? arr.subarray(ch * perChannel, (ch + 1) * perChannel) : arr);
350
+ }
351
+ return planes;
352
+ }
353
+
354
+ function asFloat32(x: unknown): Float32Array {
355
+ if (x instanceof Float32Array) return x;
356
+ const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
357
+ return new Float32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
358
+ }
359
+ function asInt16(x: unknown): Int16Array {
360
+ if (x instanceof Int16Array) return x;
361
+ const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
362
+ return new Int16Array(ta.buffer, ta.byteOffset, ta.byteLength / 2);
363
+ }
364
+ function asInt32(x: unknown): Int32Array {
365
+ if (x instanceof Int32Array) return x;
366
+ const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
367
+ return new Int32Array(ta.buffer, ta.byteOffset, ta.byteLength / 4);
368
+ }
369
+ function asUint8(x: unknown): Uint8Array {
370
+ if (x instanceof Uint8Array) return x;
371
+ const ta = x as { buffer: ArrayBuffer; byteOffset: number; byteLength: number };
372
+ return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
373
+ }
374
+
375
+ /**
376
+ * Sanitize a decoded frame's timestamp. Mutates `frame` in place.
377
+ * Returns nothing; callers that want derived metadata (e.g. a
378
+ * VideoFrame timestamp in µs) should read `frame.pts` after calling.
379
+ */
380
+ export function sanitizeFrameTimestamp(
381
+ frame: LibavFrame,
382
+ nextUs: () => number,
383
+ fallbackTimeBase?: [number, number],
384
+ ): void {
385
+ const lo = frame.pts ?? 0;
386
+ const hi = frame.ptshi ?? 0;
387
+ const isInvalid = (hi === -2147483648 && lo === 0) || !Number.isFinite(lo);
388
+ if (isInvalid) {
389
+ const us = nextUs();
390
+ frame.pts = us;
391
+ frame.ptshi = 0;
392
+ return;
393
+ }
394
+ const tb = fallbackTimeBase ?? [1, 1_000_000];
395
+ const pts64 = hi * 0x100000000 + lo;
396
+ const us = Math.round((pts64 * 1_000_000 * tb[0]) / tb[1]);
397
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
398
+ frame.pts = us;
399
+ frame.ptshi = us < 0 ? -1 : 0;
400
+ return;
401
+ }
402
+ const fallback = nextUs();
403
+ frame.pts = fallback;
404
+ frame.ptshi = 0;
405
+ }