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