avbridge 2.2.1 → 2.3.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 (119) hide show
  1. package/CHANGELOG.md +80 -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-ILKDNBSE.js → chunk-2PGRFCWB.js} +59 -10
  14. package/dist/chunk-2PGRFCWB.js.map +1 -0
  15. package/dist/chunk-5YAWWKA3.js +18 -0
  16. package/dist/chunk-5YAWWKA3.js.map +1 -0
  17. package/dist/chunk-6UUT4BEA.cjs +219 -0
  18. package/dist/chunk-6UUT4BEA.cjs.map +1 -0
  19. package/dist/{chunk-UF2N5L63.cjs → chunk-7RGG6ME7.cjs} +489 -76
  20. package/dist/chunk-7RGG6ME7.cjs.map +1 -0
  21. package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
  22. package/dist/chunk-DCSOQH2N.js.map +1 -0
  23. package/dist/chunk-F3LQJKXK.cjs +20 -0
  24. package/dist/chunk-F3LQJKXK.cjs.map +1 -0
  25. package/dist/chunk-IAYKFGFG.js +200 -0
  26. package/dist/chunk-IAYKFGFG.js.map +1 -0
  27. package/dist/chunk-NNVOHKXJ.cjs +204 -0
  28. package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
  29. package/dist/{chunk-DMWARSEF.js → chunk-NV7ILLWH.js} +483 -74
  30. package/dist/chunk-NV7ILLWH.js.map +1 -0
  31. package/dist/{chunk-HZLQNKFN.cjs → chunk-QQXBPW72.js} +54 -15
  32. package/dist/chunk-QQXBPW72.js.map +1 -0
  33. package/dist/chunk-XKPSTC34.cjs +210 -0
  34. package/dist/chunk-XKPSTC34.cjs.map +1 -0
  35. package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
  36. package/dist/chunk-Z33SBWL5.cjs.map +1 -0
  37. package/dist/element-browser.js +558 -85
  38. package/dist/element-browser.js.map +1 -1
  39. package/dist/element.cjs +4 -4
  40. package/dist/element.d.cts +1 -1
  41. package/dist/element.d.ts +1 -1
  42. package/dist/element.js +3 -3
  43. package/dist/index.cjs +174 -26
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.d.cts +48 -4
  46. package/dist/index.d.ts +48 -4
  47. package/dist/index.js +93 -12
  48. package/dist/index.js.map +1 -1
  49. package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
  50. package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-http-reader-AZLE7YFS.cjs.map} +1 -1
  51. package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
  52. package/dist/{libav-http-reader-NQJVY273.js.map → libav-http-reader-WXG3Z7AI.js.map} +1 -1
  53. package/dist/{player-U2NPmFvA.d.cts → player-B6WB74RD.d.cts} +62 -3
  54. package/dist/{player-U2NPmFvA.d.ts → player-B6WB74RD.d.ts} +62 -3
  55. package/dist/player.cjs +5500 -0
  56. package/dist/player.cjs.map +1 -0
  57. package/dist/player.d.cts +649 -0
  58. package/dist/player.d.ts +649 -0
  59. package/dist/player.js +5498 -0
  60. package/dist/player.js.map +1 -0
  61. package/dist/source-73CAH6HW.cjs +28 -0
  62. package/dist/{source-CN43EI7Z.cjs.map → source-73CAH6HW.cjs.map} +1 -1
  63. package/dist/source-F656KYYV.js +3 -0
  64. package/dist/{source-FFZ7TW2B.js.map → source-F656KYYV.js.map} +1 -1
  65. package/dist/source-QJR3OHTW.js +3 -0
  66. package/dist/source-QJR3OHTW.js.map +1 -0
  67. package/dist/source-VB74JQ7Z.cjs +28 -0
  68. package/dist/source-VB74JQ7Z.cjs.map +1 -0
  69. package/dist/variant-routing-434STYAB.js +3 -0
  70. package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
  71. package/dist/variant-routing-HONNAA6R.cjs +12 -0
  72. package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
  73. package/package.json +9 -1
  74. package/src/classify/rules.ts +27 -5
  75. package/src/convert/remux.ts +8 -0
  76. package/src/convert/transcode.ts +41 -8
  77. package/src/element/avbridge-player.ts +845 -0
  78. package/src/element/player-icons.ts +25 -0
  79. package/src/element/player-styles.ts +472 -0
  80. package/src/errors.ts +47 -0
  81. package/src/index.ts +23 -0
  82. package/src/player-element.ts +18 -0
  83. package/src/player.ts +104 -12
  84. package/src/plugins/builtin.ts +2 -2
  85. package/src/probe/avi.ts +4 -0
  86. package/src/probe/index.ts +40 -10
  87. package/src/strategies/fallback/audio-output.ts +31 -0
  88. package/src/strategies/fallback/decoder.ts +83 -2
  89. package/src/strategies/fallback/index.ts +29 -4
  90. package/src/strategies/fallback/variant-routing.ts +7 -13
  91. package/src/strategies/fallback/video-renderer.ts +124 -32
  92. package/src/strategies/hybrid/decoder.ts +131 -20
  93. package/src/strategies/hybrid/index.ts +31 -5
  94. package/src/strategies/remux/mse.ts +12 -2
  95. package/src/subtitles/index.ts +7 -3
  96. package/src/types.ts +53 -1
  97. package/src/util/libav-http-reader.ts +5 -1
  98. package/src/util/source.ts +28 -8
  99. package/src/util/transport.ts +26 -0
  100. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  101. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  102. package/dist/avi-6SJLWIWW.cjs.map +0 -1
  103. package/dist/avi-GCGM7OJI.js.map +0 -1
  104. package/dist/chunk-DMWARSEF.js.map +0 -1
  105. package/dist/chunk-HZLQNKFN.cjs.map +0 -1
  106. package/dist/chunk-ILKDNBSE.js.map +0 -1
  107. package/dist/chunk-J5MCMN3S.js +0 -27
  108. package/dist/chunk-J5MCMN3S.js.map +0 -1
  109. package/dist/chunk-L4NPOJ36.cjs.map +0 -1
  110. package/dist/chunk-NZU7W256.cjs +0 -29
  111. package/dist/chunk-NZU7W256.cjs.map +0 -1
  112. package/dist/chunk-UF2N5L63.cjs.map +0 -1
  113. package/dist/chunk-WD2ZNQA7.js.map +0 -1
  114. package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
  115. package/dist/libav-http-reader-NQJVY273.js +0 -3
  116. package/dist/source-CN43EI7Z.cjs +0 -28
  117. package/dist/source-FFZ7TW2B.js +0 -3
  118. package/dist/variant-routing-GOHB2RZN.cjs +0 -12
  119. package/dist/variant-routing-JOBWXYKD.js +0 -3
@@ -31,6 +31,51 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  mod
32
32
  ));
33
33
 
34
+ // src/util/transport.ts
35
+ function mergeFetchInit(base, extra) {
36
+ if (!base && !extra) return void 0;
37
+ return {
38
+ ...base,
39
+ ...extra,
40
+ headers: {
41
+ ...base?.headers ?? {},
42
+ ...extra?.headers ?? {}
43
+ }
44
+ };
45
+ }
46
+ function fetchWith(transport) {
47
+ return transport?.fetchFn ?? globalThis.fetch;
48
+ }
49
+ var init_transport = __esm({
50
+ "src/util/transport.ts"() {
51
+ }
52
+ });
53
+
54
+ // src/errors.ts
55
+ var AvbridgeError, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_PROBE_FETCH_FAILED, ERR_ALL_STRATEGIES_EXHAUSTED, ERR_PLAYER_NOT_READY, ERR_LIBAV_NOT_REACHABLE, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED;
56
+ var init_errors = __esm({
57
+ "src/errors.ts"() {
58
+ AvbridgeError = class extends Error {
59
+ constructor(code, message, recovery, options) {
60
+ super(message, options);
61
+ this.code = code;
62
+ this.recovery = recovery;
63
+ }
64
+ code;
65
+ recovery;
66
+ name = "AvbridgeError";
67
+ };
68
+ ERR_PROBE_FAILED = "ERR_AVBRIDGE_PROBE_FAILED";
69
+ ERR_PROBE_UNKNOWN_CONTAINER = "ERR_AVBRIDGE_PROBE_UNKNOWN_CONTAINER";
70
+ ERR_PROBE_FETCH_FAILED = "ERR_AVBRIDGE_PROBE_FETCH_FAILED";
71
+ ERR_ALL_STRATEGIES_EXHAUSTED = "ERR_AVBRIDGE_ALL_STRATEGIES_EXHAUSTED";
72
+ ERR_PLAYER_NOT_READY = "ERR_AVBRIDGE_PLAYER_NOT_READY";
73
+ ERR_LIBAV_NOT_REACHABLE = "ERR_AVBRIDGE_LIBAV_NOT_REACHABLE";
74
+ ERR_MSE_NOT_SUPPORTED = "ERR_AVBRIDGE_MSE_NOT_SUPPORTED";
75
+ ERR_MSE_CODEC_NOT_SUPPORTED = "ERR_AVBRIDGE_MSE_CODEC_NOT_SUPPORTED";
76
+ }
77
+ });
78
+
34
79
  // src/util/source.ts
