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
@@ -29,8 +29,15 @@ import { AudioOutput } from "./audio-output.js";
29
29
  import type { MediaContext } from "../../types.js";
30
30
  import { pickLibavVariant } from "./variant-routing.js";
31
31
  import { dbg } from "../../util/debug.js";
32
+
33
+ /** True when `globalThis.AVBRIDGE_DEBUG` is set. Used to gate verbose
34
+ * per-packet / per-frame trace lines that are useful for debugging
35
+ * post-seek pts behavior but unreadable in normal use. */
36
+ function isDebug(): boolean {
37
+ return typeof globalThis !== "undefined"
38
+ && !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
39
+ }
32
40
  import {
33
- sanitizeFrameTimestamp,
34
41
  libavFrameToInterleavedFloat32,
35
42
  packetPtsSec,
36
43
  } from "../../util/libav-demux.js";
@@ -169,6 +176,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
169
176
  // garbled frame ordering.
170
177
  let bsfCtx: number | null = null;
171
178
  let bsfPkt: number | null = null;
179
+ let bsfRequiredButMissing = false;
172
180
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
173
181
  try {
174
182
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -179,15 +187,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
179
187
  bsfPkt = await libav.av_packet_alloc();
180
188
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
181
189
  } else {
182
- // eslint-disable-next-line no-console
183
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available — decoding without it");
190
+ bsfRequiredButMissing = true;
184
191
  bsfCtx = null;
185
192
  }
