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
@@ -29957,7 +29957,7 @@ async function probeWithLibav(source, sniffed) {
29957
29957
  codec: ffmpegToAvbridgeVideo(codecName),
29958
29958
  width: codecpar?.width ?? 0,
29959
29959
  height: codecpar?.height ?? 0,
29960
- fps: framerate(stream)
29960
+ fps: await framerate(libav, stream)
29961
29961
  });
29962
29962
  } else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
29963
29963
  audioTracks.push({
@@ -29985,7 +29985,7 @@ async function probeWithLibav(source, sniffed) {
29985
29985
  duration
29986
29986
  };
29987
29987
  }
29988
- function framerate(stream) {
29988
+ async function framerate(libav, stream) {
29989
29989
  if (typeof stream.avg_frame_rate_num === "number" && stream.avg_frame_rate_den) {
29990
29990
  return stream.avg_frame_rate_num / stream.avg_frame_rate_den;
29991
29991
  }
@@ -29993,6 +29993,14 @@ function framerate(stream) {
29993
29993
  if (stream.avg_frame_rate.den === 0) return void 0;
29994
29994
  return stream.avg_frame_rate.num / stream.avg_frame_rate.den;
29995
29995
  }
29996
+ try {
29997
+ const num = await libav.AVCodecParameters_framerate_num?.(stream.codecpar);
29998
+ const den = await libav.AVCodecParameters_framerate_den?.(stream.codecpar);
29999
+ if (typeof num === "number" && typeof den === "number" && den > 0 && num > 0) {
30000
+ return num / den;
30001
+ }
30002
+ } catch {
30003
+ }
29996
30004
  return void 0;
29997
30005
  }
29998
30006
  async function safeDuration(libav, fmt_ctx) {
@@ -32247,22 +32255,25 @@ async function createRemuxPipeline(ctx, video) {
32247
32255
  }
32248
32256
  }
32249
32257
  let mimePromise = null;
32258
+ const myToken = pumpToken;
32250
32259
  const writable = new WritableStream({
32251
32260
  write: async (chunk) => {
32252
- if (destroyed) return;
32261
+ if (destroyed || pumpToken !== myToken) return;
32253
32262
  if (!sink) {
32254
32263
  const mime = await (mimePromise ??= output.getMimeType());
32264
+ if (destroyed || pumpToken !== myToken) return;
32255
32265
  sink = new MseSink({ mime, video });
32256
32266
  await sink.ready();
32267
+ if (destroyed || pumpToken !== myToken) return;
32257
32268
  if (pendingStartTime > 0) {
32258
32269
  sink.invalidate(pendingStartTime);
32259
32270
  }
32260
32271
  sink.setPlayOnSeek(pendingAutoPlay);
32261
32272
  }
32262
- while (sink && !destroyed && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
32273
+ while (sink && !destroyed && pumpToken === myToken && (sink.queueLength() > 10 || sink.bufferedAhead() > 60 || sink.totalBuffered() > 120)) {
32263
32274
  await new Promise((r) => setTimeout(r, 500));
32264
32275
  }
32265
- if (destroyed) return;
32276
+ if (destroyed || pumpToken !== myToken) return;
32266
32277
  sink.append(chunk.data);
32267
32278
  stats.bytesWritten += chunk.data.byteLength;
32268
32279
  stats.fragments++;
@@ -32530,7 +32541,21 @@ var VideoRenderer = class {
32530
32541
  framesPainted = 0;
32531
32542
  framesDroppedLate = 0;
32532
32543
  framesDroppedOverflow = 0;
32544
+ /** True once the head frame has been painted as a pre-roll poster
32545
+ * since the last flush. Used to ensure pre-roll paints exactly one
32546
+ * frame (held static) during the post-seek discard window. */
32533
32547
  prerolled = false;
32548
+ /** PTS (µs) of the most recently painted frame. Used as the calibration
32549
+ * reference on the first post-flush snap: the pre-roll path paints one
32550
+ * frame *before* PTS-based playback starts, so the queue head's PTS at
32551
+ * first PTS-based paint is the *next* frame, off by one frameDur from
32552
+ * the actually-displayed frame. Calibrating against the painted frame
32553
+ * instead of the queue head removes that one-frame offset and yields
32554
+ * calib ≈ 0 instead of +frameDur. */
32555
+ lastPaintedPtsUs = 0;
32556
+ hasLastPaintedPts = false;
32557
+ /** Audio-clock reading (ms) at the previous paint, for overlay Δaud. */
32558
+ lastPaintAudMs = 0;
32534
32559
  /** Wall-clock time of the last paint, in ms (performance.now()). */
32535
32560
  lastPaintWall = 0;
32536
32561
  /** Minimum ms between paints — paces video at roughly source fps. */
@@ -32561,32 +32586,47 @@ var VideoRenderer = class {
32561
32586
  /** Resolves once the first decoded frame has been enqueued. */
32562
32587
  firstFrameReady;
32563
32588
  resolveFirstFrame;
32564
- /** True once at least one frame has been enqueued. */
32589
+ /**
32590
+ * True once at least one frame has been enqueued *since the last flush*.
32591
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
32592
+ * any frame has arrived, and after a seek we want the same semantics
32593
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
32594
+ * `framesPainted > 0` that used to live here was wrong: it kept the
32595
+ * state "true forever" after the first frame ever, so post-seek
32596
+ * `waitForBuffer()` would exit immediately with an empty queue and
32597
+ * leave video frozen while audio kept going.
32598
+ */
32565
32599
  hasFrames() {
32566
- return this.queue.length > 0 || this.framesPainted > 0;
32600
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
32567
32601
  }
32602
+ hasEverEnqueuedSinceFlush = false;
32568
32603
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
32569
32604
  queueDepth() {
32570
32605
  return this.queue.length;
32571
32606
  }
32572
32607
  /**
32573
- * Soft cap for decoder backpressure. The decoder pump throttles when
32574
- * `queueDepth() >= queueHighWater`. Set high enough that normal decode
32575
- * bursts don't trigger the renderer's overflow-drop loop (which runs at
32576
- * every paint), but low enough that the decoder doesn't run unboundedly
32577
- * ahead. The hard cap in `enqueue()` is 64.
32608
+ * Cap the decoder may fill the queue up to. Used by the decoder's
32609
+ * enqueue-side discard logic (it closes new frames instead of pushing
32610
+ * them when this is reached). Sized so a long post-seek catch-up
32611
+ * fits the decoder produces frames at PTS T_kf onwards rapidly
32612
+ * while the demuxer is chewing through pre-target audio; if the
32613
+ * queue can hold the whole post-seek burst, the renderer plays
32614
+ * smoothly from pre-roll without a frozen-video gap when audio.start
32615
+ * fires. At ~340 KB per SD frame the cap is ~85 MB peak; at HD it's
32616
+ * larger but still bounded.
32578
32617
  */
32579
- queueHighWater = 30;
32618
+ queueHighWater = 256;
32580
32619
  enqueue(frame) {
32581
32620
  if (this.destroyed) {
32582
32621
  frame.close();
32583
32622
  return;
32584
32623
  }
32585
32624
  this.queue.push(frame);
32625
+ this.hasEverEnqueuedSinceFlush = true;
32586
32626
  if (this.queue.length === 1 && this.framesPainted === 0) {
32587
32627
  this.resolveFirstFrame();
32588
32628
  }
32589
- while (this.queue.length > 60) {
32629
+ while (this.queue.length > this.queueHighWater + 8) {
32590
32630
  this.queue.shift()?.close();
32591
32631
  this.framesDroppedOverflow++;
32592
32632
  }
@@ -32668,12 +32708,9 @@ var VideoRenderer = class {
32668
32708
  if (this.queue.length === 0) return;
32669
32709
  const playing = this.clock.isPlaying();
32670
32710
  if (!playing) {
32671
- if (!this.prerolled) {
32672
- const head = this.queue.shift();
32673
- this.paint(head);
32674
- head.close();
32711
+ if (!this.prerolled && this.queue.length > 0) {
32675
32712
  this.prerolled = true;
32676
- this.lastPaintWall = performance.now();
32713
+ this.paint(this.queue[0]);
32677
32714
  }
32678
32715
  return;
32679
32716
  }
@@ -32682,14 +32719,29 @@ var VideoRenderer = class {
32682
32719
  const hasPts = headTs > 0 || this.queue.length > 1;
32683
32720
  if (hasPts) {
32684
32721
  const wallNow2 = performance.now();
32685
- if (!this.ptsCalibrated || wallNow2 - this.lastCalibrationWall > 1e4) {
32686
- this.ptsCalibrationUs = headTs - rawAudioNowUs;
32722
+ if (!this.ptsCalibrated) {
32723
+ const anchorUs = (this.clock.anchorTime?.() ?? this.clock.now()) * 1e6;
32724
+ const referencePtsUs = this.hasLastPaintedPts ? this.lastPaintedPtsUs : headTs;
32725
+ this.ptsCalibrationUs = referencePtsUs - anchorUs;
32687
32726
  this.ptsCalibrated = true;
32688
32727
  this.lastCalibrationWall = wallNow2;
32728
+ if (isDebug()) {
32729
+ console.log(
32730
+ `[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`
32731
+ );
32732
+ }
32733
+ } else if (wallNow2 - this.lastCalibrationWall > 1e4) {
32734
+ const oldCalib = this.ptsCalibrationUs;
32735
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
32736
+ this.lastCalibrationWall = wallNow2;
32737
+ if (isDebug()) {
32738
+ console.log(
32739
+ `[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)`
32740
+ );
32741
+ }
32689
32742
  }
32690
32743
  const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
32691
- const frameDurationUs = this.paintIntervalMs * 1e3;
32692
- const deadlineUs = audioNowUs + frameDurationUs;
32744
+ const deadlineUs = audioNowUs;
32693
32745
  let bestIdx = -1;
32694
32746
  for (let i = 0; i < this.queue.length; i++) {
32695
32747
  const ts = this.queue[i].timestamp ?? 0;
@@ -32716,19 +32768,21 @@ var VideoRenderer = class {
32716
32768
  }
32717
32769
  return;
32718
32770
  }
32719
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
32771
+ const _relaxDrop = globalThis.AVBRIDGE_RELAX_DROP === true;
32720
32772
  let dropped = 0;
32721
- while (bestIdx > 0) {
32722
- const ts = this.queue[0].timestamp ?? 0;
32723
- if (ts < dropThresholdUs) {
32773
+ const initialBestIdx = bestIdx;
32774
+ if (!_relaxDrop) {
32775
+ while (bestIdx > 0) {
32724
32776
  this.queue.shift()?.close();
32725
32777
  this.framesDroppedLate++;
32726
32778
  bestIdx--;
32727
32779
  dropped++;
32728
- } else {
32729
- break;
32730
32780
  }
32731
32781
  }
32782
+ const paintTs = this.queue[0]?.timestamp ?? 0;
32783
+ if (isDebug()) {
32784
+ 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)}`);
32785
+ }
32732
32786
  this.ticksPainted++;
32733
32787
  if (isDebug()) {
32734
32788
  const now = performance.now();
@@ -32764,6 +32818,33 @@ var VideoRenderer = class {
32764
32818
  }
32765
32819
  try {
32766
32820
  this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
32821
+ if (isDebug()) {
32822
+ const wallNow = performance.now();
32823
+ const audNowMs = this.clock.now() * 1e3;
32824
+ const ptsMs = (frame.timestamp ?? 0) / 1e3;
32825
+ const dWall = this.lastPaintWall > 0 ? wallNow - this.lastPaintWall : 0;
32826
+ const dAud = this.lastPaintAudMs > 0 ? audNowMs - this.lastPaintAudMs : 0;
32827
+ const dPts = this.hasLastPaintedPts ? ptsMs - this.lastPaintedPtsUs / 1e3 : 0;
32828
+ this.ctx.save();
32829
+ this.ctx.font = "bold 18px monospace";
32830
+ const lines = [
32831
+ `#${this.framesPainted + 1} pts=${ptsMs.toFixed(0)} aud=${audNowMs.toFixed(0)} wall=${wallNow.toFixed(0)}`,
32832
+ `\u0394pts=${dPts.toFixed(0)} \u0394aud=${dAud.toFixed(0)} \u0394wall=${dWall.toFixed(0)}`
32833
+ ];
32834
+ const lineHeight = 22;
32835
+ const padTop = 6;
32836
+ const stripH = padTop + lineHeight * lines.length;
32837
+ this.ctx.fillStyle = "rgba(0,0,0,0.7)";
32838
+ this.ctx.fillRect(0, 0, this.canvas.width, stripH);
32839
+ this.ctx.fillStyle = "#0f0";
32840
+ for (let i = 0; i < lines.length; i++) {
32841
+ this.ctx.fillText(lines[i], 8, padTop + lineHeight * (i + 1) - 4);
32842
+ }
32843
+ this.ctx.restore();
32844
+ }
32845
+ this.lastPaintedPtsUs = frame.timestamp ?? 0;
32846
+ this.hasLastPaintedPts = true;
32847
+ this.lastPaintAudMs = this.clock.now() * 1e3;
32767
32848
  this.framesPainted++;
32768
32849
  } catch (err) {
32769
32850
  if (this.framesPainted === 0 && this.framesDroppedLate === 0) {
@@ -32776,17 +32857,30 @@ var VideoRenderer = class {
32776
32857
  const count = this.queue.length;
32777
32858
  while (this.queue.length > 0) this.queue.shift()?.close();
32778
32859
  this.prerolled = false;
32860
+ this.hasLastPaintedPts = false;
32779
32861
  this.ptsCalibrated = false;
32862
+ this.hasEverEnqueuedSinceFlush = false;
32780
32863
  if (isDebug() && count > 0) {
32781
32864
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
32782
32865
  }
32783
32866
  }
32784
32867
  stats() {
32868
+ let queueSpanMs = 0;
32869
+ let queueHeadMs = 0;
32870
+ let queueTailMs = 0;
32871
+ if (this.queue.length > 0) {
32872
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1e3);
32873
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1e3);
32874
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
32875
+ }
32785
32876
  return {
32786
32877
  framesPainted: this.framesPainted,
32787
32878
  framesDroppedLate: this.framesDroppedLate,
32788
32879
  framesDroppedOverflow: this.framesDroppedOverflow,
32789
- queueDepth: this.queue.length
32880
+ queueDepth: this.queue.length,
32881
+ queueHeadMs,
32882
+ queueTailMs,
32883
+ queueSpanMs
32790
32884
  };
32791
32885
  }
32792
32886
  destroy() {
@@ -32804,6 +32898,9 @@ var VideoRenderer = class {
32804
32898
  };
32805
32899
 
32806
32900
  // src/strategies/fallback/audio-output.ts
32901
+ function isDebug2() {
32902
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
32903
+ }
32807
32904
  var AudioOutput = class {
32808
32905
  ctx;
32809
32906
  gain;
@@ -32825,6 +32922,16 @@ var AudioOutput = class {
32825
32922
  mediaTimeOfNext = 0;
32826
32923
  /** Anchor: media time `mediaTimeOfAnchor` corresponds to ctx time `ctxTimeAtAnchor`. */
32827
32924
  mediaTimeOfAnchor = 0;
32925
+ /**
32926
+ * Ctx time at which the first audible chunk will start playing. `-1`
32927
+ * before any chunk has been scheduled successfully (clock is frozen);
32928
+ * the actual ctx time once one has. The renderer's `clock.now()` uses
32929
+ * this to avoid advancing during the silent-gap window between
32930
+ * `audio.start()` and the first chunk that schedules without being
32931
+ * dropped — that gap is what produces the "audio-less fast-forward"
32932
+ * the user sees post-seek when the gate releases on video-only grace.
32933
+ */
32934
+ firstAudibleCtxStart = -1;
32828
32935
  ctxTimeAtAnchor = 0;
32829
32936
  pendingQueue = [];
32830
32937
  framesScheduled = 0;
@@ -32897,10 +33004,16 @@ var AudioOutput = class {
32897
33004
  return this.mediaTimeOfAnchor;
32898
33005
  }
32899
33006
  if (this.state === "playing") {
33007
+ if (this.firstAudibleCtxStart < 0) {
33008
+ return this.mediaTimeOfAnchor;
33009
+ }
32900
33010
  return this.mediaTimeOfAnchor + (this.ctx.currentTime - this.ctxTimeAtAnchor) * this._rate;
32901
33011
  }
32902
33012
  return this.mediaTimeOfAnchor;
32903
33013
  }
33014
+ anchorTime() {
33015
+ return this.mediaTimeOfAnchor;
33016
+ }
32904
33017
  isPlaying() {
32905
33018
  return this.state === "playing";
32906
33019
  }
@@ -32927,18 +33040,81 @@ var AudioOutput = class {
32927
33040
  * Schedule a chunk of decoded samples. Queues internally while idle (cold
32928
33041
  * start or post-seek), schedules directly to the audio graph while playing.
32929
33042
  * In wall-clock mode, samples are silently discarded.
33043
+ *
33044
+ * `ptsSec` is the chunk's source-domain content PTS in seconds, from
33045
+ * the demuxer. When provided, the chunk plays at the ctx-time
33046
+ * corresponding to that PTS — so pre-target audio after a seek
33047
+ * naturally drops (its computed `ctxStart` falls in the past) and
33048
+ * post-target audio plays at its true content time, without any
33049
+ * external trim or anchor rebase. When `ptsSec` is null (cold start
33050
+ * with no PTS yet, or codecs whose packet→frame mapping isn't 1:1),
33051
+ * the chunk is scheduled sequentially after `mediaTimeOfNext` — the
33052
+ * pre-refactor behavior.
32930
33053
  */
32931
- schedule(samples, channels, sampleRate) {
33054
+ schedule(samples, channels, sampleRate, ptsSec) {
32932
33055
  if (this.destroyed || this.noAudio) return;
32933
33056
  const frameCount = samples.length / channels;
32934
33057
  const durationSec = frameCount / sampleRate;
33058
+ const hasPts = ptsSec != null && Number.isFinite(ptsSec);
33059
+ if (hasPts && ptsSec + durationSec / this._rate < this.mediaTimeOfAnchor) {
33060
+ return;
33061
+ }
32935
33062
  if (this.state === "idle" || this.state === "paused") {
32936
- this.pendingQueue.push({ samples, channels, sampleRate, frameCount, durationSec });
33063
+ this.pendingQueue.push({
33064
+ samples,
33065
+ channels,
33066
+ sampleRate,
33067
+ frameCount,
33068
+ durationSec,
33069
+ ptsSec: hasPts ? ptsSec : null
33070
+ });
32937
33071
  return;
32938
33072
  }
32939
- this.scheduleNow(samples, channels, sampleRate, frameCount);
33073
+ this.scheduleNow(
33074
+ samples,
33075
+ channels,
33076
+ sampleRate,
33077
+ frameCount,
33078
+ hasPts ? ptsSec : null
33079
+ );
32940
33080
  }
32941
- scheduleNow(samples, channels, sampleRate, frameCount) {
33081
+ scheduleNow(samples, channels, sampleRate, frameCount, ptsSec) {
33082
+ const durationSec = frameCount / sampleRate;
33083
+ let ctxStart;
33084
+ if (ptsSec != null) {
33085
+ ctxStart = this.ctxTimeAtAnchor + (ptsSec - this.mediaTimeOfAnchor) / this._rate;
33086
+ if (isDebug2()) {
33087
+ 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}`);
33088
+ }
33089
+ if (ctxStart < this.ctx.currentTime - 1e-3) {
33090
+ if (isDebug2()) {
33091
+ console.log(`[TRACE-AUD] DROP late chunk pts=${ptsSec.toFixed(3)} ctxStart=${ctxStart.toFixed(4)} < ctxNow=${this.ctx.currentTime.toFixed(4)}`);
33092
+ }
33093
+ return;
33094
+ }
33095
+ if (this.firstAudibleCtxStart < 0) {
33096
+ this.firstAudibleCtxStart = ctxStart;
33097
+ this.mediaTimeOfAnchor = ptsSec;
33098
+ this.ctxTimeAtAnchor = ctxStart;
33099
+ if (isDebug2()) {
33100
+ 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)}`);
33101
+ }
33102
+ }
33103
+ const endMediaTime = ptsSec + durationSec / this._rate;
33104
+ if (endMediaTime > this.mediaTimeOfNext) {
33105
+ this.mediaTimeOfNext = endMediaTime;
33106
+ }
33107
+ } else {
33108
+ ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
33109
+ console.warn(`[TRACE-AUD] LEGACY (no PTS) sched dur=${durationSec.toFixed(4)} ctxStart=${ctxStart.toFixed(4)} ctxNow=${this.ctx.currentTime.toFixed(4)}`);
33110
+ if (ctxStart < this.ctx.currentTime) {
33111
+ 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)}`);
33112
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
33113
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
33114
+ ctxStart = this.ctx.currentTime;
33115
+ }
33116
+ this.mediaTimeOfNext += durationSec;
33117
+ }
32942
33118
  const buffer = this.ctx.createBuffer(channels, frameCount, sampleRate);
32943
33119
  for (let ch = 0; ch < channels; ch++) {
32944
33120
  const channelData = buffer.getChannelData(ch);
@@ -32950,14 +33126,7 @@ var AudioOutput = class {
32950
33126
  node3.buffer = buffer;
32951
33127
  node3.connect(this.gain);
32952
33128
  if (this._rate !== 1) node3.playbackRate.value = this._rate;
32953
- let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor) / this._rate;
32954
- if (ctxStart < this.ctx.currentTime) {
32955
- this.ctxTimeAtAnchor = this.ctx.currentTime;
32956
- this.mediaTimeOfAnchor = this.mediaTimeOfNext;
32957
- ctxStart = this.ctx.currentTime;
32958
- }
32959
33129
  node3.start(ctxStart);
32960
- this.mediaTimeOfNext += frameCount / sampleRate;
32961
33130
  this.framesScheduled++;
32962
33131
  }
32963
33132
  // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -32982,12 +33151,15 @@ var AudioOutput = class {
32982
33151
  } catch {
32983
33152
  }
32984
33153
  if (this.state === "paused") {
33154
+ if (isDebug2()) {
33155
+ 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}`);
33156
+ }
32985
33157
  this.ctxTimeAtAnchor = this.ctx.currentTime;
32986
33158
  this.state = "playing";
32987
33159
  const drain2 = this.pendingQueue;
32988
33160
  this.pendingQueue = [];
32989
33161
  for (const c of drain2) {
32990
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
33162
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
32991
33163
  }
32992
33164
  return;
32993
33165
  }
@@ -32995,10 +33167,13 @@ var AudioOutput = class {
32995
33167
  this.ctxTimeAtAnchor = this.ctx.currentTime + STARTUP_DELAY;
32996
33168
  this.mediaTimeOfNext = this.mediaTimeOfAnchor;
32997
33169
  this.state = "playing";
33170
+ if (isDebug2()) {
33171
+ 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}`);
33172
+ }
32998
33173
  const drain = this.pendingQueue;
32999
33174
  this.pendingQueue = [];
33000
33175
  for (const c of drain) {
33001
- this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount);
33176
+ this.scheduleNow(c.samples, c.channels, c.sampleRate, c.frameCount, c.ptsSec);
33002
33177
  }
33003
33178
  }
33004
33179
  /** Pause playback. Suspends the audio context. */
@@ -33024,6 +33199,9 @@ var AudioOutput = class {
33024
33199
  * supplying new samples) and then call `start()` to resume playback.
33025
33200
  */
33026
33201
  async reset(newMediaTime) {
33202
+ if (isDebug2()) {
33203
+ 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}`);
33204
+ }
33027
33205
  if (this.noAudio) {
33028
33206
  this.pendingQueue = [];
33029
33207
  this.mediaTimeOfAnchor = newMediaTime;
@@ -33042,6 +33220,7 @@ var AudioOutput = class {
33042
33220
  this.mediaTimeOfAnchor = newMediaTime;
33043
33221
  this.mediaTimeOfNext = newMediaTime;
33044
33222
  this.ctxTimeAtAnchor = this.ctx.currentTime;
33223
+ this.firstAudibleCtxStart = -1;
33045
33224
  this.state = "idle";
33046
33225
  if (this.ctx.state === "running") {
33047
33226
  await this.ctx.suspend();
@@ -33225,28 +33404,6 @@ function asUint8(x) {
33225
33404
  const ta = x;
33226
33405
  return new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
33227
33406
  }
33228
- function sanitizeFrameTimestamp(frame, nextUs, fallbackTimeBase) {
33229
- const lo = frame.pts ?? 0;
33230
- const hi = frame.ptshi ?? 0;
33231
- const isInvalid = hi === -2147483648 && lo === 0 || !Number.isFinite(lo);
33232
- if (isInvalid) {
33233
- const us2 = nextUs();
33234
- frame.pts = us2;
33235
- frame.ptshi = 0;
33236
- return;
33237
- }
33238
- const tb = fallbackTimeBase ?? [1, 1e6];
33239
- const pts64 = hi * 4294967296 + lo;
33240
- const us = Math.round(pts64 * 1e6 * tb[0] / tb[1]);
33241
- if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
33242
- frame.pts = us;
33243
- frame.ptshi = us < 0 ? -1 : 0;
33244
- return;
33245
- }
33246
- const fallback = nextUs();
33247
- frame.pts = fallback;
33248
- frame.ptshi = 0;
33249
- }
33250
33407
 
33251
33408
  // src/strategies/hybrid/decoder.ts
33252
33409
  async function startHybridDecoder(opts) {
@@ -33328,6 +33485,7 @@ async function startHybridDecoder(opts) {
33328
33485
  }
33329
33486
  let bsfCtx = null;
33330
33487
  let bsfPkt = null;
33488
+ let bsfRequiredButMissing = false;
33331
33489
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
33332
33490
  try {
33333
33491
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -33338,13 +33496,19 @@ async function startHybridDecoder(opts) {
33338
33496
  bsfPkt = await libav.av_packet_alloc();
33339
33497
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
33340
33498
  } else {
33341
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
33499
+ bsfRequiredButMissing = true;
33342
33500
  bsfCtx = null;
33343
33501
  }
33344
33502
  } catch (err) {
33345
- console.warn("[avbridge] hybrid: failed to init BSF:", err.message);
33503
+ bsfRequiredButMissing = true;
33346
33504
  bsfCtx = null;
33347
33505
  bsfPkt = null;
33506
+ dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${err.message}`);
33507
+ }
33508
+ if (bsfRequiredButMissing) {
33509
+ console.error(
33510
+ "[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."
33511
+ );
33348
33512
  }
33349
33513
  }
33350
33514
  async function applyBSF(packets) {
@@ -33354,7 +33518,6 @@ async function startHybridDecoder(opts) {
33354
33518
  await libav.ff_copyin_packet(bsfPkt, pkt);
33355
33519
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
33356
33520
  if (sendErr < 0) {
33357
- out.push(pkt);
33358
33521
  continue;
33359
33522
  }
33360
33523
  while (true) {
@@ -33368,10 +33531,13 @@ async function startHybridDecoder(opts) {
33368
33531
  async function flushBSF() {
33369
33532
  if (!bsfCtx || !bsfPkt) return;
33370
33533
  try {
33371
- await libav.av_bsf_send_packet(bsfCtx, 0);
33372
- while (true) {
33373
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
33374
- if (err < 0) break;
33534
+ if (libav.av_bsf_flush) {
33535
+ await libav.av_bsf_flush(bsfCtx);
33536
+ } else {
33537
+ while (true) {
33538
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
33539
+ if (err < 0) break;
33540
+ }
33375
33541
  }
33376
33542
  } catch {
33377
33543
  }
@@ -33385,7 +33551,6 @@ async function startHybridDecoder(opts) {
33385
33551
  let videoChunksFed = 0;
33386
33552
  let bufferedUntilSec = 0;
33387
33553
  let syntheticVideoUs = 0;
33388
- let syntheticAudioUs = 0;
33389
33554
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
33390
33555
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
33391
33556
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -33417,7 +33582,13 @@ async function startHybridDecoder(opts) {
33417
33582
  }
33418
33583
  }
33419
33584
  if (audioDec && audioPackets && audioPackets.length > 0) {
33420
- await decodeAudioBatch(audioPackets, myToken);
33585
+ await decodeAudioBatch(
33586
+ audioPackets,
33587
+ myToken,
33588
+ /*flush*/
33589
+ false,
33590
+ audioTimeBase
33591
+ );
33421
33592
  }
33422
33593
  if (myToken !== pumpToken || destroyed) return;
33423
33594
  await new Promise((r) => setTimeout(r, 0));
@@ -33464,8 +33635,11 @@ async function startHybridDecoder(opts) {
33464
33635
  }
33465
33636
  }
33466
33637
  }
33467
- async function decodeAudioBatch(pkts, myToken, flush = false) {
33638
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
33468
33639
  if (!audioDec || destroyed || myToken !== pumpToken) return;
33640
+ const pktPtsSec = pkts.map(
33641
+ (p) => tb ? packetPtsSec(p, tb) : null
33642
+ );
33469
33643
  const AUDIO_SUB_BATCH = 4;
33470
33644
  let allFrames = [];
33471
33645
  for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
@@ -33503,22 +33677,13 @@ async function startHybridDecoder(opts) {
33503
33677
  }
33504
33678
  if (myToken !== pumpToken || destroyed) return;
33505
33679
  const frames = allFrames;
33506
- for (const f of frames) {
33680
+ for (let i = 0; i < frames.length; i++) {
33507
33681
  if (myToken !== pumpToken || destroyed) return;
33508
- sanitizeFrameTimestamp(
33509
- f,
33510
- () => {
33511
- const ts = syntheticAudioUs;
33512
- const samples2 = f.nb_samples ?? 1024;
33513
- const sampleRate = f.sample_rate ?? 44100;
33514
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
33515
- return ts;
33516
- },
33517
- audioTimeBase
33518
- );
33682
+ const f = frames[i];
33519
33683
  const samples = libavFrameToInterleavedFloat32(f);
33520
33684
  if (samples) {
33521
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
33685
+ const pts = pktPtsSec[i] ?? null;
33686
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
33522
33687
  audioFramesDecoded++;
33523
33688
  }
33524
33689
  }
@@ -33628,7 +33793,6 @@ async function startHybridDecoder(opts) {
33628
33793
  }
33629
33794
  await flushBSF();
33630
33795
  syntheticVideoUs = Math.round(timeSec * 1e6);
33631
- syntheticAudioUs = Math.round(timeSec * 1e6);
33632
33796
  pumpRunning = pumpLoop(newToken).catch(
33633
33797
  (err) => console.error("[avbridge] hybrid pump failed (post-setAudioTrack):", err)
33634
33798
  );
@@ -33667,7 +33831,6 @@ async function startHybridDecoder(opts) {
33667
33831
  }
33668
33832
  await flushBSF();
33669
33833
  syntheticVideoUs = Math.round(timeSec * 1e6);
33670
- syntheticAudioUs = Math.round(timeSec * 1e6);
33671
33834
  pumpRunning = pumpLoop(newToken).catch(
33672
33835
  (err) => console.error("[avbridge] hybrid pump failed (post-seek):", err)
33673
33836
  );
@@ -33683,6 +33846,7 @@ async function startHybridDecoder(opts) {
33683
33846
  videoChunksFed,
33684
33847
  audioFramesDecoded,
33685
33848
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
33849
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
33686
33850
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
33687
33851
  // Confirmed transport info — see fallback decoder for the pattern.
33688
33852
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -33921,6 +34085,9 @@ async function createHybridSession(ctx, target, transport) {
33921
34085
  // src/strategies/fallback/decoder.ts
33922
34086
  init_libav_loader();
33923
34087
  init_debug();
34088
+ function isDebug3() {
34089
+ return typeof globalThis !== "undefined" && !!globalThis.AVBRIDGE_DEBUG;
34090
+ }
33924
34091
  async function startDecoder(opts) {
33925
34092
  const variant = "avbridge";
33926
34093
  const libav = await loadLibav(variant);
@@ -33984,6 +34151,7 @@ async function startDecoder(opts) {
33984
34151
  }
33985
34152
  let bsfCtx = null;
33986
34153
  let bsfPkt = null;
34154
+ let bsfRequiredButMissing = false;
33987
34155
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
33988
34156
  try {
33989
34157
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -33994,13 +34162,19 @@ async function startDecoder(opts) {
33994
34162
  bsfPkt = await libav.av_packet_alloc();
33995
34163
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
33996
34164
  } else {
33997
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available \u2014 decoding without it");
34165
+ bsfRequiredButMissing = true;
33998
34166
  bsfCtx = null;
33999
34167
  }
34000
34168
  } catch (err) {
34001
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", err.message);
34169
+ bsfRequiredButMissing = true;
34002
34170
  bsfCtx = null;
34003
34171
  bsfPkt = null;
34172
+ dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${err.message}`);
34173
+ }
34174
+ if (bsfRequiredButMissing) {
34175
+ console.error(
34176
+ "[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."
34177
+ );
34004
34178
  }
34005
34179
  }
34006
34180
  async function applyBSF(packets) {
@@ -34010,7 +34184,6 @@ async function startDecoder(opts) {
34010
34184
  await libav.ff_copyin_packet(bsfPkt, pkt);
34011
34185
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
34012
34186
  if (sendErr < 0) {
34013
- out.push(pkt);
34014
34187
  continue;
34015
34188
  }
34016
34189
  while (true) {
@@ -34024,10 +34197,13 @@ async function startDecoder(opts) {
34024
34197
  async function flushBSF() {
34025
34198
  if (!bsfCtx || !bsfPkt) return;
34026
34199
  try {
34027
- await libav.av_bsf_send_packet(bsfCtx, 0);
34028
- while (true) {
34029
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
34030
- if (err < 0) break;
34200
+ if (libav.av_bsf_flush) {
34201
+ await libav.av_bsf_flush(bsfCtx);
34202
+ } else {
34203
+ while (true) {
34204
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
34205
+ if (err < 0) break;
34206
+ }
34031
34207
  }
34032
34208
  } catch {
34033
34209
  }
@@ -34043,8 +34219,28 @@ async function startDecoder(opts) {
34043
34219
  let watchdogSlowSinceMs = 0;
34044
34220
  let watchdogSlowWarned = false;
34045
34221
  let watchdogOverflowWarned = false;
34046
- let syntheticVideoUs = 0;
34047
- let syntheticAudioUs = 0;
34222
+ let lastContentUs = -1;
34223
+ let firstValidPtsLoggedSinceSeek = false;
34224
+ let seenFirstAudioPacketSinceSeek = false;
34225
+ let seekTargetSec = 0;
34226
+ let diagPktsLoggedSinceSeek = 0;
34227
+ let diagFramesLoggedSinceSeek = 0;
34228
+ let diagFrameKeysDumped = false;
34229
+ const DIAG_MAX_PKTS = 100;
34230
+ const DIAG_MAX_FRAMES = 300;
34231
+ let videoDecodeMsTotal = 0;
34232
+ let audioDecodeMsTotal = 0;
34233
+ let videoDecodeBatches = 0;
34234
+ let audioDecodeBatches = 0;
34235
+ let readMsTotal = 0;
34236
+ let readBatches = 0;
34237
+ let pumpThrottleMsTotal = 0;
34238
+ let pumpThrottleEntries = 0;
34239
+ let slowestVideoBatchMs = 0;
34240
+ let newestVideoPtsUs = 0;
34241
+ let lastEmittedPtsUs = -1;
34242
+ let ptsRegressions = 0;
34243
+ let worstPtsRegressionMs = 0;
34048
34244
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
34049
34245
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
34050
34246
  const videoFrameStepUs = Math.max(1, Math.round(1e6 / videoFps));
@@ -34053,9 +34249,12 @@ async function startDecoder(opts) {
34053
34249
  let readErr;
34054
34250
  let packets;
34055
34251
  try {
34252
+ const _readStart = performance.now();
34056
34253
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
34057
34254
  limit: 16 * 1024
34058
34255
  });
34256
+ readMsTotal += performance.now() - _readStart;
34257
+ readBatches++;
34059
34258
  } catch (err) {
34060
34259
  console.error("[avbridge] ff_read_frame_multi failed:", err);
34061
34260
  return;
@@ -34067,6 +34266,18 @@ async function startDecoder(opts) {
34067
34266
  for (const pkt of videoPackets) {
34068
34267
  const sec = packetPtsSec(pkt, videoTimeBase);
34069
34268
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
34269
+ if (isDebug3() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
34270
+ const rawHi = pkt.ptshi ?? 0;
34271
+ const rawLo = pkt.pts ?? 0;
34272
+ const isInvalidPts = rawHi === -2147483648 && rawLo === 0;
34273
+ const rawPts64 = isInvalidPts ? null : rawHi * 4294967296 + rawLo;
34274
+ const rawSec = rawPts64 != null && videoTimeBase ? rawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
34275
+ const pktKeys = diagPktsLoggedSinceSeek === 0 ? `[keys: ${Object.keys(pkt).join(",")}]` : "";
34276
+ console.log(
34277
+ `[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
34278
+ );
34279
+ diagPktsLoggedSinceSeek++;
34280
+ }
34070
34281
  }
34071
34282
  }
34072
34283
  if (audioPackets && audioTimeBase) {
@@ -34074,9 +34285,25 @@ async function startDecoder(opts) {
34074
34285
  const sec = packetPtsSec(pkt, audioTimeBase);
34075
34286
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
34076
34287
  }
34288
+ if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
34289
+ const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
34290
+ if (firstSec != null && Number.isFinite(firstSec)) {
34291
+ seenFirstAudioPacketSinceSeek = true;
34292
+ dbg.info(
34293
+ "av-anchor",
34294
+ `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)`
34295
+ );
34296
+ }
34297
+ }
34077
34298
  }
34078
34299
  if (audioDec && audioPackets && audioPackets.length > 0) {
34079
- await decodeAudioBatch(audioPackets, myToken);
34300
+ await decodeAudioBatch(
34301
+ audioPackets,
34302
+ myToken,
34303
+ /*flush*/
34304
+ false,
34305
+ audioTimeBase
34306
+ );
34080
34307
  }
34081
34308
  if (myToken !== pumpToken || destroyed) return;
34082
34309
  if (videoDec && videoPackets && videoPackets.length > 0) {
@@ -34117,8 +34344,17 @@ async function startDecoder(opts) {
34117
34344
  }
34118
34345
  }
34119
34346
  }
34120
- while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
34121
- await new Promise((r) => setTimeout(r, 50));
34347
+ {
34348
+ const _throttleStart = performance.now();
34349
+ let _throttled = false;
34350
+ while (!destroyed && myToken === pumpToken && opts.audio.bufferAhead() > 2) {
34351
+ _throttled = true;
34352
+ await new Promise((r) => setTimeout(r, 50));
34353
+ }
34354
+ if (_throttled) {
34355
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
34356
+ pumpThrottleEntries++;
34357
+ }
34122
34358
  }
34123
34359
  if (readErr === libav.AVERROR_EOF) {
34124
34360
  if (videoDec) await decodeVideoBatch(
@@ -34144,6 +34380,7 @@ async function startDecoder(opts) {
34144
34380
  async function decodeVideoBatch(pkts, myToken, flush = false) {
34145
34381
  if (!videoDec || destroyed || myToken !== pumpToken) return;
34146
34382
  let frames;
34383
+ const _t0 = performance.now();
34147
34384
  try {
34148
34385
  frames = await libav.ff_decode_multi(
34149
34386
  videoDec.c,
@@ -34156,32 +34393,133 @@ async function startDecoder(opts) {
34156
34393
  console.error("[avbridge] video decode batch failed:", err);
34157
34394
  return;
34158
34395
  }
34396
+ {
34397
+ const _dt = performance.now() - _t0;
34398
+ videoDecodeMsTotal += _dt;
34399
+ videoDecodeBatches++;
34400
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
34401
+ }
34159
34402
  if (myToken !== pumpToken || destroyed) return;
34160
34403
  for (const f of frames) {
34161
34404
  if (myToken !== pumpToken || destroyed) return;
34162
- sanitizeFrameTimestamp(
34163
- f,
34164
- () => {
34165
- const ts = syntheticVideoUs;
34166
- syntheticVideoUs += videoFrameStepUs;
34167
- return ts;
34168
- },
34169
- videoTimeBase
34170
- );
34405
+ const _diagShouldLog = isDebug3() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
34406
+ const _diagRawHi = f.ptshi ?? 0;
34407
+ const _diagRawLo = f.pts ?? 0;
34408
+ const _diagInvalid = _diagRawHi === -2147483648 && _diagRawLo === 0;
34409
+ const _diagRawPts64 = _diagInvalid ? null : _diagRawHi * 4294967296 + _diagRawLo;
34410
+ const _diagRawSec = _diagRawPts64 != null && videoTimeBase ? _diagRawPts64 * videoTimeBase[0] / videoTimeBase[1] : null;
34411
+ if (_diagShouldLog && !diagFrameKeysDumped) {
34412
+ diagFrameKeysDumped = true;
34413
+ const allKeys = Object.keys(f);
34414
+ const fieldDump = {};
34415
+ for (const k of allKeys) {
34416
+ const v = f[k];
34417
+ if (k === "data") continue;
34418
+ if (typeof v === "object" && v !== null && "length" in v) continue;
34419
+ fieldDump[k] = v;
34420
+ }
34421
+ console.log(`[DIAG-FRAME] FIRST FRAME post-seek \u2014 all keys: ${allKeys.join(",")}`);
34422
+ console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
34423
+ }
34424
+ let rawUs = null;
34425
+ if (!_diagInvalid && _diagRawPts64 != null) {
34426
+ const tb = videoTimeBase ?? [1, 1e6];
34427
+ const us = Math.round(_diagRawPts64 * 1e6 * tb[0] / tb[1]);
34428
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
34429
+ rawUs = us;
34430
+ }
34431
+ }
34432
+ const _diagLog = (decision, finalPtsUs, sanFallback) => {
34433
+ if (!_diagShouldLog) return;
34434
+ const ptsSrc = sanFallback ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})` : "LIBAV";
34435
+ console.log(
34436
+ `[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}`
34437
+ );
34438
+ diagFramesLoggedSinceSeek++;
34439
+ };
34440
+ let _diagSanFallbackFired = false;
34441
+ const seekTargetUs = Math.round(seekTargetSec * 1e6);
34442
+ if (lastContentUs < 0) {
34443
+ if (rawUs == null) {
34444
+ const isColdStartKeyframe = seekTargetSec === 0 && f.key_frame === 1;
34445
+ if (isColdStartKeyframe) {
34446
+ lastContentUs = 0;
34447
+ _diagSanFallbackFired = true;
34448
+ } else {
34449
+ _diagLog("PRE-ANCHOR-DROP", 0, true);
34450
+ continue;
34451
+ }
34452
+ } else {
34453
+ lastContentUs = rawUs;
34454
+ if (!firstValidPtsLoggedSinceSeek) {
34455
+ firstValidPtsLoggedSinceSeek = true;
34456
+ if (isDebug3()) {
34457
+ console.log(
34458
+ `[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)`
34459
+ );
34460
+ }
34461
+ if (rawUs >= seekTargetUs) {
34462
+ console.warn(
34463
+ `[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.`
34464
+ );
34465
+ }
34466
+ }
34467
+ }
34468
+ } else {
34469
+ if (rawUs != null) {
34470
+ lastContentUs = rawUs;
34471
+ } else {
34472
+ lastContentUs += videoFrameStepUs;
34473
+ _diagSanFallbackFired = true;
34474
+ }
34475
+ }
34476
+ f.pts = lastContentUs;
34477
+ f.ptshi = lastContentUs < 0 ? -1 : 0;
34478
+ const _fPts = lastContentUs;
34479
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
34480
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
34481
+ _diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
34482
+ ptsRegressions++;
34483
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1e3;
34484
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
34485
+ if (ptsRegressions <= 10) {
34486
+ console.warn(
34487
+ `[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.`
34488
+ );
34489
+ }
34490
+ continue;
34491
+ }
34492
+ lastEmittedPtsUs = _fPts;
34493
+ const targetUs = Math.round(seekTargetSec * 1e6);
34494
+ if (_fPts < targetUs - videoFrameStepUs) {
34495
+ _diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
34496
+ continue;
34497
+ }
34171
34498
  try {
34172
34499
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1e6] });
34173
- opts.renderer.enqueue(vf);
34500
+ if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
34501
+ vf.close();
34502
+ _diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
34503
+ } else {
34504
+ opts.renderer.enqueue(vf);
34505
+ _diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
34506
+ }
34174
34507
  videoFramesDecoded++;
34175
34508
  } catch (err) {
34176
34509
  if (videoFramesDecoded === 0) {
34177
34510
  console.warn("[avbridge] laFrameToVideoFrame failed:", err);
34178
34511
  }
34512
+ _diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
34179
34513
  }
34180
34514
  }
34181
34515
  }
34182
- async function decodeAudioBatch(pkts, myToken, flush = false) {
34516
+ async function decodeAudioBatch(pkts, myToken, flush = false, tb) {
34183
34517
  if (!audioDec || destroyed || myToken !== pumpToken) return;
34518
+ const pktPtsSec = pkts.map(
34519
+ (p) => tb ? packetPtsSec(p, tb) : null
34520
+ );
34184
34521
  let frames;
34522
+ const _t0 = performance.now();
34185
34523
  try {
34186
34524
  frames = await libav.ff_decode_multi(
34187
34525
  audioDec.c,
@@ -34194,23 +34532,20 @@ async function startDecoder(opts) {
34194
34532
  console.error("[avbridge] audio decode batch failed:", err);
34195
34533
  return;
34196
34534
  }
34535
+ audioDecodeMsTotal += performance.now() - _t0;
34536
+ audioDecodeBatches++;
34197
34537
  if (myToken !== pumpToken || destroyed) return;
34198
- for (const f of frames) {
34538
+ for (let i = 0; i < frames.length; i++) {
34199
34539
  if (myToken !== pumpToken || destroyed) return;
34200
- sanitizeFrameTimestamp(
34201
- f,
34202
- () => {
34203
- const ts = syntheticAudioUs;
34204
- const samples2 = f.nb_samples ?? 1024;
34205
- const sampleRate = f.sample_rate ?? 44100;
34206
- syntheticAudioUs += Math.round(samples2 * 1e6 / sampleRate);
34207
- return ts;
34208
- },
34209
- audioTimeBase
34210
- );
34540
+ const f = frames[i];
34211
34541
  const samples = libavFrameToInterleavedFloat32(f);
34212
34542
  if (samples) {
34213
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
34543
+ const pts = pktPtsSec[i] ?? null;
34544
+ if (isDebug3()) {
34545
+ const dur = samples.data.length / samples.channels / samples.sampleRate;
34546
+ 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}`);
34547
+ }
34548
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
34214
34549
  audioFramesDecoded++;
34215
34550
  }
34216
34551
  }
@@ -34313,13 +34648,17 @@ async function startDecoder(opts) {
34313
34648
  } catch {
34314
34649
  }
34315
34650
  await flushBSF();
34316
- syntheticVideoUs = Math.round(timeSec * 1e6);
34317
- syntheticAudioUs = Math.round(timeSec * 1e6);
34651
+ lastContentUs = -1;
34652
+ lastEmittedPtsUs = -1;
34653
+ firstValidPtsLoggedSinceSeek = false;
34318
34654
  pumpRunning = pumpLoop(newToken).catch(
34319
34655
  (err) => console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err)
34320
34656
  );
34321
34657
  },
34322
34658
  async seek(timeSec) {
34659
+ if (isDebug3()) {
34660
+ console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1e3).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
34661
+ }
34323
34662
  const newToken = ++pumpToken;
34324
34663
  if (pumpRunning) {
34325
34664
  try {
@@ -34350,8 +34689,14 @@ async function startDecoder(opts) {
34350
34689
  } catch {
34351
34690
  }
34352
34691
  await flushBSF();
34353
- syntheticVideoUs = Math.round(timeSec * 1e6);
34354
- syntheticAudioUs = Math.round(timeSec * 1e6);
34692
+ lastContentUs = -1;
34693
+ lastEmittedPtsUs = -1;
34694
+ firstValidPtsLoggedSinceSeek = false;
34695
+ seenFirstAudioPacketSinceSeek = false;
34696
+ seekTargetSec = timeSec;
34697
+ diagPktsLoggedSinceSeek = 0;
34698
+ diagFramesLoggedSinceSeek = 0;
34699
+ diagFrameKeysDumped = false;
34355
34700
  pumpRunning = pumpLoop(newToken).catch(
34356
34701
  (err) => console.error("[avbridge] decoder pump failed (post-seek):", err)
34357
34702
  );
@@ -34365,7 +34710,24 @@ async function startDecoder(opts) {
34365
34710
  packetsRead,
34366
34711
  videoFramesDecoded,
34367
34712
  audioFramesDecoded,
34713
+ // Throughput instrumentation — the stats panel turns these into
34714
+ // "decode fps actual / realtime target" and shows slowest batch
34715
+ // + producer throttle share.
34716
+ videoDecodeMsTotal,
34717
+ videoDecodeBatches,
34718
+ audioDecodeMsTotal,
34719
+ audioDecodeBatches,
34720
+ readMsTotal,
34721
+ readBatches,
34722
+ pumpThrottleMsTotal,
34723
+ pumpThrottleEntries,
34724
+ slowestVideoBatchMs,
34725
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1e3),
34726
+ ptsRegressions,
34727
+ worstPtsRegressionMs,
34728
+ sourceFps: videoFps,
34368
34729
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
34730
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
34369
34731
  // Confirmed transport info: once prepareLibavInput returns
34370
34732
  // successfully, we *know* whether the source is http-range (probe
34371
34733
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists