avbridge 2.12.0 → 2.13.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 (55) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +33 -0
  3. package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
  4. package/dist/avi-32UABODO.cjs.map +1 -0
  5. package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
  6. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  7. package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
  8. package/dist/avi-BLIH7KKV.js.map +1 -0
  9. package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
  10. package/dist/avi-GX2H34IQ.js.map +1 -0
  11. package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
  12. package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
  13. package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
  14. package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
  15. package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
  16. package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  17. package/dist/{chunk-7EF4VTUS.cjs → chunk-OFJYEITB.cjs} +489 -113
  18. package/dist/chunk-OFJYEITB.cjs.map +1 -0
  19. package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  20. package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  21. package/dist/{chunk-Z26PXRUY.js → chunk-VOC24LYF.js} +486 -110
  22. package/dist/chunk-VOC24LYF.js.map +1 -0
  23. package/dist/element-browser.js +492 -130
  24. package/dist/element-browser.js.map +1 -1
  25. package/dist/element.cjs +3 -3
  26. package/dist/element.js +2 -2
  27. package/dist/index.cjs +18 -18
  28. package/dist/index.js +6 -6
  29. package/dist/player.cjs +658 -170
  30. package/dist/player.cjs.map +1 -1
  31. package/dist/player.d.cts +36 -4
  32. package/dist/player.d.ts +36 -4
  33. package/dist/player.js +658 -170
  34. package/dist/player.js.map +1 -1
  35. package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
  36. package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  37. package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
  38. package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
  39. package/package.json +1 -1
  40. package/src/element/avbridge-player.ts +223 -43
  41. package/src/probe/avi.ts +34 -2
  42. package/src/strategies/fallback/audio-output.ts +164 -35
  43. package/src/strategies/fallback/decoder.ts +467 -60
  44. package/src/strategies/fallback/video-renderer.ts +209 -29
  45. package/src/strategies/hybrid/decoder.ts +56 -28
  46. package/src/strategies/remux/pipeline.ts +12 -3
  47. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  48. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  49. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  50. package/dist/avi-EQE6AR75.cjs.map +0 -1
  51. package/dist/avi-NNHH4AAA.js.map +0 -1
  52. package/dist/avi-S7EY54YA.js.map +0 -1
  53. package/dist/avi-Y3N325WZ.cjs.map +0 -1
  54. package/dist/chunk-7EF4VTUS.cjs.map +0 -1
  55. package/dist/chunk-Z26PXRUY.js.map +0 -1
package/dist/player.cjs CHANGED
@@ -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-Y3N325WZ.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-Y3N325WZ.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-Y3N325WZ.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);
@@ -1022,22 +1022,25 @@ async function createRemuxPipeline(ctx, video) {
1022
1022
  }
1023
1023
  }
1024
1024
  let mimePromise = null;
