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
@@ -1,7 +1,7 @@
1
1
  import { SubtitleResourceBag, discoverSidecars, attachSubtitleTracks, SubtitleOverlay } from './chunk-EDDWAN2L.js';
2
- import { probe, avbridgeVideoToMediabunny, buildMediabunnySourceFromInput, avbridgeAudioToMediabunny } from './chunk-2LNXMGT6.js';
2
+ import { probe, avbridgeVideoToMediabunny, buildMediabunnySourceFromInput, avbridgeAudioToMediabunny } from './chunk-5CX7BVVV.js';
3
3
  import { AvbridgeError, ERR_ALL_STRATEGIES_EXHAUSTED, ERR_PLAYER_NOT_READY, ERR_MSE_NOT_SUPPORTED, ERR_MSE_CODEC_NOT_SUPPORTED } from './chunk-CPJLFFCC.js';
4
- import { packetPtsSec, sanitizePacketTimestamp, sanitizeFrameTimestamp, libavFrameToInterleavedFloat32 } from './chunk-3YKWU4FM.js';
4
+ import { packetPtsSec, sanitizePacketTimestamp, libavFrameToInterleavedFloat32 } from './chunk-3YKWU4FM.js';
5
5
  import { dbg, loadLibav } from './chunk-5DMTJVIU.js';
6
6
  import { pickLibavVariant } from './chunk-5YAWWKA3.js';
7
7
 
@@ -789,22 +789,25 @@ async function createRemuxPipeline(ctx, video) {
789
789
  }
790
790
  }
791
791
  let mimePromise = null;
792
+ const myToken = pumpToken;
792
793
  const writable = new WritableStream({
793
794
  write: async (chunk) => {
794
- if (destroyed) return;
795
+ if (destroyed || pumpToken !== myToken) return;
795
796
  if (!sink) {
796
797
  const mime = await (mimePromise ??= output.getMimeType());
798
+ if (destroyed || pumpToken !== myToken) return;
797
799
  sink = new MseSink({ mime, video });
798
800
  await sink.ready();
801
+ if (destroyed || pumpToken !== myToken) return;
799
802
  if (pendingStartTime > 0) {
800
803
  sink.invalidate(pendingStartTime);
801
804
  }
802
805
  sink.setPlayOnSeek(pendingAutoPlay);
803
806
  }
804
- while (sink && !destroyed && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
807
+ while (sink && !destroyed && pumpToken === myToken && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
805
808
  await new Promise((r) => setTimeout(r, 500));
806
809
  }
807
- if (destroyed) return;
810
+ if (destroyed || pumpToken !== myToken) return;
808
811
  sink.append(chunk.data);
809
812
  stats.bytesWritten += chunk.data.byteLength;
810
813
  stats.fragments++;
@@ -1071,7 +1074,21 @@ var VideoRenderer = class {
1071
1074
  framesPainted = 0;
1072
1075
  framesDroppedLate = 0;
1073
1076
  framesDroppedOverflow = 0;
1077
+ /** True once the head frame has been painted as a pre-roll poster
1078
+ * since the last flush. Used to ensure pre-roll paints exactly one
1079
+ * frame (held static) during the post-seek discard window. */
1074
1080
  prerolled = false;
1081
+ /** PTS (µs) of the most recently painted frame. Used as the calibration
1082
+ * reference on the first post-flush snap: the pre-roll path paints one
1083
+ * frame *before* PTS-based playback starts, so the queue head's PTS at
1084
+ * first PTS-based paint is the *next* frame, off by one frameDur from
1085
+ * the actually-displayed frame. Calibrating against the painted frame
1086
+ * instead of the queue head removes that one-frame offset and yields
1087
+ * calib ≈ 0 instead of +frameDur. */
1088
+ lastPaintedPtsUs = 0;
1089
+ hasLastPaintedPts = false;
1090
+ /** Audio-clock reading (ms) at the previous paint, for overlay Δaud. */
1091
+ lastPaintAudMs = 0;
1075
1092
  /** Wall-clock time of the last paint, in ms (performance.now()). */
1076
1093
  lastPaintWall = 0;
1077
1094
  /** Minimum ms between paints — paces video at roughly source fps. */
@@ -1102,32 +1119,47 @@ var VideoRenderer = class {
1102
1119
  /** Resolves once the first decoded frame has been enqueued. */
1103
1120
  firstFrameReady;
1104
1121
  resolveFirstFrame;
1105
- /** True once at least one frame has been enqueued. */
1122
+ /**
1123
+ * True once at least one frame has been enqueued *since the last flush*.
1124
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
1125
+ * any frame has arrived, and after a seek we want the same semantics
1126
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
1127
+ * `framesPainted > 0` that used to live here was wrong: it kept the
1128
+ * state "true forever" after the first frame ever, so post-seek
1129
+ * `waitForBuffer()` would exit immediately with an empty queue and
1130
+ * leave video frozen while audio kept going.
1131
+ */
1106
1132
  hasFrames() {
1107
- return this.queue.length > 0 || this.framesPainted > 0;
1133
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
1108
1134
  }
1135
+ hasEverEnqueuedSinceFlush = false;
1109
1136
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
1110
1137
  queueDepth() {
1111
1138
  return this.queue.length;
1112
1139
  }
1113
1140
  /**
1114
- * Soft cap for decoder backpressure. The decoder pump throttles when
1115
- * `queueDepth() >= queueHighWater`. Set high enough that normal decode
1116
- * bursts don't trigger the renderer's overflow-drop loop (which runs at
1117
- * every paint), but low enough that the decoder doesn't run unboundedly
1118
- * ahead. The hard cap in `enqueue()` is 64.
1141
+ * Cap the decoder may fill the queue up to. Used by the decoder's
1142
+ * enqueue-side discard logic (it closes new frames instead of pushing
1143
+ * them when this is reached). Sized so a long post-seek catch-up
1144
+ * fits the decoder produces frames at PTS T_kf onwards rapidly
1145
+ * while the demuxer is chewing through pre-target audio; if the
1146
+ * queue can hold the whole post-seek burst, the renderer plays
1147
+ * smoothly from pre-roll without a frozen-video gap when audio.start
1148
+ * fires. At ~340 KB per SD frame the cap is ~85 MB peak; at HD it's
1149
+ * larger but still bounded.
1119
1150
  */
1120
- queueHighWater = 30;
1151
+ queueHighWater = 256;
1121
1152
  enqueue(frame) {
1122
1153
  if (this.destroyed) {
1123
1154
  frame.close();
1124
1155
  return;
1125
1156
  }
1126
1157
  this.queue.push(frame);
1158
+ this.hasEverEnqueuedSinceFlush = true;
1127
1159
  if (this.queue.length === 1 && this.framesPainted === 0) {
1128
1160
  this.resolveFirstFrame();
1129
1161
  }
1130
- while (this.queue.length > 60) {
1162
+ while (this.queue.length > this.queueHighWater + 8) {
1131
1163
  this.queue.shift()?.close();
1132
1164
  this.framesDroppedOverflow++;
1133
1165
  }
@@ -1209,12 +1241,9 @@ var VideoRenderer = class {
1209
1241
  if (this.queue.length === 0) return;
1210
1242
  const playing = this.clock.isPlaying();
1211
1243
  if (!playing) {
1212
- if (!this.prerolled) {
1213
- const head = this.queue.shift();
1214
- this.paint(head);
1215
- head.close();
1244
+ if (!this.prerolled && this.queue.length > 0) {
1216
1245
  this.prerolled = true;
1217
- this.lastPaintWall = performance.now();
1246
+ this.paint(this.queue[0]);
1218
1247
  }
1219
1248
  return;
1220
1249
  }
@@ -1223,14 +1252,29 @@ var VideoRenderer = class {
1223
1252
  const hasPts = headTs > 0 || this.queue.length > 1;
1224
1253
  if (hasPts) {
1225
1254
  const wallNow2 = performance.now();
1226
- if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
1227
- this.ptsCalibrationUs = headTs - rawAudioNowUs;
1255
+ if (!this.ptsCalibrated) {
1256
+ const anchorUs = (this.clock.anchorTime?.() ?? this.clock.now()) * 1e6;
1257
+ const referencePtsUs = this.hasLastPaintedPts ? this.lastPaintedPtsUs : headTs;
1258
+ this.ptsCalibrationUs = referencePtsUs - anchorUs;
1228
1259
  this.ptsCalibrated = true;
1229
1260
  this.lastCalibrationWall = wallNow2;
1261
+ if (isDebug()) {
1262
+ console.log(
1263
+ `[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`
1264
+ );
1265
+ }
1266
+ } else if (wallNow2 - this.lastCalibrationWall > 1e4) {
1267
+ const oldCalib = this.ptsCalibrationUs;
1268
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
1269
+ this.lastCalibrationWall = wallNow2;
1270
+ if (isDebug()) {
1271
+ console.log(
1272
+ `[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)`
1273
+ );
1274
+ }
1230
1275
  }
1231
1276
  const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
1232
- const frameDurationUs = this.paintIntervalMs * 1e3;
1233
- const deadlineUs = audioNowUs + frameDurationUs;
1277
+ const deadlineUs = audioNowUs;
1234
1278
  let bestIdx = -1;
1235
1279
  for (let i = 0; i < this.queue.length; i++) {
1236
1280
  const ts = this.queue[i].timestamp ?? 0;
@@ -1257,19 +1301,21 @@ var VideoRenderer = class {
1257
1301
  }
1258
1302
  return;
1259
1303
  }
1260
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
1304
+ const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
1261
1305
  let dropped = 0;
1262
- while (bestIdx > 0) {
1263
- const ts = this.queue[0].timestamp ?? 0;
1264
- if (ts < dropThresholdUs) {
1306
+ const initialBestIdx = bestIdx;
1307
+ if (!_relaxDrop) {
1308
+ while (bestIdx > 0) {
1265
1309
  this.queue.shift()?.close();
1266
1310
  this.framesDroppedLate++;
1267
1311
  bestIdx--;
1268
1312
  dropped++;
1269
- } else {
1270
- break;
1271
1313
  }
1272
1314
  }
1315
+ const paintTs = this.queue[0]?.timestamp ?? 0;
1316
+ if (isDebug()) {
1317
+ 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)}`);
1318
+ }
1273
1319
  this.ticksPainted++;
1274
1320
  if (isDebug()) {
1275
1321
  const now = performance.now();
@@ -1305,6 +1351,33 @@ var VideoRenderer = class {
1305
1351
  }
1306
1352
  try {
1307
1353
  this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
1354
+ if (isDebug()) {
1355
+ const wallNow = performance.now();
1356
+ const audNowMs = this.clock.now() * 1e3;
1357
+ const ptsMs = (frame.timestamp ?? 0) / 1e3;
1358
+ const dWall = this.lastPaintWall > 0 ? wallNow - this.lastPaintWall : 0;
1359
+ const dAud = this.lastPaintAudMs > 0 ? audNowMs - this.lastPaintAudMs : 0;
1360
+ const dPts = this.hasLastPaintedPts ? ptsMs - this.lastPaintedPtsUs / 1e3 : 0;
1361
+ this.ctx.save();
1362
+ this.ctx.font = "bold 18px monospace";
1363
+ const lines = [
1364
+ `#${this.framesPainted + 1} pts=${ptsMs.toFixed(0)} aud=${audNowMs.toFixed(0)} wall=${wallNow.toFixed(0)}`,
1365
+ `\u0394pts=${dPts.toFixed(0)} \u0394aud=${dAud.toFixed(0)} \u0394wall=${dWall.toFixed(0)}`
1366
+ ];
1367
+ const lineHeight = 22;
1368
+ const padTop = 6;
1369
+ const stripH = padTop + lineHeight * lines.length;
1370
+ this.ctx.fillStyle = "rgba(0,0,0,0.7)";
1371
+ this.ctx.fillRect(0, 0, this.canvas.width, stripH);
1372
+ this.ctx.fillStyle = "#0f0";
1373
+ for (let i = 0; i < lines.length; i++) {
1374
+ this.ctx.fillText(lines[i], 8, padTop + lineHeight * (i + 1) - 4);
1375
+ }
1376
+ this.ctx.restore();
1377
+ }
1378
+ this.lastPaintedPtsUs = frame.timestamp ?? 0;
1379
+ this.hasLastPaintedPts = true;
1380
+ this.lastPaintAudMs = this.clock.now() * 1e3;
1308
1381
  this.framesPainted++;
1309
1382
  } catch (err) {
1310
1383
  if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
@@ -1317,17 +1390,30 @@ var VideoRenderer = class {
1317
1390
  const count = this.queue.length;
1318
1391
  while (this.queue.length > 0) this.queue.shift()?.close();
1319
1392
  this.prerolled = false;
1393
+ this.hasLastPaintedPts = false;
1320
1394
  this.ptsCalibrated = false;
1395
+ this.hasEverEnqueuedSinceFlush = false;
1321
1396
  if (isDebug() && count > 0) {
1322
1397
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
1323
1398
  }
1324
1399
  }
1325
1400
  stats() {
1401
+ let queueSpanMs = 0;
1402
+ let queueHeadMs = 0;
1403
+ let queueTailMs = 0;
1404
+ if (this.queue.length > 0) {
1405
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1e3);
1406
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1e3);
1407
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
1408
+ }
1326
1409
  return {
1327
1410
  framesPainted: this.framesPainted,
1328
1411
  framesDroppedLate: this.framesDroppedLate,
1329
1412
  framesDroppedOverflow: this.framesDroppedOverflow,
1330
- queueDepth: this.queue.length
1413
+ queueDepth: this.queue.length,
1414
+ queueHeadMs,
1415
+ queueTailMs,
1416
+ queueSpanMs
1331
1417
  };
1332
1418
  }
1333
1419
  destroy() {
@@ -1345,6 +1431,9 @@ var VideoRenderer = class {
1345
1431
  };
1346
1432
 
1347
1433
  // src/strategies/fallback/audio-output.ts
1434
+ function isDebug2() {
1435
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
1436
+ }
1348
1437
  var AudioOutput = class {
1349
1438
  ctx;
1350
1439
  gain;
@@ -1366,6 +1455,16 @@ var AudioOutput = class {
1366
1455
  mediaTimeOfNext = 0;
1367
1456
  /** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
1368
1457
  mediaTimeOfAnchor = 0;
1458
+ /**
1459
+ * Ctx time at which the first audible chunk will start playing. `-1`
1460
+ * before any chunk has been scheduled successfully (clock is frozen);
1461
+ * the actual ctx time once one has. The renderer's `clock.now()` uses
1462
+ * this to avoid advancing during the silent-gap window between
1463
+ * `audio.start()` and the first chunk that schedules without being
1464
+ * dropped — that gap is what produces the "audio-less fast-forward"
1465
+ * the user sees post-seek when the gate releases on video-only grace.
1466
+ */
1467
+ firstAudibleCtxStart = -1;
1369
1468
  ctxTimeAtAnchor = 0;
1370
1469
  pendingQueue = [];
1371
1470
  framesScheduled = 0;
@@ -1438,10 +1537,16 @@ var AudioOutput = class {
1438
1537
  return this.mediaTimeOfAnchor;
1439
1538
  }
1440
1539
  if (this.state === "playing") {
1540
+ if (this.firstAudibleCtxStart < 0) {
1541
+ return this.mediaTimeOfAnchor;
1542
+ }
1441
1543
  return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
1442
1544
  }
1443
1545
  return this.mediaTimeOfAnchor;
1444
1546
  }
1547
+ anchorTime() {
1548
+ return this.mediaTimeOfAnchor;
1549
+ }
1445
1550
  isPlaying() {
1446
1551
  return this.state === "playing";
1447
1552
  }
@@ -1468,18 +1573,81 @@ var AudioOutput = class {
1468
1573
  * Schedule a chunk of decoded samples. Queues internally while idle (cold
1469
1574
  * start or post-seek), schedules directly to the audio graph while playing.
1470
1575
  * In wall-clock mode, samples are silently discarded.
1576
+ *
1577
+ * `ptsSec` is the chunk's source-domain content PTS in seconds, from
1578
+ * the demuxer. When provided, the chunk plays at the ctx-time
1579
+ * corresponding to that PTS — so pre-target audio after a seek
1580
+ * naturally drops (its computed `ctxStart` falls in the past) and
1581
+ * post-target audio plays at its true content time, without any
1582
+ * external trim or anchor rebase. When `ptsSec` is null (cold start
1583
+ * with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
1584
+ * the chunk is scheduled sequentially after `mediaTimeOfNext` — the
1585
+ * pre-refactor behavior.
1471
1586
  */
1472
- schedule(samples, channels, sampleRate) {
1587
+ schedule(samples, channels, sampleRate, ptsSec) {
1473
1588
  if (this.destroyed || this.noAudio) return;
1474
1589
  const frameCount = samples.length / channels;
1475
1590
  const durationSec = frameCount / sampleRate;
1591
+ const hasPts = ptsSec != null && Number.isFinite(ptsSec);
1592
+ if (hasPts && ptsSec + durationSec / this._rate < this.mediaTimeOfAnchor) {
1593
+ return;
1594
+ }
1476
1595
  if (this.state === "idle" || this.state === "paused") {
1477
- this.pendingQueue.push({ samples, channels, sampleRate, frameCount, durationSec });
1596
+ this.pendingQueue.push({
1597
+ samples,
1598
+ channels,
1599
+ sampleRate,
1600
+ frameCount,
1601
+ durationSec,
1602
+ ptsSec: hasPts ? ptsSec : null
1603
+ });
1478
1604
  return;
1479
1605
  }
1480
- this.scheduleNow(samples, channels, sampleRate, frameCount);
1606
+ this.scheduleNow(
1607
+ samples,
1608
+ channels,
1609
+ sampleRate,
1610
+ frameCount,
1611
+ hasPts ? ptsSec : null
1612
+ );
1481
1613
  }
1482
- scheduleNow(samples, channels, sampleRate, frameCount) {
1614
+ scheduleNow(samples, channels, sampleRate, frameCount, ptsSec) {
1615
+ const durationSec = frameCount / sampleRate;
1616
+ let ctxStart;
1617
+ if (ptsSec != null) {
1618
+ ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
1619
+ if (isDebug2()) {
1620
+ 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}`);
1621
+ }
1622
+ if (ctxStart < this.ctx.currentTime - 1e-3) {
1623
+ if (isDebug2()) {
1624
+ console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1625
+ }
1626
+ return;
1627
+ }
1628
+ if (this.firstAudibleCtxStart < 0) {
1629
+ this.firstAudibleCtxStart = ctxStart;
1630
+ this.mediaTimeOfAnchor = ptsSec;
1631
+ this.ctxTimeAtAnchor = ctxStart;
1632
+ if (isDebug2()) {
1633
+ 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)}`);
1634
+ }
1635
+ }
1636
+ const endMediaTime = ptsSec + durationSec / this._rate;
1637
+ if (endMediaTime > this.mediaTimeOfNext) {
1638
+ this.mediaTimeOfNext = endMediaTime;
1639
+ }
1640
+ } else {
1641
+ ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1642
+ console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
1643
+ if (ctxStart < this.ctx.currentTime) {
1644
+ 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)}`);
1645
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
1646
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1647
+ ctxStart = this.ctx.currentTime;
1648
+ }
1649
+ this.mediaTimeOfNext += durationSec;
1650
+ }
1483
1651
  const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
1484
1652
  for (let ch = 0; ch < channels; ch++) {
1485
1653
  const channelData = buffer.getChannelData(ch);
@@ -1491,14 +1659,7 @@ var AudioOutput = class {
1491
1659
  node.buffer = buffer;
1492
1660
  node.connect(this.gain);
1493
1661
  if (this._rate !== 1) node.playbackRate.value = this._rate;
1494
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
1495
- if (ctxStart < this.ctx.currentTime) {
1496
- this.ctxTimeAtAnchor = this.ctx.currentTime;
1497
- this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1498
- ctxStart = this.ctx.currentTime;
1499
- }
1500
1662
  node.start(ctxStart);
1501
- this.mediaTimeOfNext += frameCount / sampleRate;
1502
1663
  this.framesScheduled++;
1503
1664
  }
1504
1665
  // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -1523,12 +1684,15 @@ var AudioOutput = class {
1523
1684
  } catch {
1524
1685
  }
1525
1686
  if (this.state === "paused") {
1687
+ if (isDebug2()) {
1688
+ 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}`);
1689
+ }
1526
1690
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1527
1691
  this.state = "playing";
1528
1692
  const drain2 = this.pendingQueue;
1529
1693
  this.pendingQueue = [];
1530
1694
  for (const c of drain2) {
1531
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1695
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1532
1696
  }
1533
1697
  return;
1534
1698
  }
@@ -1536,10 +1700,13 @@ var AudioOutput = class {
1536
1700
  this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
1537
1701
  this.mediaTimeOfNext = this.mediaTimeOfAnchor;
1538
1702
  this.state = "playing";
1703
+ if (isDebug2()) {
1704
+ 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}`);
1705
+ }
1539
1706
  const drain = this.pendingQueue;
1540
1707
  this.pendingQueue = [];
1541
1708
  for (const c of drain) {
1542
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
1709
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
1543
1710
  }
1544
1711
  }
1545
1712
  /** Pause playback. Suspends the audio context. */
@@ -1565,6 +1732,9 @@ var AudioOutput = class {
1565
1732
  * supplying new samples) and then call `start()` to resume playback.
1566
1733
  */
1567
1734
  async reset(newMediaTime) {
1735
+ if (isDebug2()) {
1736
+ 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}`);
1737
+ }
1568
1738
  if (this.noAudio) {
1569
1739
  this.pendingQueue = [];
1570
1740
  this.mediaTimeOfAnchor = newMediaTime;
@@ -1583,6 +1753,7 @@ var AudioOutput = class {
1583
1753
  this.mediaTimeOfAnchor = newMediaTime;
1584
1754
  this.mediaTimeOfNext = newMediaTime;
1585
1755
  this.ctxTimeAtAnchor = this.ctx.currentTime;
1756
+ this.firstAudibleCtxStart = -1;
1586
1757
  this.state = "idle";
1587
1758
  if (this.ctx.state === "running") {
1588
1759
  await this.ctx.suspend();
@@ -1685,6 +1856,7 @@ async function startHybridDecoder(opts) {
1685
1856
  }
1686
1857
  let bsfCtx = null;
1687
1858
  let bsfPkt = null;
1859
+ let bsfRequiredButMissing = false;
1688
1860
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
1689
1861
  try {
1690
1862
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -1695,13 +1867,19 @@ async function startHybridDecoder(opts) {
1695
1867
  bsfPkt = await libav.av_packet_alloc();
1696
1868
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
1697
1869
  } else {
1698
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
1870
+ bsfRequiredButMissing = true;
1699
1871
  bsfCtx = null;
1700
1872
  }
1701
1873
  } catch (err) {
1702
- console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
1874
+ bsfRequiredButMissing = true;
1703
1875
  bsfCtx = null;
1704
1876
  bsfPkt = null;
1877
+ dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${err.message}`);
1878
+ }
1879
+ if (bsfRequiredButMissing) {
1880
+ console.error(
1881
+ "[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."
1882
+ );
1705
1883
  }
1706
1884
  }
1707
1885
  async function applyBSF(packets) {
@@ -1711,7 +1889,6 @@ async function startHybridDecoder(opts) {
1711
1889
  await libav.ff_copyin_packet(bsfPkt, pkt);
1712
1890
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
1713
1891
  if (sendErr < 0) {
1714
- out.push(pkt);
1715
1892
  continue;
1716
1893
  }
1717
1894
  while (true) {
@@ -1725,10 +1902,13 @@ async function startHybridDecoder(opts) {
1725
1902
  async function flushBSF() {
1726
1903
  if (!bsfCtx || !bsfPkt) return;
1727
1904
  try {
1728
- await libav.av_bsf_send_packet(bsfCtx, 0);
1729
- while (true) {
1730
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
1731
- if (err < 0) break;
1905
+ if (libav.av_bsf_flush) {
1906
+ await libav.av_bsf_flush(bsfCtx);
1907
+ } else {
1908
+ while (true) {
1909
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
1910
+ if (err < 0) break;
1911
+ }
1732
1912
  }
1733
1913
  } catch {
1734
1914
  }
@@ -1742,7 +1922,6 @@ async function startHybridDecoder(opts) {
1742
1922
  let videoChunksFed = 0;
1743
1923
  let bufferedUntilSec = 0;
1744
1924
  let syntheticVideoUs = 0;
1745
- let syntheticAudioUs = 0;
1746
1925
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
1747
1926
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
1748
1927
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -1774,7 +1953,13 @@ async function startHybridDecoder(opts) {
1774
1953
  }
1775
1954
  }
1776
1955
  if (audioDec && audioPackets && audioPackets.length > 0) {
1777
- await decodeAudioBatch(audioPackets, myToken);
1956
+ await decodeAudioBatch(
1957
+ audioPackets,
1958
+ myToken,
1959
+ /*flush*/
1960
+ false,
1961
+ audioTimeBase
1962
+ );
1778
1963
  }
1779
1964
  if (myToken !== pumpToken || destroyed) return;
1780
1965
  await new Promise((r) => setTimeout(r, 0));
@@ -1821,8 +2006,11 @@ async function startHybridDecoder(opts) {
1821
2006
  }
1822
2007
  }
1823
2008
  }
1824
- async function decodeAudioBatch(pkts, myToken, flush = false) {
2009
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
1825
2010
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2011
+ const pktPtsSec = pkts.map(
2012
+ (p) => tb ? packetPtsSec(p, tb) : null
2013
+ );
1826
2014
  const AUDIO_SUB_BATCH = 4;
1827
2015
  let allFrames = [];
1828
2016
  for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
@@ -1860,22 +2048,13 @@ async function startHybridDecoder(opts) {
1860
2048
  }
1861
2049
  if (myToken !== pumpToken || destroyed) return;
1862
2050
  const frames = allFrames;
1863
- for (const f of frames) {
2051
+ for (let i = 0; i < frames.length; i++) {
1864
2052
  if (myToken !== pumpToken || destroyed) return;
1865
- sanitizeFrameTimestamp(
1866
- f,
1867
- () => {
1868
- const ts = syntheticAudioUs;
1869
- const samples2 = f.nb_samples ?? 1024;
1870
- const sampleRate = f.sample_rate ?? 44100;
1871
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
1872
- return ts;
1873
- },
1874
- audioTimeBase
1875
- );
2053
+ const f = frames[i];
1876
2054
  const samples = libavFrameToInterleavedFloat32(f);
1877
2055
  if (samples) {
1878
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
2056
+ const pts = pktPtsSec[i] ?? null;
2057
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
1879
2058
  audioFramesDecoded++;
1880
2059
  }
1881
2060
  }
@@ -1985,7 +2164,6 @@ async function startHybridDecoder(opts) {
1985
2164
  }
1986
2165
  await flushBSF();
1987
2166
  syntheticVideoUs = Math.round(timeSec * 1e6);
1988
- syntheticAudioUs = Math.round(timeSec * 1e6);
1989
2167
  pumpRunning = pumpLoop(newToken).catch(
1990
2168
  (err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
1991
2169
  );
@@ -2024,7 +2202,6 @@ async function startHybridDecoder(opts) {
2024
2202
  }
2025
2203
  await flushBSF();
2026
2204
  syntheticVideoUs = Math.round(timeSec * 1e6);
2027
- syntheticAudioUs = Math.round(timeSec * 1e6);
2028
2205
  pumpRunning = pumpLoop(newToken).catch(
2029
2206
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
2030
2207
  );
@@ -2040,6 +2217,7 @@ async function startHybridDecoder(opts) {
2040
2217
  videoChunksFed,
2041
2218
  audioFramesDecoded,
2042
2219
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
2220
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
2043
2221
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
2044
2222
  // Confirmed transport info — see fallback decoder for the pattern.
2045
2223
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -2276,6 +2454,9 @@ async function createHybridSession(ctx, target, transport) {
2276
2454
  }
2277
2455
 
2278
2456
  // src/strategies/fallback/decoder.ts
2457
+ function isDebug3() {
2458
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
2459
+ }
2279
2460
  async function startDecoder(opts) {
2280
2461
  const variant = "avbridge";
2281
2462
  const libav = await loadLibav(variant);
@@ -2339,6 +2520,7 @@ async function startDecoder(opts) {
2339
2520
  }
2340
2521
  let bsfCtx = null;
2341
2522
  let bsfPkt = null;
2523
+ let bsfRequiredButMissing = false;
2342
2524
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
2343
2525
  try {
2344
2526
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -2349,13 +2531,19 @@ async function startDecoder(opts) {
2349
2531
  bsfPkt = await libav.av_packet_alloc();
2350
2532
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
2351
2533
  } else {
2352
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
2534
+ bsfRequiredButMissing = true;
2353
2535
  bsfCtx = null;
2354
2536
  }
2355
2537
  } catch (err) {
2356
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
2538
+ bsfRequiredButMissing = true;
2357
2539
  bsfCtx = null;
2358
2540
  bsfPkt = null;
2541
+ dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${err.message}`);
2542
+ }
2543
+ if (bsfRequiredButMissing) {
2544
+ console.error(
2545
+ "[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."
2546
+ );
2359
2547
  }
2360
2548
  }
2361
2549
  async function applyBSF(packets) {
@@ -2365,7 +2553,6 @@ async function startDecoder(opts) {
2365
2553
  await libav.ff_copyin_packet(bsfPkt, pkt);
2366
2554
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
2367
2555
  if (sendErr < 0) {
2368
- out.push(pkt);
2369
2556
  continue;
2370
2557
  }
2371
2558
  while (true) {
@@ -2379,10 +2566,13 @@ async function startDecoder(opts) {
2379
2566
  async function flushBSF() {
2380
2567
  if (!bsfCtx || !bsfPkt) return;
2381
2568
  try {
2382
- await libav.av_bsf_send_packet(bsfCtx, 0);
2383
- while (true) {
2384
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2385
- if (err < 0) break;
2569
+ if (libav.av_bsf_flush) {
2570
+ await libav.av_bsf_flush(bsfCtx);
2571
+ } else {
2572
+ while (true) {
2573
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
2574
+ if (err < 0) break;
2575
+ }
2386
2576
  }
2387
2577
  } catch {
2388
2578
  }
@@ -2398,8 +2588,28 @@ async function startDecoder(opts) {
2398
2588
  let watchdogSlowSinceMs = 0;
2399
2589
  let watchdogSlowWarned = false;
2400
2590
  let watchdogOverflowWarned = false;
2401
- let syntheticVideoUs = 0;
2402
- let syntheticAudioUs = 0;
2591
+ let lastContentUs = -1;
2592
+ let firstValidPtsLoggedSinceSeek = false;
2593
+ let seenFirstAudioPacketSinceSeek = false;
2594
+ let seekTargetSec = 0;
2595
+ let diagPktsLoggedSinceSeek = 0;
2596
+ let diagFramesLoggedSinceSeek = 0;
2597
+ let diagFrameKeysDumped = false;
2598
+ const DIAG_MAX_PKTS = 100;
2599
+ const DIAG_MAX_FRAMES = 300;
2600
+ let videoDecodeMsTotal = 0;
2601
+ let audioDecodeMsTotal = 0;
2602
+ let videoDecodeBatches = 0;
2603
+ let audioDecodeBatches = 0;
2604
+ let readMsTotal = 0;
2605
+ let readBatches = 0;
2606
+ let pumpThrottleMsTotal = 0;
2607
+ let pumpThrottleEntries = 0;
2608
+ let slowestVideoBatchMs = 0;
2609
+ let newestVideoPtsUs = 0;
2610
+ let lastEmittedPtsUs = -1;
2611
+ let ptsRegressions = 0;
2612
+ let worstPtsRegressionMs = 0;
2403
2613
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
2404
2614
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
2405
2615
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -2408,9 +2618,12 @@ async function startDecoder(opts) {
2408
2618
  let readErr;
2409
2619
  let packets;
2410
2620
  try {
2621
+ const _readStart = performance.now();
2411
2622
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
2412
2623
  limit: 16 * 1024
2413
2624
  });
2625
+ readMsTotal += performance.now() - _readStart;
2626
+ readBatches++;
2414
2627
  } catch (err) {
2415
2628
  console.error("[avbridge] ff_read_frame_multi failed:", err);
2416
2629
  return;
@@ -2422,6 +2635,18 @@ async function startDecoder(opts) {
2422
2635
  for (const pkt of videoPackets) {
2423
2636
  const sec = packetPtsSec(pkt, videoTimeBase);
2424
2637
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2638
+ if (isDebug3() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
2639
+ const rawHi = pkt.ptshi ?? 0;
2640
+ const rawLo = pkt.pts ?? 0;
2641
+ const isInvalidPts = rawHi === -2147483648 && rawLo === 0;
2642
+ const rawPts64 = isInvalidPts ? null : rawHi * 4294967296 + rawLo;
2643
+ const rawSec = rawPts64 != null && videoTimeBase ? rawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
2644
+ const pktKeys = diagPktsLoggedSinceSeek === 0 ? `[keys: ${Object.keys(pkt).join(",")}]` : "";
2645
+ console.log(
2646
+ `[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
2647
+ );
2648
+ diagPktsLoggedSinceSeek++;
2649
+ }
2425
2650
  }
2426
2651
  }
2427
2652
  if (audioPackets && audioTimeBase) {
@@ -2429,9 +2654,25 @@ async function startDecoder(opts) {
2429
2654
  const sec = packetPtsSec(pkt, audioTimeBase);
2430
2655
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
2431
2656
  }
2657
+ if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
2658
+ const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
2659
+ if (firstSec != null && Number.isFinite(firstSec)) {
2660
+ seenFirstAudioPacketSinceSeek = true;
2661
+ dbg.info(
2662
+ "av-anchor",
2663
+ `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)`
2664
+ );
2665
+ }
2666
+ }
2432
2667
  }
2433
2668
  if (audioDec && audioPackets && audioPackets.length > 0) {
2434
- await decodeAudioBatch(audioPackets, myToken);
2669
+ await decodeAudioBatch(
2670
+ audioPackets,
2671
+ myToken,
2672
+ /*flush*/
2673
+ false,
2674
+ audioTimeBase
2675
+ );
2435
2676
  }
2436
2677
  if (myToken !== pumpToken || destroyed) return;
2437
2678
  if (videoDec && videoPackets && videoPackets.length > 0) {
@@ -2472,8 +2713,17 @@ async function startDecoder(opts) {
2472
2713
  }
2473
2714
  }
2474
2715
  }
2475
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2476
- await new Promise((r) => setTimeout(r, 50));
2716
+ {
2717
+ const _throttleStart = performance.now();
2718
+ let _throttled = false;
2719
+ while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
2720
+ _throttled = true;
2721
+ await new Promise((r) => setTimeout(r, 50));
2722
+ }
2723
+ if (_throttled) {
2724
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
2725
+ pumpThrottleEntries++;
2726
+ }
2477
2727
  }
2478
2728
  if (readErr === libav.AVERROR_EOF) {
2479
2729
  if (videoDec) await decodeVideoBatch(
@@ -2499,6 +2749,7 @@ async function startDecoder(opts) {
2499
2749
  async function decodeVideoBatch(pkts, myToken, flush = false) {
2500
2750
  if (!videoDec || destroyed || myToken !== pumpToken) return;
2501
2751
  let frames;
2752
+ const _t0 = performance.now();
2502
2753
  try {
2503
2754
  frames = await libav.ff_decode_multi(
2504
2755
  videoDec.c,
@@ -2511,32 +2762,133 @@ async function startDecoder(opts) {
2511
2762
  console.error("[avbridge] video decode batch failed:", err);
2512
2763
  return;
2513
2764
  }
2765
+ {
2766
+ const _dt = performance.now() - _t0;
2767
+ videoDecodeMsTotal += _dt;
2768
+ videoDecodeBatches++;
2769
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
2770
+ }
2514
2771
  if (myToken !== pumpToken || destroyed) return;
2515
2772
  for (const f of frames) {
2516
2773
  if (myToken !== pumpToken || destroyed) return;
2517
- sanitizeFrameTimestamp(
2518
- f,
2519
- () => {
2520
- const ts = syntheticVideoUs;
2521
- syntheticVideoUs += videoFrameStepUs;
2522
- return ts;
2523
- },
2524
- videoTimeBase
2525
- );
2774
+ const _diagShouldLog = isDebug3() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
2775
+ const _diagRawHi = f.ptshi ?? 0;
2776
+ const _diagRawLo = f.pts ?? 0;
2777
+ const _diagInvalid = _diagRawHi === -2147483648 && _diagRawLo === 0;
2778
+ const _diagRawPts64 = _diagInvalid ? null : _diagRawHi * 4294967296 + _diagRawLo;
2779
+ const _diagRawSec = _diagRawPts64 != null && videoTimeBase ? _diagRawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
2780
+ if (_diagShouldLog && !diagFrameKeysDumped) {
2781
+ diagFrameKeysDumped = true;
2782
+ const allKeys = Object.keys(f);
2783
+ const fieldDump = {};
2784
+ for (const k of allKeys) {
2785
+ const v = f[k];
2786
+ if (k === "data") continue;
2787
+ if (typeof v === "object" && v !== null && "length" in v) continue;
2788
+ fieldDump[k] = v;
2789
+ }
2790
+ console.log(`[DIAG-FRAME] FIRST FRAME post-seek \u2014 all keys: ${allKeys.join(",")}`);
2791
+ console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
2792
+ }
2793
+ let rawUs = null;
2794
+ if (!_diagInvalid && _diagRawPts64 != null) {
2795
+ const tb = videoTimeBase ?? [1, 1e6];
2796
+ const us = Math.round(_diagRawPts64 * 1e6 * tb[0] / tb[1]);
2797
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
2798
+ rawUs = us;
2799
+ }
2800
+ }
2801
+ const _diagLog = (decision, finalPtsUs, sanFallback) => {
2802
+ if (!_diagShouldLog) return;
2803
+ const ptsSrc = sanFallback ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})` : "LIBAV";
2804
+ console.log(
2805
+ `[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}`
2806
+ );
2807
+ diagFramesLoggedSinceSeek++;
2808
+ };
2809
+ let _diagSanFallbackFired = false;
2810
+ const seekTargetUs = Math.round(seekTargetSec * 1e6);
2811
+ if (lastContentUs < 0) {
2812
+ if (rawUs == null) {
2813
+ const isColdStartKeyframe = seekTargetSec === 0 && f.key_frame === 1;
2814
+ if (isColdStartKeyframe) {
2815
+ lastContentUs = 0;
2816
+ _diagSanFallbackFired = true;
2817
+ } else {
2818
+ _diagLog("PRE-ANCHOR-DROP", 0, true);
2819
+ continue;
2820
+ }
2821
+ } else {
2822
+ lastContentUs = rawUs;
2823
+ if (!firstValidPtsLoggedSinceSeek) {
2824
+ firstValidPtsLoggedSinceSeek = true;
2825
+ if (isDebug3()) {
2826
+ console.log(
2827
+ `[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)`
2828
+ );
2829
+ }
2830
+ if (rawUs >= seekTargetUs) {
2831
+ console.warn(
2832
+ `[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.`
2833
+ );
2834
+ }
2835
+ }
2836
+ }
2837
+ } else {
2838
+ if (rawUs != null) {
2839
+ lastContentUs = rawUs;
2840
+ } else {
2841
+ lastContentUs += videoFrameStepUs;
2842
+ _diagSanFallbackFired = true;
2843
+ }
2844
+ }
2845
+ f.pts = lastContentUs;
2846
+ f.ptshi = lastContentUs < 0 ? -1 : 0;
2847
+ const _fPts = lastContentUs;
2848
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
2849
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
2850
+ _diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
2851
+ ptsRegressions++;
2852
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
2853
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
2854
+ if (ptsRegressions <= 10) {
2855
+ console.warn(
2856
+ `[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.`
2857
+ );
2858
+ }
2859
+ continue;
2860
+ }
2861
+ lastEmittedPtsUs = _fPts;
2862
+ const targetUs = Math.round(seekTargetSec * 1e6);
2863
+ if (_fPts < targetUs - videoFrameStepUs) {
2864
+ _diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
2865
+ continue;
2866
+ }
2526
2867
  try {
2527
2868
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
2528
- opts.renderer.enqueue(vf);
2869
+ if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
2870
+ vf.close();
2871
+ _diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
2872
+ } else {
2873
+ opts.renderer.enqueue(vf);
2874
+ _diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
2875
+ }
2529
2876
  videoFramesDecoded++;
2530
2877
  } catch (err) {
2531
2878
  if (videoFramesDecoded === 0) {
2532
2879
  console.warn("[avbridge] laFrameToVideoFrame failed:", err);
2533
2880
  }
2881
+ _diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
2534
2882
  }
2535
2883
  }
2536
2884
  }
2537
- async function decodeAudioBatch(pkts, myToken, flush = false) {
2885
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
2538
2886
  if (!audioDec || destroyed || myToken !== pumpToken) return;
2887
+ const pktPtsSec = pkts.map(
2888
+ (p) => tb ? packetPtsSec(p, tb) : null
2889
+ );
2539
2890
  let frames;
2891
+ const _t0 = performance.now();
2540
2892
  try {
2541
2893
  frames = await libav.ff_decode_multi(
2542
2894
  audioDec.c,
@@ -2549,23 +2901,20 @@ async function startDecoder(opts) {
2549
2901
  console.error("[avbridge] audio decode batch failed:", err);
2550
2902
  return;
2551
2903
  }
2904
+ audioDecodeMsTotal += performance.now() - _t0;
2905
+ audioDecodeBatches++;
2552
2906
  if (myToken !== pumpToken || destroyed) return;
2553
- for (const f of frames) {
2907
+ for (let i = 0; i < frames.length; i++) {
2554
2908
  if (myToken !== pumpToken || destroyed) return;
2555
- sanitizeFrameTimestamp(
2556
- f,
2557
- () => {
2558
- const ts = syntheticAudioUs;
2559
- const samples2 = f.nb_samples ?? 1024;
2560
- const sampleRate = f.sample_rate ?? 44100;
2561
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
2562
- return ts;
2563
- },
2564
- audioTimeBase
2565
- );
2909
+ const f = frames[i];
2566
2910
  const samples = libavFrameToInterleavedFloat32(f);
2567
2911
  if (samples) {
2568
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
2912
+ const pts = pktPtsSec[i] ?? null;
2913
+ if (isDebug3()) {
2914
+ const dur = samples.data.length / samples.channels / samples.sampleRate;
2915
+ 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}`);
2916
+ }
2917
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
2569
2918
  audioFramesDecoded++;
2570
2919
  }
2571
2920
  }
@@ -2668,13 +3017,17 @@ async function startDecoder(opts) {
2668
3017
  } catch {
2669
3018
  }
2670
3019
  await flushBSF();
2671
- syntheticVideoUs = Math.round(timeSec * 1e6);
2672
- syntheticAudioUs = Math.round(timeSec * 1e6);
3020
+ lastContentUs = -1;
3021
+ lastEmittedPtsUs = -1;
3022
+ firstValidPtsLoggedSinceSeek = false;
2673
3023
  pumpRunning = pumpLoop(newToken).catch(
2674
3024
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
2675
3025
  );
2676
3026
  },
2677
3027
  async seek(timeSec) {
3028
+ if (isDebug3()) {
3029
+ console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1e3).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
3030
+ }
2678
3031
  const newToken = ++pumpToken;
2679
3032
  if (pumpRunning) {
2680
3033
  try {
@@ -2705,8 +3058,14 @@ async function startDecoder(opts) {
2705
3058
  } catch {
2706
3059
  }
2707
3060
  await flushBSF();
2708
- syntheticVideoUs = Math.round(timeSec * 1e6);
2709
- syntheticAudioUs = Math.round(timeSec * 1e6);
3061
+ lastContentUs = -1;
3062
+ lastEmittedPtsUs = -1;
3063
+ firstValidPtsLoggedSinceSeek = false;
3064
+ seenFirstAudioPacketSinceSeek = false;
3065
+ seekTargetSec = timeSec;
3066
+ diagPktsLoggedSinceSeek = 0;
3067
+ diagFramesLoggedSinceSeek = 0;
3068
+ diagFrameKeysDumped = false;
2710
3069
  pumpRunning = pumpLoop(newToken).catch(
2711
3070
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
2712
3071
  );
@@ -2720,7 +3079,24 @@ async function startDecoder(opts) {
2720
3079
  packetsRead,
2721
3080
  videoFramesDecoded,
2722
3081
  audioFramesDecoded,
3082
+ // Throughput instrumentation — the stats panel turns these into
3083
+ // "decode fps actual / realtime target" and shows slowest batch
3084
+ // + producer throttle share.
3085
+ videoDecodeMsTotal,
3086
+ videoDecodeBatches,
3087
+ audioDecodeMsTotal,
3088
+ audioDecodeBatches,
3089
+ readMsTotal,
3090
+ readBatches,
3091
+ pumpThrottleMsTotal,
3092
+ pumpThrottleEntries,
3093
+ slowestVideoBatchMs,
3094
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1e3),
3095
+ ptsRegressions,
3096
+ worstPtsRegressionMs,
3097
+ sourceFps: videoFps,
2723
3098
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
3099
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
2724
3100
  // Confirmed transport info: once prepareLibavInput returns
2725
3101
  // successfully, we *know* whether the source is http-range (probe
2726
3102
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -3543,5 +3919,5 @@ function defaultFallbackChain(strategy) {
3543
3919
  }
3544
3920
 
3545
3921
  export { FALLBACK_AUDIO_CODECS, FALLBACK_VIDEO_CODECS, NATIVE_AUDIO_CODECS, NATIVE_VIDEO_CODECS, UnifiedPlayer, classifyContext, createPlayer };
3546
- //# sourceMappingURL=chunk-Z26PXRUY.js.map
3547
- //# sourceMappingURL=chunk-Z26PXRUY.js.map
3922
+ //# sourceMappingURL=chunk-VOC24LYF.js.map
3923
+ //# sourceMappingURL=chunk-VOC24LYF.js.map