avbridge 2.12.0 → 2.12.1

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 (52) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
  3. package/dist/avi-32UABODO.cjs.map +1 -0
  4. package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
  5. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  6. package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
  7. package/dist/avi-BLIH7KKV.js.map +1 -0
  8. package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
  9. package/dist/avi-GX2H34IQ.js.map +1 -0
  10. package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
  11. package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
  12. package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
  13. package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
  14. package/dist/{chunk-Z26PXRUY.js → chunk-BN7BRTLY.js} +137 -26
  15. package/dist/chunk-BN7BRTLY.js.map +1 -0
  16. package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
  17. package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  18. package/dist/{chunk-7EF4VTUS.cjs → chunk-UM6WCSGL.cjs} +141 -30
  19. package/dist/chunk-UM6WCSGL.cjs.map +1 -0
  20. package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  21. package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  22. package/dist/element-browser.js +144 -25
  23. package/dist/element-browser.js.map +1 -1
  24. package/dist/element.cjs +3 -3
  25. package/dist/element.js +2 -2
  26. package/dist/index.cjs +18 -18
  27. package/dist/index.js +6 -6
  28. package/dist/player.cjs +207 -41
  29. package/dist/player.cjs.map +1 -1
  30. package/dist/player.d.cts +1 -0
  31. package/dist/player.d.ts +1 -0
  32. package/dist/player.js +207 -41
  33. package/dist/player.js.map +1 -1
  34. package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
  35. package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  36. package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
  37. package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
  38. package/package.json +1 -1
  39. package/src/element/avbridge-player.ts +87 -15
  40. package/src/probe/avi.ts +34 -2
  41. package/src/strategies/fallback/decoder.ts +148 -19
  42. package/src/strategies/fallback/video-renderer.ts +41 -3
  43. package/src/strategies/hybrid/decoder.ts +34 -9
  44. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  45. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  46. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  47. package/dist/avi-EQE6AR75.cjs.map +0 -1
  48. package/dist/avi-NNHH4AAA.js.map +0 -1
  49. package/dist/avi-S7EY54YA.js.map +0 -1
  50. package/dist/avi-Y3N325WZ.cjs.map +0 -1
  51. package/dist/chunk-7EF4VTUS.cjs.map +0 -1
  52. package/dist/chunk-Z26PXRUY.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var chunkGJBNLPGI_cjs = require('./chunk-GJBNLPGI.cjs');
4
- require('./chunk-HBHSUGNI.cjs');
3
+ var chunkE5MAM2P4_cjs = require('./chunk-E5MAM2P4.cjs');
4
+ require('./chunk-VLI3Y6IJ.cjs');
5
5
  require('./chunk-2IJ66NTD.cjs');
6
6
  require('./chunk-QDJLQR53.cjs');
7
7
  require('./chunk-HZUVMXBN.cjs');
@@ -13,23 +13,23 @@ require('./chunk-F3LQJKXK.cjs');
13
13
 
14
14
  Object.defineProperty(exports, "createOutputFormat", {
15
15
  enumerable: true,
16
- get: function () { return chunkGJBNLPGI_cjs.createOutputFormat; }
16
+ get: function () { return chunkE5MAM2P4_cjs.createOutputFormat; }
17
17
  });
18
18
  Object.defineProperty(exports, "generateFilename", {
19
19
  enumerable: true,
20
- get: function () { return chunkGJBNLPGI_cjs.generateFilename; }
20
+ get: function () { return chunkE5MAM2P4_cjs.generateFilename; }
21
21
  });
22
22
  Object.defineProperty(exports, "mimeForFormat", {
23
23
  enumerable: true,
24
- get: function () { return chunkGJBNLPGI_cjs.mimeForFormat; }
24
+ get: function () { return chunkE5MAM2P4_cjs.mimeForFormat; }
25
25
  });
26
26
  Object.defineProperty(exports, "remux", {
27
27
  enumerable: true,
28
- get: function () { return chunkGJBNLPGI_cjs.remux; }
28
+ get: function () { return chunkE5MAM2P4_cjs.remux; }
29
29
  });
30
30
  Object.defineProperty(exports, "validateRemuxEligibility", {
31
31
  enumerable: true,
32
- get: function () { return chunkGJBNLPGI_cjs.validateRemuxEligibility; }
32
+ get: function () { return chunkE5MAM2P4_cjs.validateRemuxEligibility; }
33
33
  });
34
- //# sourceMappingURL=remux-VPKCLHHM.cjs.map
35
- //# sourceMappingURL=remux-VPKCLHHM.cjs.map
34
+ //# sourceMappingURL=remux-NSBJFMLG.cjs.map
35
+ //# sourceMappingURL=remux-NSBJFMLG.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-VPKCLHHM.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-NSBJFMLG.cjs"}
@@ -1,10 +1,10 @@
1
- export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-5Y5BTB5D.js';
2
- import './chunk-2LNXMGT6.js';
1
+ export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-B76QWPFM.js';
2
+ import './chunk-5CX7BVVV.js';
3
3
  import './chunk-CPJLFFCC.js';
4
4
  import './chunk-LUFA47FP.js';
5
5
  import './chunk-3YKWU4FM.js';
6
6
  import './chunk-3AI5WFFN.js';
7
7
  import './chunk-5DMTJVIU.js';
8
8
  import './chunk-5YAWWKA3.js';
9
- //# sourceMappingURL=remux-7TA4FKTY.js.map
10
- //# sourceMappingURL=remux-7TA4FKTY.js.map
9
+ //# sourceMappingURL=remux-PHUHO3VV.js.map
10
+ //# sourceMappingURL=remux-PHUHO3VV.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-7TA4FKTY.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-PHUHO3VV.js"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.12.0",
3
+ "version": "2.12.1",
4
4
  "description": "Play and convert arbitrary video files in the browser. Native, remux, hybrid, fallback, and transcode — one API.",
5
5
  "license": "MIT",
6
6
  "author": "Keishi Hattori",