186
193
  } catch (err) {
187
- // eslint-disable-next-line no-console
188
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", (err as Error).message);
194
+ bsfRequiredButMissing = true;
189
195
  bsfCtx = null;
190
196
  bsfPkt = null;
197
+ dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${(err as Error).message}`);
198
+ }
199
+ if (bsfRequiredButMissing) {
200
+ // eslint-disable-next-line no-console
201
+ console.error(
202
+ "[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes " +
203
+ "BSF is unavailable in this libav variant. Files with packed B-frames " +
204
+ "will play with incorrect frame ordering (backwards PTS jumps, heavy " +
205
+ "late-drop stuttering). Rebuild the libav variant with the `avbsf` " +
206
+ "fragment included. See docs/dev/POSTMORTEMS.md for details.",
207
+ );
191
208
  }
192
209
  }
193
210
 
@@ -199,7 +216,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
199
216
  await libav.ff_copyin_packet(bsfPkt, pkt);
200
217
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
201
218
  if (sendErr < 0) {
202
- out.push(pkt); // BSF rejected — pass through original
219
+ // BSF rejected — DON'T pass the original through. `ff_copyin_packet`
220
+ // above may have transferred pkt.data's ArrayBuffer into the worker,
221
+ // in which case re-posting the same packet to the decoder fails
222
+ // with DataCloneError on a detached buffer. Skipping the packet is
223
+ // safer; the decoder's error recovery will resync at the next
224
+ // keyframe if this was transient.
203
225
  continue;
204
226
  }
205
227
  while (true) {
@@ -215,10 +237,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
215
237
  async function flushBSF(): Promise<void> {
216
238
  if (!bsfCtx || !bsfPkt) return;
217
239
  try {
218
- await libav.av_bsf_send_packet(bsfCtx, 0);
219
- while (true) {
220
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
221
- if (err < 0) break;
240
+ // `av_bsf_flush` resets the BSF state without putting it in EOF
241
+ // mode. The old approach — sending a NULL packet — is the EOF
242
+ // signal; after that every subsequent `av_bsf_send_packet` fails,
243
+ // which made `applyBSF` fall back to pushing the ORIGINAL packet
244
+ // through (with its buffer already transferred to WASM by
245
+ // `ff_copyin_packet`). That detached buffer then failed to
246
+ // `postMessage` into the decoder worker with DataCloneError on
247
+ // the first post-seek batch.
248
+ if (libav.av_bsf_flush) {
249
+ await libav.av_bsf_flush(bsfCtx);
250
+ } else {
251
+ // Fallback for older libav.js variants without av_bsf_flush:
252
+ // drain any internal packets but DON'T send NULL-EOF.
253
+ while (true) {
254
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
255
+ if (err < 0) break;
256
+ }
222
257
  }
223
258
  } catch { /* ignore flush errors */ }
224
259
  }
@@ -247,9 +282,60 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
247
282
  let watchdogSlowWarned = false;
248
283
  let watchdogOverflowWarned = false;
249
284
 
250
- // Synthetic timestamp counters. Reset on seek.
251
- let syntheticVideoUs = 0;
252
- let syntheticAudioUs = 0;
285
+ // Content clock for video frames. Tracks the last frame's content time
286
+ // in µs. The invariant per emit:
287
+ // - raw libav pts valid → lastContentUs = raw_pts (sync to truth)
288
+ // - raw libav pts NOPTS → lastContentUs += frameStep (extend by one frame)
289
+ // This makes synthetic labels always relative to the *immediately
290
+ // preceding* frame's real content, self-correcting at every valid pts.
291
+ // -1 means "unanchored" — pre-anchor NOPTS frames are discarded outright
292
+ // because we don't know where the decoder actually landed. The anchor
293
+ // is established at the first valid raw pts post-seek.
294
+ //
295
+ // This replaced an older "synthetic counter reset to seekTarget on seek"
296
+ // path which stamped NOPTS preroll frames with the user's requested seek
297
+ // time — producing labels 4+ seconds ahead of actual content, dropping
298
+ // every valid-pts frame as a "regression", and surfacing as a ~2s
299
+ // post-seek fast-forward as the slow-advancing synthetic counter slowly
300
+ // converged with real content. See POSTMORTEMS.md (2026-06-01).
301
+ let lastContentUs = -1;
302
+ let firstValidPtsLoggedSinceSeek = false;
303
+
304
+ // Diagnostic: first post-seek audio packet's PTS. Logged once per seek
305
+ // so the operator can see the demuxer's actual content alignment vs
306
+ // the user's click. With PTS-based audio scheduling, audio packets
307
+ // with PTS before the seek target *naturally* don't get scheduled
308
+ // (their computed ctxStart falls in the past) — no manual trim needed.
309
+ let seenFirstAudioPacketSinceSeek = false;
310
+ let seekTargetSec = 0;
311
+ // Post-seek diagnostic counters. Capture raw pts/dts/pos for the first
312
+ // ~N packets and frames after each seek so we can tell whether libav
313
+ // hands us a valid pts at seek landing, when (if ever) it becomes
314
+ // valid mid-stream, and whether sanitize's NOPTS fallback is firing.
315
+ let diagPktsLoggedSinceSeek = 0;
316
+ let diagFramesLoggedSinceSeek = 0;
317
+ let diagFrameKeysDumped = false;
318
+ const DIAG_MAX_PKTS = 100;
319
+ const DIAG_MAX_FRAMES = 300;
320
+
321
+ // Throughput instrumentation — answers "is the decoder keeping up?".
322
+ // All counters are cumulative since bootstrap (not reset on seek), so
323
+ // the stats panel can compute rolling deltas. Times are wall-ms spent
324
+ // inside the respective libav call; JS↔WASM boundary is inside the
325
+ // worker so this is the real cost the producer pays per batch.
326
+ let videoDecodeMsTotal = 0;
327
+ let audioDecodeMsTotal = 0;
328
+ let videoDecodeBatches = 0;
329
+ let audioDecodeBatches = 0;
330
+ let readMsTotal = 0;
331
+ let readBatches = 0;
332
+ let pumpThrottleMsTotal = 0;
333
+ let pumpThrottleEntries = 0;
334
+ let slowestVideoBatchMs = 0;
335
+ let newestVideoPtsUs = 0; // set by decodeVideoBatch after each emitted frame
336
+ let lastEmittedPtsUs = -1; // previous emitted frame's pts, for monotonicity check
337
+ let ptsRegressions = 0;
338
+ let worstPtsRegressionMs = 0;
253
339
 
254
340
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
255
341
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
@@ -274,9 +360,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
274
360
  // typical bitrates, so the worst-case queue spike stays under
275
361
  // `queueHighWater` and the throttle has a chance to apply
276
362
  // backpressure *between* batches rather than within one.
363
+ const _readStart = performance.now();
277
364
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
278
365
  limit: 16 * 1024,
279
366
  });
367
+ readMsTotal += performance.now() - _readStart;
368
+ readBatches++;
280
369
  } catch (err) {
281
370
  console.error("[avbridge] ff_read_frame_multi failed:", err);
282
371
  return;
@@ -295,6 +384,37 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
295
384
  for (const pkt of videoPackets) {
296
385
  const sec = packetPtsSec(pkt, videoTimeBase);
297
386
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
387
+ // [DIAG-PKT] Raw pre-sanitize packet fields for the first N
388
+ // post-seek video packets. Most important question: does the
389
+ // FIRST packet after av_seek_frame carry a valid pts? If yes,
390
+ // we can anchor synthetic counter to that — cheap & robust.
391
+ // If no, fall back to pkt_pos → AVI index → chunk_idx × frameDur.
392
+ if (isDebug() && diagPktsLoggedSinceSeek < DIAG_MAX_PKTS) {
393
+ const rawHi = (pkt as { ptshi?: number }).ptshi ?? 0;
394
+ const rawLo = pkt.pts ?? 0;
395
+ const isInvalidPts = (rawHi === -2147483648 && rawLo === 0);
396
+ const rawPts64 = isInvalidPts ? null : (rawHi * 0x100000000 + rawLo);
397
+ const rawSec = rawPts64 != null && videoTimeBase
398
+ ? (rawPts64 * videoTimeBase[0]) / videoTimeBase[1]
399
+ : null;
400
+ const pktKeys = diagPktsLoggedSinceSeek === 0
401
+ ? `[keys: ${Object.keys(pkt).join(",")}]`
402
+ : "";
403
+ // eslint-disable-next-line no-console
404
+ console.log(
405
+ `[DIAG-PKT] vidx=${diagPktsLoggedSinceSeek} ` +
406
+ `pts=${isInvalidPts ? "NOPTS" : rawPts64} ` +
407
+ `pts_sec=${rawSec != null ? rawSec.toFixed(3) : "n/a"} ` +
408
+ `ptshi=${rawHi} ptslo=${rawLo} ` +
409
+ `flags=0x${(pkt.flags ?? 0).toString(16)} ` +
410
+ `keyframe=${((pkt.flags ?? 0) & 1) ? "Y" : "N"} ` +
411
+ `stream=${pkt.stream_index} ` +
412
+ `dataLen=${pkt.data?.length ?? 0} ` +
413
+ `seekTarget=${seekTargetSec.toFixed(3)} ` +
414
+ pktKeys,
415
+ );
416
+ diagPktsLoggedSinceSeek++;
417
+ }
298
418
  }
299
419
  }
300
420
  if (audioPackets && audioTimeBase) {
@@ -302,6 +422,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
302
422
  const sec = packetPtsSec(pkt, audioTimeBase);
303
423
  if (sec != null && sec > bufferedUntilSec) bufferedUntilSec = sec;
304
424
  }
425
+ // Diagnostic: log the first post-seek audio packet's PTS. With
426
+ // PTS-based scheduling, packets whose PTS is before the seek
427
+ // target won't be played (AudioOutput skips them silently), so
428
+ // this is informational only — it tells you how far off the
429
+ // demuxer's seek granularity is from the user's click.
430
+ if (!seenFirstAudioPacketSinceSeek && audioPackets.length > 0) {
431
+ const firstSec = packetPtsSec(audioPackets[0], audioTimeBase);
432
+ if (firstSec != null && Number.isFinite(firstSec)) {
433
+ seenFirstAudioPacketSinceSeek = true;
434
+ dbg.info("av-anchor",
435
+ `seek-target=${seekTargetSec.toFixed(3)}s, ` +
436
+ `first-audio-pkt-pts=${firstSec.toFixed(3)}s ` +
437
+ `(Δ=${((firstSec - seekTargetSec) * 1000).toFixed(1)}ms — ` +
438
+ `pre-target packets will be skipped by AudioOutput)`,
439
+ );
440
+ }
441
+ }
305
442
  }
306
443
 
307
444
  // Decode audio BEFORE video. On software-decode-bound content
@@ -313,7 +450,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
313
450
  // packet for cook/mp3/aac, so doing it first barely delays
314
451
  // video decoding at all.
315
452
  if (audioDec && audioPackets && audioPackets.length > 0) {
316
- await decodeAudioBatch(audioPackets, myToken);
453
+ await decodeAudioBatch(audioPackets, myToken, /*flush*/ false, audioTimeBase);
317
454
  }
318
455
  if (myToken !== pumpToken || destroyed) return;
319
456
  if (videoDec && videoPackets && videoPackets.length > 0) {
@@ -381,20 +518,30 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
381
518
  }
382
519
  }
383
520
 
384
- // Throttle: don't run too far ahead of playback. Two backpressure
385
- // signals:
386
- // - Audio buffer (mediaTimeOfNext - now()) > 2 sec — we have
387
- // plenty of audio scheduled.
388
- // - Renderer queue depth >= queueHighWater the canvas can't
389
- // drain fast enough. Without this, fast software decode of
390
- // small frames piles up in the renderer and overflows.
391
- while (
392
- !destroyed &&
393
- myToken === pumpToken &&
394
- (opts.audio.bufferAhead() > 2.0 ||
395
- opts.renderer.queueDepth() >= opts.renderer.queueHighWater)
396
- ) {
397
- await new Promise((r) => setTimeout(r, 50));
521
+ // Throttle: only on audio buffer (mediaTimeOfNext - now() > 2 s).
522
+ // Renderer queue backpressure is enforced at the *enqueue* side in
523
+ // `decodeVideoBatch` when the queue is at `queueHighWater`, the
524
+ // freshly decoded VideoFrame is closed without being enqueued, so
525
+ // the decoder keeps consuming packets in order. That preserves the
526
+ // reference-frame state needed to decode P/B frames cleanly during
527
+ // post-seek catch-up. Throttling the *pump* on queue depth here
528
+ // would block demuxer reads, which would also stall audio packet
529
+ // processing and starve `audio.bufferAhead()`.
530
+ {
531
+ const _throttleStart = performance.now();
532
+ let _throttled = false;
533
+ while (
534
+ !destroyed &&
535
+ myToken === pumpToken &&
536
+ opts.audio.bufferAhead() > 2.0
537
+ ) {
538
+ _throttled = true;
539
+ await new Promise((r) => setTimeout(r, 50));
540
+ }
541
+ if (_throttled) {
542
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
543
+ pumpThrottleEntries++;
544
+ }
398
545
  }
399
546
 
400
547
  if (readErr === libav.AVERROR_EOF) {
@@ -412,6 +559,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
412
559
  async function decodeVideoBatch(pkts: LibavPacket[], myToken: number, flush = false) {
413
560
  if (!videoDec || destroyed || myToken !== pumpToken) return;
414
561
  let frames: LibavFrame[];
562
+ const _t0 = performance.now();
415
563
  try {
416
564
  frames = await libav.ff_decode_multi(
417
565
  videoDec.c,
@@ -424,36 +572,262 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
424
572
  console.error("[avbridge] video decode batch failed:", err);
425
573
  return;
426
574
  }
575
+ {
576
+ const _dt = performance.now() - _t0;
577
+ videoDecodeMsTotal += _dt;
578
+ videoDecodeBatches++;
579
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
580
+ }
427
581
  if (myToken !== pumpToken || destroyed) return;
428
582
 
429
583
  for (const f of frames) {
430
584
  if (myToken !== pumpToken || destroyed) return;
431
- sanitizeFrameTimestamp(
432
- f,
433
- () => {
434
- const ts = syntheticVideoUs;
435
- syntheticVideoUs += videoFrameStepUs;
436
- return ts;
437
- },
438
- videoTimeBase,
439
- );
440
- // sanitizeFrameTimestamp normalizes pts to µs, so the bridge can
441
- // always use the 1/1e6 timebase.
585
+ // [DIAG-FRAME] Capture raw pre-sanitize fields. One-shot key dump
586
+ // on first frame post-seek so we can see which fields libav
587
+ // actually exposes (best_effort_timestamp? pkt_dts? pkt_pos?).
588
+ const _diagShouldLog = isDebug() && diagFramesLoggedSinceSeek < DIAG_MAX_FRAMES;
589
+ const _diagRawHi = f.ptshi ?? 0;
590
+ const _diagRawLo = f.pts ?? 0;
591
+ const _diagInvalid = (_diagRawHi === -2147483648 && _diagRawLo === 0);
592
+ const _diagRawPts64 = _diagInvalid ? null : (_diagRawHi * 0x100000000 + _diagRawLo);
593
+ const _diagRawSec = _diagRawPts64 != null && videoTimeBase
594
+ ? (_diagRawPts64 * videoTimeBase[0]) / videoTimeBase[1]
595
+ : null;
596
+ if (_diagShouldLog && !diagFrameKeysDumped) {
597
+ diagFrameKeysDumped = true;
598
+ const allKeys = Object.keys(f);
599
+ const fieldDump: Record<string, unknown> = {};
600
+ for (const k of allKeys) {
601
+ const v = (f as unknown as Record<string, unknown>)[k];
602
+ // Skip the data buffer; everything else is metadata.
603
+ if (k === "data") continue;
604
+ if (typeof v === "object" && v !== null && "length" in (v as object)) continue;
605
+ fieldDump[k] = v;
606
+ }
607
+ // eslint-disable-next-line no-console
608
+ console.log(`[DIAG-FRAME] FIRST FRAME post-seek — all keys: ${allKeys.join(",")}`);
609
+ // eslint-disable-next-line no-console
610
+ console.log(`[DIAG-FRAME] FIRST FRAME field dump:`, fieldDump);
611
+ }
612
+ // Convert raw libav pts (in stream timebase) to µs, or null if NOPTS.
613
+ let rawUs: number | null = null;
614
+ if (!_diagInvalid && _diagRawPts64 != null) {
615
+ const tb = videoTimeBase ?? [1, 1_000_000];
616
+ const us = Math.round((_diagRawPts64 * 1_000_000 * tb[0]) / tb[1]);
617
+ if (Number.isFinite(us) && Math.abs(us) <= Number.MAX_SAFE_INTEGER) {
618
+ rawUs = us;
619
+ }
620
+ }
621
+
622
+ // Forward declare _diagLog so PRE-ANCHOR-DROP can call it.
623
+ // Final pts isn't known until after the anchor/step block, so we pass
624
+ // it as a parameter rather than closing over a `let`.
625
+ const _diagLog = (decision: string, finalPtsUs: number, sanFallback: boolean): void => {
626
+ if (!_diagShouldLog) return;
627
+ const ptsSrc = sanFallback
628
+ ? `SYNTHETIC(${_diagInvalid ? "NOPTS" : "invalid-range"})`
629
+ : "LIBAV";
630
+ // eslint-disable-next-line no-console
631
+ console.log(
632
+ `[DIAG-FRAME] vidx=${diagFramesLoggedSinceSeek} ` +
633
+ `raw_pts=${_diagInvalid ? "NOPTS" : _diagRawPts64} ` +
634
+ `raw_pts_sec=${_diagRawSec != null ? _diagRawSec.toFixed(3) : "n/a"} ` +
635
+ `pts_src=${ptsSrc} ` +
636
+ `final_pts_us=${finalPtsUs} ` +
637
+ `final_pts_sec=${(finalPtsUs / 1_000_000).toFixed(3)} ` +
638
+ `seekTarget=${seekTargetSec.toFixed(3)} ` +
639
+ `offset_to_target_ms=${((finalPtsUs / 1000) - (seekTargetSec * 1000)).toFixed(1)} ` +
640
+ `lastEmittedPts_us=${lastEmittedPtsUs} ` +
641
+ `decision=${decision}`,
642
+ );
643
+ diagFramesLoggedSinceSeek++;
644
+ };
645
+
646
+ // Anchor + step invariant.
647
+ // - Unanchored (post-seek, no valid pts seen yet) AND NOPTS frame
648
+ // → discard outright. We don't know where the decoder landed, so
649
+ // stamping a synthetic label would be a lie (this was the source
650
+ // of the post-seek fast-forward bug).
651
+ // - First valid raw pts → anchor `lastContentUs` to it. The pipeline
652
+ // below will then drop this and subsequent frames as pre-target
653
+ // until content reaches seekTarget.
654
+ // - Anchored AND valid → sync `lastContentUs` to truth.
655
+ // - Anchored AND NOPTS → step `lastContentUs += frameStep`.
656
+ let _diagSanFallbackFired = false;
657
+ const seekTargetUs = Math.round(seekTargetSec * 1_000_000);
658
+ if (lastContentUs < 0) {
659
+ if (rawUs == null) {
660
+ // Cold-start keyframe special case. At seekTargetSec === 0 the
661
+ // demuxer guarantees the very first emitted keyframe is content
662
+ // 0 (container start=0.000000). Anchoring there directly avoids
663
+ // discarding the opening I-frame — without this, cold start
664
+ // loses 1-2 frames and the first paint is ~80ms late.
665
+ //
666
+ // STRICTLY gated to seekTarget === 0. The seek path proved
667
+ // correct via the offset-ground-truth experiment (POSTMORTEMS
668
+ // 2026-06-01); this branch must not change its behavior.
669
+ //
670
+ // Why keyframe-pin instead of back-computing from the first
671
+ // valid pts: the I/P/B reorder is densest at the stream head,
672
+ // so `firstValidPts − N × frameStep` is off by however many
673
+ // early B-frames the decoder dropped. The keyframe identity
674
+ // (`f.key_frame === 1`) is the only signal that doesn't depend
675
+ // on frame-spacing assumptions.
676
+ const isColdStartKeyframe =
677
+ seekTargetSec === 0
678
+ && (f as { key_frame?: number }).key_frame === 1;
679
+ if (isColdStartKeyframe) {
680
+ lastContentUs = 0;
681
+ _diagSanFallbackFired = true;
682
+ // Fall through: the frame gets labeled 0 and runs through
683
+ // the regression/pre-target/enqueue pipeline normally.
684
+ } else {
685
+ // Pre-anchor NOPTS: discard. Decoder retains the frame internally
686
+ // as a reference — we just don't expose it to the renderer.
687
+ _diagLog("PRE-ANCHOR-DROP", 0, true);
688
+ continue;
689
+ }
690
+ } else {
691
+ // First valid raw pts post-seek = the anchor.
692
+ lastContentUs = rawUs;
693
+ if (!firstValidPtsLoggedSinceSeek) {
694
+ firstValidPtsLoggedSinceSeek = true;
695
+ if (isDebug()) {
696
+ // eslint-disable-next-line no-console
697
+ console.log(
698
+ `[avbridge:decoder] post-seek anchor established: ` +
699
+ `first valid raw pts = ${(rawUs / 1000).toFixed(1)}ms ` +
700
+ `(seekTarget = ${(seekTargetSec * 1000).toFixed(1)}ms, ` +
701
+ `Δ = ${((rawUs - seekTargetUs) / 1000).toFixed(1)}ms)`,
702
+ );
703
+ }
704
+ // Guard: if the first valid pts is at or beyond the seek
705
+ // target, the pre-anchor NOPTS frames we already discarded
706
+ // may have straddled the target. In normal AVI MPEG-4 seeks,
707
+ // the demuxer lands well before the target (previous
708
+ // keyframe), so this shouldn't happen — log a warning if it
709
+ // does so we know to implement a pkt_pos→AVI-index
710
+ // back-computation path. The cold-start case (seekTarget=0)
711
+ // is handled by the keyframe-pin branch above and shouldn't
712
+ // reach this warning.
713
+ if (rawUs >= seekTargetUs) {
714
+ // eslint-disable-next-line no-console
715
+ console.warn(
716
+ `[avbridge:decoder] first valid raw pts ≥ seek target — ` +
717
+ `pre-anchor NOPTS frames may have straddled the target ` +
718
+ `and been mis-discarded. First painted frame may be late ` +
719
+ `by up to one keyframe interval.`,
720
+ );
721
+ }
722
+ }
723
+ }
724
+ } else {
725
+ if (rawUs != null) {
726
+ lastContentUs = rawUs; // sync to truth on every valid pts
727
+ } else {
728
+ lastContentUs += videoFrameStepUs; // extend from last truth
729
+ _diagSanFallbackFired = true;
730
+ }
731
+ }
732
+ // Write the content label into the frame so the bridge sees it.
733
+ f.pts = lastContentUs;
734
+ f.ptshi = lastContentUs < 0 ? -1 : 0;
735
+ const _fPts = lastContentUs;
736
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
737
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
738
+ _diagLog("REGRESSED-DROP", _fPts, _diagSanFallbackFired);
739
+ // Decoder emitted a frame with lower PTS than the previous
740
+ // output. Dropping out-of-order frames here is the right move:
741
+ // the renderer's paint loop assumes monotonic queue order and
742
+ // breaks (stale frame stuck at head, newer frames drop as late,
743
+ // paint cadence collapses) if we let them through. Two scenarios
744
+ // produce this in practice:
745
+ // - Post-seek tail of a B-frame reorder buffer that survives
746
+ // avcodec_flush_buffers + av_bsf_flush (rare but observed
747
+ // on mpeg4 after large seeks).
748
+ // - A BSF that doesn't repair packed B-frames perfectly and
749
+ // lets a DTS/PTS swap through.
750
+ // The decoder will catch up at the next I-frame.
751
+ ptsRegressions++;
752
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1000;
753
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
754
+ if (ptsRegressions <= 10) {
755
+ // eslint-disable-next-line no-console
756
+ console.warn(
757
+ `[avbridge:decoder] dropped out-of-order frame #${ptsRegressions}: ` +
758
+ `pts=${(_fPts / 1000).toFixed(1)}ms < previous=${(lastEmittedPtsUs / 1000).toFixed(1)}ms ` +
759
+ `(regression=${regressMs.toFixed(1)}ms). Typically a post-seek B-frame reorder tail.`,
760
+ );
761
+ }
762
+ continue; // skip enqueue
763
+ }
764
+ lastEmittedPtsUs = _fPts;
765
+ // Decode-to-display: after a seek the demuxer lands at the
766
+ // keyframe ≤ click target and the decoder produces frames
767
+ // starting there. Pre-target frames are still DECODED (they're
768
+ // reference frames for later P/B decodes) but they MUST NOT be
769
+ // displayed — otherwise the renderer paints them in a brief
770
+ // fast-forward burst as it catches up to audio (T_click). Drop
771
+ // them at the enqueue boundary; the decoder doesn't care.
772
+ //
773
+ // Tolerance of one frame duration: source frames are quantized
774
+ // (PTS = N × frameStep) but the user's click is arbitrary, so
775
+ // the frame nearest the click is typically a few ms *before* it.
776
+ // Convention (matches `<video>.currentTime = T` and ffplay):
777
+ // display the frame at the largest PTS ≤ T.
778
+ const targetUs = Math.round(seekTargetSec * 1_000_000);
779
+ if (_fPts < targetUs - videoFrameStepUs) {
780
+ _diagLog("PRE-TARGET-DROP", _fPts, _diagSanFallbackFired);
781
+ continue;
782
+ }
442
783
  try {
443
784
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
444
- opts.renderer.enqueue(vf);
785
+ // Renderer-queue backpressure at the enqueue side. Discarding
786
+ // here (rather than throttling the pump on `queueHighWater`)
787
+ // keeps the decoder consuming packets sequentially so its
788
+ // reference-frame state stays intact — essential during
789
+ // post-seek catch-up, when the pump must continue reading
790
+ // packets to advance the demuxer past pre-target audio. Without
791
+ // sequential decode, the next batch's P/B frames decode against
792
+ // a stale reference and produce gray + glitchy output until
793
+ // the next keyframe.
794
+ if (opts.renderer.queueDepth() >= opts.renderer.queueHighWater) {
795
+ vf.close();
796
+ _diagLog("OVERFLOW-DROP", _fPts, _diagSanFallbackFired);
797
+ } else {
798
+ opts.renderer.enqueue(vf);
799
+ _diagLog("ENQUEUED", _fPts, _diagSanFallbackFired);
800
+ }
445
801
  videoFramesDecoded++;
446
802
  } catch (err) {
447
803
  if (videoFramesDecoded === 0) {
448
804
  console.warn("[avbridge] laFrameToVideoFrame failed:", err);
449
805
  }
806
+ _diagLog("BRIDGE-ERROR", _fPts, _diagSanFallbackFired);
450
807
  }