1025
+ const myToken = pumpToken;
1025
1026
  const writable = new WritableStream({
1026
1027
  write: async (chunk) => {
1027
- if (destroyed) return;
1028
+ if (destroyed || pumpToken !== myToken) return;
1028
1029
  if (!sink) {
1029
1030
  const mime = await (mimePromise ??= output.getMimeType());
1031
+ if (destroyed || pumpToken !== myToken) return;
1030
1032
  sink = new MseSink({ mime, video });
1031
1033
  await sink.ready();
1034
+ if (destroyed || pumpToken !== myToken) return;
1032
1035
  if (pendingStartTime > 0) {
1033
1036
  sink.invalidate(pendingStartTime);
1034
1037
  }
1035
1038
  sink.setPlayOnSeek(pendingAutoPlay);
1036
1039
  }
1037
- while (sink && !destroyed && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
1040
+ while (sink && !destroyed && pumpToken === myToken && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
1038
1041
  await new Promise((r) => setTimeout(r, 500));
1039
1042
  }
1040
- if (destroyed) return;
1043
+ if (destroyed || pumpToken !== myToken) return;
1041
1044
  sink.append(chunk.data);
1042
1045
  stats.bytesWritten += chunk.data.byteLength;
1043
1046
  stats.fragments++;
@@ -1304,7 +1307,21 @@ var VideoRenderer = class {
1304
1307
  framesPainted = 0;
1305
1308
  framesDroppedLate = 0;
1306
1309
  framesDroppedOverflow = 0;
1310
+ /** True once the head frame has been painted as a pre-roll poster
1311
+ * since the last flush. Used to ensure pre-roll paints exactly one
1312
+ * frame (held static) during the post-seek discard window. */
1307
1313
  prerolled = false;
1314
+ /** PTS (µs) of the most recently painted frame. Used as the calibration
1315
+ * reference on the first post-flush snap: the pre-roll path paints one
1316
+ * frame *before* PTS-based playback starts, so the queue head's PTS at
1317
+ * first PTS-based paint is the *next* frame, off by one frameDur from
1318
+ * the actually-displayed frame. Calibrating against the painted frame
1319
+ * instead of the queue head removes that one-frame offset and yields
1320
+ * calib ≈ 0 instead of +frameDur. */
1321
+ lastPaintedPtsUs = 0;
1322
+ hasLastPaintedPts = false;
1323
+ /** Audio-clock reading (ms) at the previous paint, for overlay Δaud. */
1324
+ lastPaintAudMs = 0;
1308
1325
  /** Wall-clock time of the last paint, in ms (performance.now()). */
1309
1326
  lastPaintWall = 0;
1310
1327
  /** Minimum ms between paints — paces video at roughly source fps. */
@@ -1335,32 +1352,47 @@ var VideoRenderer = class {
1335
1352
  /** Resolves once the first decoded frame has been enqueued. */
1336
1353
  firstFrameReady;
1337
1354
  resolveFirstFrame;
1338
- /** True once at least one frame has been enqueued. */
1355
+ /**
1356
+ * True once at least one frame has been enqueued *since the last flush*.
1357
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
1358
+ * any frame has arrived, and after a seek we want the same semantics
1359
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
1360
+ * `framesPainted > 0` that used to live here was wrong: it kept the
1361
+ * state "true forever" after the first frame ever, so post-seek
1362
+ * `waitForBuffer()` would exit immediately with an empty queue and
1363
+ * leave video frozen while audio kept going.
1364
+ */
1339
1365
  hasFrames() {
1340
- return this.queue.length > 0 || this.framesPainted > 0;
1366
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
1341
1367
  }
1368
+ hasEverEnqueuedSinceFlush = false;
1342
1369
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
1343
1370
  queueDepth() {
1344
1371
  return this.queue.length;
1345
1372
  }
1346
1373
  /**
1347
- * Soft cap for decoder backpressure. The decoder pump throttles when
1348
- * `queueDepth() >= queueHighWater`. Set high enough that normal decode
1349
- * bursts don't trigger the renderer's overflow-drop loop (which runs at
1350
- * every paint), but low enough that the decoder doesn't run unboundedly
1351
- * ahead. The hard cap in `enqueue()` is 64.
1374
+ * Cap the decoder may fill the queue up to. Used by the decoder's
1375
+ * enqueue-side discard logic (it closes new frames instead of pushing
1376
+ * them when this is reached). Sized so a long post-seek catch-up
1377
+ * fits the decoder produces frames at PTS T_kf onwards rapidly
1378
+ * while the demuxer is chewing through pre-target audio; if the
1379
+ * queue can hold the whole post-seek burst, the renderer plays
1380
+ * smoothly from pre-roll without a frozen-video gap when audio.start
1381
+ * fires. At ~340 KB per SD frame the cap is ~85 MB peak; at HD it's
1382
+ * larger but still bounded.
1352
1383
  */
1353
- queueHighWater = 30;
1384
+ queueHighWater = 256;
1354
1385
  enqueue(frame) {
1355
1386
  if (this.destroyed) {
1356
1387
  frame.close();
1357
1388
  return;
1358
1389
  }
1359
1390
  this.queue.push(frame);
1391
+ this.hasEverEnqueuedSinceFlush = true;
1360
1392
  if (this.queue.length === 1 && this.framesPainted === 0) {
1361
1393
  this.resolveFirstFrame();
1362
1394
  }
1363
- while (this.queue.length > 60) {
1395
+ while (this.queue.length > this.queueHighWater + 8) {
1364
1396
  this.queue.shift()?.close();
1365
1397
  this.framesDroppedOverflow++;
1366
1398
  }
@@ -1442,12 +1474,9 @@ var VideoRenderer = class {
1442
1474
  if (this.queue.length === 0) return;
1443
1475
  const playing = this.clock.isPlaying();
1444
1476
  if (!playing) {
1445
- if (!this.prerolled) {
1446
- const head = this.queue.shift();
1447
- this.paint(head);
1448
- head.close();
1477
+ if (!this.prerolled && this.queue.length > 0) {
1449
1478
  this.prerolled = true;
1450
- this.lastPaintWall = performance.now();
1479
+ this.paint(this.queue[0]);
1451
1480
  }
1452
1481
  return;
1453
1482
  }
@@ -1456,14 +1485,29 @@ var VideoRenderer = class {
1456
1485
  const hasPts = headTs > 0 || this.queue.length > 1;
1457
1486
  if (hasPts) {
1458
1487
  const wallNow2 = performance.now();
1459
- if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
1460
- this.ptsCalibrationUs = headTs - rawAudioNowUs;
1488
+ if (!this.ptsCalibrated) {
1489
+ const anchorUs = (this.clock.anchorTime?.() ?? this.clock.now()) * 1e6;
1490
+ const referencePtsUs = this.hasLastPaintedPts ? this.lastPaintedPtsUs : headTs;
1491
+ this.ptsCalibrationUs = referencePtsUs - anchorUs;
1461
1492
  this.ptsCalibrated = true;
1462
1493
  this.lastCalibrationWall = wallNow2;
1494
+ if (isDebug()) {
1495
+ console.log(
1496
+ `[avbridge:renderer] CALIB-FIRST audioAnchor=${(anchorUs / 1e3).toFixed(1)}ms prerolledPTS=${this.hasLastPaintedPts ? (this.lastPaintedPtsUs / 1e3).toFixed(1) : "n/a"}ms queueHeadPTS=${(headTs / 1e3).toFixed(1)}ms rawAudioNow=${(rawAudioNowUs / 1e3).toFixed(1)}ms \u2192 calib=${(this.ptsCalibrationUs / 1e3).toFixed(1)}ms`
1497
+ );
1498
+ }
1499
+ } else if (wallNow2 - this.lastCalibrationWall > 1e4) {
1500
+ const oldCalib = this.ptsCalibrationUs;
1501
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
1502
+ this.lastCalibrationWall = wallNow2;
1503
+ if (isDebug()) {
1504
+ console.log(
1505
+ `[avbridge:renderer] CALIB-RESNAP headPTS=${(headTs / 1e3).toFixed(1)}ms rawAudioNow=${(rawAudioNowUs / 1e3).toFixed(1)}ms calib ${(oldCalib / 1e3).toFixed(1)}ms \u2192 ${(this.ptsCalibrationUs / 1e3).toFixed(1)}ms (\u0394=${((this.ptsCalibrationUs - oldCalib) / 1e3).toFixed(1)}ms after 10s)`
1506
+ );
1507
+ }
1463
1508
  }
1464
1509
  const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
1465
- const frameDurationUs = this.paintIntervalMs * 1e3;
1466
- const deadlineUs = audioNowUs + frameDurationUs;
1510
+ const deadlineUs = audioNowUs;
1467
1511
  let bestIdx = -1;
1468
1512
  for (let i = 0; i < this.queue.length; i++) {
1469
1513
  const ts = this.queue[i].timestamp ?? 0;
@@ -1490,19 +1534,21 @@ var VideoRenderer = class {
1490
1534
  }
1491
1535
  return;
1492
1536
  }
1493
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
1537
+ const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
1494
1538
  let dropped = 0;
1495
- while (bestIdx > 0) {
1496
- const ts = this.queue[0].timestamp ?? 0;
1497
- if (ts < dropThresholdUs) {
1539
+ const initialBestIdx = bestIdx;
1540
+ if (!_relaxDrop) {
1541
+ while (bestIdx > 0) {
1498
1542
  this.queue.shift()?.close();
1499
1543
  this.framesDroppedLate++;
1500
1544
  bestIdx--;
1501
1545
  dropped++;
1502
- } else {
1503
- break;
1504
1546
  }
1505
1547
  }
1548
+ const paintTs = this.queue[0]?.timestamp ?? 0;
1549
+ if (isDebug()) {
1550
+ console.log(`[TRACE] PAINT bestIdx_initial=${initialBestIdx} dropped=${dropped} paintPts=${(paintTs / 1e3).toFixed(1)}ms audioNow=${(audioNowUs / 1e3).toFixed(1)}ms deadline=${(deadlineUs / 1e3).toFixed(1)}ms queueLen=${this.queue.length} wall=${performance.now().toFixed(0)}`);
1551
+ }
1506
1552
  this.ticksPainted++;
1507
1553
  if (isDebug()) {
1508
1554
  const now = performance.now();
@@ -1538,6 +1584,33 @@ var VideoRenderer = class {
1538
1584
  }
1539
1585
  try {
1540
1586
  this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
1587
+ if (isDebug()) {
1588
+ const wallNow = performance.now();
1589
+ const audNowMs = this.clock.now() * 1e3;
1590
+ const ptsMs = (frame.timestamp ?? 0) / 1e3;
1591
+ const dWall = this.lastPaintWall > 0 ? wallNow - this.lastPaintWall : 0;
1592
+ const dAud = this.lastPaintAudMs > 0 ? audNowMs - this.lastPaintAudMs : 0;
1593
+ const dPts = this.hasLastPaintedPts ? ptsMs - this.lastPaintedPtsUs / 1e3 : 0;
1594
+ this.ctx.save();
1595
+ this.ctx.font = "bold 18px monospace";
1596
+ const lines = [
1597
+ `#${this.framesPainted + 1} pts=${ptsMs.toFixed(0)} aud=${audNowMs.toFixed(0)} wall=${wallNow.toFixed(0)}`,
1598
+ `\u0394pts=${dPts.toFixed(0)} \u0394aud=${dAud.toFixed(0)} \u0394wall=${dWall.toFixed(0)}`
1599
+ ];
1600
+ const lineHeight = 22;
1601
+ const padTop = 6;
1602
+ const stripH = padTop + lineHeight * lines.length;
1603
+ this.ctx.fillStyle = "rgba(0,0,0,0.7)";
1604
+ this.ctx.fillRect(0, 0, this.canvas.width, stripH);
1605
+ this.ctx.fillStyle = "#0f0";
1606
+ for (let i = 0; i < lines.length; i++) {
1607
+ this.ctx.fillText(lines[i], 8, padTop + lineHeight * (i + 1) - 4);
1608
+ }
1609
+ this.ctx.restore();
1610
+ }
1611
+ this.lastPaintedPtsUs = frame.timestamp ?? 0;
1612
+ this.hasLastPaintedPts = true;
1613
+ this.lastPaintAudMs = this.clock.now() * 1e3;
1541
1614
  this.framesPainted++;
1542
1615
  } catch (err) {
1543
1616
  if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
@@ -1550,17 +1623,30 @@ var VideoRenderer = class {
1550
1623
  const count = this.queue.length;
1551
1624
  while (this.queue.length > 0) this.queue.shift()?.close();
1552
1625
  this.prerolled = false;
1626
+ this.hasLastPaintedPts = false;
1553
1627
  this.ptsCalibrated = false;
1628
+ this.hasEverEnqueuedSinceFlush = false;
1554
1629
  if (isDebug() && count > 0) {
1555
1630
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
1556
1631
  }
1557
1632
  }
1558
1633
  stats() {
1634
+ let queueSpanMs = 0;
1635
+ let queueHeadMs = 0;
1636
+ let queueTailMs = 0;
1637
+ if (this.queue.length > 0) {
1638
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1e3);
1639
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1e3);
1640
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
1641
+ }
1559
1642
  return {
1560
1643
  framesPainted: this.framesPainted,
1561
1644
  framesDroppedLate: this.framesDroppedLate,
1562
1645
  framesDroppedOverflow: this.framesDroppedOverflow,
1563
- queueDepth: this.queue.length
1646
+ queueDepth: this.queue.length,
1647
+ queueHeadMs,
1648
+ queueTailMs,
1649
+ queueSpanMs
1564
1650
  };
1565
1651
  }
1566
1652
  destroy() {
@@ -1578,6 +1664,9 @@ var VideoRenderer = class {
1578
1664
  };
1579
1665
 
1580
1666
  // src/strategies/fallback/audio-output.ts
1667
+ function isDebug2() {
1668
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
1669
+ }
1581
1670
  var AudioOutput = class {
1582
1671
  ctx;
1583
1672
  gain;
@@ -1599,6 +1688,16 @@ var AudioOutput = class {
1599
1688
  mediaTimeOfNext = 0;
1600
1689
  /** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
1601
1690
  mediaTimeOfAnchor = 0;
1691
+ /**
1692
+ * Ctx time at which the first audible chunk will start playing. `-1`
1693
+ * before any chunk has been scheduled successfully (clock is frozen);
1694
+ * the actual ctx time once one has. The renderer's `clock.now()` uses
1695
+ * this to avoid advancing during the silent-gap window between
1696
+ * `audio.start()` and the first chunk that schedules without being
1697
+ * dropped — that gap is what produces the "audio-less fast-forward"
1698
+ * the user sees post-seek when the gate releases on video-only grace.
1699
+ */
1700
+ firstAudibleCtxStart = -1;
1602
1701
  ctxTimeAtAnchor = 0;
1603
1702
  pendingQueue = [];
1604
1703
  framesScheduled = 0;
@@ -1671,10 +1770,16 @@ var AudioOutput = class {
1671
1770
  return this.mediaTimeOfAnchor;
1672
1771
  }
1673
1772
  if (this.state === "playing") {
1773
+ if (this.firstAudibleCtxStart < 0) {
1774
+ return this.mediaTimeOfAnchor;
1775
+ }
1674
1776
  return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1675
1777
  }
1676
1778
  return this.mediaTimeOfAnchor;
1677
1779
  }
1780
+ anchorTime() {
1781
+ return this.mediaTimeOfAnchor;
1782
+ }
1678
1783
  isPlaying() {
1679
1784
  return this.state === "playing";
1680
1785
  }
@@ -1701,18 +1806,81 @@ var AudioOutput = class {
1701
1806
  * Schedule a chunk of decoded samples. Queues internally while idle (cold
1702
1807
  * start or post-seek), schedules directly to the audio graph while playing.
1703
1808
  * In wall-clock mode, samples are silently discarded.
1809
+ *
1810
+ * `ptsSec` is the chunk's source-domain content PTS in seconds, from
1811
+ * the demuxer. When provided, the chunk plays at the ctx-time
1812
+ * corresponding to that PTS — so pre-target audio after a seek
1813
+ * naturally drops (its computed `ctxStart` falls in the past) and
1814
+ * post-target audio plays at its true content time, without any
1815
+ * external trim or anchor rebase. When `ptsSec` is null (cold start
1816
+ * with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
1817
+ * the chunk is scheduled sequentially after `mediaTimeOfNext` — the
1818
+ * pre-refactor behavior.
1704
1819
  */
1705
- schedule(samples, channels, sampleRate) {
1820
+ schedule(samples, channels, sampleRate, ptsSec) {
1706
1821
  if (this.destroyed || this.noAudio) return;
1707
1822
  const frameCount = samples.length / channels;
1708
1823
  const durationSec = frameCount / sampleRate;
1824
+ const hasPts = ptsSec != null && Number.isFinite(ptsSec);
1825
+ if (hasPts && ptsSec + durationSec / this._rate < this.mediaTimeOfAnchor) {
1826
+ return;
1827
+ }
1709
1828
  if (this.state === "idle" || this.state === "paused") {
1710
- this.pendingQueue.push({ samples, channels, sampleRate, frameCount, durationSec });
1829
+ this.pendingQueue.push({
1830
+ samples,
1831
+ channels,
1832
+ sampleRate,
1833
+ frameCount,
1834
+ durationSec,
1835
+ ptsSec: hasPts ? ptsSec : null
1836
+ });
1711
1837
  return;
1712
1838
  }
1713
- this.scheduleNow(samples, channels, sampleRate, frameCount);
1839
+ this.scheduleNow(
1840
+ samples,
1841
+ channels,
1842
+ sampleRate,
1843
+ frameCount,
1844
+ hasPts ? ptsSec : null
1845
+ );
1714
1846
  }
1715
- scheduleNow(samples, channels, sampleRate, frameCount) {
1847
+ scheduleNow(samples, channels, sampleRate, frameCount, ptsSec) {
1848
+ const durationSec = frameCount / sampleRate;
1849
+ let ctxStart;
1850
+ if (ptsSec != null) {
1851
+ ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
1852
+ if (isDebug2()) {
1853
+ console.log(`[TRACE-AUD] PTS sched #${this.framesScheduled} pts=${ptsSec.toFixed(3)} dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} rate=${this._rate}`);
1854
+ }
1855
+ if (ctxStart < this.ctx.currentTime - 1e-3) {
1856
+ if (isDebug2()) {
1857
+ console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1858
+ }
1859
+ return;
1860
+ }
1861
+ if (this.firstAudibleCtxStart < 0) {
1862
+ this.firstAudibleCtxStart = ctxStart;
1863
+ this.mediaTimeOfAnchor = ptsSec;
1864
+ this.ctxTimeAtAnchor = ctxStart;
1865
+ if (isDebug2()) {
1866
+ console.log(`[TRACE-AUD] UNFREEZE clock \u2014 first audible chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} \u2192 anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)}`);
1867
+ }
1868
+ }
1869
+ const endMediaTime = ptsSec + durationSec / this._rate;
1870
+ if (endMediaTime > this.mediaTimeOfNext) {
1871
+ this.mediaTimeOfNext = endMediaTime;
1872
+ }
1873
+ } else {
1874
+ ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1875
+ console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1876
+ if (ctxStart < this.ctx.currentTime) {
1877
+ console.warn(`[TRACE-AUD] REBASE anchor was=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor was=${this.ctxTimeAtAnchor.toFixed(4)} \u2192 anchor=${this.mediaTimeOfNext.toFixed(3)} ctxAnchor=${this.ctx.currentTime.toFixed(4)}`);
1878
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
1879
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1880
+ ctxStart = this.ctx.currentTime;
1881
+ }
1882
+ this.mediaTimeOfNext += durationSec;
1883
+ }
1716
1884
  const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
1717
1885
  for (let ch = 0; ch < channels; ch++) {
1718
1886
  const channelData = buffer.getChannelData(ch);
@@ -1724,14 +1892,7 @@ var AudioOutput = class {
1724
1892
  node.buffer = buffer;
1725
1893
  node.connect(this.gain);
1726
1894
  if (this._rate !== 1) node.playbackRate.value = this._rate;
1727
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1728
- if (ctxStart < this.ctx.currentTime) {
1729
- this.ctxTimeAtAnchor = this.ctx.currentTime;
1730
- this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1731
- ctxStart = this.ctx.currentTime;
1732
- }
1733
1895
  node.start(ctxStart);
1734
- this.mediaTimeOfNext += frameCount / sampleRate;
1735
1896
  this.framesScheduled++;
1736
1897
  }
1737
1898
  // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -1756,12 +1917,15 @@ var AudioOutput = class {
1756
1917
  } catch {
1757
1918
  }
1758
1919
  if (this.state === "paused") {
1920
+ if (isDebug2()) {
1921
+ console.log(`[TRACE-AUD] START(resume) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} \u2192 ctxAnchor=${this.ctx.currentTime.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
1922
+ }
1759
1923
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1760
1924
  this.state = "playing";
1761
1925
  const drain2 = this.pendingQueue;
1762
1926
  this.pendingQueue = [];
1763
1927
  for (const c of drain2) {
1764
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1928
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1765
1929
  }
1766
1930
  return;
1767
1931
  }
@@ -1769,10 +1933,13 @@ var AudioOutput = class {
1769
1933
  this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
1770
1934
  this.mediaTimeOfNext = this.mediaTimeOfAnchor;
1771
1935
  this.state = "playing";
1936
+ if (isDebug2()) {
1937
+ console.log(`[TRACE-AUD] START(cold) anchor=${this.mediaTimeOfAnchor.toFixed(3)} ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} mtNext=${this.mediaTimeOfNext.toFixed(3)} ctxNow=${this.ctx.currentTime.toFixed(4)} pendingCount=${this.pendingQueue.length}`);
1938
+ }
1772
1939
  const drain = this.pendingQueue;
1773
1940
  this.pendingQueue = [];
1774
1941
  for (const c of drain) {
1775
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1942
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1776
1943
  }
1777
1944
  }
1778
1945
  /** Pause playback. Suspends the audio context. */
@@ -1798,6 +1965,9 @@ var AudioOutput = class {
1798
1965
  * supplying new samples) and then call `start()` to resume playback.
1799
1966
  */
1800
1967
  async reset(newMediaTime) {
1968
+ if (isDebug2()) {
1969
+ console.log(`[TRACE-AUD] RESET to=${newMediaTime.toFixed(3)} prev_anchor=${this.mediaTimeOfAnchor.toFixed(3)} prev_mtNext=${this.mediaTimeOfNext.toFixed(3)} prev_ctxAnchor=${this.ctxTimeAtAnchor.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)} state=${this.state}`);
1970
+ }
1801
1971
  if (this.noAudio) {
1802
1972
  this.pendingQueue = [];
1803
1973
  this.mediaTimeOfAnchor = newMediaTime;
@@ -1816,6 +1986,7 @@ var AudioOutput = class {
1816
1986
  this.mediaTimeOfAnchor = newMediaTime;
1817
1987
  this.mediaTimeOfNext = newMediaTime;
1818
1988
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1989
+ this.firstAudibleCtxStart = -1;
1819
1990
  this.state = "idle";
1820
1991
  if (this.ctx.state === "running") {
1821
1992
  await this.ctx.suspend();
@@ -1995,28 +2166,6 @@ function asUint8(x) {
1995
2166
  const ta = x;
1996
2167
  return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
1997
2168
  }
1998
- function sanitizeFrameTimestamp(frame, nextUs, fallbackTimeBase) {
1999
- const lo = frame.pts ?? 0;
2000
- const hi = frame.ptshi ?? 0;
2001
- const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
2002
- if (isInvalid) {
2003
- const us2 = nextUs();
2004
- frame.pts = us2;
2005
- frame.ptshi = 0;
2006
- return;
2007
- }
2008
- const tb = fallbackTimeBase ?? [1, 1e6];
2009
- const pts64 = hi * 4294967296 + lo;
2010
- const us = Math.round(pts64 * 1e6 * tb[0] / tb[1]);
2011
- if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
2012
- frame.pts = us;
2013
- frame.ptshi = us < 0 ? -1 : 0;
2014
- return;
2015
- }
2016
- const fallback = nextUs();
2017
- frame.pts = fallback;
2018
- frame.ptshi = 0;
2019
- }
2020
2169
 
2021
2170
  // src/strategies/hybrid/decoder.ts
2022
2171
  async function startHybridDecoder(opts) {
@@ -2098,6 +2247,7 @@ async function startHybridDecoder(opts) {
2098
2247
  }
2099
2248
  let bsfCtx = null;
2100
2249
  let bsfPkt = null;
2250
+ let bsfRequiredButMissing = false;
2101
2251
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2102
2252
  try {
2103
2253
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2108,13 +2258,19 @@ async function startHybridDecoder(opts) {
2108
2258
  bsfPkt = await libav.av_packet_alloc();
2109
2259
  chunkNNVOHKXJ_cjs.dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
2110
2260
  } else {
2111
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
2261
+ bsfRequiredButMissing = true;
2112
2262
  bsfCtx = null;
2113
2263
  }
2114
2264
  } catch (err) {
2115
- console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
2265
+ bsfRequiredButMissing = true;
2116
2266
  bsfCtx = null;
2117
2267
  bsfPkt = null;
2268
+ chunkNNVOHKXJ_cjs.dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2269
+ }
2270
+ if (bsfRequiredButMissing) {
2271
+ console.error(
2272
+ "[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."
2273
+ );
2118
2274
  }
2119
2275
  }
2120
2276
  async function applyBSF(packets) {
@@ -2124,7 +2280,6 @@ async function startHybridDecoder(opts) {
2124
2280
  await libav.ff_copyin_packet(bsfPkt, pkt);
2125
2281
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2126
2282
  if (sendErr < 0) {
2127
- out.push(pkt);
2128
2283
  continue;
2129
2284
  }
2130
2285
  while (true) {
@@ -2138,10 +2293,13 @@ async function startHybridDecoder(opts) {
2138
2293
  async function flushBSF() {
2139
2294
  if (!bsfCtx || !bsfPkt) return;
2140
2295
  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;
2296
+ if (libav.av_bsf_flush) {
2297
+ await libav.av_bsf_flush(bsfCtx);
2298
+ } else {
2299
+ while (true) {
2300
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2301
+ if (err < 0) break;
2302
+ }
2145
2303
  }
2146
2304
  } catch {
2147
2305
  }
@@ -2155,7 +2313,6 @@ async function startHybridDecoder(opts) {
2155
2313
  let videoChunksFed = 0;
2156
2314
  let bufferedUntilSec = 0;
2157
2315
  let syntheticVideoUs = 0;
2158
- let syntheticAudioUs = 0;
2159
2316
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
2160
2317
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
2161
2318
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -2187,7 +2344,13 @@ async function startHybridDecoder(opts) {
2187
2344
  }
2188
2345
  }
2189
2346
  if (audioDec && audioPackets && audioPackets.length > 0) {
2190
- await decodeAudioBatch(audioPackets, myToken);
2347
+ await decodeAudioBatch(
2348
+ audioPackets,
2349
+ myToken,
2350
+ /*flush*/
2351
+ false,
2352
+ audioTimeBase
2353
+ );
2191
2354
  }
2192
2355
  if (myToken !== pumpToken || destroyed) return;
2193
2356
  await new Promise((r) => setTimeout(r, 0));
@@ -2234,8 +2397,11 @@ async function startHybridDecoder(opts) {
2234
2397
  }
2235
2398
  }
2236
2399
  }
2237
- async function decodeAudioBatch(pkts, myToken, flush = false) {
2400
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
2238
2401
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2402
+ const pktPtsSec = pkts.map(
2403
+ (p) => tb ? packetPtsSec(p, tb) : null
2404
+ );
2239
2405
  const AUDIO_SUB_BATCH = 4;
2240
2406
  let allFrames = [];
2241
2407
  for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
@@ -2273,22 +2439,13 @@ async function startHybridDecoder(opts) {
2273
2439
  }
2274
2440
  if (myToken !== pumpToken || destroyed) return;
2275
2441
  const frames = allFrames;
2276
- for (const f of frames) {
2442
+ for (let i = 0; i < frames.length; i++) {
2277
2443
  if (myToken !== pumpToken || destroyed) return;
2278
- sanitizeFrameTimestamp(
2279
- f,
2280
- () => {
2281
- const ts = syntheticAudioUs;
2282
- const samples2 = f.nb_samples ?? 1024;
2283
- const sampleRate = f.sample_rate ?? 44100;
2284
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
2285
- return ts;
2286
- },
2287
- audioTimeBase
2288
- );
2444
+ const f = frames[i];
2289
2445
  const samples = libavFrameToInterleavedFloat32(f);
2290
2446
  if (samples) {
2291
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
2447
+ const pts = pktPtsSec[i] ?? null;
2448
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
2292
2449
  audioFramesDecoded++;
2293
2450
  }
2294
2451
  }
@@ -2398,7 +2555,6 @@ async function startHybridDecoder(opts) {
2398
2555
  }
2399
2556
  await flushBSF();
2400
2557
  syntheticVideoUs = Math.round(timeSec * 1e6);
2401
- syntheticAudioUs = Math.round(timeSec * 1e6);
2402
2558
  pumpRunning = pumpLoop(newToken).catch(
2403
2559
  (err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
2404
2560
  );
@@ -2437,7 +2593,6 @@ async function startHybridDecoder(opts) {
2437
2593
  }
2438
2594
  await flushBSF();
2439
2595
  syntheticVideoUs = Math.round(timeSec * 1e6);
2440
- syntheticAudioUs = Math.round(timeSec * 1e6);
2441
2596
  pumpRunning = pumpLoop(newToken).catch(
2442
2597
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2443
2598
  );
@@ -2453,6 +2608,7 @@ async function startHybridDecoder(opts) {
2453
2608
  videoChunksFed,
2454
2609
  audioFramesDecoded,
2455
2610
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
2611
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
2456
2612
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
2457
2613
  // Confirmed transport info — see fallback decoder for the pattern.
2458
2614
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -2689,6 +2845,9 @@ async function createHybridSession(ctx, target, transport) {
2689
2845
  }
2690
2846
 
2691
2847
  // src/strategies/fallback/decoder.ts
2848
+ function isDebug3() {
2849
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
2850
+ }
2692
2851
  async function startDecoder(opts) {
2693
2852
  const variant = "avbridge";
2694
2853
  const libav = await chunkNNVOHKXJ_cjs.loadLibav(variant);
@@ -2752,6 +2911,7 @@ async function startDecoder(opts) {
2752
2911
  }
2753
2912
  let bsfCtx = null;
2754
2913
  let bsfPkt = null;
2914
+ let bsfRequiredButMissing = false;
2755
2915
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2756
2916
  try {
2757
2917
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2762,13 +2922,19 @@ async function startDecoder(opts) {
2762
2922
  bsfPkt = await libav.av_packet_alloc();
2763
2923
  chunkNNVOHKXJ_cjs.dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
2764
2924
  } else {
2765
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
2925
+ bsfRequiredButMissing = true;
2766
2926
  bsfCtx = null;
2767
2927
  }
2768
2928
  } catch (err) {
2769
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
2929
+ bsfRequiredButMissing = true;
2770
2930
  bsfCtx = null;
2771
2931
  bsfPkt = null;
2932
+ chunkNNVOHKXJ_cjs.dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2933
+ }
2934
+ if (bsfRequiredButMissing) {
2935
+ console.error(
2936
+ "[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."
2937
+ );
2772
2938
  }
2773
2939
  }
2774
2940
  async function applyBSF(packets) {
@@ -2778,7 +2944,6 @@ async function startDecoder(opts) {
2778
2944
  await libav.ff_copyin_packet(bsfPkt, pkt);
2779
2945
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2780
2946
  if (sendErr < 0) {
2781
- out.push(pkt);
2782
2947
  continue;
2783
2948
  }
2784
2949
  while (true) {
@@ -2792,10 +2957,13 @@ async function startDecoder(opts) {
2792
2957
  async function flushBSF() {
2793
2958
  if (!bsfCtx || !bsfPkt) return;
2794
2959
  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;
2960
+ if (libav.av_bsf_flush) {
2961
+ await libav.av_bsf_flush(bsfCtx);
2962
+ } else {
2963
+ while (true) {
2964
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2965
+ if (err < 0) break;
2966
+ }
2799
2967
  }
2800
2968
  } catch {
2801
2969
  }
@@ -2811,8 +2979,28 @@ async function startDecoder(opts) {
2811
2979
  let watchdogSlowSinceMs = 0;
2812
2980
  let watchdogSlowWarned = false;
2813
2981
  let watchdogOverflowWarned = false;
2814
- let syntheticVideoUs = 0;
2815
- let syntheticAudioUs = 0;
2982
+ let lastContentUs = -1;
2983
+ let firstValidPtsLoggedSinceSeek = false;
2984
+ let seenFirstAudioPacketSinceSeek = false;
2985
+ let seekTargetSec = 0;
2986
+ let diagPktsLoggedSinceSeek = 0;
2987
+ let diagFramesLoggedSinceSeek = 0;
2988
+ let diagFrameKeysDumped = false;
2989
+ const DIAG_MAX_PKTS = 100;
2990
+ const DIAG_MAX_FRAMES = 300;
2991
+ let videoDecodeMsTotal = 0;
2992
+ let audioDecodeMsTotal = 0;
2993
+ let videoDecodeBatches = 0;
2994
+ let audioDecodeBatches = 0;
2995
+ let readMsTotal = 0;
2996
+ let readBatches = 0;
2997
+ let pumpThrottleMsTotal = 0;
2998
+ let pumpThrottleEntries = 0;
2999
+ let slowestVideoBatchMs = 0;
3000
+ let newestVideoPtsUs = 0;
3001
+ let lastEmittedPtsUs = -1;
3002
+ let ptsRegressions = 0;
3003
+ let worstPtsRegressionMs = 0;
2816
3004
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
2817
3005
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
2818
3006
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -2821,9 +3009,12 @@ async function startDecoder(opts) {
2821
3009
  let readErr;
2822
3010
  let packets;
2823
3011
  try {
3012
+ const _readStart = performance.now();
2824
3013
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
2825
3014
  limit: 16 * 1024
2826
3015
  });
3016
+ readMsTotal += performance.now() - _readStart;
3017
+ readBatches++;
2827
3018
  } catch (err) {
2828
3019
  console.error("[avbridge] ff_read_frame_multi failed:", err);
2829
3020
  return;
@@ -2835,6 +3026,18 @@ async function startDecoder(opts) {
2835
3026
  for (const pkt of videoPackets) {
2836
3027
  const sec = packetPtsSec(pkt, videoTimeBase);
2837
3028
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
3029
+ if (isDebug3() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
3030
+ const rawHi = pkt.ptshi ?? 0;
3031
+ const rawLo = pkt.pts ?? 0;
3032
+ const isInvalidPts = rawHi === -2147483648 && rawLo === 0;
3033
+ const rawPts64 = isInvalidPts ? null : rawHi * 4294967296 + rawLo;
3034
+ const rawSec = rawPts64 != null && videoTimeBase ? rawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
3035
+ const pktKeys = diagPktsLoggedSinceSeek === 0 ? `[keys: ${Object.keys(pkt).join(",")}]` : "";
3036
+ console.log(
3037
+ `[DIAG-PKT] vidx=${diagPktsLoggedSinceSeek} pts=${isInvalidPts ? "NOPTS" : rawPts64} pts_sec=${rawSec != null ? rawSec.toFixed(3) : "n/a"} ptshi=${rawHi} ptslo=${rawLo} flags=0x${(pkt.flags ?? 0).toString(16)} keyframe=${(pkt.flags ?? 0) & 1 ? "Y" : "N"} stream=${pkt.stream_index} dataLen=${pkt.data?.length ?? 0} seekTarget=${seekTargetSec.toFixed(3)} ` + pktKeys
3038
+ );
3039
+ diagPktsLoggedSinceSeek++;
3040
+ }
2838
3041
  }
2839
3042
  }
2840
3043
  if (audioPackets && audioTimeBase) {
@@ -2842,9 +3045,25 @@ async function startDecoder(opts) {
2842
3045
  const sec = packetPtsSec(pkt, audioTimeBase);
2843
3046
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2844
3047
  }
3048
+ if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
3049
+ const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
3050
+ if (firstSec != null && Number.isFinite(firstSec)) {
3051
+ seenFirstAudioPacketSinceSeek = true;
3052
+ chunkNNVOHKXJ_cjs.dbg.info(
3053
+ "av-anchor",
3054
+ `seek-target=${seekTargetSec.toFixed(3)}s, first-audio-pkt-pts=${firstSec.toFixed(3)}s (\u0394=${((firstSec - seekTargetSec) * 1e3).toFixed(1)}ms \u2014 pre-target packets will be skipped by AudioOutput)`
3055
+ );
3056
+ }
3057
+ }
2845
3058
  }
2846
3059
  if (audioDec && audioPackets && audioPackets.length > 0) {
2847
- await decodeAudioBatch(audioPackets, myToken);
3060
+ await decodeAudioBatch(
3061
+ audioPackets,
3062
+ myToken,
3063
+ /*flush*/
3064
+ false,
3065
+ audioTimeBase
3066
+ );
2848
3067
  }
2849
3068
  if (myToken !== pumpToken || destroyed) return;
2850
3069
  if (videoDec && videoPackets && videoPackets.length > 0) {
@@ -2885,8 +3104,17 @@ async function startDecoder(opts) {
2885
3104
  }
2886
3105
  }
2887
3106
  }
2888
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2889
- await new Promise((r) => setTimeout(r, 50));
3107
+ {
3108
+ const _throttleStart = performance.now();
3109
+ let _throttled = false;
3110
+ while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
3111
+ _throttled = true;
3112
+ await new Promise((r) => setTimeout(r, 50));
3113
+ }
3114
+ if (_throttled) {
3115
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
3116
+ pumpThrottleEntries++;
3117
+ }
2890
3118
  }
2891
3119
  if (readErr === libav.AVERROR_EOF) {
2892
3120
  if (videoDec) await decodeVideoBatch(
@@ -2912,6 +3140,7 @@ async function startDecoder(opts) {
2912
3140
  async function decodeVideoBatch(pkts, myToken, flush = false) {
2913
3141
  if (!videoDec || destroyed || myToken !== pumpToken) return;
2914
3142
  let frames;
3143
+ const _t0 = performance.now();
2915
3144
  try {
2916
3145
  frames = await libav.ff_decode_multi(
2917
3146
  videoDec.c,
@@ -2924,32 +3153,133 @@ async function startDecoder(opts) {
2924
3153
  console.error("[avbridge] video decode batch failed:", err);
2925
3154
  return;
2926
3155
  }
3156
+ {
3157
+ const _dt = performance.now() - _t0;
3158
+ videoDecodeMsTotal += _dt;
3159
+ videoDecodeBatches++;
3160
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
3161
+ }
2927
3162
  if (myToken !== pumpToken || destroyed) return;
2928
3163
  for (const f of frames) {
2929
3164
  if (myToken !== pumpToken || destroyed) return;
2930
- sanitizeFrameTimestamp(
2931
- f,
2932
- () => {
2933
- const ts = syntheticVideoUs;
2934
- syntheticVideoUs += videoFrameStepUs;
2935
- return ts;
2936
- },
2937
- videoTimeBase
2938
- );
3165
+ const _diagShouldLog = isDebug3() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
3166
+ const _diagRawHi = f.ptshi ?? 0;
3167
+ const _diagRawLo = f.pts ?? 0;
3168
+ const _diagInvalid = _diagRawHi === -2147483648 && _diagRawLo === 0;
3169
+ const _diagRawPts64 = _diagInvalid ? null : _diagRawHi * 4294967296 + _diagRawLo;
3170
+ const _diagRawSec = _diagRawPts64 != null && videoTimeBase ? _diagRawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
3171
+ if (_diagShouldLog && !diagFrameKeysDumped) {
3172
+ diagFrameKeysDumped = true;
3173
+ const allKeys = Object.keys(f);
3174
+ const fieldDump = {};
3175
+ for (const k of allKeys) {
3176
+ const v = f[k];
3177
+ if (k === "data") continue;
3178
+ if (typeof v === "object" && v !== null && "length" in v) continue;
3179
+ fieldDump[k] = v;
3180
+ }
3181
+ console.log(`[DIAG-FRAME] FIRST FRAME post-seek \u2014 all keys: ${allKeys.join(",")}`);
3182
+ console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
3183
+ }
3184
+ let rawUs = null;
3185
+ if (!_diagInvalid && _diagRawPts64 != null) {
3186
+ const tb = videoTimeBase ?? [1, 1e6];
3187
+ const us = Math.round(_diagRawPts64 * 1e6 * tb[0] / tb[1]);
3188
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
3189
+ rawUs = us;
3190
+ }
3191
+ }
3192
+ const _diagLog = (decision, finalPtsUs, sanFallback) => {
3193
+ if (!_diagShouldLog) return;
3194
+ const ptsSrc = sanFallback ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})` : "LIBAV";
3195
+ console.log(
3196
+ `[DIAG-FRAME] vidx=${diagFramesLoggedSinceSeek} raw_pts=${_diagInvalid ? "NOPTS" : _diagRawPts64} raw_pts_sec=${_diagRawSec != null ? _diagRawSec.toFixed(3) : "n/a"} pts_src=${ptsSrc} final_pts_us=${finalPtsUs} final_pts_sec=${(finalPtsUs / 1e6).toFixed(3)} seekTarget=${seekTargetSec.toFixed(3)} offset_to_target_ms=${(finalPtsUs / 1e3 - seekTargetSec * 1e3).toFixed(1)} lastEmittedPts_us=${lastEmittedPtsUs} decision=${decision}`
3197
+ );
3198
+ diagFramesLoggedSinceSeek++;
3199
+ };
3200
+ let _diagSanFallbackFired = false;
3201
+ const seekTargetUs = Math.round(seekTargetSec * 1e6);
3202
+ if (lastContentUs < 0) {
3203
+ if (rawUs == null) {
3204
+ const isColdStartKeyframe = seekTargetSec === 0 && f.key_frame === 1;
3205
+ if (isColdStartKeyframe) {
3206
+ lastContentUs = 0;
3207
+ _diagSanFallbackFired = true;
3208
+ } else {
3209
+ _diagLog("PRE-ANCHOR-DROP", 0, true);
3210
+ continue;
3211
+ }
3212
+ } else {
3213
+ lastContentUs = rawUs;
3214
+ if (!firstValidPtsLoggedSinceSeek) {
3215
+ firstValidPtsLoggedSinceSeek = true;
3216
+ if (isDebug3()) {
3217
+ console.log(
3218
+ `[avbridge:decoder] post-seek anchor established: first valid raw pts = ${(rawUs / 1e3).toFixed(1)}ms (seekTarget = ${(seekTargetSec * 1e3).toFixed(1)}ms, \u0394 = ${((rawUs - seekTargetUs) / 1e3).toFixed(1)}ms)`
3219
+ );
3220
+ }
3221
+ if (rawUs >= seekTargetUs) {
3222
+ console.warn(
3223
+ `[avbridge:decoder] first valid raw pts \u2265 seek target \u2014 pre-anchor NOPTS frames may have straddled the target and been mis-discarded. First painted frame may be late by up to one keyframe interval.`
3224
+ );
3225
+ }
3226
+ }
3227
+ }
3228
+ } else {
3229
+ if (rawUs != null) {
3230
+ lastContentUs = rawUs;
3231
+ } else {
3232
+ lastContentUs += videoFrameStepUs;
3233
+ _diagSanFallbackFired = true;
3234
+ }
3235
+ }
3236
+ f.pts = lastContentUs;
3237
+ f.ptshi = lastContentUs < 0 ? -1 : 0;
3238
+ const _fPts = lastContentUs;
3239
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
3240
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
3241
+ _diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
3242
+ ptsRegressions++;
3243
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
3244
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
3245
+ if (ptsRegressions <= 10) {
3246
+ console.warn(
3247
+ `[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.`
3248
+ );
3249
+ }
3250
+ continue;
3251
+ }
3252
+ lastEmittedPtsUs = _fPts;
3253
+ const targetUs = Math.round(seekTargetSec * 1e6);
3254
+ if (_fPts < targetUs - videoFrameStepUs) {
3255
+ _diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
3256
+ continue;
3257
+ }
2939
3258
  try {
2940
3259
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
2941
- opts.renderer.enqueue(vf);
3260
+ if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
3261
+ vf.close();
3262
+ _diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
3263
+ } else {
3264
+ opts.renderer.enqueue(vf);
3265
+ _diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
3266
+ }
2942
3267
  videoFramesDecoded++;
2943
3268
  } catch (err) {
2944
3269
  if (videoFramesDecoded === 0) {
2945
3270
  console.warn("[avbridge] laFrameToVideoFrame failed:", err);
2946
3271
  }
3272
+ _diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
2947
3273
  }
2948
3274
  }
2949
3275
  }
2950
- async function decodeAudioBatch(pkts, myToken, flush = false) {
3276
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
2951
3277
  if (!audioDec || destroyed || myToken !== pumpToken) return;
3278
+ const pktPtsSec = pkts.map(
3279
+ (p) => tb ? packetPtsSec(p, tb) : null
3280
+ );
2952
3281
  let frames;
3282
+ const _t0 = performance.now();
2953
3283
  try {
2954
3284
  frames = await libav.ff_decode_multi(
2955
3285
  audioDec.c,
@@ -2962,23 +3292,20 @@ async function startDecoder(opts) {
2962
3292
  console.error("[avbridge] audio decode batch failed:", err);
2963
3293
  return;
2964
3294
  }
3295
+ audioDecodeMsTotal += performance.now() - _t0;
3296
+ audioDecodeBatches++;
2965
3297
  if (myToken !== pumpToken || destroyed) return;
2966
- for (const f of frames) {
3298
+ for (let i = 0; i < frames.length; i++) {
2967
3299
  if (myToken !== pumpToken || destroyed) return;
2968
- sanitizeFrameTimestamp(
2969
- f,
2970
- () => {
2971
- const ts = syntheticAudioUs;
2972
- const samples2 = f.nb_samples ?? 1024;
2973
- const sampleRate = f.sample_rate ?? 44100;
2974
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
2975
- return ts;
2976
- },
2977
- audioTimeBase
2978
- );
3300
+ const f = frames[i];
2979
3301
  const samples = libavFrameToInterleavedFloat32(f);
2980
3302
  if (samples) {
2981
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
3303
+ const pts = pktPtsSec[i] ?? null;
3304
+ if (isDebug3()) {
3305
+ const dur = samples.data.length / samples.channels / samples.sampleRate;
3306
+ console.log(`[TRACE-DEC] audio frame #${audioFramesDecoded} pts=${pts != null ? pts.toFixed(4) : "NULL"} dur=${dur.toFixed(4)} samples=${samples.data.length / samples.channels} sr=${samples.sampleRate} ch=${samples.channels} pktsIn=${pkts.length} framesOut=${frames.length}`);
3307
+ }
3308
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
2982
3309
  audioFramesDecoded++;
2983
3310
  }
2984
3311
  }
@@ -3081,13 +3408,17 @@ async function startDecoder(opts) {
3081
3408
  } catch {
3082
3409
  }
3083
3410
  await flushBSF();
3084
- syntheticVideoUs = Math.round(timeSec * 1e6);
3085
- syntheticAudioUs = Math.round(timeSec * 1e6);
3411
+ lastContentUs = -1;
3412
+ lastEmittedPtsUs = -1;
3413
+ firstValidPtsLoggedSinceSeek = false;
3086
3414
  pumpRunning = pumpLoop(newToken).catch(
3087
3415
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
3088
3416
  );
3089
3417
  },
3090
3418
  async seek(timeSec) {
3419
+ if (isDebug3()) {
3420
+ console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1e3).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
3421
+ }
3091
3422
  const newToken = ++pumpToken;
3092
3423
  if (pumpRunning) {
3093
3424
  try {
@@ -3118,8 +3449,14 @@ async function startDecoder(opts) {
3118
3449
  } catch {
3119
3450
  }
3120
3451
  await flushBSF();
3121
- syntheticVideoUs = Math.round(timeSec * 1e6);
3122
- syntheticAudioUs = Math.round(timeSec * 1e6);
3452
+ lastContentUs = -1;
3453
+ lastEmittedPtsUs = -1;
3454
+ firstValidPtsLoggedSinceSeek = false;
3455
+ seenFirstAudioPacketSinceSeek = false;
3456
+ seekTargetSec = timeSec;
3457
+ diagPktsLoggedSinceSeek = 0;
3458
+ diagFramesLoggedSinceSeek = 0;
3459
+ diagFrameKeysDumped = false;
3123
3460
  pumpRunning = pumpLoop(newToken).catch(
3124
3461
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
3125
3462
  );
@@ -3133,7 +3470,24 @@ async function startDecoder(opts) {
3133
3470
  packetsRead,
3134
3471
  videoFramesDecoded,
3135
3472
  audioFramesDecoded,
3473
+ // Throughput instrumentation — the stats panel turns these into
3474
+ // "decode fps actual / realtime target" and shows slowest batch
3475
+ // + producer throttle share.
3476
+ videoDecodeMsTotal,
3477
+ videoDecodeBatches,
3478
+ audioDecodeMsTotal,
3479
+ audioDecodeBatches,
3480
+ readMsTotal,
3481
+ readBatches,
3482
+ pumpThrottleMsTotal,
3483
+ pumpThrottleEntries,
3484
+ slowestVideoBatchMs,
3485
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1e3),
3486
+ ptsRegressions,
3487
+ worstPtsRegressionMs,
3488
+ sourceFps: videoFps,
3136
3489
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
3490
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
3137
3491
  // Confirmed transport info: once prepareLibavInput returns
3138
3492
  // successfully, we *know* whether the source is http-range (probe
3139
3493
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -5473,8 +5827,42 @@ var PROXY_ATTRIBUTES = [
5473
5827
  ];
5474
5828
  var PLAYER_ATTRIBUTES = ["show-fit"];
5475
5829
  var FIT_MODES = ["contain", "cover", "fill"];
5476
- var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5830
+ var AvbridgePlayerElement = class extends HTMLElement {
5477
5831
  static observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
5832
+ /**
5833
+ * Returns `true` if a DOM event originated from one of the player's
5834
+ * **interactive chrome elements** (seek bar, control buttons, settings
5835
+ * menu, overlay play button) rather than the bare video surface.
5836
+ *
5837
+ * This is the escape hatch for host pages that wrap the player in a
5838
+ * gesture recognizer (e.g. TikTok-style vertical-swipe pager). For
5839
+ * bubble-phase listeners the player's own handlers already call
5840
+ * `stopPropagation()` on chrome interactions — but **capture-phase**
5841
+ * listeners run *before* the player's handlers, so they need to check
5842
+ * the event's path themselves and bail. This helper does that check
5843
+ * via `composedPath()`, which traverses shadow boundaries correctly.
5844
+ *
5845
+ * Returns `false` for events on the bare video surface — host pages
5846
+ * remain free to claim those for their own gestures (e.g. swipe-to-pan
5847
+ * to the next video). Returns `false` for events that never hit a
5848
+ * player at all.
5849
+ *
5850
+ * @example
5851
+ * // TikTok-style vertical swipe on the document, capture phase:
5852
+ * document.addEventListener("pointerdown", (e) => {
5853
+ * if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return;
5854
+ * startSwipeGesture(e);
5855
+ * }, { capture: true });
5856
+ */
5857
+ static isPlayerChromeEvent(event) {
5858
+ const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn";
5859
+ for (const node of event.composedPath()) {
5860
+ if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) {
5861
+ return true;
5862
+ }
5863
+ }
5864
+ return false;
5865
+ }
5478
5866
  // ── Internal DOM refs ──────────────────────────────────────────────────
5479
5867
  _video;
5480
5868
  _playBtn;
@@ -5504,6 +5892,11 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5504
5892
  _activeAudioTrackId = null;
5505
5893
  _activeSubtitleTrackId = null;
5506
5894
  _userSeeking = false;
5895
+ /** Last seek target the user committed. The thumb stays here (and
5896
+ * `_updateTime` skips updating from `timeupdate`) until the underlying
5897
+ * `currentTime` actually catches up — otherwise the thumb visibly snaps
5898
+ * back to the pre-seek position while the remux pipeline rebuilds. */
5899
+ _pendingSeekTarget = null;
5507
5900
  _holdTimer = null;
5508
5901
  _holdSpeedActive = false;
5509
5902
  _savedPlaybackRate = 1;
@@ -5610,7 +6003,10 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5610
6003
  );
5611
6004
  });
5612
6005
  }
5613
- on(this._video, "loadstart", () => this._setState("loading"));
6006
+ on(this._video, "loadstart", () => {
6007
+ this._pendingSeekTarget = null;
6008
+ this._setState("loading");
6009
+ });
5614
6010
  on(this._video, "ready", () => {
5615
6011
  this._setState(this._video.paused ? "paused" : "playing");
5616
6012
  this._seekInput.max = String(this._video.duration || 0);
@@ -5757,7 +6153,9 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5757
6153
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`;
5758
6154
  }
5759
6155
  _onSeekCommit() {
5760
- this._video.currentTime = Number(this._seekInput.value);
6156
+ const target = Number(this._seekInput.value);
6157
+ this._pendingSeekTarget = target;
6158
+ this._video.currentTime = target;
5761
6159
  this._userSeeking = false;
5762
6160
  }
5763
6161
  /** Linear click-to-time mapping across the full track width (no edge clamping). */
@@ -5767,40 +6165,57 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5767
6165
  const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
5768
6166
  return frac * (this._video.duration || 0);
5769
6167
  }
5770
- /** Seekbar width below which drag-to-scrub seeks in real-time (vs
5771
- * preview-only). On narrow bars precise positioning is hard, so
5772
- * immediate video feedback is more useful than a time tooltip. */
5773
- static SCRUB_WIDTH_THRESHOLD = 400;
5774
6168
  _onSeekPointerDown(e) {
5775
6169
  if (e.button !== 0 && e.pointerType === "mouse") return;
5776
6170
  e.preventDefault();
6171
+ e.stopPropagation();
5777
6172
  this._userSeeking = true;
5778
6173
  const seekBar = this.shadowRoot.querySelector(".avp-seek");
5779
6174
  seekBar.setPointerCapture(e.pointerId);
5780
6175
  seekBar.setAttribute("data-seeking", "");
5781
- const scrubMode = seekBar.getBoundingClientRect().width < _AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
5782
- let lastScrubCommit = 0;
5783
- const initial = this._timeFromSeekPointer(e.clientX);
5784
- this._seekInput.value = String(initial);
5785
- this._onSeekInput();
5786
- this._updateSeekTooltip(e.clientX);
5787
- if (scrubMode) this._onSeekCommit();
6176
+ const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches;
6177
+ const startTime = coarse ? this._video.currentTime || 0 : 0;
6178
+ const startClientX = e.clientX;
6179
+ let lastCommit = 0;
6180
+ const timeAt = (clientX) => {
6181
+ if (coarse) {
6182
+ const rect = seekBar.getBoundingClientRect();
6183
+ const dx = clientX - startClientX;
6184
+ const dt = dx / rect.width * (this._video.duration || 0);
6185
+ return Math.max(0, Math.min(this._video.duration || 0, startTime + dt));
6186
+ }
6187
+ return this._timeFromSeekPointer(clientX);
6188
+ };
6189
+ const showTooltip = (t, clientX) => {
6190
+ if (coarse) this._updateSeekTooltipAtTime(t);
6191
+ else this._updateSeekTooltip(clientX);
6192
+ };
6193
+ if (!coarse) {
6194
+ const initial = timeAt(e.clientX);
6195
+ this._seekInput.value = String(initial);
6196
+ this._onSeekInput();
6197
+ showTooltip(initial, e.clientX);
6198
+ this._onSeekCommit();
6199
+ this._userSeeking = true;
6200
+ } else {
6201
+ showTooltip(startTime, e.clientX);
6202
+ }
5788
6203
  const onMove = (ev) => {
5789
- const t = this._timeFromSeekPointer(ev.clientX);
6204
+ ev.stopPropagation();
6205
+ const t = timeAt(ev.clientX);
5790
6206
  this._seekInput.value = String(t);
5791
6207
  this._onSeekInput();
5792
- this._updateSeekTooltip(ev.clientX);
5793
- if (scrubMode) {
5794
- const now = performance.now();
5795
- if (now - lastScrubCommit > 250) {
5796
- lastScrubCommit = now;
5797
- this._onSeekCommit();
5798
- this._userSeeking = true;
5799
- }
6208
+ showTooltip(t, ev.clientX);
6209
+ const now = performance.now();
6210
+ if (now - lastCommit > 250) {
6211
+ lastCommit = now;
6212
+ this._onSeekCommit();
6213
+ this._userSeeking = true;
5800
6214
  }
5801
6215
  };
5802
6216
  const onUp = (ev) => {
5803
- const t = this._timeFromSeekPointer(ev.clientX);
6217
+ ev.stopPropagation();
6218
+ const t = timeAt(ev.clientX);
5804
6219
  this._seekInput.value = String(t);
5805
6220
  this._onSeekCommit();
5806
6221
  this._seekInput.focus();
@@ -5827,6 +6242,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5827
6242
  this._seekTooltip.textContent = formatTime(t);
5828
6243
  this._seekTooltip.style.left = `${frac * 100}%`;
5829
6244
  }
6245
+ /** Position the tooltip over a specific time (vs. pointer X). Used by
6246
+ * relative-drag scrub on coarse pointers, where the displayed time
6247
+ * is decoupled from the finger position. */
6248
+ _updateSeekTooltipAtTime(t) {
6249
+ const dur = this._video.duration || 0;
6250
+ const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0;
6251
+ this._seekTooltip.textContent = formatTime(t);
6252
+ this._seekTooltip.style.left = `${frac * 100}%`;
6253
+ }
5830
6254
  _updateSeekVisuals(t) {
5831
6255
  const dur = this._video.duration || 0;
5832
6256
  const pct = dur > 0 ? t / dur * 100 : 0;
@@ -5838,6 +6262,15 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
5838
6262
  if (this._userSeeking) return;
5839
6263
  const t = this._video.currentTime;
5840
6264
  const d = this._video.duration;
6265
+ if (this._pendingSeekTarget !== null) {
6266
+ if (Math.abs(t - this._pendingSeekTarget) < 0.5) {
6267
+ this._pendingSeekTarget = null;
6268
+ } else {
6269
+ this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`;
6270
+ this._updateBuffered();
6271
+ return;
6272
+ }
6273
+ }
5841
6274
  this._seekInput.value = String(t);
5842
6275
  this._updateSeekVisuals(t);
5843
6276
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
@@ -6022,10 +6455,12 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
6022
6455
  }
6023
6456
  }
6024
6457
  // ── Stats for nerds ────────────────────────────────────────────────────
6458
+ _statsPrev = null;
6025
6459
  _toggleStats() {
6026
6460
  this._statsOpen = !this._statsOpen;
6027
6461
  this._statsEl.classList.toggle("open", this._statsOpen);
6028
6462
  if (this._statsOpen) {
6463
+ this._statsPrev = null;
6029
6464
  this._updateStats();
6030
6465
  this._statsInterval = setInterval(() => this._updateStats(), 1e3);
6031
6466
  } else {
@@ -6042,23 +6477,76 @@ var AvbridgePlayerElement = class _AvbridgePlayerElement extends HTMLElement {
6042
6477
  return;
6043
6478
  }
6044
6479
  const rt = d.runtime ?? {};
6045
- const lines = [
6046
- `Container: ${d.container ?? "?"}`,
6047
- `Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}`,
6048
- `Audio: ${d.audioCodec ?? "none"}`,
6049
- `Strategy: ${d.strategy ?? "?"} Class: ${d.strategyClass ?? "?"}`,
6050
- `Transport: ${d.transport ?? "?"} Range: ${d.rangeSupported ?? "?"}`,
6051
- `Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"}`
6052
- ];
6053
- if (rt.framesDecoded != null) lines.push(`Frames: ${rt.framesDecoded} decoded, ${rt.framesDropped ?? 0} dropped`);
6054
- if (rt.framesPainted != null) lines.push(`Painted: ${rt.framesPainted} Late: ${rt.framesDroppedLate ?? 0} Overflow: ${rt.framesDroppedOverflow ?? 0}`);
6055
- if (rt.videoFramesDecoded != null) lines.push(`Video decoded: ${rt.videoFramesDecoded} Chunks fed: ${rt.videoChunksFed ?? "?"}`);
6056
- if (rt.audioFramesDecoded != null) lines.push(`Audio decoded: ${rt.audioFramesDecoded}`);
6057
- if (rt.packetsRead != null) lines.push(`Packets read: ${rt.packetsRead}`);
6058
- if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF: ${rt.bsfApplied.join(", ")}`);
6059
- if (rt.audioState != null) lines.push(`Audio state: ${rt.audioState} Clock: ${rt.clockMode ?? "?"}`);
6480
+ const now = performance.now();
6481
+ const prev = this._statsPrev;
6482
+ const dtSec = prev ? Math.max(1e-3, (now - prev.ts) / 1e3) : 0;
6483
+ const delta = (key) => {
6484
+ if (!prev) return null;
6485
+ const a = rt[key];
6486
+ const b = prev.rt[key];
6487
+ if (typeof a === "number" && typeof b === "number") return a - b;
6488
+ return null;
6489
+ };
6490
+ const rate = (key) => {
6491
+ const d_ = delta(key);
6492
+ return d_ != null ? d_ / dtSec : null;
6493
+ };
6494
+ const fmt = (n, digits = 1) => n == null ? "?" : n.toFixed(digits);
6495
+ const sourceFps = typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps;
6496
+ const lines = [];
6497
+ lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
6498
+ lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}\xD7${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
6499
+ lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
6500
+ lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
6501
+ if (rt.videoFramesDecoded != null) {
6502
+ const decFps = rate("videoFramesDecoded");
6503
+ const paintFps = rate("framesPainted");
6504
+ const dropLateFps = rate("framesDroppedLate");
6505
+ const dropOverflowFps = rate("framesDroppedOverflow");
6506
+ const pct = sourceFps && decFps != null ? ` (${(decFps / sourceFps * 100).toFixed(0)}% of realtime)` : "";
6507
+ lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
6508
+ lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
6509
+ }
6510
+ if (typeof rt.videoDecodeMsTotal === "number") {
6511
+ const msDelta = delta("videoDecodeMsTotal");
6512
+ const batchesDelta = delta("videoDecodeBatches");
6513
+ const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
6514
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6515
+ lines.push(
6516
+ `Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs)}ms`
6517
+ );
6518
+ }
6519
+ if (typeof rt.audioDecodeMsTotal === "number") {
6520
+ const msDelta = delta("audioDecodeMsTotal");
6521
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6522
+ lines.push(`Audio decode: ${fmt(share)}% of wall`);
6523
+ }
6524
+ if (typeof rt.pumpThrottleMsTotal === "number") {
6525
+ const msDelta = delta("pumpThrottleMsTotal");
6526
+ const share = msDelta != null && dtSec > 0 ? msDelta / (dtSec * 1e3) * 100 : null;
6527
+ lines.push(`Producer throttled: ${fmt(share)}% of wall`);
6528
+ }
6529
+ if (rt.queueDepth != null) {
6530
+ lines.push(
6531
+ `Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs)}ms head=${fmt(rt.queueHeadMs)}ms tail=${fmt(rt.queueTailMs)}ms`
6532
+ );
6533
+ }
6534
+ if (typeof rt.newestVideoPtsMs === "number") {
6535
+ lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1e3).toFixed(2)}s`);
6536
+ }
6537
+ if (rt.audioState != null) {
6538
+ lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead, 2)}s clock=${rt.clockMode ?? "?"}`);
6539
+ }
6540
+ if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
6541
+ lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs)}ms) \u2014 decoder emitting out of order`);
6542
+ }
6543
+ if (rt.bsfApplied && rt.bsfApplied.length > 0) lines.push(`BSF active: ${rt.bsfApplied.join(", ")}`);
6544
+ if (rt.bsfMissing && rt.bsfMissing.length > 0) {
6545
+ lines.push(`BSF MISSING: ${rt.bsfMissing.join(", ")} (rebuild libav with avbsf)`);
6546
+ }
6060
6547
  if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
6061
6548
  this._statsEl.textContent = lines.join("\n");
6549
+ this._statsPrev = { ts: now, rt: { ...rt } };
6062
6550
  }
6063
6551
  // ── Controls: fullscreen ───────────────────────────────────────────────
6064
6552
  _toggleFullscreen() {