@@ -752,10 +752,13 @@ export class AvbridgePlayerElement extends HTMLElement {
752
752
 
753
753
  // ── Stats for nerds ────────────────────────────────────────────────────
754
754
 
755
+ private _statsPrev: { ts: number; rt: Record<string, unknown> } | null = null;
756
+
755
757
  private _toggleStats(): void {
756
758
  this._statsOpen = !this._statsOpen;
757
759
  this._statsEl.classList.toggle("open", this._statsOpen);
758
760
  if (this._statsOpen) {
761
+ this._statsPrev = null; // reset delta baseline
759
762
  this._updateStats();
760
763
  this._statsInterval = setInterval(() => this._updateStats(), 1000);
761
764
  } else {
@@ -767,23 +770,92 @@ export class AvbridgePlayerElement extends HTMLElement {
767
770
  const d = this._video.getDiagnostics() as Record<string, unknown> | null;
768
771
  if (!d) { this._statsEl.textContent = "No diagnostics"; return; }
769
772
  const rt = (d.runtime ?? {}) as Record<string, unknown>;
770
- const lines: string[] = [
771
- `Container: ${d.container ?? "?"}`,
772
- `Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}×${d.height ?? "?"}`,
773
- `Audio: ${d.audioCodec ?? "none"}`,
774
- `Strategy: ${d.strategy ?? "?"} Class: ${d.strategyClass ?? "?"}`,
775
- `Transport: ${d.transport ?? "?"} Range: ${d.rangeSupported ?? "?"}`,
776
- `Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"}`,
777
- ];
778
- if (rt.framesDecoded != null) lines.push(`Frames: ${rt.framesDecoded} decoded, ${rt.framesDropped ?? 0} dropped`);
779
- if (rt.framesPainted != null) lines.push(`Painted: ${rt.framesPainted} Late: ${rt.framesDroppedLate ?? 0} Overflow: ${rt.framesDroppedOverflow ?? 0}`);
780
- if (rt.videoFramesDecoded != null) lines.push(`Video decoded: ${rt.videoFramesDecoded} Chunks fed: ${rt.videoChunksFed ?? "?"}`);
781
- if (rt.audioFramesDecoded != null) lines.push(`Audio decoded: ${rt.audioFramesDecoded}`);
782
- if (rt.packetsRead != null) lines.push(`Packets read: ${rt.packetsRead}`);
783
- if (rt.bsfApplied && (rt.bsfApplied as string[]).length > 0) lines.push(`BSF: ${(rt.bsfApplied as string[]).join(", ")}`);
784
- if (rt.audioState != null) lines.push(`Audio state: ${rt.audioState} Clock: ${rt.clockMode ?? "?"}`);
773
+ const now = performance.now();
774
+ const prev = this._statsPrev;
775
+ const dtSec = prev ? Math.max(0.001, (now - prev.ts) / 1000) : 0;
776
+ const delta = (key: string): number | null => {
777
+ if (!prev) return null;
778
+ const a = rt[key];
779
+ const b = prev.rt[key];
780
+ if (typeof a === "number" && typeof b === "number") return a - b;
781
+ return null;
782
+ };
783
+ const rate = (key: string): number | null => {
784
+ const d_ = delta(key);
785
+ return d_ != null ? d_ / dtSec : null;
786
+ };
787
+ const fmt = (n: number | null, digits = 1) => (n == null ? "?" : n.toFixed(digits));
788
+
789
+ const sourceFps = (typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps) as number | undefined;
790
+ const lines: string[] = [];
791
+
792
+ // ── Identity ──────────────────────────────────────────────────────
793
+ lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
794
+ lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}×${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
795
+ lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
796
+ lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
797
+
798
+ // ── Realtime rates (deltas per second) ─────────────────────────────
799
+ if (rt.videoFramesDecoded != null) {
800
+ const decFps = rate("videoFramesDecoded");
801
+ const paintFps = rate("framesPainted");
802
+ const dropLateFps = rate("framesDroppedLate");
803
+ const dropOverflowFps = rate("framesDroppedOverflow");
804
+ const pct = sourceFps && decFps != null ? ` (${((decFps / sourceFps) * 100).toFixed(0)}% of realtime)` : "";
805
+ lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
806
+ lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
807
+ }
808
+
809
+ // ── Decode-time breakdown (wall ms spent inside libav) ────────────
810
+ if (typeof rt.videoDecodeMsTotal === "number") {
811
+ const msDelta = delta("videoDecodeMsTotal");
812
+ const batchesDelta = delta("videoDecodeBatches");
813
+ const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
814
+ const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
815
+ lines.push(
816
+ `Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs as number)}ms`,
817
+ );
818
+ }
819
+ if (typeof rt.audioDecodeMsTotal === "number") {
820
+ const msDelta = delta("audioDecodeMsTotal");
821
+ const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
822
+ lines.push(`Audio decode: ${fmt(share)}% of wall`);
823
+ }
824
+ if (typeof rt.pumpThrottleMsTotal === "number") {
825
+ const msDelta = delta("pumpThrottleMsTotal");
826
+ const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
827
+ lines.push(`Producer throttled: ${fmt(share)}% of wall`);
828
+ }
829
+
830
+ // ── Queue health ──────────────────────────────────────────────────
831
+ if (rt.queueDepth != null) {
832
+ lines.push(
833
+ `Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs as number)}ms ` +
834
+ `head=${fmt(rt.queueHeadMs as number)}ms tail=${fmt(rt.queueTailMs as number)}ms`,
835
+ );
836
+ }
837
+ if (typeof rt.newestVideoPtsMs === "number") {
838
+ lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1000).toFixed(2)}s`);
839
+ }
840
+
841
+ // ── Audio ─────────────────────────────────────────────────────────
842
+ if (rt.audioState != null) {
843
+ lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead as number, 2)}s clock=${rt.clockMode ?? "?"}`);
844
+ }
845
+
846
+ // ── BSF + warnings ────────────────────────────────────────────────
847
+ if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
848
+ lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs as number)}ms) — decoder emitting out of order`);
849
+ }
850
+ if (rt.bsfApplied && (rt.bsfApplied as string[]).length > 0) lines.push(`BSF active: ${(rt.bsfApplied as string[]).join(", ")}`);
851
+ if (rt.bsfMissing && (rt.bsfMissing as string[]).length > 0) {
852
+ lines.push(`BSF MISSING: ${(rt.bsfMissing as string[]).join(", ")} (rebuild libav with avbsf)`);
853
+ }
854
+
785
855
  if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
856
+
786
857
  this._statsEl.textContent = lines.join("\n");
858
+ this._statsPrev = { ts: now, rt: { ...rt } };
787
859
  }
788
860
 
789
861
  // ── Controls: fullscreen ───────────────────────────────────────────────
package/src/probe/avi.ts CHANGED
@@ -77,7 +77,7 @@ export async function probeWithLibav(
77
77
  codec: ffmpegToAvbridgeVideo(codecName),
78
78
  width: codecpar?.width ?? 0,
79
79
  height: codecpar?.height ?? 0,
80
- fps: framerate(stream),
80
+ fps: await framerate(libav, stream),
81
81
  });
82
82
  } else if (stream.codec_type === libav.AVMEDIA_TYPE_AUDIO) {
83
83
  audioTracks.push({
@@ -111,7 +111,27 @@ export async function probeWithLibav(
111
111
  };
112
112
  }
113
113
 
114
- function framerate(stream: LibavStream): number | undefined {
114
+ /**
115
+ * Read frame rate from the stream's `AVCodecParameters.framerate`.
116
+ *
117
+ * `avg_frame_rate` / `r_frame_rate` live on the AVStream in C but libav.js
118
+ * doesn't expose them as JS properties on the stream record — only via
119
+ * dedicated accessor functions we don't ship. `codecpar.framerate` IS
120
+ * accessible via `AVCodecParameters_framerate_num/_den` and is populated
121
+ * for most containers (for AVI it's parsed from `dwRate`/`dwScale` in the
122
+ * stream header).
123
+ *
124
+ * Returns undefined if unavailable so the caller can fall back to a
125
+ * container-appropriate default (currently 30 fps, which is wrong for
126
+ * 25 fps PAL and 23.976 fps film content — hence the importance of
127
+ * reading this correctly).
128
+ */
129
+ async function framerate(
130
+ libav: LibavInstance,
131
+ stream: LibavStream,
132
+ ): Promise<number | undefined> {
133
+ // The stream record may still carry these (new libav.js versions) —
134
+ // prefer them when present.
115
135
  if (typeof stream.avg_frame_rate_num === "number" && stream.avg_frame_rate_den) {
116
136
  return stream.avg_frame_rate_num / stream.avg_frame_rate_den;
117
137
  }
@@ -119,6 +139,15 @@ function framerate(stream: LibavStream): number | undefined {
119
139
  if (stream.avg_frame_rate.den === 0) return undefined;
120
140
  return stream.avg_frame_rate.num / stream.avg_frame_rate.den;
121
141
  }
142
+ try {
143
+ const num = await libav.AVCodecParameters_framerate_num?.(stream.codecpar);
144
+ const den = await libav.AVCodecParameters_framerate_den?.(stream.codecpar);
145
+ if (typeof num === "number" && typeof den === "number" && den > 0 && num > 0) {
146
+ return num / den;
147
+ }
148
+ } catch {
149
+ // Fall through.
150
+ }
122
151
  return undefined;
123
152
  }
124
153
 
@@ -250,6 +279,9 @@ interface LibavInstance {
250
279
  AVFormatContext_durationhi?(ctx: number): Promise<number>;
251
280
  i64tof64?(lo: number, hi: number): number;
252
281
 
282
+ AVCodecParameters_framerate_num?(codecpar: number): Promise<number>;
283
+ AVCodecParameters_framerate_den?(codecpar: number): Promise<number>;
284
+
253
285
  AVMEDIA_TYPE_VIDEO: number;
254
286
  AVMEDIA_TYPE_AUDIO: number;
255
287
  }
@@ -169,6 +169,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
169
169
  // garbled frame ordering.
170
170
  let bsfCtx: number | null = null;
171
171
  let bsfPkt: number | null = null;
172
+ let bsfRequiredButMissing = false;
172
173
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
173
174
  try {
174
175
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -179,15 +180,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
179
180
  bsfPkt = await libav.av_packet_alloc();
180
181
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active");
181
182
  } else {
182
- // eslint-disable-next-line no-console
183
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available — decoding without it");
183
+ bsfRequiredButMissing = true;
184
184
  bsfCtx = null;
185
185
  }
186
186
  } catch (err) {
187
- // eslint-disable-next-line no-console
188
- console.warn("[avbridge] failed to init mpeg4_unpack_bframes BSF:", (err as Error).message);
187
+ bsfRequiredButMissing = true;
189
188
  bsfCtx = null;
190
189
  bsfPkt = null;
190
+ dbg.warn("bsf", `mpeg4_unpack_bframes BSF init failed: ${(err as Error).message}`);
191
+ }
192
+ if (bsfRequiredButMissing) {
193
+ // eslint-disable-next-line no-console
194
+ console.error(
195
+ "[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes " +
196
+ "BSF is unavailable in this libav variant. Files with packed B-frames " +
197
+ "will play with incorrect frame ordering (backwards PTS jumps, heavy " +
198
+ "late-drop stuttering). Rebuild the libav variant with the `avbsf` " +
199
+ "fragment included. See docs/dev/POSTMORTEMS.md for details.",
200
+ );
191
201
  }
192
202
  }
193
203
 
@@ -199,7 +209,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
199
209
  await libav.ff_copyin_packet(bsfPkt, pkt);
200
210
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
201
211
  if (sendErr < 0) {
202
- out.push(pkt); // BSF rejected — pass through original
212
+ // BSF rejected — DON'T pass the original through. `ff_copyin_packet`
213
+ // above may have transferred pkt.data's ArrayBuffer into the worker,
214
+ // in which case re-posting the same packet to the decoder fails
215
+ // with DataCloneError on a detached buffer. Skipping the packet is
216
+ // safer; the decoder's error recovery will resync at the next
217
+ // keyframe if this was transient.
203
218
  continue;
204
219
  }
205
220
  while (true) {
@@ -215,10 +230,23 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
215
230
  async function flushBSF(): Promise<void> {
216
231
  if (!bsfCtx || !bsfPkt) return;
217
232
  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;
233
+ // `av_bsf_flush` resets the BSF state without putting it in EOF
234
+ // mode. The old approach — sending a NULL packet — is the EOF
235
+ // signal; after that every subsequent `av_bsf_send_packet` fails,
236
+ // which made `applyBSF` fall back to pushing the ORIGINAL packet
237
+ // through (with its buffer already transferred to WASM by
238
+ // `ff_copyin_packet`). That detached buffer then failed to
239
+ // `postMessage` into the decoder worker with DataCloneError on
240
+ // the first post-seek batch.
241
+ if (libav.av_bsf_flush) {
242
+ await libav.av_bsf_flush(bsfCtx);
243
+ } else {
244
+ // Fallback for older libav.js variants without av_bsf_flush:
245
+ // drain any internal packets but DON'T send NULL-EOF.
246
+ while (true) {
247
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
248
+ if (err < 0) break;
249
+ }
222
250
  }
223
251
  } catch { /* ignore flush errors */ }
224
252
  }
@@ -251,6 +279,25 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
251
279
  let syntheticVideoUs = 0;
252
280
  let syntheticAudioUs = 0;
253
281
 
282
+ // Throughput instrumentation — answers "is the decoder keeping up?".
283
+ // All counters are cumulative since bootstrap (not reset on seek), so
284
+ // the stats panel can compute rolling deltas. Times are wall-ms spent
285
+ // inside the respective libav call; JS↔WASM boundary is inside the
286
+ // worker so this is the real cost the producer pays per batch.
287
+ let videoDecodeMsTotal = 0;
288
+ let audioDecodeMsTotal = 0;
289
+ let videoDecodeBatches = 0;
290
+ let audioDecodeBatches = 0;
291
+ let readMsTotal = 0;
292
+ let readBatches = 0;
293
+ let pumpThrottleMsTotal = 0;
294
+ let pumpThrottleEntries = 0;
295
+ let slowestVideoBatchMs = 0;
296
+ let newestVideoPtsUs = 0; // set by decodeVideoBatch after each emitted frame
297
+ let lastEmittedPtsUs = -1; // previous emitted frame's pts, for monotonicity check
298
+ let ptsRegressions = 0;
299
+ let worstPtsRegressionMs = 0;
300
+
254
301
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
255
302
  const videoFps = videoTrackInfo?.fps && videoTrackInfo.fps > 0 ? videoTrackInfo.fps : 30;
256
303
  const videoFrameStepUs = Math.max(1, Math.round(1_000_000 / videoFps));
@@ -274,9 +321,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
274
321
  // typical bitrates, so the worst-case queue spike stays under
275
322
  // `queueHighWater` and the throttle has a chance to apply
276
323
  // backpressure *between* batches rather than within one.
324
+ const _readStart = performance.now();
277
325
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
278
326
  limit: 16 * 1024,
279
327
  });
328
+ readMsTotal += performance.now() - _readStart;
329
+ readBatches++;
280
330
  } catch (err) {
281
331
  console.error("[avbridge] ff_read_frame_multi failed:", err);
282
332
  return;
@@ -388,13 +438,22 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
388
438
  // - Renderer queue depth >= queueHighWater — the canvas can't
389
439
  // drain fast enough. Without this, fast software decode of
390
440
  // 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));
441
+ {
442
+ const _throttleStart = performance.now();
443
+ let _throttled = false;
444
+ while (
445
+ !destroyed &&
446
+ myToken === pumpToken &&
447
+ (opts.audio.bufferAhead() > 2.0 ||
448
+ opts.renderer.queueDepth() >= opts.renderer.queueHighWater)
449
+ ) {
450
+ _throttled = true;
451
+ await new Promise((r) => setTimeout(r, 50));
452
+ }
453
+ if (_throttled) {
454
+ pumpThrottleMsTotal += performance.now() - _throttleStart;
455
+ pumpThrottleEntries++;
456
+ }
398
457
  }
399
458
 
400
459
  if (readErr === libav.AVERROR_EOF) {
@@ -412,6 +471,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
412
471
  async function decodeVideoBatch(pkts: LibavPacket[], myToken: number, flush = false) {
413
472
  if (!videoDec || destroyed || myToken !== pumpToken) return;
414
473
  let frames: LibavFrame[];
474
+ const _t0 = performance.now();
415
475
  try {
416
476
  frames = await libav.ff_decode_multi(
417
477
  videoDec.c,
@@ -424,6 +484,12 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
424
484
  console.error("[avbridge] video decode batch failed:", err);
425
485
  return;
426
486
  }
487
+ {
488
+ const _dt = performance.now() - _t0;
489
+ videoDecodeMsTotal += _dt;
490
+ videoDecodeBatches++;
491
+ if (_dt > slowestVideoBatchMs) slowestVideoBatchMs = _dt;
492
+ }
427
493
  if (myToken !== pumpToken || destroyed) return;
428
494
 
429
495
  for (const f of frames) {
@@ -431,14 +497,54 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
431
497
  sanitizeFrameTimestamp(
432
498
  f,
433
499
  () => {
434
- const ts = syntheticVideoUs;
435
- syntheticVideoUs += videoFrameStepUs;
436
- return ts;
500
+ // Anchor the synthetic timestamp to the last emitted frame's
501
+ // pts + one frame step. A plain counter (the old behavior)
502
+ // started at 0 and only advanced on invalid frames, which
503
+ // made the occasional AV_NOPTS_VALUE output get assigned a
504
+ // timestamp near the stream start — causing the renderer to
505
+ // paint backwards and drop healthy frames around it. Anchoring
506
+ // to `lastEmittedPtsUs` keeps invalid frames monotonic with
507
+ // their valid neighbors.
508
+ const base =
509
+ lastEmittedPtsUs >= 0
510
+ ? lastEmittedPtsUs + videoFrameStepUs
511
+ : syntheticVideoUs;
512
+ syntheticVideoUs = base + videoFrameStepUs;
513
+ return base;
437
514
  },
438
515
  videoTimeBase,
439
516
  );
440
517
  // sanitizeFrameTimestamp normalizes pts to µs, so the bridge can
441
518
  // always use the 1/1e6 timebase.
519
+ const _fPts = (f.ptshi ?? 0) * 0x100000000 + (f.pts ?? 0);
520
+ if (_fPts > newestVideoPtsUs) newestVideoPtsUs = _fPts;
521
+ if (lastEmittedPtsUs >= 0 && _fPts < lastEmittedPtsUs) {
522
+ // Decoder emitted a frame with lower PTS than the previous
523
+ // output. Dropping out-of-order frames here is the right move:
524
+ // the renderer's paint loop assumes monotonic queue order and
525
+ // breaks (stale frame stuck at head, newer frames drop as late,
526
+ // paint cadence collapses) if we let them through. Two scenarios
527
+ // produce this in practice:
528
+ // - Post-seek tail of a B-frame reorder buffer that survives
529
+ // avcodec_flush_buffers + av_bsf_flush (rare but observed
530
+ // on mpeg4 after large seeks).
531
+ // - A BSF that doesn't repair packed B-frames perfectly and
532
+ // lets a DTS/PTS swap through.
533
+ // The decoder will catch up at the next I-frame.
534
+ ptsRegressions++;
535
+ const regressMs = (lastEmittedPtsUs - _fPts) / 1000;
536
+ if (regressMs > worstPtsRegressionMs) worstPtsRegressionMs = regressMs;
537
+ if (ptsRegressions <= 10) {
538
+ // eslint-disable-next-line no-console
539
+ console.warn(
540
+ `[avbridge:decoder] dropped out-of-order frame #${ptsRegressions}: ` +
541
+ `pts=${(_fPts / 1000).toFixed(1)}ms < previous=${(lastEmittedPtsUs / 1000).toFixed(1)}ms ` +
542
+ `(regression=${regressMs.toFixed(1)}ms). Typically a post-seek B-frame reorder tail.`,
543
+ );
544
+ }
545
+ continue; // skip enqueue
546
+ }
547
+ lastEmittedPtsUs = _fPts;
442
548
  try {
443
549
  const vf = bridge.laFrameToVideoFrame(f, { timeBase: [1, 1_000_000] });
444
550
  opts.renderer.enqueue(vf);
@@ -454,6 +560,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
454
560
  async function decodeAudioBatch(pkts: LibavPacket[], myToken: number, flush = false) {
455
561
  if (!audioDec || destroyed || myToken !== pumpToken) return;
456
562
  let frames: LibavFrame[];
563
+ const _t0 = performance.now();
457
564
  try {
458
565
  frames = await libav.ff_decode_multi(
459
566
  audioDec.c,
@@ -466,6 +573,8 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
466
573
  console.error("[avbridge] audio decode batch failed:", err);
467
574
  return;
468
575
  }
576
+ audioDecodeMsTotal += performance.now() - _t0;
577
+ audioDecodeBatches++;
469
578
  if (myToken !== pumpToken || destroyed) return;
470
579
 
471
580
  for (const f of frames) {
@@ -575,6 +684,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
575
684
 
576
685
  syntheticVideoUs = Math.round(timeSec * 1_000_000);
577
686
  syntheticAudioUs = Math.round(timeSec * 1_000_000);
687
+ lastEmittedPtsUs = -1;
578
688
 
579
689
  pumpRunning = pumpLoop(newToken).catch((err) =>
580
690
  console.error("[avbridge] fallback pump failed (post-setAudioTrack):", err),
@@ -633,6 +743,7 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
633
743
  // decoded frames start at the right media time.
634
744
  syntheticVideoUs = Math.round(timeSec * 1_000_000);
635
745
  syntheticAudioUs = Math.round(timeSec * 1_000_000);
746
+ lastEmittedPtsUs = -1;
636
747
 
637
748
  // The renderer & audio output are reset by the fallback session
638
749
  // wrapper that called us — see strategies/fallback/index.ts.
@@ -653,7 +764,24 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
653
764
  packetsRead,
654
765
  videoFramesDecoded,
655
766
  audioFramesDecoded,
767
+ // Throughput instrumentation — the stats panel turns these into
768
+ // "decode fps actual / realtime target" and shows slowest batch
769
+ // + producer throttle share.
770
+ videoDecodeMsTotal,
771
+ videoDecodeBatches,
772
+ audioDecodeMsTotal,
773
+ audioDecodeBatches,
774
+ readMsTotal,
775
+ readBatches,
776
+ pumpThrottleMsTotal,
777
+ pumpThrottleEntries,
778
+ slowestVideoBatchMs,
779
+ newestVideoPtsMs: Math.round(newestVideoPtsUs / 1000),
780
+ ptsRegressions,
781
+ worstPtsRegressionMs,
782
+ sourceFps: videoFps,
656
783
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
784
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
657
785
  // Confirmed transport info: once prepareLibavInput returns
658
786
  // successfully, we *know* whether the source is http-range (probe
659
787
  // succeeded and returned 206) or in-memory blob. Diagnostics hoists
@@ -776,6 +904,7 @@ interface LibavRuntime {
776
904
  av_bsf_init(ctx: number): Promise<number>;
777
905
  av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
778
906
  av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
907
+ av_bsf_flush?(ctx: number): Promise<void>;
779
908
  av_bsf_free(ctx: number): Promise<void>;
780
909
 
781
910
  // Packet copy helpers — bridge JS packet objects to/from C-level pointers
@@ -141,11 +141,22 @@ export class VideoRenderer {
141
141
  this.rafHandle = requestAnimationFrame(this.tick);
142
142
  }
143
143
 
144
- /** True once at least one frame has been enqueued. */
144
+ /**
145
+ * True once at least one frame has been enqueued *since the last flush*.
146
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
147
+ * any frame has arrived, and after a seek we want the same semantics
148
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
149
+ * `framesPainted > 0` that used to live here was wrong: it kept the
150
+ * state "true forever" after the first frame ever, so post-seek
151
+ * `waitForBuffer()` would exit immediately with an empty queue and
152
+ * leave video frozen while audio kept going.
153
+ */
145
154
  hasFrames(): boolean {
146
- return this.queue.length > 0 || this.framesPainted > 0;
155
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
147
156
  }
148
157
 
158
+ private hasEverEnqueuedSinceFlush = false;
159
+
149
160
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
150
161
  queueDepth(): number {
151
162
  return this.queue.length;
@@ -166,6 +177,7 @@ export class VideoRenderer {
166
177
  return;
167
178
  }
168
179
  this.queue.push(frame);
180
+ this.hasEverEnqueuedSinceFlush = true;
169
181
  if (this.queue.length === 1 && this.framesPainted === 0) {
170
182
  this.resolveFirstFrame();
171
183
  }
@@ -342,7 +354,16 @@ export class VideoRenderer {
342
354
  }
343
355
 
344
356
  // Only drop frames that are more than 2 frame-durations behind.
345
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
357
+ // Diagnostic escape hatch: `globalThis.AVBRIDGE_RELAX_DROP = true`
358
+ // pushes the threshold so far back that frames are effectively
359
+ // never dropped as late. The display will run behind the audio
360
+ // clock but won't stutter from drop bursts. Useful for isolating
361
+ // "is the problem decode throughput or drop policy?".
362
+ const _relaxDrop =
363
+ (globalThis as { AVBRIDGE_RELAX_DROP?: boolean }).AVBRIDGE_RELAX_DROP === true;
364
+ const dropThresholdUs = _relaxDrop
365
+ ? audioNowUs - 60 * 1_000_000 /* 60 s */
366
+ : audioNowUs - frameDurationUs * 2;
346
367
  let dropped = 0;
347
368
  while (bestIdx > 0) {
348
369
  const ts = this.queue[0].timestamp ?? 0;
@@ -419,6 +440,7 @@ export class VideoRenderer {
419
440
  while (this.queue.length > 0) this.queue.shift()?.close();
420
441
  this.prerolled = false;
421
442
  this.ptsCalibrated = false; // recalibrate at new seek position
443
+ this.hasEverEnqueuedSinceFlush = false; // so waitForBuffer() waits for post-flush frames
422
444
  if (isDebug() && count > 0) {
423
445
  // eslint-disable-next-line no-console
424
446
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
@@ -426,11 +448,27 @@ export class VideoRenderer {
426
448
  }
427
449
 
428
450
  stats(): Record<string, unknown> {
451
+ // Queue span — the gap between the oldest and newest queued frame's
452
+ // PTS, in ms. If this collapses while audio keeps advancing, the
453
+ // producer has stalled. If it stays wide with stale head, the
454
+ // producer is bursting faster than realtime but the renderer can't
455
+ // catch up.
456
+ let queueSpanMs = 0;
457
+ let queueHeadMs = 0;
458
+ let queueTailMs = 0;
459
+ if (this.queue.length > 0) {
460
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1000);
461
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1000);
462
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
463
+ }
429
464
  return {
430
465
  framesPainted: this.framesPainted,
431
466
  framesDroppedLate: this.framesDroppedLate,
432
467
  framesDroppedOverflow: this.framesDroppedOverflow,
433
468
  queueDepth: this.queue.length,
469
+ queueHeadMs,
470
+ queueTailMs,
471
+ queueSpanMs,
434
472
  };
435
473
  }
436
474