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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AvbridgeError, ERR_ALL_STRATEGIES_EXHAUSTED, ERR_PLAYER_NOT_READY, normalizeSource, sniffNormalizedSource, ERR_PROBE_FAILED, ERR_PROBE_UNKNOWN_CONTAINER, ERR_LIBAV_NOT_REACHABLE, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from './chunk-E76AMWI4.js';
2
2
  import { dbg, loadLibav } from './chunk-IAYKFGFG.js';
3
- import './chunk-DCSOQH2N.js';
3
+ import './chunk-3AI5WFFN.js';
4
4
  import { SubtitleResourceBag, discoverSidecars, attachSubtitleTracks, SubtitleOverlay } from './chunk-EDDWAN2L.js';
5
5
  import './chunk-LUFA47FP.js';
6
6
 
@@ -237,7 +237,7 @@ async function probe(source, transport) {
237
237
  const hasUnknownCodec = result.videoTracks.some((t) => t.codec === "unknown") || result.audioTracks.some((t) => t.codec === "unknown");
238
238
  if (hasUnknownCodec) {
239
239
  try {
240
- const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
240
+ const { probeWithLibav } = await import('./avi-GX2H34IQ.js');
241
241
  return await probeWithLibav(normalized, sniffed);
242
242
  } catch {
243
243
  return result;
@@ -250,7 +250,7 @@ async function probe(source, transport) {
250
250
  mediabunnyErr.message
251
251
  );
252
252
  try {
253
- const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
253
+ const { probeWithLibav } = await import('./avi-GX2H34IQ.js');
254
254
  return await probeWithLibav(normalized, sniffed);
255
255
  } catch (libavErr) {
256
256
  const mbMsg = mediabunnyErr.message || String(mediabunnyErr);
@@ -264,7 +264,7 @@ async function probe(source, transport) {
264
264
  }
265
265
  }
266
266
  try {
267
- const { probeWithLibav } = await import('./avi-JXU4GQL2.js');
267
+ const { probeWithLibav } = await import('./avi-GX2H34IQ.js');
268
268
  return await probeWithLibav(normalized, sniffed);
269
269
  } catch (err) {
270
270
  const inner = err instanceof Error ? err.message : String(err);
@@ -1333,10 +1333,20 @@ var VideoRenderer = class {
1333
1333
  /** Resolves once the first decoded frame has been enqueued. */
1334
1334
  firstFrameReady;
1335
1335
  resolveFirstFrame;
1336
- /** True once at least one frame has been enqueued. */
1336
+ /**
1337
+ * True once at least one frame has been enqueued *since the last flush*.
1338
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
1339
+ * any frame has arrived, and after a seek we want the same semantics
1340
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
1341
+ * `framesPainted > 0` that used to live here was wrong: it kept the
1342
+ * state "true forever" after the first frame ever, so post-seek
1343
+ * `waitForBuffer()` would exit immediately with an empty queue and
1344
+ * leave video frozen while audio kept going.
1345
+ */
1337
1346
  hasFrames() {
1338
- return this.queue.length > 0 || this.framesPainted > 0;
1347
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
1339
1348
  }
1349
+ hasEverEnqueuedSinceFlush = false;
1340
1350
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
1341
1351
  queueDepth() {
1342
1352
  return this.queue.length;
@@ -1355,6 +1365,7 @@ var VideoRenderer = class {
1355
1365
  return;
1356
1366
  }
1357
1367
  this.queue.push(frame);
1368
+ this.hasEverEnqueuedSinceFlush = true;
1358
1369
  if (this.queue.length === 1 && this.framesPainted === 0) {
1359
1370
  this.resolveFirstFrame();
1360
1371
  }
@@ -1488,7 +1499,8 @@ var VideoRenderer = class {
1488
1499
  }
1489
1500
  return;
1490
1501
  }
1491
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
1502
+ const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
1503
+ const dropThresholdUs = _relaxDrop ? audioNowUs - 60 * 1e6 : audioNowUs - frameDurationUs * 2;
1492
1504
  let dropped = 0;
1493
1505
  while (bestIdx > 0) {
1494
1506
  const ts = this.queue[0].timestamp ?? 0;
@@ -1549,16 +1561,28 @@ var VideoRenderer = class {
1549
1561
  while (this.queue.length > 0) this.queue.shift()?.close();
1550
1562
  this.prerolled = false;
1551
1563
  this.ptsCalibrated = false;
1564
+ this.hasEverEnqueuedSinceFlush = false;
1552
1565
  if (isDebug() && count > 0) {
1553
1566
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
1554
1567
  }
1555
1568
  }
1556
1569
  stats() {
1570
+ let queueSpanMs = 0;
1571
+ let queueHeadMs = 0;
1572
+ let queueTailMs = 0;
1573
+ if (this.queue.length > 0) {
1574
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1e3);
1575
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1e3);
1576
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
1577
+ }
1557
1578
  return {
1558
1579
  framesPainted: this.framesPainted,
1559
1580
  framesDroppedLate: this.framesDroppedLate,
1560
1581
  framesDroppedOverflow: this.framesDroppedOverflow,
1561
- queueDepth: this.queue.length
1582
+ queueDepth: this.queue.length,
1583
+ queueHeadMs,
1584
+ queueTailMs,
1585
+ queueSpanMs
1562
1586
  };
1563
1587
  }
1564
1588
  destroy() {
@@ -2021,7 +2045,7 @@ async function startHybridDecoder(opts) {
2021
2045
  const variant = pickLibavVariant(opts.context);
2022
2046
  const libav = await loadLibav(variant);
2023
2047
  const bridge = await loadBridge();
2024
- const { prepareLibavInput } = await import('./libav-http-reader-WXG3Z7AI.js');
2048
+ const { prepareLibavInput } = await import('./libav-http-reader-2S5HAHW4.js');
2025
2049
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2026
2050
  const readPkt = await libav.av_packet_alloc();
2027
2051
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -2096,6 +2120,7 @@ async function startHybridDecoder(opts) {
2096
2120
  }
2097
2121
  let bsfCtx = null;
2098
2122
  let bsfPkt = null;
2123
+ let bsfRequiredButMissing = false;
2099
2124
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2100
2125
  try {
2101
2126
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2106,13 +2131,19 @@ async function startHybridDecoder(opts) {
2106
2131
  bsfPkt = await libav.av_packet_alloc();
2107
2132
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
2108
2133
  } else {
2109
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
2134
+ bsfRequiredButMissing = true;
2110
2135
  bsfCtx = null;
2111
2136
  }
2112
2137
  } catch (err) {
2113
- console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
2138
+ bsfRequiredButMissing = true;
2114
2139
  bsfCtx = null;
2115
2140
  bsfPkt = null;
2141
+ dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2142
+ }
2143
+ if (bsfRequiredButMissing) {
2144
+ console.error(
2145
+ "[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."
2146
+ );
2116
2147
  }
2117
2148
  }
2118
2149
  async function applyBSF(packets) {
@@ -2122,7 +2153,6 @@ async function startHybridDecoder(opts) {
2122
2153
  await libav.ff_copyin_packet(bsfPkt, pkt);
2123
2154
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2124
2155
  if (sendErr < 0) {
2125
- out.push(pkt);
2126
2156
  continue;
2127
2157
  }
2128
2158
  while (true) {
@@ -2136,10 +2166,13 @@ async function startHybridDecoder(opts) {
2136
2166
  async function flushBSF() {
2137
2167
  if (!bsfCtx || !bsfPkt) return;
2138
2168
  try {
2139
- await libav.av_bsf_send_packet(bsfCtx, 0);
2140
- while (true) {
2141
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2142
- if (err < 0) break;
2169
+ if (libav.av_bsf_flush) {
2170
+ await libav.av_bsf_flush(bsfCtx);
2171
+ } else {
2172
+ while (true) {
2173
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2174
+ if (err < 0) break;
2175
+ }
2143
2176
  }
2144
2177
  } catch {
2145
2178
  }
@@ -2451,6 +2484,7 @@ async function startHybridDecoder(opts) {
2451
2484
  videoChunksFed,
2452
2485
  audioFramesDecoded,
2453
2486
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
2487
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
2454
2488
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
2455
2489
  // Confirmed transport info — see fallback decoder for the pattern.
2456
2490
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -2691,7 +2725,7 @@ async function startDecoder(opts) {
2691
2725
  const variant = "avbridge";
2692
2726
  const libav = await loadLibav(variant);
2693
2727
  const bridge = await loadBridge2();
2694
- const { prepareLibavInput } = await import('./libav-http-reader-WXG3Z7AI.js');
2728
+ const { prepareLibavInput } = await import('./libav-http-reader-2S5HAHW4.js');
2695
2729
  const inputHandle = await prepareLibavInput(libav, opts.filename, opts.source, opts.transport);
2696
2730
  const readPkt = await libav.av_packet_alloc();
2697
2731
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -2750,6 +2784,7 @@ async function startDecoder(opts) {
2750
2784
  }
2751
2785
  let bsfCtx = null;
2752
2786
  let bsfPkt = null;
2787
+ let bsfRequiredButMissing = false;
2753
2788
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2754
2789
  try {
2755
2790
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2760,13 +2795,19 @@ async function startDecoder(opts) {
2760
2795
  bsfPkt = await libav.av_packet_alloc();
2761
2796
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
2762
2797
  } else {
2763
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
2798
+ bsfRequiredButMissing = true;
2764
2799
  bsfCtx = null;
2765
2800
  }
2766
2801
  } catch (err) {
2767
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
2802
+ bsfRequiredButMissing = true;
2768
2803
  bsfCtx = null;
2769
2804
  bsfPkt = null;
2805
+ dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2806
+ }
2807
+ if (bsfRequiredButMissing) {
2808
+ console.error(
2809
+ "[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."
2810
+ );
2770
2811
  }
2771
2812
  }
2772
2813
  async function applyBSF(packets) {
@@ -2776,7 +2817,6 @@ async function startDecoder(opts) {
2776
2817
  await libav.ff_copyin_packet(bsfPkt, pkt);
2777
2818
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2778
2819
  if (sendErr < 0) {
2779
- out.push(pkt);
2780
2820
  continue;
2781
2821
  }
2782
2822
  while (true) {
@@ -2790,10 +2830,13 @@ async function startDecoder(opts) {
2790
2830
  async function flushBSF() {
2791
2831
  if (!bsfCtx || !bsfPkt) return;
2792
2832
  try {
2793
- await libav.av_bsf_send_packet(bsfCtx, 0);
2794
- while (true) {
2795
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2796
- if (err < 0) break;
2833
+ if (libav.av_bsf_flush) {
2834
+ await libav.av_bsf_flush(bsfCtx);
2835
+ } else {
2836
+ while (true) {
2837
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2838
+ if (err < 0) break;
2839
+ }
2797
2840
  }
2798
2841
  } catch {
2799
2842
  }
@@ -2811,6 +2854,19 @@ async function startDecoder(opts) {
2811
2854
  let watchdogOverflowWarned = false;
2812
2855
  let syntheticVideoUs = 0;
2813
2856
  let syntheticAudioUs = 0;
2857
+ let videoDecodeMsTotal = 0;
2858
+ let audioDecodeMsTotal = 0;
2859
+ let videoDecodeBatches = 0;
2860
+ let audioDecodeBatches = 0;
2861
+ let readMsTotal = 0;
2862
+ let readBatches = 0;
2863
+ let pumpThrottleMsTotal = 0;
2864
+ let pumpThrottleEntries = 0;
2865
+ let slowestVideoBatchMs = 0;
2866
+ let newestVideoPtsUs = 0;
2867
+ let lastEmittedPtsUs = -1;
2868
+ let ptsRegressions = 0;
2869
+ let worstPtsRegressionMs = 0;
2814
2870
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
2815
2871
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
2816
2872
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -2819,9 +2875,12 @@ async function startDecoder(opts) {
2819
2875
  let readErr;
2820
2876
  let packets;
2821
2877
  try {
2878
+ const _readStart = performance.now();
2822
2879
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
2823
2880
  limit: 16 * 1024
2824
2881
  });
2882
+ readMsTotal += performance.now() - _readStart;
2883
+ readBatches++;
2825
2884
  } catch (err) {
2826
2885
  console.error("[avbridge] ff_read_frame_multi failed:", err);
2827
2886
  return;
@@ -2883,8 +2942,17 @@ async function startDecoder(opts) {
2883
2942
  }
2884
2943
  }
2885
2944
  }
2886
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2887
- await new Promise((r) => setTimeout(r, 50));
2945
+ {
2946
+ const _throttleStart = performance.now();
2947
+ let _throttled = false;
2948
+ while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2949
+ _throttled = true;
2950
+ await new Promise((r) => setTimeout(r, 50));
2951
+ }
2952
+ if (_throttled) {
2953
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
2954
+ pumpThrottleEntries++;
2955
+ }
2888
2956
  }
2889
2957
  if (readErr === libav.AVERROR_EOF) {
2890
2958
  if (videoDec) await decodeVideoBatch(
@@ -2910,6 +2978,7 @@ async function startDecoder(opts) {
2910
2978
  async function decodeVideoBatch(pkts, myToken, flush = false) {
2911
2979
  if (!videoDec || destroyed || myToken !== pumpToken) return;
2912
2980
  let frames;
2981
+ const _t0 = performance.now();
2913
2982
  try {
2914
2983
  frames = await libav.ff_decode_multi(
2915
2984
  videoDec.c,
@@ -2922,18 +2991,38 @@ async function startDecoder(opts) {
2922
2991
  console.error("[avbridge] video decode batch failed:", err);
2923
2992
  return;
2924
2993
  }
2994
+ {
2995
+ const _dt = performance.now() - _t0;
2996
+ videoDecodeMsTotal += _dt;
2997
+ videoDecodeBatches++;
2998
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
2999
+ }
2925
3000
  if (myToken !== pumpToken || destroyed) return;
2926
3001
  for (const f of frames) {
2927
3002
  if (myToken !== pumpToken || destroyed) return;
2928
3003
  sanitizeFrameTimestamp(
2929
3004
  f,
2930
3005
  () => {
2931
- const ts = syntheticVideoUs;
2932
- syntheticVideoUs += videoFrameStepUs;
2933
- return ts;
3006
+ const base = lastEmittedPtsUs >= 0 ? lastEmittedPtsUs + videoFrameStepUs : syntheticVideoUs;
3007
+ syntheticVideoUs = base + videoFrameStepUs;
3008
+ return base;
2934
3009
  },
2935
3010
  videoTimeBase
2936
3011
  );
3012
+ const _fPts = (f.ptshi ?? 0) * 4294967296 + (f.pts ?? 0);
3013
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
3014
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
3015
+ ptsRegressions++;
3016
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
3017
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
3018
+ if (ptsRegressions <= 10) {
3019
+ console.warn(
3020
+ `[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.`
3021
+ );
3022
+ }
3023
+ continue;
3024
+ }
3025
+ lastEmittedPtsUs = _fPts;
2937
3026
  try {
2938
3027
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
2939
3028
  opts.renderer.enqueue(vf);
@@ -2948,6 +3037,7 @@ async function startDecoder(opts) {
2948
3037
  async function decodeAudioBatch(pkts, myToken, flush = false) {
2949
3038
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2950
3039
  let frames;
3040
+ const _t0 = performance.now();
2951
3041
  try {
2952
3042
  frames = await libav.ff_decode_multi(
2953
3043
  audioDec.c,
@@ -2960,6 +3050,8 @@ async function startDecoder(opts) {
2960
3050
  console.error("[avbridge] audio decode batch failed:", err);
2961
3051
  return;
2962
3052
  }
3053
+ audioDecodeMsTotal += performance.now() - _t0;
3054
+ audioDecodeBatches++;
2963
3055
  if (myToken !== pumpToken || destroyed) return;
2964
3056
  for (const f of frames) {
2965
3057
  if (myToken !== pumpToken || destroyed) return;
@@ -3081,6 +3173,7 @@ async function startDecoder(opts) {
3081
3173
  await flushBSF();
3082
3174
  syntheticVideoUs = Math.round(timeSec * 1e6);
3083
3175
  syntheticAudioUs = Math.round(timeSec * 1e6);
3176
+ lastEmittedPtsUs = -1;
3084
3177
  pumpRunning = pumpLoop(newToken).catch(
3085
3178
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
3086
3179
  );
@@ -3118,6 +3211,7 @@ async function startDecoder(opts) {
3118
3211
  await flushBSF();
3119
3212
  syntheticVideoUs = Math.round(timeSec * 1e6);
3120
3213
  syntheticAudioUs = Math.round(timeSec * 1e6);
3214
+ lastEmittedPtsUs = -1;
3121
3215
  pumpRunning = pumpLoop(newToken).catch(
3122
3216
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3123
3217
  );
@@ -3131,7 +3225,24 @@ async function startDecoder(opts) {
3131
3225
  packetsRead,
3132
3226
  videoFramesDecoded,
3133
3227
  audioFramesDecoded,
3228
+ // Throughput instrumentation — the stats panel turns these into
3229
+ // "decode fps actual / realtime target" and shows slowest batch
3230
+ // + producer throttle share.
3231
+ videoDecodeMsTotal,
3232
+ videoDecodeBatches,
3233
+ audioDecodeMsTotal,
3234
+ audioDecodeBatches,
3235
+ readMsTotal,
3236
+ readBatches,
3237
+ pumpThrottleMsTotal,
3238
+ pumpThrottleEntries,
3239
+ slowestVideoBatchMs,
3240
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1e3),
3241
+ ptsRegressions,
3242
+ worstPtsRegressionMs,
3243
+ sourceFps: videoFps,
3134
3244
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
3245
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
3135
3246
  // Confirmed transport info: once prepareLibavInput returns
3136
3247
  // successfully, we *know* whether the source is http-range (probe
3137
3248
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -3420,9 +3531,9 @@ var UnifiedPlayer = class _UnifiedPlayer {
3420
3531
  constructor(options, registry) {
3421
3532
  this.options = options;
3422
3533
  this.registry = registry;
3423
- const { requestInit, fetchFn } = options;
3424
- if (requestInit || fetchFn) {
3425
- this.transport = { requestInit, fetchFn };
3534
+ const { requestInit, fetchFn, cacheBytes } = options;
3535
+ if (requestInit || fetchFn || cacheBytes !== void 0) {
3536
+ this.transport = { requestInit, fetchFn, cacheBytes };
3426
3537
  }
3427
3538
  }
3428
3539
  options;
@@ -5033,6 +5144,12 @@ var PLAYER_STYLES = (
5033
5144
  display: flex;
5034
5145
  align-items: center;
5035
5146
  cursor: pointer;
5147
+ /* Claim all touch gestures on the seek bar. Without this, Android
5148
+ * browsers (Chrome, Samsung Internet) treat horizontal drags as
5149
+ * scroll candidates and cancel pointermove once the gesture
5150
+ * resolves, breaking scrub. touch-action must be set in CSS \u2014
5151
+ * preventDefault() on pointerdown is too late. */
5152
+ touch-action: none;
5036
5153
  }
5037
5154
 
5038
5155
  .avp-seek-track {
@@ -5050,7 +5167,13 @@ var PLAYER_STYLES = (
5050
5167
 
5051
5168
  .avp-seek-buffered {
5052
5169
  position: absolute;
5053
- left: 0;
5170
+ inset: 0;
5171
+ pointer-events: none;
5172
+ }
5173
+
5174
+ .avp-seek-buffered-range {
5175
+ position: absolute;
5176
+ top: 0;
5054
5177
  height: 100%;
5055
5178
  background: rgba(255, 255, 255, 0.35);
5056
5179
  border-radius: inherit;
@@ -5610,6 +5733,7 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5610
5733
  on(this._video, "ended", () => this._setState("ended"));
5611
5734
  on(this._video, "error", () => this._setState("error"));
5612
5735
  on(this._video, "timeupdate", () => this._updateTime());
5736
+ on(this._video, "progress", () => this._updateBuffered());
5613
5737
  on(this._video, "volumechange", () => this._updateVolume());
5614
5738
  on(this._video, "trackschange", () => this._buildSettingsMenu());
5615
5739
  on(this._video, "durationchange", () => {
@@ -5826,13 +5950,45 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5826
5950
  this._seekInput.value = String(t);
5827
5951
  this._updateSeekVisuals(t);
5828
5952
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
5953
+ this._updateBuffered();
5954
+ }
5955
+ /**
5956
+ * Render every buffered range as its own segment so gaps (common on MSE
5957
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
5958
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
5959
+ */
5960
+ _updateBuffered() {
5961
+ const d = this._video.duration;
5962
+ if (!(d > 0)) return;
5963
+ let buf;
5829
5964
  try {
5830
- const buf = this._video.buffered;
5831
- if (buf && buf.length > 0 && d > 0) {
5832
- const end = buf.end(buf.length - 1);
5833
- this._seekBuffered.style.width = `${end / d * 100}%`;
5834
- }
5965
+ buf = this._video.buffered;
5835
5966
  } catch {
5967
+ return;
5968
+ }
5969
+ const count = buf ? buf.length : 0;
5970
+ const host = this._seekBuffered;
5971
+ while (host.childElementCount > count) host.lastElementChild.remove();
5972
+ while (host.childElementCount < count) {
5973
+ const seg = document.createElement("div");
5974
+ seg.className = "avp-seek-buffered-range";
5975
+ host.appendChild(seg);
5976
+ }
5977
+ for (let i = 0; i < count; i++) {
5978
+ let start;
5979
+ let end;
5980
+ try {
5981
+ start = buf.start(i);
5982
+ end = buf.end(i);
5983
+ } catch {
5984
+ continue;
5985
+ }
5986
+ const s = Math.max(0, start);
5987
+ const e = Math.min(d, end);
5988
+ if (e <= s) continue;
5989
+ const seg = host.children[i];
5990
+ seg.style.left = `${s / d * 100}%`;
5991
+ seg.style.width = `${(e - s) / d * 100}%`;
5836
5992
  }
5837
5993
  }
5838
5994
  // ── Controls: volume ───────────────────────────────────────────────────
@@ -5975,10 +6131,12 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5975
6131
  }
5976
6132
  }
5977
6133
  // ── Stats for nerds ────────────────────────────────────────────────────
6134
+ _statsPrev = null;
5978
6135
  _toggleStats() {
5979
6136
  this._statsOpen = !this._statsOpen;
5980
6137
  this._statsEl.classList.toggle("open", this._statsOpen);
5981
6138
  if (this._statsOpen) {
6139
+ this._statsPrev = null;
5982
6140
  this._updateStats();
5983
6141
  this._statsInterval = setInterval(() => this._updateStats(), 1e3);
5984
6142
  } else {
@@ -5995,23 +6153,76 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5995
6153
  return;
5996
6154
  }
5997
6155
  const rt = d.runtime ?? {};
5998
- const lines = [
5999
- `Container: ${d.container ?? "?"}`,
6000
- `Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}`,
6001
- `Audio: ${d.audioCodec ?? "none"}`,
6002
- `Strategy: ${d.strategy ?? "?"} Class: ${d.strategyClass ?? "?"}`,
6003
- `Transport: ${d.transport ?? "?"} Range: ${d.rangeSupported ?? "?"}`,
6004
- `Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"}`
6005
- ];
6006
- if (rt.framesDecoded != null) lines.push(`Frames: ${rt.framesDecoded} decoded, ${rt.framesDropped ?? 0} dropped`);
6007
- if (rt.framesPainted != null) lines.push(`Painted: ${rt.framesPainted} Late: ${rt.framesDroppedLate ?? 0} Overflow: ${rt.framesDroppedOverflow ?? 0}`);
6008
- if (rt.videoFramesDecoded != null) lines.push(`Video decoded: ${rt.videoFramesDecoded} Chunks fed: ${rt.videoChunksFed ?? "?"}`);
6009
- if (rt.audioFramesDecoded != null) lines.push(`Audio decoded: ${rt.audioFramesDecoded}`);
6010
- if (rt.packetsRead != null) lines.push(`Packets read: ${rt.packetsRead}`);
6011
- if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF: ${rt.bsfApplied.join(", ")}`);
6012
- if (rt.audioState != null) lines.push(`Audio state: ${rt.audioState} Clock: ${rt.clockMode ?? "?"}`);
6156
+ const now = performance.now();
6157
+ const prev = this._statsPrev;
6158
+ const dtSec = prev ? Math.max(1e-3, (now - prev.ts) / 1e3) : 0;
6159
+ const delta = (key) => {
6160
+ if (!prev) return null;
6161
+ const a = rt[key];
6162
+ const b = prev.rt[key];
6163
+ if (typeof a === "number" && typeof b === "number") return a - b;
6164
+ return null;
6165
+ };
6166
+ const rate = (key) => {
6167
+ const d_ = delta(key);
6168
+ return d_ != null ? d_ / dtSec : null;
6169
+ };
6170
+ const fmt = (n, digits = 1) => n == null ? "?" : n.toFixed(digits);
6171
+ const sourceFps = typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps;
6172
+ const lines = [];
6173
+ lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
6174
+ lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
6175
+ lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
6176
+ lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
6177
+ if (rt.videoFramesDecoded != null) {
6178
+ const decFps = rate("videoFramesDecoded");
6179
+ const paintFps = rate("framesPainted");
6180
+ const dropLateFps = rate("framesDroppedLate");
6181
+ const dropOverflowFps = rate("framesDroppedOverflow");
6182
+ const pct = sourceFps && decFps != null ? ` (${(decFps / sourceFps * 100).toFixed(0)}% of realtime)` : "";
6183
+ lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
6184
+ lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
6185
+ }
6186
+ if (typeof rt.videoDecodeMsTotal === "number") {
6187
+ const msDelta = delta("videoDecodeMsTotal");
6188
+ const batchesDelta = delta("videoDecodeBatches");
6189
+ const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
6190
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6191
+ lines.push(
6192
+ `Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs)}ms`
6193
+ );
6194
+ }
6195
+ if (typeof rt.audioDecodeMsTotal === "number") {
6196
+ const msDelta = delta("audioDecodeMsTotal");
6197
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6198
+ lines.push(`Audio decode: ${fmt(share)}% of wall`);
6199
+ }
6200
+ if (typeof rt.pumpThrottleMsTotal === "number") {
6201
+ const msDelta = delta("pumpThrottleMsTotal");
6202
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6203
+ lines.push(`Producer throttled: ${fmt(share)}% of wall`);
6204
+ }
6205
+ if (rt.queueDepth != null) {
6206
+ lines.push(
6207
+ `Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs)}ms head=${fmt(rt.queueHeadMs)}ms tail=${fmt(rt.queueTailMs)}ms`
6208
+ );
6209
+ }
6210
+ if (typeof rt.newestVideoPtsMs === "number") {
6211
+ lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1e3).toFixed(2)}s`);
6212
+ }
6213
+ if (rt.audioState != null) {
6214
+ lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead, 2)}s clock=${rt.clockMode ?? "?"}`);
6215
+ }
6216
+ if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
6217
+ lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs)}ms) \u2014 decoder emitting out of order`);
6218
+ }
6219
+ if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF active: ${rt.bsfApplied.join(", ")}`);
6220
+ if (rt.bsfMissing && rt.bsfMissing.length > 0) {
6221
+ lines.push(`BSF MISSING: ${rt.bsfMissing.join(", ")} (rebuild libav with avbsf)`);
6222
+ }
6013
6223
  if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
6014
6224
  this._statsEl.textContent = lines.join("\n");
6225
+ this._statsPrev = { ts: now, rt: { ...rt } };
6015
6226
  }
6016
6227
  // ── Controls: fullscreen ───────────────────────────────────────────────
6017
6228
  _toggleFullscreen() {