avbridge 2.11.0 → 2.12.1

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 (84) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/{avi-B5CQYB7L.cjs → avi-32UABODO.cjs} +14 -6
  3. package/dist/avi-32UABODO.cjs.map +1 -0
  4. package/dist/{avi-2ILLBNPQ.cjs → avi-5BPR6QUX.cjs} +14 -6
  5. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  6. package/dist/{avi-RWWPN2PR.js → avi-BLIH7KKV.js} +13 -5
  7. package/dist/avi-BLIH7KKV.js.map +1 -0
  8. package/dist/{avi-JXU4GQL2.js → avi-GX2H34IQ.js} +13 -5
  9. package/dist/avi-GX2H34IQ.js.map +1 -0
  10. package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
  11. package/dist/chunk-3AI5WFFN.js.map +1 -0
  12. package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
  13. package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
  14. package/dist/{chunk-GYIJU44C.js → chunk-5CX7BVVV.js} +5 -5
  15. package/dist/{chunk-GYIJU44C.js.map → chunk-5CX7BVVV.js.map} +1 -1
  16. package/dist/{chunk-CL6UEUQF.js → chunk-B76QWPFM.js} +5 -5
  17. package/dist/{chunk-CL6UEUQF.js.map → chunk-B76QWPFM.js.map} +1 -1
  18. package/dist/{chunk-IHNHHEA2.js → chunk-BN7BRTLY.js} +143 -32
  19. package/dist/chunk-BN7BRTLY.js.map +1 -0
  20. package/dist/{chunk-OTFS7DC4.cjs → chunk-E5MAM2P4.cjs} +14 -14
  21. package/dist/{chunk-OTFS7DC4.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  22. package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
  23. package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
  24. package/dist/{chunk-37UOSAVI.cjs → chunk-UM6WCSGL.cjs} +157 -46
  25. package/dist/chunk-UM6WCSGL.cjs.map +1 -0
  26. package/dist/{chunk-BYGZN4Z5.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  27. package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  28. package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
  29. package/dist/chunk-YPZFGJV3.cjs.map +1 -0
  30. package/dist/element-browser.js +186 -43
  31. package/dist/element-browser.js.map +1 -1
  32. package/dist/element.cjs +5 -5
  33. package/dist/element.d.cts +1 -1
  34. package/dist/element.d.ts +1 -1
  35. package/dist/element.js +4 -4
  36. package/dist/index.cjs +21 -21
  37. package/dist/index.d.cts +2 -2
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.js +9 -9
  40. package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
  41. package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
  42. package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
  43. package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
  44. package/dist/libav-http-reader-2S5HAHW4.js +3 -0
  45. package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
  46. package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
  47. package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
  48. package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
  49. package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
  50. package/dist/player.cjs +264 -53
  51. package/dist/player.cjs.map +1 -1
  52. package/dist/player.d.cts +22 -0
  53. package/dist/player.d.ts +22 -0
  54. package/dist/player.js +264 -53
  55. package/dist/player.js.map +1 -1
  56. package/dist/remux-NSBJFMLG.cjs +35 -0
  57. package/dist/{remux-KUS5GIL6.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  58. package/dist/remux-PHUHO3VV.js +10 -0
  59. package/dist/{remux-56V7LDAD.js.map → remux-PHUHO3VV.js.map} +1 -1
  60. package/package.json +1 -1
  61. package/src/element/avbridge-player.ts +123 -23
  62. package/src/element/player-styles.ts +13 -1
  63. package/src/player.ts +3 -3
  64. package/src/probe/avi.ts +34 -2
  65. package/src/strategies/fallback/decoder.ts +148 -19
  66. package/src/strategies/fallback/video-renderer.ts +41 -3
  67. package/src/strategies/hybrid/decoder.ts +34 -9
  68. package/src/types.ts +15 -0
  69. package/src/util/libav-http-reader.ts +58 -19
  70. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  71. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  72. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  73. package/dist/avi-2ILLBNPQ.cjs.map +0 -1
  74. package/dist/avi-B5CQYB7L.cjs.map +0 -1
  75. package/dist/avi-JXU4GQL2.js.map +0 -1
  76. package/dist/avi-RWWPN2PR.js.map +0 -1
  77. package/dist/chunk-37UOSAVI.cjs.map +0 -1
  78. package/dist/chunk-DCSOQH2N.js.map +0 -1
  79. package/dist/chunk-IHNHHEA2.js.map +0 -1
  80. package/dist/chunk-Z33SBWL5.cjs.map +0 -1
  81. package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
  82. package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
  83. package/dist/remux-56V7LDAD.js +0 -10
  84. package/dist/remux-KUS5GIL6.cjs +0 -35
package/dist/player.cjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  var chunk2XW2O3YI_cjs = require('./chunk-2XW2O3YI.cjs');
4
4
  var chunkNNVOHKXJ_cjs = require('./chunk-NNVOHKXJ.cjs');
5
- require('./chunk-Z33SBWL5.cjs');
5
+ require('./chunk-YPZFGJV3.cjs');
6
6
  var chunkWRKO6Q42_cjs = require('./chunk-WRKO6Q42.cjs');
7
7
  require('./chunk-QDJLQR53.cjs');
8
8
 
@@ -239,7 +239,7 @@ async function probe(source, transport) {
239
239
  const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
240
240
  if (hasUnknownCodec) {
241
241
  try {
242
- const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
242
+ const { probeWithLibav } = await import('./avi-5BPR6QUX.cjs');
243
243
  return await probeWithLibav(normalized, sniffed);
244
244
  } catch {
245
245
  return result;
@@ -252,7 +252,7 @@ async function probe(source, transport) {
252
252
  mediabunnyErr.message
253
253
  );
254
254
  try {
255
- const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
255
+ const { probeWithLibav } = await import('./avi-5BPR6QUX.cjs');
256
256
  return await probeWithLibav(normalized, sniffed);
257
257
  } catch (libavErr) {
258
258
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
@@ -266,7 +266,7 @@ async function probe(source, transport) {
266
266
  }
267
267
  }
268
268
  try {
269
- const { probeWithLibav } = await import('./avi-2ILLBNPQ.cjs');
269
+ const { probeWithLibav } = await import('./avi-5BPR6QUX.cjs');
270
270
  return await probeWithLibav(normalized, sniffed);
271
271
  } catch (err) {
272
272
  const inner = err instanceof Error ? err.message : String(err);
@@ -1335,10 +1335,20 @@ var VideoRenderer = class {
1335
1335
  /** Resolves once the first decoded frame has been enqueued. */
1336
1336
  firstFrameReady;
1337
1337
  resolveFirstFrame;
1338
- /** True once at least one frame has been enqueued. */
1338
+ /**
1339
+ * True once at least one frame has been enqueued *since the last flush*.
1340
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
1341
+ * any frame has arrived, and after a seek we want the same semantics
1342
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
1343
+ * `framesPainted > 0` that used to live here was wrong: it kept the
1344
+ * state "true forever" after the first frame ever, so post-seek
1345
+ * `waitForBuffer()` would exit immediately with an empty queue and
1346
+ * leave video frozen while audio kept going.
1347
+ */
1339
1348
  hasFrames() {
1340
- return this.queue.length > 0 || this.framesPainted > 0;
1349
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
1341
1350
  }
1351
+ hasEverEnqueuedSinceFlush = false;
1342
1352
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
1343
1353
  queueDepth() {
1344
1354
  return this.queue.length;
@@ -1357,6 +1367,7 @@ var VideoRenderer = class {
1357
1367
  return;
1358
1368
  }
1359
1369
  this.queue.push(frame);
1370
+ this.hasEverEnqueuedSinceFlush = true;
1360
1371
  if (this.queue.length === 1 && this.framesPainted === 0) {
1361
1372
  this.resolveFirstFrame();
1362
1373
  }
@@ -1490,7 +1501,8 @@ var VideoRenderer = class {
1490
1501
  }
1491
1502
  return;
1492
1503
  }
1493
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
1504
+ const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
1505
+ const dropThresholdUs = _relaxDrop ? audioNowUs - 60 * 1e6 : audioNowUs - frameDurationUs * 2;
1494
1506
  let dropped = 0;
1495
1507
  while (bestIdx > 0) {
1496
1508
  const ts = this.queue[0].timestamp ?? 0;
@@ -1551,16 +1563,28 @@ var VideoRenderer = class {
1551
1563
  while (this.queue.length > 0) this.queue.shift()?.close();
1552
1564
  this.prerolled = false;
1553
1565
  this.ptsCalibrated = false;
1566
+ this.hasEverEnqueuedSinceFlush = false;
1554
1567
  if (isDebug() && count > 0) {
1555
1568
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
1556
1569
  }
1557
1570
  }
1558
1571
  stats() {
1572
+ let queueSpanMs = 0;
1573
+ let queueHeadMs = 0;
1574
+ let queueTailMs = 0;
1575
+ if (this.queue.length > 0) {
1576
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1e3);
1577
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1e3);
1578
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
1579
+ }
1559
1580
  return {
1560
1581
  framesPainted: this.framesPainted,
1561
1582
  framesDroppedLate: this.framesDroppedLate,
1562
1583
  framesDroppedOverflow: this.framesDroppedOverflow,
1563
- queueDepth: this.queue.length
1584
+ queueDepth: this.queue.length,
1585
+ queueHeadMs,
1586
+ queueTailMs,
1587
+ queueSpanMs
1564
1588
  };
1565
1589
  }
1566
1590
  destroy() {
@@ -2023,7 +2047,7 @@ async function startHybridDecoder(opts) {
2023
2047
  const variant = pickLibavVariant(opts.context);
2024
2048
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
2025
2049
  const bridge = await loadBridge();
2026
- const { prepareLibavInput } = await import('./libav-http-reader-AZLE7YFS.cjs');
2050
+ const { prepareLibavInput } = await import('./libav-http-reader-Q356EO2K.cjs');
2027
2051
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2028
2052
  const readPkt = await libav.av_packet_alloc();
2029
2053
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -2098,6 +2122,7 @@ async function startHybridDecoder(opts) {
2098
2122
  }
2099
2123
  let bsfCtx = null;
2100
2124
  let bsfPkt = null;
2125
+ let bsfRequiredButMissing = false;
2101
2126
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2102
2127
  try {
2103
2128
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2108,13 +2133,19 @@ async function startHybridDecoder(opts) {
2108
2133
  bsfPkt = await libav.av_packet_alloc();
2109
2134
  chunkNNVOHKXJ_cjs.dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
2110
2135
  } else {
2111
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
2136
+ bsfRequiredButMissing = true;
2112
2137
  bsfCtx = null;
2113
2138
  }
2114
2139
  } catch (err) {
2115
- console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
2140
+ bsfRequiredButMissing = true;
2116
2141
  bsfCtx = null;
2117
2142
  bsfPkt = null;
2143
+ chunkNNVOHKXJ_cjs.dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2144
+ }
2145
+ if (bsfRequiredButMissing) {
2146
+ console.error(
2147
+ "[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes BSF is unavailable in this libav variant. Files with packed B-frames will play with incorrect frame ordering. Rebuild the libav variant with the `avbsf` fragment included."
2148
+ );
2118
2149
  }
2119
2150
  }
2120
2151
  async function applyBSF(packets) {
@@ -2124,7 +2155,6 @@ async function startHybridDecoder(opts) {
2124
2155
  await libav.ff_copyin_packet(bsfPkt, pkt);
2125
2156
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2126
2157
  if (sendErr < 0) {
2127
- out.push(pkt);
2128
2158
  continue;
2129
2159
  }
2130
2160
  while (true) {
@@ -2138,10 +2168,13 @@ async function startHybridDecoder(opts) {
2138
2168
  async function flushBSF() {
2139
2169
  if (!bsfCtx || !bsfPkt) return;
2140
2170
  try {
2141
- await libav.av_bsf_send_packet(bsfCtx, 0);
2142
- while (true) {
2143
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2144
- if (err < 0) break;
2171
+ if (libav.av_bsf_flush) {
2172
+ await libav.av_bsf_flush(bsfCtx);
2173
+ } else {
2174
+ while (true) {
2175
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2176
+ if (err < 0) break;
2177
+ }
2145
2178
  }
2146
2179
  } catch {
2147
2180
  }
@@ -2453,6 +2486,7 @@ async function startHybridDecoder(opts) {
2453
2486
  videoChunksFed,
2454
2487
  audioFramesDecoded,
2455
2488
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
2489
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
2456
2490
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
2457
2491
  // Confirmed transport info — see fallback decoder for the pattern.
2458
2492
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -2693,7 +2727,7 @@ async function startDecoder(opts) {
2693
2727
  const variant = "avbridge";
2694
2728
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
2695
2729
  const bridge = await loadBridge2();
2696
- const { prepareLibavInput } = await import('./libav-http-reader-AZLE7YFS.cjs');
2730
+ const { prepareLibavInput } = await import('./libav-http-reader-Q356EO2K.cjs');
2697
2731
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2698
2732
  const readPkt = await libav.av_packet_alloc();
2699
2733
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -2752,6 +2786,7 @@ async function startDecoder(opts) {
2752
2786
  }
2753
2787
  let bsfCtx = null;
2754
2788
  let bsfPkt = null;
2789
+ let bsfRequiredButMissing = false;
2755
2790
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2756
2791
  try {
2757
2792
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2762,13 +2797,19 @@ async function startDecoder(opts) {
2762
2797
  bsfPkt = await libav.av_packet_alloc();
2763
2798
  chunkNNVOHKXJ_cjs.dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
2764
2799
  } else {
2765
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
2800
+ bsfRequiredButMissing = true;
2766
2801
  bsfCtx = null;
2767
2802
  }
2768
2803
  } catch (err) {
2769
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
2804
+ bsfRequiredButMissing = true;
2770
2805
  bsfCtx = null;
2771
2806
  bsfPkt = null;
2807
+ chunkNNVOHKXJ_cjs.dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2808
+ }
2809
+ if (bsfRequiredButMissing) {
2810
+ console.error(
2811
+ "[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes BSF is unavailable in this libav variant. Files with packed B-frames will play with incorrect frame ordering (backwards PTS jumps, heavy late-drop stuttering). Rebuild the libav variant with the `avbsf` fragment included. See docs/dev/POSTMORTEMS.md for details."
2812
+ );
2772
2813
  }
2773
2814
  }
2774
2815
  async function applyBSF(packets) {
@@ -2778,7 +2819,6 @@ async function startDecoder(opts) {
2778
2819
  await libav.ff_copyin_packet(bsfPkt, pkt);
2779
2820
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2780
2821
  if (sendErr < 0) {
2781
- out.push(pkt);
2782
2822
  continue;
2783
2823
  }
2784
2824
  while (true) {
@@ -2792,10 +2832,13 @@ async function startDecoder(opts) {
2792
2832
  async function flushBSF() {
2793
2833
  if (!bsfCtx || !bsfPkt) return;
2794
2834
  try {
2795
- await libav.av_bsf_send_packet(bsfCtx, 0);
2796
- while (true) {
2797
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2798
- if (err < 0) break;
2835
+ if (libav.av_bsf_flush) {
2836
+ await libav.av_bsf_flush(bsfCtx);
2837
+ } else {
2838
+ while (true) {
2839
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2840
+ if (err < 0) break;
2841
+ }
2799
2842
  }
2800
2843
  } catch {
2801
2844
  }
@@ -2813,6 +2856,19 @@ async function startDecoder(opts) {
2813
2856
  let watchdogOverflowWarned = false;
2814
2857
  let syntheticVideoUs = 0;
2815
2858
  let syntheticAudioUs = 0;
2859
+ let videoDecodeMsTotal = 0;
2860
+ let audioDecodeMsTotal = 0;
2861
+ let videoDecodeBatches = 0;
2862
+ let audioDecodeBatches = 0;
2863
+ let readMsTotal = 0;
2864
+ let readBatches = 0;
2865
+ let pumpThrottleMsTotal = 0;
2866
+ let pumpThrottleEntries = 0;
2867
+ let slowestVideoBatchMs = 0;
2868
+ let newestVideoPtsUs = 0;
2869
+ let lastEmittedPtsUs = -1;
2870
+ let ptsRegressions = 0;
2871
+ let worstPtsRegressionMs = 0;
2816
2872
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
2817
2873
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
2818
2874
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -2821,9 +2877,12 @@ async function startDecoder(opts) {
2821
2877
  let readErr;
2822
2878
  let packets;
2823
2879
  try {
2880
+ const _readStart = performance.now();
2824
2881
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
2825
2882
  limit: 16 * 1024
2826
2883
  });
2884
+ readMsTotal += performance.now() - _readStart;
2885
+ readBatches++;
2827
2886
  } catch (err) {
2828
2887
  console.error("[avbridge] ff_read_frame_multi failed:", err);
2829
2888
  return;
@@ -2885,8 +2944,17 @@ async function startDecoder(opts) {
2885
2944
  }
2886
2945
  }
2887
2946
  }
2888
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2889
- await new Promise((r) => setTimeout(r, 50));
2947
+ {
2948
+ const _throttleStart = performance.now();
2949
+ let _throttled = false;
2950
+ while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2951
+ _throttled = true;
2952
+ await new Promise((r) => setTimeout(r, 50));
2953
+ }
2954
+ if (_throttled) {
2955
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
2956
+ pumpThrottleEntries++;
2957
+ }
2890
2958
  }
2891
2959
  if (readErr === libav.AVERROR_EOF) {
2892
2960
  if (videoDec) await decodeVideoBatch(
@@ -2912,6 +2980,7 @@ async function startDecoder(opts) {
2912
2980
  async function decodeVideoBatch(pkts, myToken, flush = false) {
2913
2981
  if (!videoDec || destroyed || myToken !== pumpToken) return;
2914
2982
  let frames;
2983
+ const _t0 = performance.now();
2915
2984
  try {
2916
2985
  frames = await libav.ff_decode_multi(
2917
2986
  videoDec.c,
@@ -2924,18 +2993,38 @@ async function startDecoder(opts) {
2924
2993
  console.error("[avbridge] video decode batch failed:", err);
2925
2994
  return;
2926
2995
  }
2996
+ {
2997
+ const _dt = performance.now() - _t0;
2998
+ videoDecodeMsTotal += _dt;
2999
+ videoDecodeBatches++;
3000
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
3001
+ }
2927
3002
  if (myToken !== pumpToken || destroyed) return;
2928
3003
  for (const f of frames) {
2929
3004
  if (myToken !== pumpToken || destroyed) return;
2930
3005
  sanitizeFrameTimestamp(
2931
3006
  f,
2932
3007
  () => {
2933
- const ts = syntheticVideoUs;
2934
- syntheticVideoUs += videoFrameStepUs;
2935
- return ts;
3008
+ const base = lastEmittedPtsUs >= 0 ? lastEmittedPtsUs + videoFrameStepUs : syntheticVideoUs;
3009
+ syntheticVideoUs = base + videoFrameStepUs;
3010
+ return base;
2936
3011
  },
2937
3012
  videoTimeBase
2938
3013
  );
3014
+ const _fPts = (f.ptshi ?? 0) * 4294967296 + (f.pts ?? 0);
3015
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
3016
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
3017
+ ptsRegressions++;
3018
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
3019
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
3020
+ if (ptsRegressions <= 10) {
3021
+ console.warn(
3022
+ `[avbridge:decoder] dropped out-of-order frame #${ptsRegressions}: pts=${(_fPts / 1e3).toFixed(1)}ms < previous=${(lastEmittedPtsUs / 1e3).toFixed(1)}ms (regression=${regressMs.toFixed(1)}ms). Typically a post-seek B-frame reorder tail.`
3023
+ );
3024
+ }
3025
+ continue;
3026
+ }
3027
+ lastEmittedPtsUs = _fPts;
2939
3028
  try {
2940
3029
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
2941
3030
  opts.renderer.enqueue(vf);
@@ -2950,6 +3039,7 @@ async function startDecoder(opts) {
2950
3039
  async function decodeAudioBatch(pkts, myToken, flush = false) {
2951
3040
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2952
3041
  let frames;
3042
+ const _t0 = performance.now();
2953
3043
  try {
2954
3044
  frames = await libav.ff_decode_multi(
2955
3045
  audioDec.c,
@@ -2962,6 +3052,8 @@ async function startDecoder(opts) {
2962
3052
  console.error("[avbridge] audio decode batch failed:", err);
2963
3053
  return;
2964
3054
  }
3055
+ audioDecodeMsTotal += performance.now() - _t0;
3056
+ audioDecodeBatches++;
2965
3057
  if (myToken !== pumpToken || destroyed) return;
2966
3058
  for (const f of frames) {
2967
3059
  if (myToken !== pumpToken || destroyed) return;
@@ -3083,6 +3175,7 @@ async function startDecoder(opts) {
3083
3175
  await flushBSF();
3084
3176
  syntheticVideoUs = Math.round(timeSec * 1e6);
3085
3177
  syntheticAudioUs = Math.round(timeSec * 1e6);
3178
+ lastEmittedPtsUs = -1;
3086
3179
  pumpRunning = pumpLoop(newToken).catch(
3087
3180
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
3088
3181
  );
@@ -3120,6 +3213,7 @@ async function startDecoder(opts) {
3120
3213
  await flushBSF();
3121
3214
  syntheticVideoUs = Math.round(timeSec * 1e6);
3122
3215
  syntheticAudioUs = Math.round(timeSec * 1e6);
3216
+ lastEmittedPtsUs = -1;
3123
3217
  pumpRunning = pumpLoop(newToken).catch(
3124
3218
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3125
3219
  );
@@ -3133,7 +3227,24 @@ async function startDecoder(opts) {
3133
3227
  packetsRead,
3134
3228
  videoFramesDecoded,
3135
3229
  audioFramesDecoded,
3230
+ // Throughput instrumentation — the stats panel turns these into
3231
+ // "decode fps actual / realtime target" and shows slowest batch
3232
+ // + producer throttle share.
3233
+ videoDecodeMsTotal,
3234
+ videoDecodeBatches,
3235
+ audioDecodeMsTotal,
3236
+ audioDecodeBatches,
3237
+ readMsTotal,
3238
+ readBatches,
3239
+ pumpThrottleMsTotal,
3240
+ pumpThrottleEntries,
3241
+ slowestVideoBatchMs,
3242
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1e3),
3243
+ ptsRegressions,
3244
+ worstPtsRegressionMs,
3245
+ sourceFps: videoFps,
3136
3246
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
3247
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
3137
3248
  // Confirmed transport info: once prepareLibavInput returns
3138
3249
  // successfully, we *know* whether the source is http-range (probe
3139
3250
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -3422,9 +3533,9 @@ var UnifiedPlayer = class _UnifiedPlayer {
3422
3533
  constructor(options, registry) {
3423
3534
  this.options = options;
3424
3535
  this.registry = registry;
3425
- const { requestInit, fetchFn } = options;
3426
- if (requestInit || fetchFn) {
3427
- this.transport = { requestInit, fetchFn };
3536
+ const { requestInit, fetchFn, cacheBytes } = options;
3537
+ if (requestInit || fetchFn || cacheBytes !== void 0) {
3538
+ this.transport = { requestInit, fetchFn, cacheBytes };
3428
3539
  }
3429
3540
  }
3430
3541
  options;
@@ -5035,6 +5146,12 @@ var PLAYER_STYLES = (
5035
5146
  display: flex;
5036
5147
  align-items: center;
5037
5148
  cursor: pointer;
5149
+ /* Claim all touch gestures on the seek bar. Without this, Android
5150
+ * browsers (Chrome, Samsung Internet) treat horizontal drags as
5151
+ * scroll candidates and cancel pointermove once the gesture
5152
+ * resolves, breaking scrub. touch-action must be set in CSS \u2014
5153
+ * preventDefault() on pointerdown is too late. */
5154
+ touch-action: none;
5038
5155
  }
5039
5156
 
5040
5157
  .avp-seek-track {
@@ -5052,7 +5169,13 @@ var PLAYER_STYLES = (
5052
5169
 
5053
5170
  .avp-seek-buffered {
5054
5171
  position: absolute;
5055
- left: 0;
5172
+ inset: 0;
5173
+ pointer-events: none;
5174
+ }
5175
+
5176
+ .avp-seek-buffered-range {
5177
+ position: absolute;
5178
+ top: 0;
5056
5179
  height: 100%;
5057
5180
  background: rgba(255, 255, 255, 0.35);
5058
5181
  border-radius: inherit;
@@ -5612,6 +5735,7 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5612
5735
  on(this._video, "ended", () => this._setState("ended"));
5613
5736
  on(this._video, "error", () => this._setState("error"));
5614
5737
  on(this._video, "timeupdate", () => this._updateTime());
5738
+ on(this._video, "progress", () => this._updateBuffered());
5615
5739
  on(this._video, "volumechange", () => this._updateVolume());
5616
5740
  on(this._video, "trackschange", () => this._buildSettingsMenu());
5617
5741
  on(this._video, "durationchange", () => {
@@ -5828,13 +5952,45 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5828
5952
  this._seekInput.value = String(t);
5829
5953
  this._updateSeekVisuals(t);
5830
5954
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
5955
+ this._updateBuffered();
5956
+ }
5957
+ /**
5958
+ * Render every buffered range as its own segment so gaps (common on MSE
5959
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
5960
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
5961
+ */
5962
+ _updateBuffered() {
5963
+ const d = this._video.duration;
5964
+ if (!(d > 0)) return;
5965
+ let buf;
5831
5966
  try {
5832
- const buf = this._video.buffered;
5833
- if (buf && buf.length > 0 && d > 0) {
5834
- const end = buf.end(buf.length - 1);
5835
- this._seekBuffered.style.width = `${end / d * 100}%`;
5836
- }
5967
+ buf = this._video.buffered;
5837
5968
  } catch {
5969
+ return;
5970
+ }
5971
+ const count = buf ? buf.length : 0;
5972
+ const host = this._seekBuffered;
5973
+ while (host.childElementCount > count) host.lastElementChild.remove();
5974
+ while (host.childElementCount < count) {
5975
+ const seg = document.createElement("div");
5976
+ seg.className = "avp-seek-buffered-range";
5977
+ host.appendChild(seg);
5978
+ }
5979
+ for (let i = 0; i < count; i++) {
5980
+ let start;
5981
+ let end;
5982
+ try {
5983
+ start = buf.start(i);
5984
+ end = buf.end(i);
5985
+ } catch {
5986
+ continue;
5987
+ }
5988
+ const s = Math.max(0, start);
5989
+ const e = Math.min(d, end);
5990
+ if (e <= s) continue;
5991
+ const seg = host.children[i];
5992
+ seg.style.left = `${s / d * 100}%`;
5993
+ seg.style.width = `${(e - s) / d * 100}%`;
5838
5994
  }
5839
5995
  }
5840
5996
  // ── Controls: volume ───────────────────────────────────────────────────
@@ -5977,10 +6133,12 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5977
6133
  }
5978
6134
  }
5979
6135
  // ── Stats for nerds ────────────────────────────────────────────────────
6136
+ _statsPrev = null;
5980
6137
  _toggleStats() {
5981
6138
  this._statsOpen = !this._statsOpen;
5982
6139
  this._statsEl.classList.toggle("open", this._statsOpen);
5983
6140
  if (this._statsOpen) {
6141
+ this._statsPrev = null;
5984
6142
  this._updateStats();
5985
6143
  this._statsInterval = setInterval(() => this._updateStats(), 1e3);
5986
6144
  } else {
@@ -5997,23 +6155,76 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5997
6155
  return;
5998
6156
  }
5999
6157
  const rt = d.runtime ?? {};
6000
- const lines = [
6001
- `Container: ${d.container ?? "?"}`,
6002
- `Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}`,
6003
- `Audio: ${d.audioCodec ?? "none"}`,
6004
- `Strategy: ${d.strategy ?? "?"} Class: ${d.strategyClass ?? "?"}`,
6005
- `Transport: ${d.transport ?? "?"} Range: ${d.rangeSupported ?? "?"}`,
6006
- `Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"}`
6007
- ];
6008
- if (rt.framesDecoded != null) lines.push(`Frames: ${rt.framesDecoded} decoded, ${rt.framesDropped ?? 0} dropped`);
6009
- if (rt.framesPainted != null) lines.push(`Painted: ${rt.framesPainted} Late: ${rt.framesDroppedLate ?? 0} Overflow: ${rt.framesDroppedOverflow ?? 0}`);
6010
- if (rt.videoFramesDecoded != null) lines.push(`Video decoded: ${rt.videoFramesDecoded} Chunks fed: ${rt.videoChunksFed ?? "?"}`);
6011
- if (rt.audioFramesDecoded != null) lines.push(`Audio decoded: ${rt.audioFramesDecoded}`);
6012
- if (rt.packetsRead != null) lines.push(`Packets read: ${rt.packetsRead}`);
6013
- if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF: ${rt.bsfApplied.join(", ")}`);
6014
- if (rt.audioState != null) lines.push(`Audio state: ${rt.audioState} Clock: ${rt.clockMode ?? "?"}`);
6158
+ const now = performance.now();
6159
+ const prev = this._statsPrev;
6160
+ const dtSec = prev ? Math.max(1e-3, (now - prev.ts) / 1e3) : 0;
6161
+ const delta = (key) => {
6162
+ if (!prev) return null;
6163
+ const a = rt[key];
6164
+ const b = prev.rt[key];
6165
+ if (typeof a === "number" && typeof b === "number") return a - b;
6166
+ return null;
6167
+ };
6168
+ const rate = (key) => {
6169
+ const d_ = delta(key);
6170
+ return d_ != null ? d_ / dtSec : null;
6171
+ };
6172
+ const fmt = (n, digits = 1) => n == null ? "?" : n.toFixed(digits);
6173
+ const sourceFps = typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps;
6174
+ const lines = [];
6175
+ lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
6176
+ lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
6177
+ lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
6178
+ lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
6179
+ if (rt.videoFramesDecoded != null) {
6180
+ const decFps = rate("videoFramesDecoded");
6181
+ const paintFps = rate("framesPainted");
6182
+ const dropLateFps = rate("framesDroppedLate");
6183
+ const dropOverflowFps = rate("framesDroppedOverflow");
6184
+ const pct = sourceFps && decFps != null ? ` (${(decFps / sourceFps * 100).toFixed(0)}% of realtime)` : "";
6185
+ lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
6186
+ lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
6187
+ }
6188
+ if (typeof rt.videoDecodeMsTotal === "number") {
6189
+ const msDelta = delta("videoDecodeMsTotal");
6190
+ const batchesDelta = delta("videoDecodeBatches");
6191
+ const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
6192
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6193
+ lines.push(
6194
+ `Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs)}ms`
6195
+ );
6196
+ }
6197
+ if (typeof rt.audioDecodeMsTotal === "number") {
6198
+ const msDelta = delta("audioDecodeMsTotal");
6199
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6200
+ lines.push(`Audio decode: ${fmt(share)}% of wall`);
6201
+ }
6202
+ if (typeof rt.pumpThrottleMsTotal === "number") {
6203
+ const msDelta = delta("pumpThrottleMsTotal");
6204
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6205
+ lines.push(`Producer throttled: ${fmt(share)}% of wall`);
6206
+ }
6207
+ if (rt.queueDepth != null) {
6208
+ lines.push(
6209
+ `Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs)}ms head=${fmt(rt.queueHeadMs)}ms tail=${fmt(rt.queueTailMs)}ms`
6210
+ );
6211
+ }
6212
+ if (typeof rt.newestVideoPtsMs === "number") {
6213
+ lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1e3).toFixed(2)}s`);
6214
+ }
6215
+ if (rt.audioState != null) {
6216
+ lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead, 2)}s clock=${rt.clockMode ?? "?"}`);
6217
+ }
6218
+ if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
6219
+ lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs)}ms) \u2014 decoder emitting out of order`);
6220
+ }
6221
+ if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF active: ${rt.bsfApplied.join(", ")}`);
6222
+ if (rt.bsfMissing && rt.bsfMissing.length > 0) {
6223
+ lines.push(`BSF MISSING: ${rt.bsfMissing.join(", ")} (rebuild libav with avbsf)`);
6224
+ }
6015
6225
  if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
6016
6226
  this._statsEl.textContent = lines.join("\n");
6227
+ this._statsPrev = { ts: now, rt: { ...rt } };
6017
6228
  }
6018
6229
  // ── Controls: fullscreen ───────────────────────────────────────────────
6019
6230
  _toggleFullscreen() {