451
808
  }
452
809
  }
453
810
 
454
- async function decodeAudioBatch(pkts: LibavPacket[], myToken: number, flush = false) {
811
+ async function decodeAudioBatch(
812
+ pkts: LibavPacket[],
813
+ myToken: number,
814
+ flush = false,
815
+ tb?: [number, number],
816
+ ) {
455
817
  if (!audioDec || destroyed || myToken !== pumpToken) return;
818
+ // Capture the packet-level PTS *before* decoding. libav's reported
819
+ // `frame.pts` after decode is unreliable for mp3-in-AVI (returns a
820
+ // value that doesn't agree with the stream's reported time base —
821
+ // see POSTMORTEMS.md 2026-05-31). The demuxer's packet PTS is
822
+ // reliable, and for mp3/aac the packet→frame mapping is 1:1, so we
823
+ // forward each packet's PTS to the matching output frame. For codecs
824
+ // where the mapping isn't 1:1, the trailing frames fall back to a
825
+ // synthetic running counter — same behavior as before this change.
826
+ const pktPtsSec: (number | null)[] = pkts.map((p) =>
827
+ tb ? packetPtsSec(p, tb) : null,
828
+ );
456
829
  let frames: LibavFrame[];
830
+ const _t0 = performance.now();
457
831
  try {
458
832
  frames = await libav.ff_decode_multi(
459
833
  audioDec.c,
@@ -466,24 +840,25 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
466
840
  console.error("[avbridge] audio decode batch failed:", err);
467
841
  return;
468
842
  }
843
+ audioDecodeMsTotal += performance.now() - _t0;
844
+ audioDecodeBatches++;
469
845
  if (myToken !== pumpToken || destroyed) return;
470
846
 
471
- for (const f of frames) {
847
+ for (let i = 0; i < frames.length; i++) {
472
848
  if (myToken !== pumpToken || destroyed) return;
473
- sanitizeFrameTimestamp(
474
- f,
475
- () => {
476
- const ts = syntheticAudioUs;
477
- const samples = f.nb_samples ?? 1024;
478
- const sampleRate = f.sample_rate ?? 44100;
479
- syntheticAudioUs += Math.round((samples * 1_000_000) / sampleRate);
480
- return ts;
481
- },
482
- audioTimeBase,
483
- );
849
+ const f = frames[i];
484
850
  const samples = libavFrameToInterleavedFloat32(f);
485
851
  if (samples) {
486
- opts.audio.schedule(samples.data, samples.channels, samples.sampleRate);
852
+ const pts = pktPtsSec[i] ?? null;
853
+ if (isDebug()) {
854
+ const dur = samples.data.length / samples.channels / samples.sampleRate;
855
+ // Log every frame — we need to see what happens around seeks.
856
+ // Also surface explicitly when the per-frame PTS is null, which
857
+ // would route the chunk to the LEGACY rebase path in AudioOutput.
858
+ // eslint-disable-next-line no-console
859
+ 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}`);
860
+ }
861
+ opts.audio.schedule(samples.data, samples.channels, samples.sampleRate, pts);
487
862
  audioFramesDecoded++;
488
863
  }
489
864
  }
@@ -573,8 +948,9 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
573
948
  try { if (videoDec) await libav.avcodec_flush_buffers?.(videoDec.c); } catch { /* ignore */ }
574
949
  await flushBSF();
575
950
 
576
- syntheticVideoUs = Math.round(timeSec * 1_000_000);
577
- syntheticAudioUs = Math.round(timeSec * 1_000_000);
951
+ lastContentUs = -1;
952
+ lastEmittedPtsUs = -1;
953
+ firstValidPtsLoggedSinceSeek = false;
578
954
 
579
955
  pumpRunning = pumpLoop(newToken).catch((err) =>
580
956
  console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err),
@@ -582,6 +958,10 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
582
958
  },
583
959
 
584
960
  async seek(timeSec) {
961
+ if (isDebug()) {
962
+ // eslint-disable-next-line no-console
963
+ console.log(`[SEEK] target=${timeSec.toFixed(3)}s (${(timeSec * 1000).toFixed(0)}ms) wall=${performance.now().toFixed(0)}`);
964
+ }
585
965
  // Cancel the current pump and wait for it to actually exit before
586
966
  // we start moving file pointers around — concurrent ff_decode_multi
587
967
  // and av_seek_frame on the same context would be a recipe for memory
@@ -629,10 +1009,19 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
629
1009
  } catch { /* ignore */ }
630
1010
  await flushBSF();
631
1011
 
632
- // Reset synthetic timestamp counters to the seek target so newly
633
- // decoded frames start at the right media time.
634
- syntheticVideoUs = Math.round(timeSec * 1_000_000);
635
- syntheticAudioUs = Math.round(timeSec * 1_000_000);
1012
+ // Reset the content clock to "unanchored". The next decode loop
1013
+ // will discard NOPTS frames until the first valid libav pts
1014
+ // establishes a real anchor, then label every frame relative to
1015
+ // truth. Do NOT set anything to seekTarget here — that lie was
1016
+ // the post-seek fast-forward bug.
1017
+ lastContentUs = -1;
1018
+ lastEmittedPtsUs = -1;
1019
+ firstValidPtsLoggedSinceSeek = false;
1020
+ seenFirstAudioPacketSinceSeek = false;
1021
+ seekTargetSec = timeSec;
1022
+ diagPktsLoggedSinceSeek = 0;
1023
+ diagFramesLoggedSinceSeek = 0;
1024
+ diagFrameKeysDumped = false;
636
1025
 
637
1026
  // The renderer & audio output are reset by the fallback session
638
1027
  // wrapper that called us — see strategies/fallback/index.ts.
@@ -653,7 +1042,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
653
1042
  packetsRead,
654
1043
  videoFramesDecoded,
655
1044
  audioFramesDecoded,
1045
+ // Throughput instrumentation — the stats panel turns these into
1046
+ // "decode fps actual / realtime target" and shows slowest batch
1047
+ // + producer throttle share.
1048
+ videoDecodeMsTotal,
1049
+ videoDecodeBatches,
1050
+ audioDecodeMsTotal,
1051
+ audioDecodeBatches,
1052
+ readMsTotal,
1053
+ readBatches,
1054
+ pumpThrottleMsTotal,
1055
+ pumpThrottleEntries,
1056
+ slowestVideoBatchMs,
1057
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1000),
1058
+ ptsRegressions,
1059
+ worstPtsRegressionMs,
1060
+ sourceFps: videoFps,
656
1061
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
1062
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
657
1063
  // Confirmed transport info: once prepareLibavInput returns
658
1064
  // successfully, we *know* whether the source is http-range (probe
659
1065
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -776,6 +1182,7 @@ interface LibavRuntime {
776
1182
  av_bsf_init(ctx: number): Promise<number>;
777
1183
  av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
778
1184
  av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
1185
+ av_bsf_flush?(ctx: number): Promise<void>;
779
1186
  av_bsf_free(ctx: number): Promise<void>;
780
1187
 
781
1188
  // Packet copy helpers — bridge JS packet objects to/from C-level pointers