avbridge 2.12.1 → 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.
@@ -791,22 +791,25 @@ async function createRemuxPipeline(ctx, video) {
791
791
  }
792
792
  }
793
793
  let mimePromise = null;
794
+ const myToken = pumpToken;
794
795
  const writable = new WritableStream({
795
796
  write: async (chunk) => {
796
- if (destroyed) return;
797
+ if (destroyed || pumpToken !== myToken) return;
797
798
  if (!sink) {
798
799
  const mime = await (mimePromise ??= output.getMimeType());
800
+ if (destroyed || pumpToken !== myToken) return;
799
801
  sink = new MseSink({ mime, video });
800
802
  await sink.ready();
803
+ if (destroyed || pumpToken !== myToken) return;
801
804
  if (pendingStartTime > 0) {
802
805
  sink.invalidate(pendingStartTime);
803
806
  }
804
807
  sink.setPlayOnSeek(pendingAutoPlay);
805
808
  }
806
- while (sink && !destroyed && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
809
+ while (sink && !destroyed && pumpToken === myToken && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
807
810
  await new Promise((r) => setTimeout(r, 500));
808
811
  }
809
- if (destroyed) return;
812
+ if (destroyed || pumpToken !== myToken) return;
810
813
  sink.append(chunk.data);
811
814
  stats.bytesWritten += chunk.data.byteLength;
812
815
  stats.fragments++;
@@ -1073,7 +1076,21 @@ var VideoRenderer = class {
1073
1076
  framesPainted = 0;
1074
1077
  framesDroppedLate = 0;
1075
1078
  framesDroppedOverflow = 0;
1079
+ /** True once the head frame has been painted as a pre-roll poster
1080
+ * since the last flush. Used to ensure pre-roll paints exactly one
1081
+ * frame (held static) during the post-seek discard window. */
1076
1082
  prerolled = false;
1083
+ /** PTS (µs) of the most recently painted frame. Used as the calibration
1084
+ * reference on the first post-flush snap: the pre-roll path paints one
1085
+ * frame *before* PTS-based playback starts, so the queue head's PTS at
1086
+ * first PTS-based paint is the *next* frame, off by one frameDur from
1087
+ * the actually-displayed frame. Calibrating against the painted frame
1088
+ * instead of the queue head removes that one-frame offset and yields
1089
+ * calib ≈ 0 instead of +frameDur. */
1090
+ lastPaintedPtsUs = 0;
1091
+ hasLastPaintedPts = false;
1092
+ /** Audio-clock reading (ms) at the previous paint, for overlay Δaud. */
1093
+ lastPaintAudMs = 0;
1077
1094
  /** Wall-clock time of the last paint, in ms (performance.now()). */
1078
1095
  lastPaintWall = 0;
1079
1096
  /** Minimum ms between paints — paces video at roughly source fps. */
@@ -1123,13 +1140,17 @@ var VideoRenderer = class {
1123
1140
  return this.queue.length;
1124
1141
  }
1125
1142
  /**
1126
- * Soft cap for decoder backpressure. The decoder pump throttles when
1127
- * `queueDepth() >= queueHighWater`. Set high enough that normal decode
1128
- * bursts don't trigger the renderer's overflow-drop loop (which runs at
1129
- * every paint), but low enough that the decoder doesn't run unboundedly
1130
- * ahead. The hard cap in `enqueue()` is 64.
1143
+ * Cap the decoder may fill the queue up to. Used by the decoder's
1144
+ * enqueue-side discard logic (it closes new frames instead of pushing
1145
+ * them when this is reached). Sized so a long post-seek catch-up
1146
+ * fits the decoder produces frames at PTS T_kf onwards rapidly
1147
+ * while the demuxer is chewing through pre-target audio; if the
1148
+ * queue can hold the whole post-seek burst, the renderer plays
1149
+ * smoothly from pre-roll without a frozen-video gap when audio.start
1150
+ * fires. At ~340 KB per SD frame the cap is ~85 MB peak; at HD it's
1151
+ * larger but still bounded.
1131
1152
  */
1132
- queueHighWater = 30;
1153
+ queueHighWater = 256;
1133
1154
  enqueue(frame) {
1134
1155
  if (this.destroyed) {
1135
1156
  frame.close();
@@ -1140,7 +1161,7 @@ var VideoRenderer = class {
1140
1161
  if (this.queue.length === 1 && this.framesPainted === 0) {
1141
1162
  this.resolveFirstFrame();
1142
1163
  }
1143
- while (this.queue.length > 60) {
1164
+ while (this.queue.length > this.queueHighWater + 8) {
1144
1165
  this.queue.shift()?.close();
1145
1166
  this.framesDroppedOverflow++;
1146
1167
  }
@@ -1222,12 +1243,9 @@ var VideoRenderer = class {
1222
1243
  if (this.queue.length === 0) return;
1223
1244
  const playing = this.clock.isPlaying();
1224
1245
  if (!playing) {
1225
- if (!this.prerolled) {
1226
- const head = this.queue.shift();
1227
- this.paint(head);
1228
- head.close();
1246
+ if (!this.prerolled && this.queue.length > 0) {
1229
1247
  this.prerolled = true;
1230
- this.lastPaintWall = performance.now();
1248
+ this.paint(this.queue[0]);
1231
1249
  }
1232
1250
  return;
1233
1251
  }
@@ -1236,14 +1254,29 @@ var VideoRenderer = class {
1236
1254
  const hasPts = headTs > 0 || this.queue.length > 1;
1237
1255
  if (hasPts) {
1238
1256
  const wallNow2 = performance.now();
1239
- if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
1240
- this.ptsCalibrationUs = headTs - rawAudioNowUs;
1257
+ if (!this.ptsCalibrated) {
1258
+ const anchorUs = (this.clock.anchorTime?.() ?? this.clock.now()) * 1e6;
1259
+ const referencePtsUs = this.hasLastPaintedPts ? this.lastPaintedPtsUs : headTs;
1260
+ this.ptsCalibrationUs = referencePtsUs - anchorUs;
1241
1261
  this.ptsCalibrated = true;
1242
1262
  this.lastCalibrationWall = wallNow2;
1263
+ if (isDebug()) {
1264
+ console.log(
1265
+ `[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`
1266
+ );
1267
+ }
1268
+ } else if (wallNow2 - this.lastCalibrationWall > 1e4) {
1269
+ const oldCalib = this.ptsCalibrationUs;
1270
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
1271
+ this.lastCalibrationWall = wallNow2;
1272
+ if (isDebug()) {
1273
+ console.log(
1274
+ `[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)`
1275
+ );
1276
+ }
1243
1277
  }
1244
1278
  const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
1245
- const frameDurationUs = this.paintIntervalMs * 1e3;
1246
- const deadlineUs = audioNowUs + frameDurationUs;
1279
+ const deadlineUs = audioNowUs;
1247
1280
  let bestIdx = -1;
1248
1281
  for (let i = 0; i < this.queue.length; i++) {
1249
1282
  const ts = this.queue[i].timestamp ?? 0;
@@ -1271,19 +1304,20 @@ var VideoRenderer = class {
1271
1304
  return;
1272
1305
  }
1273
1306
  const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
1274
- const dropThresholdUs = _relaxDrop ? audioNowUs - 60 * 1e6 : audioNowUs - frameDurationUs * 2;
1275
1307
  let dropped = 0;
1276
- while (bestIdx > 0) {
1277
- const ts = this.queue[0].timestamp ?? 0;
1278
- if (ts < dropThresholdUs) {
1308
+ const initialBestIdx = bestIdx;
1309
+ if (!_relaxDrop) {
1310
+ while (bestIdx > 0) {
1279
1311
  this.queue.shift()?.close();
1280
1312
  this.framesDroppedLate++;
1281
1313
  bestIdx--;
1282
1314
  dropped++;
1283
- } else {
1284
- break;
1285
1315
  }
1286
1316
  }
1317
+ const paintTs = this.queue[0]?.timestamp ?? 0;
1318
+ if (isDebug()) {
1319
+ 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)}`);
1320
+ }
1287
1321
  this.ticksPainted++;
1288
1322
  if (isDebug()) {
1289
1323
  const now = performance.now();
@@ -1319,6 +1353,33 @@ var VideoRenderer = class {
1319
1353
  }
1320
1354
  try {
1321
1355
  this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
1356
+ if (isDebug()) {
1357
+ const wallNow = performance.now();
1358
+ const audNowMs = this.clock.now() * 1e3;
1359
+ const ptsMs = (frame.timestamp ?? 0) / 1e3;
1360
+ const dWall = this.lastPaintWall > 0 ? wallNow - this.lastPaintWall : 0;
1361
+ const dAud = this.lastPaintAudMs > 0 ? audNowMs - this.lastPaintAudMs : 0;
1362
+ const dPts = this.hasLastPaintedPts ? ptsMs - this.lastPaintedPtsUs / 1e3 : 0;
1363
+ this.ctx.save();
1364
+ this.ctx.font = "bold 18px monospace";
1365
+ const lines = [
1366
+ `#${this.framesPainted + 1} pts=${ptsMs.toFixed(0)} aud=${audNowMs.toFixed(0)} wall=${wallNow.toFixed(0)}`,
1367
+ `\u0394pts=${dPts.toFixed(0)} \u0394aud=${dAud.toFixed(0)} \u0394wall=${dWall.toFixed(0)}`
1368
+ ];
1369
+ const lineHeight = 22;
1370
+ const padTop = 6;
1371
+ const stripH = padTop + lineHeight * lines.length;
1372
+ this.ctx.fillStyle = "rgba(0,0,0,0.7)";
1373
+ this.ctx.fillRect(0, 0, this.canvas.width, stripH);
1374
+ this.ctx.fillStyle = "#0f0";
1375
+ for (let i = 0; i < lines.length; i++) {
1376
+ this.ctx.fillText(lines[i], 8, padTop + lineHeight * (i + 1) - 4);
1377
+ }
1378
+ this.ctx.restore();
1379
+ }
1380
+ this.lastPaintedPtsUs = frame.timestamp ?? 0;
1381
+ this.hasLastPaintedPts = true;
1382
+ this.lastPaintAudMs = this.clock.now() * 1e3;
1322
1383
  this.framesPainted++;
1323
1384
  } catch (err) {
1324
1385
  if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
@@ -1331,6 +1392,7 @@ var VideoRenderer = class {
1331
1392
  const count = this.queue.length;
1332
1393
  while (this.queue.length > 0) this.queue.shift()?.close();
1333
1394
  this.prerolled = false;
1395
+ this.hasLastPaintedPts = false;
1334
1396
  this.ptsCalibrated = false;
1335
1397
  this.hasEverEnqueuedSinceFlush = false;
1336
1398
  if (isDebug() && count > 0) {
@@ -1371,6 +1433,9 @@ var VideoRenderer = class {
1371
1433
  };
1372
1434
 
1373
1435
  // src/strategies/fallback/audio-output.ts
1436
+ function isDebug2() {
1437
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
1438
+ }
1374
1439
  var AudioOutput = class {
1375
1440
  ctx;
1376
1441
  gain;
@@ -1392,6 +1457,16 @@ var AudioOutput = class {
1392
1457
  mediaTimeOfNext = 0;
1393
1458
  /** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
1394
1459
  mediaTimeOfAnchor = 0;
1460
+ /**
1461
+ * Ctx time at which the first audible chunk will start playing. `-1`
1462
+ * before any chunk has been scheduled successfully (clock is frozen);
1463
+ * the actual ctx time once one has. The renderer's `clock.now()` uses
1464
+ * this to avoid advancing during the silent-gap window between
1465
+ * `audio.start()` and the first chunk that schedules without being
1466
+ * dropped — that gap is what produces the "audio-less fast-forward"
1467
+ * the user sees post-seek when the gate releases on video-only grace.
1468
+ */
1469
+ firstAudibleCtxStart = -1;
1395
1470
  ctxTimeAtAnchor = 0;
1396
1471
  pendingQueue = [];
1397
1472
  framesScheduled = 0;
@@ -1464,10 +1539,16 @@ var AudioOutput = class {
1464
1539
  return this.mediaTimeOfAnchor;
1465
1540
  }
1466
1541
  if (this.state === "playing") {
1542
+ if (this.firstAudibleCtxStart < 0) {
1543
+ return this.mediaTimeOfAnchor;
1544
+ }
1467
1545
  return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1468
1546
  }
1469
1547
  return this.mediaTimeOfAnchor;
1470
1548
  }
1549
+ anchorTime() {
1550
+ return this.mediaTimeOfAnchor;
1551
+ }
1471
1552
  isPlaying() {
1472
1553
  return this.state === "playing";
1473
1554
  }
@@ -1494,18 +1575,81 @@ var AudioOutput = class {
1494
1575
  * Schedule a chunk of decoded samples. Queues internally while idle (cold
1495
1576
  * start or post-seek), schedules directly to the audio graph while playing.
1496
1577
  * In wall-clock mode, samples are silently discarded.
1578
+ *
1579
+ * `ptsSec` is the chunk's source-domain content PTS in seconds, from
1580
+ * the demuxer. When provided, the chunk plays at the ctx-time
1581
+ * corresponding to that PTS — so pre-target audio after a seek
1582
+ * naturally drops (its computed `ctxStart` falls in the past) and
1583
+ * post-target audio plays at its true content time, without any
1584
+ * external trim or anchor rebase. When `ptsSec` is null (cold start
1585
+ * with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
1586
+ * the chunk is scheduled sequentially after `mediaTimeOfNext` — the
1587
+ * pre-refactor behavior.
1497
1588
  */
1498
- schedule(samples, channels, sampleRate) {
1589
+ schedule(samples, channels, sampleRate, ptsSec) {
1499
1590
  if (this.destroyed || this.noAudio) return;
1500
1591
  const frameCount = samples.length / channels;
1501
1592
  const durationSec = frameCount / sampleRate;
1593
+ const hasPts = ptsSec != null && Number.isFinite(ptsSec);
1594
+ if (hasPts && ptsSec + durationSec / this._rate < this.mediaTimeOfAnchor) {
1595
+ return;
1596
+ }
1502
1597
  if (this.state === "idle" || this.state === "paused") {
1503
- this.pendingQueue.push({ samples, channels, sampleRate, frameCount, durationSec });
1598
+ this.pendingQueue.push({
1599
+ samples,
1600
+ channels,
1601
+ sampleRate,
1602
+ frameCount,
1603
+ durationSec,
1604
+ ptsSec: hasPts ? ptsSec : null
1605
+ });
1504
1606
  return;
1505
1607
  }
1506
- this.scheduleNow(samples, channels, sampleRate, frameCount);
1608
+ this.scheduleNow(
1609
+ samples,
1610
+ channels,
1611
+ sampleRate,
1612
+ frameCount,
1613
+ hasPts ? ptsSec : null
1614
+ );
1507
1615
  }
1508
- scheduleNow(samples, channels, sampleRate, frameCount) {
1616
+ scheduleNow(samples, channels, sampleRate, frameCount, ptsSec) {
1617
+ const durationSec = frameCount / sampleRate;
1618
+ let ctxStart;
1619
+ if (ptsSec != null) {
1620
+ ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
1621
+ if (isDebug2()) {
1622
+ 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}`);
1623
+ }
1624
+ if (ctxStart < this.ctx.currentTime - 1e-3) {
1625
+ if (isDebug2()) {
1626
+ console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1627
+ }
1628
+ return;
1629
+ }
1630
+ if (this.firstAudibleCtxStart < 0) {
1631
+ this.firstAudibleCtxStart = ctxStart;
1632
+ this.mediaTimeOfAnchor = ptsSec;
1633
+ this.ctxTimeAtAnchor = ctxStart;
1634
+ if (isDebug2()) {
1635
+ 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)}`);
1636
+ }
1637
+ }
1638
+ const endMediaTime = ptsSec + durationSec / this._rate;
1639
+ if (endMediaTime > this.mediaTimeOfNext) {
1640
+ this.mediaTimeOfNext = endMediaTime;
1641
+ }
1642
+ } else {
1643
+ ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1644
+ console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1645
+ if (ctxStart < this.ctx.currentTime) {
1646
+ 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)}`);
1647
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
1648
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1649
+ ctxStart = this.ctx.currentTime;
1650
+ }
1651
+ this.mediaTimeOfNext += durationSec;
1652
+ }
1509
1653
  const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
1510
1654
  for (let ch = 0; ch < channels; ch++) {
1511
1655
  const channelData = buffer.getChannelData(ch);
@@ -1517,14 +1661,7 @@ var AudioOutput = class {
1517
1661
  node.buffer = buffer;
1518
1662
  node.connect(this.gain);
1519
1663
  if (this._rate !== 1) node.playbackRate.value = this._rate;
1520
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1521
- if (ctxStart < this.ctx.currentTime) {
1522
- this.ctxTimeAtAnchor = this.ctx.currentTime;
1523
- this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1524
- ctxStart = this.ctx.currentTime;
1525
- }
1526
1664
  node.start(ctxStart);
1527
- this.mediaTimeOfNext += frameCount / sampleRate;
1528
1665
  this.framesScheduled++;
1529
1666
  }
1530
1667
  // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -1549,12 +1686,15 @@ var AudioOutput = class {
1549
1686
  } catch {
1550
1687
  }
1551
1688
  if (this.state === "paused") {
1689
+ if (isDebug2()) {
1690
+ 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}`);
1691
+ }
1552
1692
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1553
1693
  this.state = "playing";
1554
1694
  const drain2 = this.pendingQueue;
1555
1695
  this.pendingQueue = [];
1556
1696
  for (const c of drain2) {
1557
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1697
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1558
1698
  }
1559
1699
  return;
1560
1700
  }
@@ -1562,10 +1702,13 @@ var AudioOutput = class {
1562
1702
  this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
1563
1703
  this.mediaTimeOfNext = this.mediaTimeOfAnchor;
1564
1704
  this.state = "playing";
1705
+ if (isDebug2()) {
1706
+ 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}`);
1707
+ }
1565
1708
  const drain = this.pendingQueue;
1566
1709
  this.pendingQueue = [];
1567
1710
  for (const c of drain) {
1568
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1711
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1569
1712
  }
1570
1713
  }
1571
1714
  /** Pause playback. Suspends the audio context. */
@@ -1591,6 +1734,9 @@ var AudioOutput = class {
1591
1734
  * supplying new samples) and then call `start()` to resume playback.
1592
1735
  */
1593
1736
  async reset(newMediaTime) {
1737
+ if (isDebug2()) {
1738
+ 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}`);
1739
+ }
1594
1740
  if (this.noAudio) {
1595
1741
  this.pendingQueue = [];
1596
1742
  this.mediaTimeOfAnchor = newMediaTime;
@@ -1609,6 +1755,7 @@ var AudioOutput = class {
1609
1755
  this.mediaTimeOfAnchor = newMediaTime;
1610
1756
  this.mediaTimeOfNext = newMediaTime;
1611
1757
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1758
+ this.firstAudibleCtxStart = -1;
1612
1759
  this.state = "idle";
1613
1760
  if (this.ctx.state === "running") {
1614
1761
  await this.ctx.suspend();
@@ -1777,7 +1924,6 @@ async function startHybridDecoder(opts) {
1777
1924
  let videoChunksFed = 0;
1778
1925
  let bufferedUntilSec = 0;
1779
1926
  let syntheticVideoUs = 0;
1780
- let syntheticAudioUs = 0;
1781
1927
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
1782
1928
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
1783
1929
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -1809,7 +1955,13 @@ async function startHybridDecoder(opts) {
1809
1955
  }
1810
1956
  }
1811
1957
  if (audioDec && audioPackets && audioPackets.length > 0) {
1812
- await decodeAudioBatch(audioPackets, myToken);
1958
+ await decodeAudioBatch(
1959
+ audioPackets,
1960
+ myToken,
1961
+ /*flush*/
1962
+ false,
1963
+ audioTimeBase
1964
+ );
1813
1965
  }
1814
1966
  if (myToken !== pumpToken || destroyed) return;
1815
1967
  await new Promise((r) => setTimeout(r, 0));
@@ -1856,8 +2008,11 @@ async function startHybridDecoder(opts) {
1856
2008
  }
1857
2009
  }
1858
2010
  }
1859
- async function decodeAudioBatch(pkts, myToken, flush = false) {
2011
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
1860
2012
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2013
+ const pktPtsSec = pkts.map(
2014
+ (p) => tb ? chunkHZUVMXBN_cjs.packetPtsSec(p, tb) : null
2015
+ );
1861
2016
  const AUDIO_SUB_BATCH = 4;
1862
2017
  let allFrames = [];
1863
2018
  for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
@@ -1895,22 +2050,13 @@ async function startHybridDecoder(opts) {
1895
2050
  }
1896
2051
  if (myToken !== pumpToken || destroyed) return;
1897
2052
  const frames = allFrames;
1898
- for (const f of frames) {
2053
+ for (let i = 0; i < frames.length; i++) {
1899
2054
  if (myToken !== pumpToken || destroyed) return;
1900
- chunkHZUVMXBN_cjs.sanitizeFrameTimestamp(
1901
- f,
1902
- () => {
1903
- const ts = syntheticAudioUs;
1904
- const samples2 = f.nb_samples ?? 1024;
1905
- const sampleRate = f.sample_rate ?? 44100;
1906
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
1907
- return ts;
1908
- },
1909
- audioTimeBase
1910
- );
2055
+ const f = frames[i];
1911
2056
  const samples = chunkHZUVMXBN_cjs.libavFrameToInterleavedFloat32(f);
1912
2057
  if (samples) {
1913
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
2058
+ const pts = pktPtsSec[i] ?? null;
2059
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
1914
2060
  audioFramesDecoded++;
1915
2061
  }
1916
2062
  }
@@ -2020,7 +2166,6 @@ async function startHybridDecoder(opts) {
2020
2166
  }
2021
2167
  await flushBSF();
2022
2168
  syntheticVideoUs = Math.round(timeSec * 1e6);
2023
- syntheticAudioUs = Math.round(timeSec * 1e6);
2024
2169
  pumpRunning = pumpLoop(newToken).catch(
2025
2170
  (err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
2026
2171
  );
@@ -2059,7 +2204,6 @@ async function startHybridDecoder(opts) {
2059
2204
  }
2060
2205
  await flushBSF();
2061
2206
  syntheticVideoUs = Math.round(timeSec * 1e6);
2062
- syntheticAudioUs = Math.round(timeSec * 1e6);
2063
2207
  pumpRunning = pumpLoop(newToken).catch(
2064
2208
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2065
2209
  );
@@ -2312,6 +2456,9 @@ async function createHybridSession(ctx, target, transport) {
2312
2456
  }
2313
2457
 
2314
2458
  // src/strategies/fallback/decoder.ts
2459
+ function isDebug3() {
2460
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
2461
+ }
2315
2462
  async function startDecoder(opts) {
2316
2463
  const variant = "avbridge";
2317
2464
  const libav = await chunkG4APZMCP_cjs.loadLibav(variant);
@@ -2443,8 +2590,15 @@ async function startDecoder(opts) {
2443
2590
  let watchdogSlowSinceMs = 0;
2444
2591
  let watchdogSlowWarned = false;
2445
2592
  let watchdogOverflowWarned = false;
2446
- let syntheticVideoUs = 0;
2447
- let syntheticAudioUs = 0;
2593
+ let lastContentUs = -1;
2594
+ let firstValidPtsLoggedSinceSeek = false;
2595
+ let seenFirstAudioPacketSinceSeek = false;
2596
+ let seekTargetSec = 0;
2597
+ let diagPktsLoggedSinceSeek = 0;
2598
+ let diagFramesLoggedSinceSeek = 0;
2599
+ let diagFrameKeysDumped = false;
2600
+ const DIAG_MAX_PKTS = 100;
2601
+ const DIAG_MAX_FRAMES = 300;
2448
2602
  let videoDecodeMsTotal = 0;
2449
2603
  let audioDecodeMsTotal = 0;
2450
2604
  let videoDecodeBatches = 0;
@@ -2483,6 +2637,18 @@ async function startDecoder(opts) {
2483
2637
  for (const pkt of videoPackets) {
2484
2638
  const sec = chunkHZUVMXBN_cjs.packetPtsSec(pkt, videoTimeBase);
2485
2639
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2640
+ if (isDebug3() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
2641
+ const rawHi = pkt.ptshi ?? 0;
2642
+ const rawLo = pkt.pts ?? 0;
2643
+ const isInvalidPts = rawHi === -2147483648 && rawLo === 0;
2644
+ const rawPts64 = isInvalidPts ? null : rawHi * 4294967296 + rawLo;
2645
+ const rawSec = rawPts64 != null && videoTimeBase ? rawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
2646
+ const pktKeys = diagPktsLoggedSinceSeek === 0 ? `[keys: ${Object.keys(pkt).join(",")}]` : "";
2647
+ console.log(
2648
+ `[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
2649
+ );
2650
+ diagPktsLoggedSinceSeek++;
2651
+ }
2486
2652
  }
2487
2653
  }
2488
2654
  if (audioPackets && audioTimeBase) {
@@ -2490,9 +2656,25 @@ async function startDecoder(opts) {
2490
2656
  const sec = chunkHZUVMXBN_cjs.packetPtsSec(pkt, audioTimeBase);
2491
2657
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2492
2658
  }
2659
+ if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
2660
+ const firstSec = chunkHZUVMXBN_cjs.packetPtsSec(audioPackets[0], audioTimeBase);
2661
+ if (firstSec != null && Number.isFinite(firstSec)) {
2662
+ seenFirstAudioPacketSinceSeek = true;
2663
+ chunkG4APZMCP_cjs.dbg.info(
2664
+ "av-anchor",
2665
+ `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)`
2666
+ );
2667
+ }
2668
+ }
2493
2669
  }
2494
2670
  if (audioDec && audioPackets && audioPackets.length > 0) {
2495
- await decodeAudioBatch(audioPackets, myToken);
2671
+ await decodeAudioBatch(
2672
+ audioPackets,
2673
+ myToken,
2674
+ /*flush*/
2675
+ false,
2676
+ audioTimeBase
2677
+ );
2496
2678
  }
2497
2679
  if (myToken !== pumpToken || destroyed) return;
2498
2680
  if (videoDec && videoPackets && videoPackets.length > 0) {
@@ -2536,7 +2718,7 @@ async function startDecoder(opts) {
2536
2718
  {
2537
2719
  const _throttleStart = performance.now();
2538
2720
  let _throttled = false;
2539
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2721
+ while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
2540
2722
  _throttled = true;
2541
2723
  await new Promise((r) => setTimeout(r, 50));
2542
2724
  }
@@ -2591,18 +2773,83 @@ async function startDecoder(opts) {
2591
2773
  if (myToken !== pumpToken || destroyed) return;
2592
2774
  for (const f of frames) {
2593
2775
  if (myToken !== pumpToken || destroyed) return;
2594
- chunkHZUVMXBN_cjs.sanitizeFrameTimestamp(
2595
- f,
2596
- () => {
2597
- const base = lastEmittedPtsUs >= 0 ? lastEmittedPtsUs + videoFrameStepUs : syntheticVideoUs;
2598
- syntheticVideoUs = base + videoFrameStepUs;
2599
- return base;
2600
- },
2601
- videoTimeBase
2602
- );
2603
- const _fPts = (f.ptshi ?? 0) * 4294967296 + (f.pts ?? 0);
2776
+ const _diagShouldLog = isDebug3() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
2777
+ const _diagRawHi = f.ptshi ?? 0;
2778
+ const _diagRawLo = f.pts ?? 0;
2779
+ const _diagInvalid = _diagRawHi === -2147483648 && _diagRawLo === 0;
2780
+ const _diagRawPts64 = _diagInvalid ? null : _diagRawHi * 4294967296 + _diagRawLo;
2781
+ const _diagRawSec = _diagRawPts64 != null && videoTimeBase ? _diagRawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
2782
+ if (_diagShouldLog && !diagFrameKeysDumped) {
2783
+ diagFrameKeysDumped = true;
2784
+ const allKeys = Object.keys(f);
2785
+ const fieldDump = {};
2786
+ for (const k of allKeys) {
2787
+ const v = f[k];
2788
+ if (k === "data") continue;
2789
+ if (typeof v === "object" && v !== null && "length" in v) continue;
2790
+ fieldDump[k] = v;
2791
+ }
2792
+ console.log(`[DIAG-FRAME] FIRST FRAME post-seek \u2014 all keys: ${allKeys.join(",")}`);
2793
+ console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
2794
+ }
2795
+ let rawUs = null;
2796
+ if (!_diagInvalid && _diagRawPts64 != null) {
2797
+ const tb = videoTimeBase ?? [1, 1e6];
2798
+ const us = Math.round(_diagRawPts64 * 1e6 * tb[0] / tb[1]);
2799
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
2800
+ rawUs = us;
2801
+ }
2802
+ }
2803
+ const _diagLog = (decision, finalPtsUs, sanFallback) => {
2804
+ if (!_diagShouldLog) return;
2805
+ const ptsSrc = sanFallback ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})` : "LIBAV";
2806
+ console.log(
2807
+ `[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}`
2808
+ );
2809
+ diagFramesLoggedSinceSeek++;
2810
+ };
2811
+ let _diagSanFallbackFired = false;
2812
+ const seekTargetUs = Math.round(seekTargetSec * 1e6);
2813
+ if (lastContentUs < 0) {
2814
+ if (rawUs == null) {
2815
+ const isColdStartKeyframe = seekTargetSec === 0 && f.key_frame === 1;
2816
+ if (isColdStartKeyframe) {
2817
+ lastContentUs = 0;
2818
+ _diagSanFallbackFired = true;
2819
+ } else {
2820
+ _diagLog("PRE-ANCHOR-DROP", 0, true);
2821
+ continue;
2822
+ }
2823
+ } else {
2824
+ lastContentUs = rawUs;
2825
+ if (!firstValidPtsLoggedSinceSeek) {
2826
+ firstValidPtsLoggedSinceSeek = true;
2827
+ if (isDebug3()) {
2828
+ console.log(
2829
+ `[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)`
2830
+ );
2831
+ }
2832
+ if (rawUs >= seekTargetUs) {
2833
+ console.warn(
2834
+ `[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.`
2835
+ );
2836
+ }
2837
+ }
2838
+ }
2839
+ } else {
2840
+ if (rawUs != null) {
2841
+ lastContentUs = rawUs;
2842
+ } else {
2843
+ lastContentUs += videoFrameStepUs;
2844
+ _diagSanFallbackFired = true;
2845
+ }
2846
+ }
2847
+ f.pts = lastContentUs;
2848
+ f.ptshi = lastContentUs < 0 ? -1 : 0;
2849
+ const _fPts = lastContentUs;
2604
2850
  if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
2605
2851
  if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
2852
+ _diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
2606
2853
  ptsRegressions++;
2607
2854
  const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
2608
2855
  if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
@@ -2614,19 +2861,34 @@ async function startDecoder(opts) {
2614
2861
  continue;
2615
2862
  }
2616
2863
  lastEmittedPtsUs = _fPts;
2864
+ const targetUs = Math.round(seekTargetSec * 1e6);
2865
+ if (_fPts < targetUs - videoFrameStepUs) {
2866
+ _diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
2867
+ continue;
2868
+ }
2617
2869
  try {
2618
2870
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
2619
- opts.renderer.enqueue(vf);
2871
+ if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
2872
+ vf.close();
2873
+ _diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
2874
+ } else {
2875
+ opts.renderer.enqueue(vf);
2876
+ _diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
2877
+ }
2620
2878
  videoFramesDecoded++;
2621
2879
  } catch (err) {
2622
2880
  if (videoFramesDecoded === 0) {
2623
2881
  console.warn("[avbridge] laFrameToVideoFrame failed:", err);
2624
2882
  }
2883
+ _diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
2625
2884
  }
2626
2885
  }
2627
2886
  }
2628
- async function decodeAudioBatch(pkts, myToken, flush = false) {
2887
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
2629
2888
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2889
+ const pktPtsSec = pkts.map(
2890
+ (p) => tb ? chunkHZUVMXBN_cjs.packetPtsSec(p, tb) : null
2891
+ );
2630
2892
  let frames;
2631
2893
  const _t0 = performance.now();
2632
2894
  try {
@@ -2644,22 +2906,17 @@ async function startDecoder(opts) {
2644
2906
  audioDecodeMsTotal += performance.now() - _t0;
2645
2907
  audioDecodeBatches++;
2646
2908
  if (myToken !== pumpToken || destroyed) return;
2647
- for (const f of frames) {
2909
+ for (let i = 0; i < frames.length; i++) {
2648
2910
  if (myToken !== pumpToken || destroyed) return;
2649
- chunkHZUVMXBN_cjs.sanitizeFrameTimestamp(
2650
- f,
2651
- () => {
2652
- const ts = syntheticAudioUs;
2653
- const samples2 = f.nb_samples ?? 1024;
2654
- const sampleRate = f.sample_rate ?? 44100;
2655
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
2656
- return ts;
2657
- },
2658
- audioTimeBase
2659
- );
2911
+ const f = frames[i];
2660
2912
  const samples = chunkHZUVMXBN_cjs.libavFrameToInterleavedFloat32(f);
2661
2913
  if (samples) {
2662
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
2914
+ const pts = pktPtsSec[i] ?? null;
2915
+ if (isDebug3()) {
2916
+ const dur = samples.data.length / samples.channels / samples.sampleRate;
2917
+ 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}`);
2918
+ }
2919
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
2663
2920
  audioFramesDecoded++;
2664
2921
  }
2665
2922
  }
@@ -2762,14 +3019,17 @@ async function startDecoder(opts) {
2762
3019
  } catch {
2763
3020
  }
2764
3021
  await flushBSF();
2765
- syntheticVideoUs = Math.round(timeSec * 1e6);
2766
- syntheticAudioUs = Math.round(timeSec * 1e6);
3022
+ lastContentUs = -1;
2767
3023
  lastEmittedPtsUs = -1;
3024
+ firstValidPtsLoggedSinceSeek = false;
2768
3025
  pumpRunning = pumpLoop(newToken).catch(
2769
3026
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
2770
3027
  );
2771
3028
  },
2772
3029
  async seek(timeSec) {
3030
+ if (isDebug3()) {
3031
+ console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1e3).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
3032
+ }
2773
3033
  const newToken = ++pumpToken;
2774
3034
  if (pumpRunning) {
2775
3035
  try {
@@ -2800,9 +3060,14 @@ async function startDecoder(opts) {
2800
3060
  } catch {
2801
3061
  }
2802
3062
  await flushBSF();
2803
- syntheticVideoUs = Math.round(timeSec * 1e6);
2804
- syntheticAudioUs = Math.round(timeSec * 1e6);
3063
+ lastContentUs = -1;
2805
3064
  lastEmittedPtsUs = -1;
3065
+ firstValidPtsLoggedSinceSeek = false;
3066
+ seenFirstAudioPacketSinceSeek = false;
3067
+ seekTargetSec = timeSec;
3068
+ diagPktsLoggedSinceSeek = 0;
3069
+ diagFramesLoggedSinceSeek = 0;
3070
+ diagFrameKeysDumped = false;
2806
3071
  pumpRunning = pumpLoop(newToken).catch(
2807
3072
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
2808
3073
  );
@@ -3662,5 +3927,5 @@ exports.NATIVE_VIDEO_CODECS = NATIVE_VIDEO_CODECS;
3662
3927
  exports.UnifiedPlayer = UnifiedPlayer;
3663
3928
  exports.classifyContext = classifyContext;
3664
3929
  exports.createPlayer = createPlayer;
3665
- //# sourceMappingURL=chunk-UM6WCSGL.cjs.map
3666
- //# sourceMappingURL=chunk-UM6WCSGL.cjs.map
3930
+ //# sourceMappingURL=chunk-OFJYEITB.cjs.map
3931
+ //# sourceMappingURL=chunk-OFJYEITB.cjs.map