35
80
  var source_exports = {};
36
81
  __export(source_exports, {
@@ -43,7 +88,7 @@ __export(source_exports, {
43
88
  function isInMemorySource(source) {
44
89
  return source.kind === "blob";
45
90
  }
46
- async function normalizeSource(source) {
91
+ async function normalizeSource(source, transport) {
47
92
  if (source instanceof File) {
48
93
  return {
49
94
  kind: "blob",
@@ -66,22 +111,31 @@ async function normalizeSource(source) {
66
111
  }
67
112
  if (typeof source === "string" || source instanceof URL) {
68
113
  const url2 = source instanceof URL ? source.toString() : source;
69
- return await fetchUrlForSniff(url2, source);
114
+ return await fetchUrlForSniff(url2, source, transport);
70
115
  }
71
116
  throw new TypeError("unsupported source type");
72
117
  }
73
- async function fetchUrlForSniff(url2, originalSource) {
118
+ async function fetchUrlForSniff(url2, originalSource, transport) {
74
119
  const name = url2.split("/").pop()?.split("?")[0] ?? void 0;
120
+ const doFetch = fetchWith(transport);
75
121
  let res;
76
122
  try {
77
- res = await fetch(url2, {
123
+ res = await doFetch(url2, mergeFetchInit(transport?.requestInit, {
78
124
  headers: { Range: `bytes=0-${URL_SNIFF_RANGE_BYTES - 1}` }
79
- });
125
+ }));
80
126
  } catch (err) {
81
- throw new Error(`failed to fetch source ${url2}: ${err.message}`);
127
+ throw new AvbridgeError(
128
+ ERR_PROBE_FETCH_FAILED,
129
+ `Failed to fetch source ${url2}: ${err.message}`,
130
+ "Check that the URL is reachable and CORS is configured. If the source requires authentication, pass requestInit with credentials/headers."
131
+ );
82
132
  }
83
133
  if (!res.ok && res.status !== 206) {
84
- throw new Error(`failed to fetch source ${url2}: ${res.status} ${res.statusText}`);
134
+ throw new AvbridgeError(
135
+ ERR_PROBE_FETCH_FAILED,
136
+ `Failed to fetch source ${url2}: ${res.status} ${res.statusText}`,
137
+ res.status === 403 || res.status === 401 ? "The server rejected the request. Pass requestInit with the required Authorization header or credentials." : "Check that the URL is correct and the server is reachable."
138
+ );
85
139
  }
86
140
  let byteLength;
87
141
  const contentRange = res.headers.get("content-range");
@@ -186,6 +240,8 @@ async function readBlobBytes(blob, limit) {
186
240
  var SNIFF_BYTES_NEEDED, URL_SNIFF_RANGE_BYTES;
187
241
  var init_source = __esm({
188
242
  "src/util/source.ts"() {
243
+ init_transport();
244
+ init_errors();
189
245
  SNIFF_BYTES_NEEDED = 380;
190
246
  URL_SNIFF_RANGE_BYTES = 32 * 1024;
191
247
  }
@@ -29453,9 +29509,12 @@ __export(libav_http_reader_exports, {
29453
29509
  attachLibavHttpReader: () => attachLibavHttpReader,
29454
29510
  prepareLibavInput: () => prepareLibavInput
29455
29511
  });
29456
- async function prepareLibavInput(libav, filename, source) {
29512
+ async function prepareLibavInput(libav, filename, source, transport) {
29457
29513
  if (source.kind === "url") {
29458
- const handle = await attachLibavHttpReader(libav, filename, source.url);
29514
+ const handle = await attachLibavHttpReader(libav, filename, source.url, {
29515
+ requestInit: transport?.requestInit,
29516
+ fetchFn: transport?.fetchFn
29517
+ });
29459
29518
  return {
29460
29519
  filename,
29461
29520
  transport: "http-range",
@@ -30009,6 +30068,12 @@ function ffmpegToAvbridgeAudio(name) {
30009
30068
  return "sipr";
30010
30069
  case "atrac3":
30011
30070
  return "atrac3";
30071
+ case "dca":
30072
+ case "dts":
30073
+ return "dts";
30074
+ case "truehd":
30075
+ case "mlp":
30076
+ return "truehd";
30012
30077
  default:
30013
30078
  return name;
30014
30079
  }
@@ -31152,6 +31217,7 @@ async function safe(fn) {
31152
31217
  }
31153
31218
 
31154
31219
  // src/probe/index.ts
31220
+ init_errors();
31155
31221
  var MEDIABUNNY_CONTAINERS = /* @__PURE__ */ new Set([
31156
31222
  "mp4",
31157
31223
  "mov",
@@ -31164,12 +31230,22 @@ var MEDIABUNNY_CONTAINERS = /* @__PURE__ */ new Set([
31164
31230
  "adts",
31165
31231
  "mpegts"
31166
31232
  ]);
31167
- async function probe(source) {
31168
- const normalized = await normalizeSource(source);
31233
+ async function probe(source, transport) {
31234
+ const normalized = await normalizeSource(source, transport);
31169
31235
  const sniffed = await sniffNormalizedSource(normalized);
31170
31236
  if (MEDIABUNNY_CONTAINERS.has(sniffed)) {
31171
31237
  try {
31172
- return await probeWithMediabunny(normalized, sniffed);
31238
+ const result = await probeWithMediabunny(normalized, sniffed);
31239
+ const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
31240
+ if (hasUnknownCodec) {
31241
+ try {
31242
+ const { probeWithLibav: probeWithLibav2 } = await Promise.resolve().then(() => (init_avi(), avi_exports));
31243
+ return await probeWithLibav2(normalized, sniffed);
31244
+ } catch {
31245
+ return result;
31246
+ }
31247
+ }
31248
+ return result;
31173
31249
  } catch (mediabunnyErr) {
31174
31250
  console.warn(
31175
31251
  `[avbridge] mediabunny rejected ${sniffed} file, falling back to libav:`,
@@ -31181,8 +31257,10 @@ async function probe(source) {
31181
31257
  } catch (libavErr) {
31182
31258
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
31183
31259
  const lvMsg = libavErr instanceof Error ? libavErr.message : String(libavErr);
31184
- throw new Error(
31185
- `failed to probe ${sniffed} file. mediabunny: ${mbMsg}. libav fallback: ${lvMsg}.`
31260
+ throw new AvbridgeError(
31261
+ ERR_PROBE_FAILED,
31262
+ `Failed to probe ${sniffed.toUpperCase()} file. mediabunny: ${mbMsg}. libav: ${lvMsg}.`,
31263
+ "The file may be corrupt, truncated, or in an unsupported format. Enable AVBRIDGE_DEBUG for detailed logs."
31186
31264
  );
31187
31265
  }
31188
31266
  }
@@ -31193,8 +31271,17 @@ async function probe(source) {
31193
31271
  } catch (err) {
31194
31272
  const inner = err instanceof Error ? err.message : String(err);
31195
31273
  console.error("[avbridge] libav probe failed for", sniffed, "file:", err);
31196
- throw new Error(
31197
- sniffed === "unknown" ? `unable to probe source: container could not be identified, and the libav.js fallback also failed: ${inner || "(no message \u2014 see browser console for the original error)"}` : `${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no message \u2014 see browser console for the original error)"}`
31274
+ if (sniffed === "unknown") {
31275
+ throw new AvbridgeError(
31276
+ ERR_PROBE_UNKNOWN_CONTAINER,
31277
+ `Unable to probe source: container format could not be identified. libav fallback: ${inner || "(no details)"}`,
31278
+ "The file may be corrupt or in a format avbridge doesn't recognize. Check the file plays in VLC or ffprobe."
31279
+ );
31280
+ }
31281
+ throw new AvbridgeError(
31282
+ ERR_LIBAV_NOT_REACHABLE,
31283
+ `${sniffed.toUpperCase()} files require libav.js, which failed to load: ${inner || "(no details)"}`,
31284
+ "Install @libav.js/variant-webcodecs, or check that AVBRIDGE_LIBAV_BASE points to the correct path."
31198
31285
  );
31199
31286
  }
31200
31287
  }
@@ -31295,7 +31382,9 @@ var FALLBACK_AUDIO_CODECS = /* @__PURE__ */ new Set([
31295
31382
  "ra_144",
31296
31383
  "ra_288",
31297
31384
  "sipr",
31298
- "atrac3"
31385
+ "atrac3",
31386
+ "dts",
31387
+ "truehd"
31299
31388
  ]);
31300
31389
  var NATIVE_CONTAINERS = /* @__PURE__ */ new Set([
31301
31390
  "mp4",
@@ -31357,7 +31446,16 @@ function classifyContext(ctx) {
31357
31446
  reason: `video codec "${video.codec}" has no browser decoder; WASM fallback required`
31358
31447
  };
31359
31448
  }
31360
- if (audio && FALLBACK_AUDIO_CODECS.has(audio.codec)) {
31449
+ const audioNeedsFallback = audio && (FALLBACK_AUDIO_CODECS.has(audio.codec) || !NATIVE_AUDIO_CODECS.has(audio.codec));
31450
+ if (audioNeedsFallback) {
31451
+ if (NATIVE_VIDEO_CODECS.has(video.codec) && webCodecsAvailable()) {
31452
+ return {
31453
+ class: "HYBRID_CANDIDATE",
31454
+ strategy: "hybrid",
31455
+ reason: `video "${video.codec}" is hardware-decodable via WebCodecs; audio "${audio.codec}" decoded in software by libav`,
31456
+ fallbackChain: ["fallback"]
31457
+ };
31458
+ }
31361
31459
  return {
31362
31460
  class: "FALLBACK_REQUIRED",
31363
31461
  strategy: "fallback",
@@ -31642,14 +31740,23 @@ function sourceToVideoUrl(source) {
31642
31740
  }
31643
31741
 
31644
31742
  // src/strategies/remux/mse.ts
31743
+ init_errors();
31645
31744
  var MseSink = class {
31646
31745
  constructor(options) {
31647
31746
  this.options = options;
31648
31747
  if (typeof MediaSource === "undefined") {
31649
- throw new Error("MSE not supported in this environment");
31748
+ throw new AvbridgeError(
31749
+ ERR_MSE_NOT_SUPPORTED,
31750
+ "MediaSource Extensions (MSE) are not supported in this environment.",
31751
+ "MSE is required for the remux strategy. Use a browser that supports MSE, or try the fallback strategy."
31752
+ );
31650
31753
  }
31651
31754
  if (!MediaSource.isTypeSupported(options.mime)) {
31652
- throw new Error(`MSE does not support MIME "${options.mime}" \u2014 cannot remux`);
31755
+ throw new AvbridgeError(
31756
+ ERR_MSE_CODEC_NOT_SUPPORTED,
31757
+ `This browser's MSE does not support "${options.mime}".`,
31758
+ "The codec combination can't be played via remux in this browser. The player will try the next strategy automatically."
31759
+ );
31653
31760
  }
31654
31761
  this.mediaSource = new MediaSource();
31655
31762
  this.objectUrl = URL.createObjectURL(this.mediaSource);
@@ -32065,6 +32172,10 @@ async function createRemuxSession(context, video) {
32065
32172
  }
32066
32173
 
32067
32174
  // src/strategies/fallback/video-renderer.ts
32175
+ function isDebug() {
32176
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
32177
+ }
32178
+ var lastDebugLog = 0;
32068
32179
  var VideoRenderer = class {
32069
32180
  constructor(target, clock, fps = 30) {
32070
32181
  this.target = target;
@@ -32111,6 +32222,20 @@ var VideoRenderer = class {
32111
32222
  lastPaintWall = 0;
32112
32223
  /** Minimum ms between paints — paces video at roughly source fps. */
32113
32224
  paintIntervalMs;
32225
+ /** Cumulative count of frames skipped because all PTS are in the future. */
32226
+ ticksWaiting = 0;
32227
+ /** Cumulative count of ticks where PTS mode painted a frame. */
32228
+ ticksPainted = 0;
32229
+ /**
32230
+ * Calibration offset (microseconds) between video PTS and audio clock.
32231
+ * Video PTS and AudioContext.currentTime can drift ~0.1% relative to
32232
+ * each other (different clock domains). Over 45 minutes that's 2.6s.
32233
+ * We measure the offset on the first painted frame and update it
32234
+ * periodically so the PTS comparison stays calibrated.
32235
+ */
32236
+ ptsCalibrationUs = 0;
32237
+ ptsCalibrated = false;
32238
+ lastCalibrationWall = 0;
32114
32239
  /** Resolves once the first decoded frame has been enqueued. */
32115
32240
  firstFrameReady;
32116
32241
  resolveFirstFrame;
@@ -32159,21 +32284,81 @@ var VideoRenderer = class {
32159
32284
  }
32160
32285
  return;
32161
32286
  }
32162
- const wallNow = performance.now();
32163
- if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
32164
- if (this.queue.length === 0) return;
32165
- if (this.framesPainted > 0 && this.framesPainted % 30 === 0) {
32166
- const audioNowUs = this.clock.now() * 1e6;
32167
- const headTs = this.queue[0].timestamp ?? 0;
32168
- const driftUs = headTs - audioNowUs;
32169
- if (driftUs < -15e4) {
32170
- this.queue.shift()?.close();
32171
- this.framesDroppedLate++;
32172
- if (this.queue.length === 0) return;
32173
- } else if (driftUs > 15e4) {
32287
+ const rawAudioNowUs = this.clock.now() * 1e6;
32288
+ const headTs = this.queue[0].timestamp ?? 0;
32289
+ const hasPts = headTs > 0 || this.queue.length > 1;
32290
+ if (hasPts) {
32291
+ const wallNow2 = performance.now();
32292
+ if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
32293
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
32294
+ this.ptsCalibrated = true;
32295
+ this.lastCalibrationWall = wallNow2;
32296
+ }
32297
+ const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
32298
+ const frameDurationUs = this.paintIntervalMs * 1e3;
32299
+ const deadlineUs = audioNowUs + frameDurationUs;
32300
+ let bestIdx = -1;
32301
+ for (let i = 0; i < this.queue.length; i++) {
32302
+ const ts = this.queue[i].timestamp ?? 0;
32303
+ if (ts <= deadlineUs) {
32304
+ bestIdx = i;
32305
+ } else {
32306
+ break;
32307
+ }
32308
+ }
32309
+ if (bestIdx < 0) {
32310
+ this.ticksWaiting++;
32311
+ if (isDebug()) {
32312
+ const now = performance.now();
32313
+ if (now - lastDebugLog > 1e3) {
32314
+ const headPtsMs = (headTs / 1e3).toFixed(1);
32315
+ const audioMs = (audioNowUs / 1e3).toFixed(1);
32316
+ const rawDriftMs = ((headTs - rawAudioNowUs) / 1e3).toFixed(1);
32317
+ const calibMs = (this.ptsCalibrationUs / 1e3).toFixed(1);
32318
+ console.log(
32319
+ `[avbridge:renderer] WAIT q=${this.queue.length} headPTS=${headPtsMs}ms calibAudio=${audioMs}ms rawDrift=${rawDriftMs}ms calib=${calibMs}ms painted=${this.framesPainted} dropped=${this.framesDroppedLate}`
32320
+ );
32321
+ lastDebugLog = now;
32322
+ }
32323
+ }
32174
32324
  return;
32175
32325
  }
32326
+ const dropThresholdUs = audioNowUs - frameDurationUs * 2;
32327
+ let dropped = 0;
32328
+ while (bestIdx > 0) {
32329
+ const ts = this.queue[0].timestamp ?? 0;
32330
+ if (ts < dropThresholdUs) {
32331
+ this.queue.shift()?.close();
32332
+ this.framesDroppedLate++;
32333
+ bestIdx--;
32334
+ dropped++;
32335
+ } else {
32336
+ break;
32337
+ }
32338
+ }
32339
+ this.ticksPainted++;
32340
+ if (isDebug()) {
32341
+ const now = performance.now();
32342
+ if (now - lastDebugLog > 1e3) {
32343
+ const paintedTs = this.queue[0]?.timestamp ?? 0;
32344
+ const audioMs = (audioNowUs / 1e3).toFixed(1);
32345
+ const ptsMs = (paintedTs / 1e3).toFixed(1);
32346
+ const rawDriftMs = ((paintedTs - rawAudioNowUs) / 1e3).toFixed(1);
32347
+ const calibMs = (this.ptsCalibrationUs / 1e3).toFixed(1);
32348
+ console.log(
32349
+ `[avbridge:renderer] PAINT q=${this.queue.length} calibAudio=${audioMs}ms nextPTS=${ptsMs}ms rawDrift=${rawDriftMs}ms calib=${calibMs}ms dropped=${dropped} total_drops=${this.framesDroppedLate} painted=${this.framesPainted}`
32350
+ );
32351
+ lastDebugLog = now;
32352
+ }
32353
+ }
32354
+ const frame2 = this.queue.shift();
32355
+ this.paint(frame2);
32356
+ frame2.close();
32357
+ this.lastPaintWall = performance.now();
32358
+ return;
32176
32359
  }
32360
+ const wallNow = performance.now();
32361
+ if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
32177
32362
  const frame = this.queue.shift();
32178
32363
  this.paint(frame);
32179
32364
  frame.close();
@@ -32195,8 +32380,13 @@ var VideoRenderer = class {
32195
32380
  }
32196
32381
  /** Discard all queued frames. Used by seek to drop stale buffers. */
32197
32382
  flush() {
32383
+ const count = this.queue.length;
32198
32384
  while (this.queue.length > 0) this.queue.shift()?.close();
32199
32385
  this.prerolled = false;
32386
+ this.ptsCalibrated = false;
32387
+ if (isDebug() && count > 0) {
32388
+ console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
32389
+ }
32200
32390
  }
32201
32391
  stats() {
32202
32392
  return {
@@ -32241,11 +32431,38 @@ var AudioOutput = class {
32241
32431
  pendingQueue = [];
32242
32432
  framesScheduled = 0;
32243
32433
  destroyed = false;
32434
+ /** User-set volume (0..1). Applied to the gain node. */
32435
+ _volume = 1;
32436
+ /** User-set muted flag. When true, gain is forced to 0. */
32437
+ _muted = false;
32244
32438
  constructor() {
32245
32439
  this.ctx = new AudioContext();
32246
32440
  this.gain = this.ctx.createGain();
32247
32441
  this.gain.connect(this.ctx.destination);
32248
32442
  }
32443
+ /** Set volume (0..1). Applied immediately to the gain node. */
32444
+ setVolume(v) {
32445
+ this._volume = Math.max(0, Math.min(1, v));
32446
+ this.applyGain();
32447
+ }
32448
+ getVolume() {
32449
+ return this._volume;
32450
+ }
32451
+ /** Set muted. When true, output is silenced regardless of volume. */
32452
+ setMuted(m) {
32453
+ this._muted = m;
32454
+ this.applyGain();
32455
+ }
32456
+ getMuted() {
32457
+ return this._muted;
32458
+ }
32459
+ applyGain() {
32460
+ const target = this._muted ? 0 : this._volume;
32461
+ try {
32462
+ this.gain.gain.value = target;
32463
+ } catch {
32464
+ }
32465
+ }
32249
32466
  /**
32250
32467
  * Switch into wall-clock fallback mode. Called by the decoder when no
32251
32468
  * audio decoder could be initialized for the source. Once set, this
@@ -32395,6 +32612,7 @@ var AudioOutput = class {
32395
32612
  }
32396
32613
  this.gain = this.ctx.createGain();
32397
32614
  this.gain.connect(this.ctx.destination);
32615
+ this.applyGain();
32398
32616
  this.pendingQueue = [];
32399
32617
  this.mediaTimeOfAnchor = newMediaTime;
32400
32618
  this.mediaTimeOfNext = newMediaTime;
@@ -32423,27 +32641,19 @@ var AudioOutput = class {
32423
32641
 
32424
32642
  // src/strategies/hybrid/decoder.ts
32425
32643
  init_libav_loader();
32644
+ init_debug();
32426
32645
 
32427
32646
  // src/strategies/fallback/variant-routing.ts
32428
32647
  var LEGACY_CONTAINERS = /* @__PURE__ */ new Set(["avi", "asf", "flv"]);
32429
- var LEGACY_VIDEO_CODECS = /* @__PURE__ */ new Set([
32430
- "wmv3",
32431
- "vc1",
32432
- "mpeg4",
32433
- // MPEG-4 Part 2 / DivX / Xvid
32434
- "rv40",
32435
- "mpeg2",
32436
- "mpeg1",
32437
- "theora"
32438
- ]);
32439
- var LEGACY_AUDIO_CODECS = /* @__PURE__ */ new Set(["wmav2", "wmapro", "ac3", "eac3"]);
32648
+ var WEBCODECS_AUDIO = /* @__PURE__ */ new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
32649
+ var WEBCODECS_VIDEO = /* @__PURE__ */ new Set(["h264", "h265", "vp8", "vp9", "av1"]);
32440
32650
  function pickLibavVariant(ctx) {
32441
32651
  if (LEGACY_CONTAINERS.has(ctx.container)) return "avbridge";
32442
32652
  for (const v of ctx.videoTracks) {
32443
- if (LEGACY_VIDEO_CODECS.has(v.codec)) return "avbridge";
32653
+ if (!WEBCODECS_VIDEO.has(v.codec)) return "avbridge";
32444
32654
  }
32445
32655
  for (const a of ctx.audioTracks) {
32446
- if (LEGACY_AUDIO_CODECS.has(a.codec)) return "avbridge";
32656
+ if (!WEBCODECS_AUDIO.has(a.codec)) return "avbridge";
32447
32657
  }
32448
32658
  return "webcodecs";
32449
32659
  }
@@ -32454,7 +32664,7 @@ async function startHybridDecoder(opts) {
32454
32664
  const libav = await loadLibav(variant);
32455
32665
  const bridge = await loadBridge();
32456
32666
  const { prepareLibavInput: prepareLibavInput2 } = await Promise.resolve().then(() => (init_libav_http_reader(), libav_http_reader_exports));
32457
- const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source);
32667
+ const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source, opts.transport);
32458
32668
  const readPkt = await libav.av_packet_alloc();
32459
32669
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
32460
32670
  const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
@@ -32525,6 +32735,56 @@ async function startHybridDecoder(opts) {
32525
32735
  });
32526
32736
  throw new Error("hybrid decoder: could not initialize any decoders");
32527
32737
  }
32738
+ let bsfCtx = null;
32739
+ let bsfPkt = null;
32740
+ if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
32741
+ try {
32742
+ bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
32743
+ if (bsfCtx != null && bsfCtx >= 0) {
32744
+ const parIn = await libav.AVBSFContext_par_in(bsfCtx);
32745
+ await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
32746
+ await libav.av_bsf_init(bsfCtx);
32747
+ bsfPkt = await libav.av_packet_alloc();
32748
+ dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
32749
+ } else {
32750
+ console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
32751
+ bsfCtx = null;
32752
+ }
32753
+ } catch (err) {
32754
+ console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
32755
+ bsfCtx = null;
32756
+ bsfPkt = null;
32757
+ }
32758
+ }
32759
+ async function applyBSF(packets) {
32760
+ if (!bsfCtx || !bsfPkt) return packets;
32761
+ const out = [];
32762
+ for (const pkt of packets) {
32763
+ await libav.ff_copyin_packet(bsfPkt, pkt);
32764
+ const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
32765
+ if (sendErr < 0) {
32766
+ out.push(pkt);
32767
+ continue;
32768
+ }
32769
+ while (true) {
32770
+ const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
32771
+ if (recvErr < 0) break;
32772
+ out.push(await libav.ff_copyout_packet(bsfPkt));
32773
+ }
32774
+ }
32775
+ return out;
32776
+ }
32777
+ async function flushBSF() {
32778
+ if (!bsfCtx || !bsfPkt) return;
32779
+ try {
32780
+ await libav.av_bsf_send_packet(bsfCtx, 0);
32781
+ while (true) {
32782
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
32783
+ if (err < 0) break;
32784
+ }
32785
+ } catch {
32786
+ }
32787
+ }
32528
32788
  let destroyed = false;
32529
32789
  let pumpToken = 0;
32530
32790
  let pumpRunning = null;
@@ -32552,8 +32812,15 @@ async function startHybridDecoder(opts) {
32552
32812
  if (myToken !== pumpToken || destroyed) return;
32553
32813
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
32554
32814
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
32815
+ if (audioDec && audioPackets && audioPackets.length > 0) {
32816
+ await decodeAudioBatch(audioPackets, myToken);
32817
+ }
32818
+ if (myToken !== pumpToken || destroyed) return;
32819
+ await new Promise((r) => setTimeout(r, 0));
32820
+ if (myToken !== pumpToken || destroyed) return;
32555
32821
  if (videoDecoder && videoPackets && videoPackets.length > 0) {
32556
- for (const pkt of videoPackets) {
32822
+ const processed = await applyBSF(videoPackets);
32823
+ for (const pkt of processed) {
32557
32824
  if (myToken !== pumpToken || destroyed) return;
32558
32825
  sanitizePacketTimestamp(pkt, () => {
32559
32826
  const ts = syntheticVideoUs;
@@ -32573,9 +32840,6 @@ async function startHybridDecoder(opts) {
32573
32840
  }
32574
32841
  }
32575
32842
  }
32576
- if (audioDec && audioPackets && audioPackets.length > 0) {
32577
- await decodeAudioBatch(audioPackets, myToken);
32578
- }
32579
32843
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
32580
32844
  while (!destroyed && myToken === pumpToken && (videoDecoder && videoDecoder.decodeQueueSize > 10 || opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
32581
32845
  await new Promise((r) => setTimeout(r, 50));
@@ -32598,20 +32862,43 @@ async function startHybridDecoder(opts) {
32598
32862
  }
32599
32863
  async function decodeAudioBatch(pkts, myToken, flush = false) {
32600
32864
  if (!audioDec || destroyed || myToken !== pumpToken) return;
32601
- let frames;
32602
- try {
32603
- frames = await libav.ff_decode_multi(
32604
- audioDec.c,
32605
- audioDec.pkt,
32606
- audioDec.frame,
32607
- pkts,
32608
- flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true }
32609
- );
32610
- } catch (err) {
32611
- console.error("[avbridge] hybrid audio decode failed:", err);
32612
- return;
32865
+ const AUDIO_SUB_BATCH = 4;
32866
+ let allFrames = [];
32867
+ for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
32868
+ if (myToken !== pumpToken || destroyed) return;
32869
+ const slice = pkts.slice(i, i + AUDIO_SUB_BATCH);
32870
+ const isLast = i + AUDIO_SUB_BATCH >= pkts.length;
32871
+ try {
32872
+ const frames2 = await libav.ff_decode_multi(
32873
+ audioDec.c,
32874
+ audioDec.pkt,
32875
+ audioDec.frame,
32876
+ slice,
32877
+ isLast && flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true }
32878
+ );
32879
+ allFrames = allFrames.concat(frames2);
32880
+ } catch (err) {
32881
+ console.error("[avbridge] hybrid audio decode failed:", err);
32882
+ return;
32883
+ }
32884
+ if (!isLast) await new Promise((r) => setTimeout(r, 0));
32885
+ }
32886
+ if (pkts.length === 0 && flush) {
32887
+ try {
32888
+ allFrames = await libav.ff_decode_multi(
32889
+ audioDec.c,
32890
+ audioDec.pkt,
32891
+ audioDec.frame,
32892
+ [],
32893
+ { fin: true, ignoreErrors: true }
32894
+ );
32895
+ } catch (err) {
32896
+ console.error("[avbridge] hybrid audio flush failed:", err);
32897
+ return;
32898
+ }
32613
32899
  }
32614
32900
  if (myToken !== pumpToken || destroyed) return;
32901
+ const frames = allFrames;
32615
32902
  for (const f of frames) {
32616
32903
  if (myToken !== pumpToken || destroyed) return;
32617
32904
  sanitizeFrameTimestamp(
@@ -32648,6 +32935,14 @@ async function startHybridDecoder(opts) {
32648
32935
  await pumpRunning;
32649
32936
  } catch {
32650
32937
  }
32938
+ try {
32939
+ if (bsfCtx) await libav.av_bsf_free(bsfCtx);
32940
+ } catch {
32941
+ }
32942
+ try {
32943
+ if (bsfPkt) await libav.av_packet_free?.(bsfPkt);
32944
+ } catch {
32945
+ }
32651
32946
  try {
32652
32947
  if (videoDecoder && videoDecoder.state !== "closed") videoDecoder.close();
32653
32948
  } catch {
@@ -32701,6 +32996,7 @@ async function startHybridDecoder(opts) {
32701
32996
  if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
32702
32997
  } catch {
32703
32998
  }
32999
+ await flushBSF();
32704
33000
  syntheticVideoUs = Math.round(timeSec * 1e6);
32705
33001
  syntheticAudioUs = Math.round(timeSec * 1e6);
32706
33002
  pumpRunning = pumpLoop(newToken).catch(
@@ -32714,6 +33010,7 @@ async function startHybridDecoder(opts) {
32714
33010
  videoFramesDecoded,
32715
33011
  videoChunksFed,
32716
33012
  audioFramesDecoded,
33013
+ bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
32717
33014
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
32718
33015
  // Confirmed transport info — see fallback decoder for the pattern.
32719
33016
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -32890,7 +33187,7 @@ async function loadBridge() {
32890
33187
  // src/strategies/hybrid/index.ts
32891
33188
  var READY_AUDIO_BUFFER_SECONDS = 0.3;
32892
33189
  var READY_TIMEOUT_SECONDS = 10;
32893
- async function createHybridSession(ctx, target) {
33190
+ async function createHybridSession(ctx, target, transport) {
32894
33191
  const { normalizeSource: normalizeSource2 } = await Promise.resolve().then(() => (init_source(), source_exports));
32895
33192
  const source = await normalizeSource2(ctx.source);
32896
33193
  const fps = ctx.videoTracks[0]?.fps ?? 30;
@@ -32903,7 +33200,8 @@ async function createHybridSession(ctx, target) {
32903
33200
  filename: ctx.name ?? "input.bin",
32904
33201
  context: ctx,
32905
33202
  renderer,
32906
- audio
33203
+ audio,
33204
+ transport
32907
33205
  });
32908
33206
  } catch (err) {
32909
33207
  audio.destroy();
@@ -32921,6 +33219,22 @@ async function createHybridSession(ctx, target) {
32921
33219
  configurable: true,
32922
33220
  get: () => !audio.isPlaying()
32923
33221
  });
33222
+ Object.defineProperty(target, "volume", {
33223
+ configurable: true,
33224
+ get: () => audio.getVolume(),
33225
+ set: (v) => {
33226
+ audio.setVolume(v);
33227
+ target.dispatchEvent(new Event("volumechange"));
33228
+ }
33229
+ });
33230
+ Object.defineProperty(target, "muted", {
33231
+ configurable: true,
33232
+ get: () => audio.getMuted(),
33233
+ set: (m) => {
33234
+ audio.setMuted(m);
33235
+ target.dispatchEvent(new Event("volumechange"));
33236
+ }
33237
+ });
32924
33238
  if (ctx.duration && Number.isFinite(ctx.duration)) {
32925
33239
  Object.defineProperty(target, "duration", {
32926
33240
  configurable: true,
@@ -32960,10 +33274,13 @@ async function createHybridSession(ctx, target) {
32960
33274
  if (!audio.isPlaying()) {
32961
33275
  await waitForBuffer();
32962
33276
  await audio.start();
33277
+ target.dispatchEvent(new Event("play"));
33278
+ target.dispatchEvent(new Event("playing"));
32963
33279
  }
32964
33280
  },
32965
33281
  pause() {
32966
33282
  void audio.pause();
33283
+ target.dispatchEvent(new Event("pause"));
32967
33284
  },
32968
33285
  async seek(time) {
32969
33286
  await doSeek(time);
@@ -32986,6 +33303,8 @@ async function createHybridSession(ctx, target) {
32986
33303
  delete target.currentTime;
32987
33304
  delete target.duration;
32988
33305
  delete target.paused;
33306
+ delete target.volume;
33307
+ delete target.muted;
32989
33308
  } catch {
32990
33309
  }
32991
33310
  },
@@ -32997,12 +33316,13 @@ async function createHybridSession(ctx, target) {
32997
33316
 
32998
33317
  // src/strategies/fallback/decoder.ts
32999
33318
  init_libav_loader();
33319
+ init_debug();
33000
33320
  async function startDecoder(opts) {
33001
33321
  const variant = pickLibavVariant(opts.context);
33002
33322
  const libav = await loadLibav(variant);
33003
33323
  const bridge = await loadBridge2();
33004
33324
  const { prepareLibavInput: prepareLibavInput2 } = await Promise.resolve().then(() => (init_libav_http_reader(), libav_http_reader_exports));
33005
- const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source);
33325
+ const inputHandle = await prepareLibavInput2(libav, opts.filename, opts.source, opts.transport);
33006
33326
  const readPkt = await libav.av_packet_alloc();
33007
33327
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
33008
33328
  const videoStream = streams.find((s) => s.codec_type === libav.AVMEDIA_TYPE_VIDEO) ?? null;
@@ -33058,6 +33378,56 @@ async function startDecoder(opts) {
33058
33378
  `fallback decoder: could not initialize any libav decoders (${codecs}).${hint}`
33059
33379
  );
33060
33380
  }
33381
+ let bsfCtx = null;
33382
+ let bsfPkt = null;
33383
+ if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
33384
+ try {
33385
+ bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
33386
+ if (bsfCtx != null && bsfCtx >= 0) {
33387
+ const parIn = await libav.AVBSFContext_par_in(bsfCtx);
33388
+ await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
33389
+ await libav.av_bsf_init(bsfCtx);
33390
+ bsfPkt = await libav.av_packet_alloc();
33391
+ dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
33392
+ } else {
33393
+ console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
33394
+ bsfCtx = null;
33395
+ }
33396
+ } catch (err) {
33397
+ console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
33398
+ bsfCtx = null;
33399
+ bsfPkt = null;
33400
+ }
33401
+ }
33402
+ async function applyBSF(packets) {
33403
+ if (!bsfCtx || !bsfPkt) return packets;
33404
+ const out = [];
33405
+ for (const pkt of packets) {
33406
+ await libav.ff_copyin_packet(bsfPkt, pkt);
33407
+ const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
33408
+ if (sendErr < 0) {
33409
+ out.push(pkt);
33410
+ continue;
33411
+ }
33412
+ while (true) {
33413
+ const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
33414
+ if (recvErr < 0) break;
33415
+ out.push(await libav.ff_copyout_packet(bsfPkt));
33416
+ }
33417
+ }
33418
+ return out;
33419
+ }
33420
+ async function flushBSF() {
33421
+ if (!bsfCtx || !bsfPkt) return;
33422
+ try {
33423
+ await libav.av_bsf_send_packet(bsfCtx, 0);
33424
+ while (true) {
33425
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
33426
+ if (err < 0) break;
33427
+ }
33428
+ } catch {
33429
+ }
33430
+ }
33061
33431
  let destroyed = false;
33062
33432
  let pumpToken = 0;
33063
33433
  let pumpRunning = null;
@@ -33093,7 +33463,8 @@ async function startDecoder(opts) {
33093
33463
  }
33094
33464
  if (myToken !== pumpToken || destroyed) return;
33095
33465
  if (videoDec && videoPackets && videoPackets.length > 0) {
33096
- await decodeVideoBatch(videoPackets, myToken);
33466
+ const processed = await applyBSF(videoPackets);
33467
+ await decodeVideoBatch(processed, myToken);
33097
33468
  }
33098
33469
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
33099
33470
  if (videoFramesDecoded > 0) {
@@ -33239,6 +33610,14 @@ async function startDecoder(opts) {
33239
33610
  await pumpRunning;
33240
33611
  } catch {
33241
33612
  }
33613
+ try {
33614
+ if (bsfCtx) await libav.av_bsf_free(bsfCtx);
33615
+ } catch {
33616
+ }
33617
+ try {
33618
+ if (bsfPkt) await libav.av_packet_free?.(bsfPkt);
33619
+ } catch {
33620
+ }
33242
33621
  try {
33243
33622
  if (videoDec) await libav.ff_free_decoder?.(videoDec.c, videoDec.pkt, videoDec.frame);
33244
33623
  } catch {
@@ -33290,6 +33669,7 @@ async function startDecoder(opts) {
33290
33669
  if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
33291
33670
  } catch {
33292
33671
  }
33672
+ await flushBSF();
33293
33673
  syntheticVideoUs = Math.round(timeSec * 1e6);
33294
33674
  syntheticAudioUs = Math.round(timeSec * 1e6);
33295
33675
  pumpRunning = pumpLoop(newToken).catch(
@@ -33302,6 +33682,7 @@ async function startDecoder(opts) {
33302
33682
  packetsRead,
33303
33683
  videoFramesDecoded,
33304
33684
  audioFramesDecoded,
33685
+ bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
33305
33686
  // Confirmed transport info: once prepareLibavInput returns
33306
33687
  // successfully, we *know* whether the source is http-range (probe
33307
33688
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -33458,7 +33839,7 @@ async function loadBridge2() {
33458
33839
  init_debug();
33459
33840
  var READY_AUDIO_BUFFER_SECONDS2 = 0.04;
33460
33841
  var READY_TIMEOUT_SECONDS2 = 3;
33461
- async function createFallbackSession(ctx, target) {
33842
+ async function createFallbackSession(ctx, target, transport) {
33462
33843
  const { normalizeSource: normalizeSource2 } = await Promise.resolve().then(() => (init_source(), source_exports));
33463
33844
  const source = await normalizeSource2(ctx.source);
33464
33845
  const fps = ctx.videoTracks[0]?.fps ?? 30;
@@ -33471,7 +33852,8 @@ async function createFallbackSession(ctx, target) {
33471
33852
  filename: ctx.name ?? "input.bin",
33472
33853
  context: ctx,
33473
33854
  renderer,
33474
- audio
33855
+ audio,
33856
+ transport
33475
33857
  });
33476
33858
  } catch (err) {
33477
33859
  audio.destroy();
@@ -33489,6 +33871,22 @@ async function createFallbackSession(ctx, target) {
33489
33871
  configurable: true,
33490
33872
  get: () => !audio.isPlaying()
33491
33873
  });
33874
+ Object.defineProperty(target, "volume", {
33875
+ configurable: true,
33876
+ get: () => audio.getVolume(),
33877
+ set: (v) => {
33878
+ audio.setVolume(v);
33879
+ target.dispatchEvent(new Event("volumechange"));
33880
+ }
33881
+ });
33882
+ Object.defineProperty(target, "muted", {
33883
+ configurable: true,
33884
+ get: () => audio.getMuted(),
33885
+ set: (m) => {
33886
+ audio.setMuted(m);
33887
+ target.dispatchEvent(new Event("volumechange"));
33888
+ }
33889
+ });
33492
33890
  if (ctx.duration && Number.isFinite(ctx.duration)) {
33493
33891
  Object.defineProperty(target, "duration", {
33494
33892
  configurable: true,
@@ -33552,10 +33950,13 @@ async function createFallbackSession(ctx, target) {
33552
33950
  if (!audio.isPlaying()) {
33553
33951
  await waitForBuffer();
33554
33952
  await audio.start();
33953
+ target.dispatchEvent(new Event("play"));
33954
+ target.dispatchEvent(new Event("playing"));
33555
33955
  }
33556
33956
  },
33557
33957
  pause() {
33558
33958
  void audio.pause();
33959
+ target.dispatchEvent(new Event("pause"));
33559
33960
  },
33560
33961
  async seek(time) {
33561
33962
  await doSeek(time);
@@ -33575,6 +33976,8 @@ async function createFallbackSession(ctx, target) {
33575
33976
  delete target.currentTime;
33576
33977
  delete target.duration;
33577
33978
  delete target.paused;
33979
+ delete target.volume;
33980
+ delete target.muted;
33578
33981
  } catch {
33579
33982
  }
33580
33983
  },
@@ -33598,12 +34001,12 @@ var remuxPlugin = {
33598
34001
  var hybridPlugin = {
33599
34002
  name: "hybrid",
33600
34003
  canHandle: () => typeof VideoDecoder !== "undefined",
33601
- execute: (ctx, video) => createHybridSession(ctx, video)
34004
+ execute: (ctx, video, transport) => createHybridSession(ctx, video, transport)
33602
34005
  };
33603
34006
  var fallbackPlugin = {
33604
34007
  name: "fallback",
33605
34008
  canHandle: () => true,
33606
- execute: (ctx, video) => createFallbackSession(ctx, video)
34009
+ execute: (ctx, video, transport) => createFallbackSession(ctx, video, transport)
33607
34010
  };
33608
34011
  function registerBuiltins(registry) {
33609
34012
  registry.register(nativePlugin);
@@ -33612,6 +34015,9 @@ function registerBuiltins(registry) {
33612
34015
  registry.register(fallbackPlugin);
33613
34016
  }
33614
34017
 
34018
+ // src/subtitles/index.ts
34019
+ init_transport();
34020
+
33615
34021
  // src/subtitles/srt.ts
33616
34022
  function srtToVtt(srt) {
33617
34023
  if (srt.charCodeAt(0) === 65279) srt = srt.slice(1);
@@ -33689,7 +34095,8 @@ var SubtitleResourceBag = class {
33689
34095
  this.urls.clear();
33690
34096
  }
33691
34097
  };
33692
- async function attachSubtitleTracks(video, tracks, bag, onError) {
34098
+ async function attachSubtitleTracks(video, tracks, bag, onError, transport) {
34099
+ const doFetch = fetchWith(transport);
33693
34100
  for (const t of Array.from(video.querySelectorAll("track[data-avbridge]"))) {
33694
34101
  t.remove();
33695
34102
  }
@@ -33698,13 +34105,13 @@ async function attachSubtitleTracks(video, tracks, bag, onError) {
33698
34105
  try {
33699
34106
  let url2 = t.sidecarUrl;
33700
34107
  if (t.format === "srt") {
33701
- const res = await fetch(t.sidecarUrl);
34108
+ const res = await doFetch(t.sidecarUrl, transport?.requestInit);
33702
34109
  const text = await res.text();
33703
34110
  const vtt = srtToVtt(text);
33704
34111
  const blob = new Blob([vtt], { type: "text/vtt" });
33705
34112
  url2 = bag ? bag.createObjectURL(blob) : URL.createObjectURL(blob);
33706
34113
  } else if (t.format === "vtt") {
33707
- const res = await fetch(t.sidecarUrl);
34114
+ const res = await doFetch(t.sidecarUrl, transport?.requestInit);
33708
34115
  const text = await res.text();
33709
34116
  if (!isVtt(text)) {
33710
34117
  console.warn("[avbridge] subtitle missing WEBVTT header:", t.sidecarUrl);
@@ -33726,6 +34133,7 @@ async function attachSubtitleTracks(video, tracks, bag, onError) {
33726
34133
 
33727
34134
  // src/player.ts
33728
34135
  init_debug();
34136
+ init_errors();
33729
34137
  var UnifiedPlayer = class _UnifiedPlayer {
33730
34138
  /**
33731
34139
  * @internal Use {@link createPlayer} or {@link UnifiedPlayer.create} instead.
@@ -33733,6 +34141,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
33733
34141
  constructor(options, registry) {
33734
34142
  this.options = options;
33735
34143
  this.registry = registry;
34144
+ const { requestInit, fetchFn } = options;
34145
+ if (requestInit || fetchFn) {
34146
+ this.transport = { requestInit, fetchFn };
34147
+ }
33736
34148
  }
33737
34149
  options;
33738
34150
  registry;
@@ -33752,11 +34164,23 @@ var UnifiedPlayer = class _UnifiedPlayer {
33752
34164
  // listener outlives the player and accumulates on elements that swap
33753
34165
  // source (e.g. <avbridge-video>).
33754
34166
  endedListener = null;
34167
+ // Background tab handling. userIntent is what the user last asked for
34168
+ // (play vs pause) — used to decide whether to auto-resume on visibility
34169
+ // return. autoPausedForVisibility tracks whether we paused because the
34170
+ // tab was hidden, so we don't resume playback the user deliberately
34171
+ // paused (e.g. via media keys while hidden).
34172
+ userIntent = "pause";
34173
+ autoPausedForVisibility = false;
34174
+ visibilityListener = null;
33755
34175
  // Serializes escalation / setStrategy calls
33756
34176
  switchingPromise = Promise.resolve();
33757
34177
  // Owns blob URLs created during sidecar discovery + SRT->VTT conversion.
33758
34178
  // Revoked at destroy() so repeated source swaps don't leak.
33759
34179
  subtitleResources = new SubtitleResourceBag();
34180
+ // Transport config extracted from CreatePlayerOptions. Threaded to probe,
34181
+ // subtitle fetches, and strategy session creators. Not stored on MediaContext
34182
+ // because it's runtime config, not media analysis.
34183
+ transport;
33760
34184
  static async create(options) {
33761
34185
  const registry = new PluginRegistry();
33762
34186
  registerBuiltins(registry);
@@ -33780,7 +34204,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33780
34204
  const bootstrapStart = performance.now();
33781
34205
  try {
33782
34206
  dbg.info("bootstrap", "start");
33783
- const ctx = await dbg.timed("probe", "probe", 3e3, () => probe(this.options.source));
34207
+ const ctx = await dbg.timed("probe", "probe", 3e3, () => probe(this.options.source, this.transport));
33784
34208
  dbg.info(
33785
34209
  "probe",
33786
34210
  `container=${ctx.container} video=${ctx.videoTracks[0]?.codec ?? "-"} audio=${ctx.audioTracks[0]?.codec ?? "-"} probedBy=${ctx.probedBy}`
@@ -33828,7 +34252,8 @@ var UnifiedPlayer = class _UnifiedPlayer {
33828
34252
  this.subtitleResources,
33829
34253
  (err, track) => {
33830
34254
  console.warn(`[avbridge] subtitle ${track.id} failed: ${err.message}`);
33831
- }
34255
+ },
34256
+ this.transport
33832
34257
  );
33833
34258
  }
33834
34259
  this.emitter.emitSticky("tracks", {
@@ -33839,6 +34264,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
33839
34264
  this.startTimeupdateLoop();
33840
34265
  this.endedListener = () => this.emitter.emit("ended", void 0);
33841
34266
  this.options.target.addEventListener("ended", this.endedListener);
34267
+ if (this.options.backgroundBehavior !== "continue" && typeof document !== "undefined") {
34268
+ this.visibilityListener = () => this.onVisibilityChange();
34269
+ document.addEventListener("visibilitychange", this.visibilityListener);
34270
+ }
33842
34271
  this.emitter.emitSticky("ready", void 0);
33843
34272
  const bootstrapElapsed = performance.now() - bootstrapStart;
33844
34273
  dbg.info("bootstrap", `ready in ${bootstrapElapsed.toFixed(0)}ms`);
@@ -33865,7 +34294,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33865
34294
  throw new Error(`no plugin available for strategy "${strategy}"`);
33866
34295
  }
33867
34296
  try {
33868
- this.session = await plugin.execute(this.mediaContext, this.options.target);
34297
+ this.session = await plugin.execute(this.mediaContext, this.options.target, this.transport);
33869
34298
  } catch (err) {
33870
34299
  const chain2 = this.classification?.fallbackChain;
33871
34300
  if (chain2 && chain2.length > 0) {
@@ -33938,7 +34367,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
33938
34367
  continue;
33939
34368
  }
33940
34369
  try {
33941
- this.session = await plugin.execute(this.mediaContext, this.options.target);
34370
+ this.session = await plugin.execute(this.mediaContext, this.options.target, this.transport);
33942
34371
  } catch (err) {
33943
34372
  const msg = err instanceof Error ? err.message : String(err);
33944
34373
  errors.push(`${nextStrategy}: ${msg}`);
@@ -33961,8 +34390,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
33961
34390
  }
33962
34391
  return;
33963
34392
  }
33964
- this.emitter.emit("error", new Error(
33965
- `all fallback strategies failed: ${errors.join("; ")}`
34393
+ this.emitter.emit("error", new AvbridgeError(
34394
+ ERR_ALL_STRATEGIES_EXHAUSTED,
34395
+ `All playback strategies failed: ${errors.join("; ")}`,
34396
+ "This file may require a codec or container that isn't available in this browser. Try the fallback strategy or check browser codec support."
33966
34397
  ));
33967
34398
  }
33968
34399
  // ── Stall supervision ─────────────────────────────────────────────────
@@ -34014,7 +34445,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
34014
34445
  // ── Public: manual strategy switch ────────────────────────────────────
34015
34446
  /** Manually switch to a different playback strategy. Preserves current position and play/pause state. Concurrent calls are serialized. */
34016
34447
  async setStrategy(strategy, reason) {
34017
- if (!this.mediaContext) throw new Error("player not ready");
34448
+ if (!this.mediaContext) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34018
34449
  if (this.session?.strategy === strategy) return;
34019
34450
  this.switchingPromise = this.switchingPromise.then(
34020
34451
  () => this.doSetStrategy(strategy, reason)
@@ -34043,7 +34474,7 @@ var UnifiedPlayer = class _UnifiedPlayer {
34043
34474
  }
34044
34475
  const plugin = this.registry.findFor(this.mediaContext, strategy);
34045
34476
  if (!plugin) throw new Error(`no plugin available for strategy "${strategy}"`);
34046
- this.session = await plugin.execute(this.mediaContext, this.options.target);
34477
+ this.session = await plugin.execute(this.mediaContext, this.options.target, this.transport);
34047
34478
  this.emitter.emitSticky("strategy", {
34048
34479
  strategy,
34049
34480
  reason: switchReason
@@ -34077,26 +34508,56 @@ var UnifiedPlayer = class _UnifiedPlayer {
34077
34508
  }
34078
34509
  /** Begin or resume playback. Throws if the player is not ready. */
34079
34510
  async play() {
34080
- if (!this.session) throw new Error("player not ready");
34511
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34512
+ this.userIntent = "play";
34513
+ this.autoPausedForVisibility = false;
34081
34514
  await this.session.play();
34082
34515
  }
34083
34516
  /** Pause playback. No-op if the player is not ready or already paused. */
34084
34517
  pause() {
34518
+ this.userIntent = "pause";
34519
+ this.autoPausedForVisibility = false;
34085
34520
  this.session?.pause();
34086
34521
  }
34522
+ /**
34523
+ * Handle browser tab visibility changes. On hide: pause if the user
34524
+ * had been playing. On show: resume if we were the one who paused.
34525
+ * Skips when `backgroundBehavior: "continue"` is set (listener isn't
34526
+ * installed in that case).
34527
+ */
34528
+ onVisibilityChange() {
34529
+ if (!this.session) return;
34530
+ const action = decideVisibilityAction({
34531
+ hidden: document.hidden,
34532
+ userIntent: this.userIntent,
34533
+ sessionIsPlaying: !this.options.target.paused,
34534
+ autoPausedForVisibility: this.autoPausedForVisibility
34535
+ });
34536
+ if (action === "pause") {
34537
+ this.autoPausedForVisibility = true;
34538
+ dbg.info("visibility", "tab hidden \u2014 auto-paused");
34539
+ this.session.pause();
34540
+ } else if (action === "resume") {
34541
+ this.autoPausedForVisibility = false;
34542
+ dbg.info("visibility", "tab visible \u2014 auto-resuming");
34543
+ void this.session.play().catch((err) => {
34544
+ console.warn("[avbridge] auto-resume after tab return failed:", err);
34545
+ });
34546
+ }
34547
+ }
34087
34548
  /** Seek to the given time in seconds. Throws if the player is not ready. */
34088
34549
  async seek(time) {
34089
- if (!this.session) throw new Error("player not ready");
34550
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34090
34551
  await this.session.seek(time);
34091
34552
  }
34092
34553
  /** Switch the active audio track by track ID. Throws if the player is not ready. */
34093
34554
  async setAudioTrack(id) {
34094
- if (!this.session) throw new Error("player not ready");
34555
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34095
34556
  await this.session.setAudioTrack(id);
34096
34557
  }
34097
34558
  /** Switch the active subtitle track by track ID, or pass `null` to disable subtitles. */
34098
34559
  async setSubtitleTrack(id) {
34099
- if (!this.session) throw new Error("player not ready");
34560
+ if (!this.session) throw new AvbridgeError(ERR_PLAYER_NOT_READY, "Player not ready \u2014 wait for the 'ready' event before calling playback methods.", "Await the 'ready' event or check player.readyState before calling play/pause/seek.");
34100
34561
  await this.session.setSubtitleTrack(id);
34101
34562
  }
34102
34563
  /** Return a snapshot of current diagnostics: container, codecs, strategy, runtime stats, and strategy history. */
@@ -34128,6 +34589,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
34128
34589
  this.options.target.removeEventListener("ended", this.endedListener);
34129
34590
  this.endedListener = null;
34130
34591
  }
34592
+ if (this.visibilityListener) {
34593
+ document.removeEventListener("visibilitychange", this.visibilityListener);
34594
+ this.visibilityListener = null;
34595
+ }
34131
34596
  if (this.session) {
34132
34597
  await this.session.destroy();
34133
34598
  this.session = null;
@@ -34139,6 +34604,14 @@ var UnifiedPlayer = class _UnifiedPlayer {
34139
34604
  async function createPlayer(options) {
34140
34605
  return UnifiedPlayer.create(options);
34141
34606
  }
34607
+ function decideVisibilityAction(state) {
34608
+ if (state.hidden) {
34609
+ if (state.userIntent === "play" && state.sessionIsPlaying) return "pause";
34610
+ return "noop";
34611
+ }
34612
+ if (state.autoPausedForVisibility) return "resume";
34613
+ return "noop";
34614
+ }
34142
34615
  function buildInitialDecision(initial, ctx) {
34143
34616
  const natural = classifyContext(ctx);
34144
34617
  const cls = strategyToClass(initial, natural);