avbridge 2.11.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 (84) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/{avi-B5CQYB7L.cjs → avi-32UABODO.cjs} +14 -6
  3. package/dist/avi-32UABODO.cjs.map +1 -0
  4. package/dist/{avi-2ILLBNPQ.cjs → avi-5BPR6QUX.cjs} +14 -6
  5. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  6. package/dist/{avi-RWWPN2PR.js → avi-BLIH7KKV.js} +13 -5
  7. package/dist/avi-BLIH7KKV.js.map +1 -0
  8. package/dist/{avi-JXU4GQL2.js → avi-GX2H34IQ.js} +13 -5
  9. package/dist/avi-GX2H34IQ.js.map +1 -0
  10. package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
  11. package/dist/chunk-3AI5WFFN.js.map +1 -0
  12. package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
  13. package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
  14. package/dist/{chunk-GYIJU44C.js → chunk-5CX7BVVV.js} +5 -5
  15. package/dist/{chunk-GYIJU44C.js.map → chunk-5CX7BVVV.js.map} +1 -1
  16. package/dist/{chunk-CL6UEUQF.js → chunk-B76QWPFM.js} +5 -5
  17. package/dist/{chunk-CL6UEUQF.js.map → chunk-B76QWPFM.js.map} +1 -1
  18. package/dist/{chunk-IHNHHEA2.js → chunk-BN7BRTLY.js} +143 -32
  19. package/dist/chunk-BN7BRTLY.js.map +1 -0
  20. package/dist/{chunk-OTFS7DC4.cjs → chunk-E5MAM2P4.cjs} +14 -14
  21. package/dist/{chunk-OTFS7DC4.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  22. package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
  23. package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
  24. package/dist/{chunk-37UOSAVI.cjs → chunk-UM6WCSGL.cjs} +157 -46
  25. package/dist/chunk-UM6WCSGL.cjs.map +1 -0
  26. package/dist/{chunk-BYGZN4Z5.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  27. package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  28. package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
  29. package/dist/chunk-YPZFGJV3.cjs.map +1 -0
  30. package/dist/element-browser.js +186 -43
  31. package/dist/element-browser.js.map +1 -1
  32. package/dist/element.cjs +5 -5
  33. package/dist/element.d.cts +1 -1
  34. package/dist/element.d.ts +1 -1
  35. package/dist/element.js +4 -4
  36. package/dist/index.cjs +21 -21
  37. package/dist/index.d.cts +2 -2
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.js +9 -9
  40. package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
  41. package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
  42. package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
  43. package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
  44. package/dist/libav-http-reader-2S5HAHW4.js +3 -0
  45. package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
  46. package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
  47. package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
  48. package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
  49. package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
  50. package/dist/player.cjs +264 -53
  51. package/dist/player.cjs.map +1 -1
  52. package/dist/player.d.cts +22 -0
  53. package/dist/player.d.ts +22 -0
  54. package/dist/player.js +264 -53
  55. package/dist/player.js.map +1 -1
  56. package/dist/remux-NSBJFMLG.cjs +35 -0
  57. package/dist/{remux-KUS5GIL6.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  58. package/dist/remux-PHUHO3VV.js +10 -0
  59. package/dist/{remux-56V7LDAD.js.map → remux-PHUHO3VV.js.map} +1 -1
  60. package/package.json +1 -1
  61. package/src/element/avbridge-player.ts +123 -23
  62. package/src/element/player-styles.ts +13 -1
  63. package/src/player.ts +3 -3
  64. package/src/probe/avi.ts +34 -2
  65. package/src/strategies/fallback/decoder.ts +148 -19
  66. package/src/strategies/fallback/video-renderer.ts +41 -3
  67. package/src/strategies/hybrid/decoder.ts +34 -9
  68. package/src/types.ts +15 -0
  69. package/src/util/libav-http-reader.ts +58 -19
  70. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  71. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  72. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  73. package/dist/avi-2ILLBNPQ.cjs.map +0 -1
  74. package/dist/avi-B5CQYB7L.cjs.map +0 -1
  75. package/dist/avi-JXU4GQL2.js.map +0 -1
  76. package/dist/avi-RWWPN2PR.js.map +0 -1
  77. package/dist/chunk-37UOSAVI.cjs.map +0 -1
  78. package/dist/chunk-DCSOQH2N.js.map +0 -1
  79. package/dist/chunk-IHNHHEA2.js.map +0 -1
  80. package/dist/chunk-Z33SBWL5.cjs.map +0 -1
  81. package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
  82. package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
  83. package/dist/remux-56V7LDAD.js +0 -10
  84. package/dist/remux-KUS5GIL6.cjs +0 -35
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ var chunkE5MAM2P4_cjs = require('./chunk-E5MAM2P4.cjs');
4
+ require('./chunk-VLI3Y6IJ.cjs');
5
+ require('./chunk-2IJ66NTD.cjs');
6
+ require('./chunk-QDJLQR53.cjs');
7
+ require('./chunk-HZUVMXBN.cjs');
8
+ require('./chunk-YPZFGJV3.cjs');
9
+ require('./chunk-G4APZMCP.cjs');
10
+ require('./chunk-F3LQJKXK.cjs');
11
+
12
+
13
+
14
+ Object.defineProperty(exports, "createOutputFormat", {
15
+ enumerable: true,
16
+ get: function () { return chunkE5MAM2P4_cjs.createOutputFormat; }
17
+ });
18
+ Object.defineProperty(exports, "generateFilename", {
19
+ enumerable: true,
20
+ get: function () { return chunkE5MAM2P4_cjs.generateFilename; }
21
+ });
22
+ Object.defineProperty(exports, "mimeForFormat", {
23
+ enumerable: true,
24
+ get: function () { return chunkE5MAM2P4_cjs.mimeForFormat; }
25
+ });
26
+ Object.defineProperty(exports, "remux", {
27
+ enumerable: true,
28
+ get: function () { return chunkE5MAM2P4_cjs.remux; }
29
+ });
30
+ Object.defineProperty(exports, "validateRemuxEligibility", {
31
+ enumerable: true,
32
+ get: function () { return chunkE5MAM2P4_cjs.validateRemuxEligibility; }
33
+ });
34
+ //# sourceMappingURL=remux-NSBJFMLG.cjs.map
35
+ //# sourceMappingURL=remux-NSBJFMLG.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-KUS5GIL6.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-NSBJFMLG.cjs"}
@@ -0,0 +1,10 @@
1
+ export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-B76QWPFM.js';
2
+ import './chunk-5CX7BVVV.js';
3
+ import './chunk-CPJLFFCC.js';
4
+ import './chunk-LUFA47FP.js';
5
+ import './chunk-3YKWU4FM.js';
6
+ import './chunk-3AI5WFFN.js';
7
+ import './chunk-5DMTJVIU.js';
8
+ import './chunk-5YAWWKA3.js';
9
+ //# sourceMappingURL=remux-PHUHO3VV.js.map
10
+ //# sourceMappingURL=remux-PHUHO3VV.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"remux-56V7LDAD.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.11.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",
@@ -247,6 +247,11 @@ export class AvbridgePlayerElement extends HTMLElement {
247
247
  on(this._video, "ended", () => this._setState("ended"));
248
248
  on(this._video, "error", () => this._setState("error"));
249
249
  on(this._video, "timeupdate", () => this._updateTime());
250
+ // `progress` fires as the inner element's buffered ranges grow — keep the
251
+ // seek bar's buffered indicator fresh even when paused or filling ahead
252
+ // without timeupdate advancing. `<avbridge-video>` dispatches this on
253
+ // all strategies (including the synthesized ranges for canvas strategies).
254
+ on(this._video, "progress", () => this._updateBuffered());
250
255
  on(this._video, "volumechange", () => this._updateVolume());
251
256
  // Strategy changes are visible in Stats for Nerds.
252
257
  on(this._video, "trackschange", () => this._buildSettingsMenu());
@@ -524,15 +529,38 @@ export class AvbridgePlayerElement extends HTMLElement {
524
529
  this._seekInput.value = String(t);
525
530
  this._updateSeekVisuals(t);
526
531
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
532
+ this._updateBuffered();
533
+ }
527
534
 
528
- // Buffered ranges
529
- try {
530
- const buf = this._video.buffered;
531
- if (buf && buf.length > 0 && d > 0) {
532
- const end = buf.end(buf.length - 1);
533
- this._seekBuffered.style.width = `${(end / d) * 100}%`;
534
- }
535
- } catch { /* ignore */ }
535
+ /**
536
+ * Render every buffered range as its own segment so gaps (common on MSE
537
+ * after seeks) are visible. Not gated by `_userSeeking` — ranges should
538
+ * keep updating while the user scrubs, and runs cheaply on `progress`.
539
+ */
540
+ private _updateBuffered(): void {
541
+ const d = this._video.duration;
542
+ if (!(d > 0)) return;
543
+ let buf: TimeRanges;
544
+ try { buf = this._video.buffered; } catch { return; }
545
+ const count = buf ? buf.length : 0;
546
+ const host = this._seekBuffered;
547
+ // Reconcile child count. Segment divs are styled via .avp-seek-buffered-range.
548
+ while (host.childElementCount > count) host.lastElementChild!.remove();
549
+ while (host.childElementCount < count) {
550
+ const seg = document.createElement("div");
551
+ seg.className = "avp-seek-buffered-range";
552
+ host.appendChild(seg);
553
+ }
554
+ for (let i = 0; i < count; i++) {
555
+ let start: number; let end: number;
556
+ try { start = buf.start(i); end = buf.end(i); } catch { continue; }
557
+ const s = Math.max(0, start);
558
+ const e = Math.min(d, end);
559
+ if (e <= s) continue;
560
+ const seg = host.children[i] as HTMLElement;
561
+ seg.style.left = `${(s / d) * 100}%`;
562
+ seg.style.width = `${((e - s) / d) * 100}%`;
563
+ }
536
564
  }
537
565
 
538
566
  // ── Controls: volume ───────────────────────────────────────────────────
@@ -724,10 +752,13 @@ export class AvbridgePlayerElement extends HTMLElement {
724
752
 
725
753
  // ── Stats for nerds ────────────────────────────────────────────────────
726
754
 
755
+ private _statsPrev: { ts: number; rt: Record<string, unknown> } | null = null;
756
+
727
757
  private _toggleStats(): void {
728
758
  this._statsOpen = !this._statsOpen;
729
759
  this._statsEl.classList.toggle("open", this._statsOpen);
730
760
  if (this._statsOpen) {
761
+ this._statsPrev = null; // reset delta baseline
731
762
  this._updateStats();
732
763
  this._statsInterval = setInterval(() => this._updateStats(), 1000);
733
764
  } else {
@@ -739,23 +770,92 @@ export class AvbridgePlayerElement extends HTMLElement {
739
770
  const d = this._video.getDiagnostics() as Record<string, unknown> | null;
740
771
  if (!d) { this._statsEl.textContent = "No diagnostics"; return; }
741
772
  const rt = (d.runtime ?? {}) as Record<string, unknown>;
742
- const lines: string[] = [
743
- `Container: ${d.container ?? "?"}`,
744
- `Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}×${d.height ?? "?"}`,
745
- `Audio: ${d.audioCodec ?? "none"}`,
746
- `Strategy: ${d.strategy ?? "?"} Class: ${d.strategyClass ?? "?"}`,
747
- `Transport: ${d.transport ?? "?"} Range: ${d.rangeSupported ?? "?"}`,
748
- `Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"}`,
749
- ];
750
- if (rt.framesDecoded != null) lines.push(`Frames: ${rt.framesDecoded} decoded, ${rt.framesDropped ?? 0} dropped`);
751
- if (rt.framesPainted != null) lines.push(`Painted: ${rt.framesPainted} Late: ${rt.framesDroppedLate ?? 0} Overflow: ${rt.framesDroppedOverflow ?? 0}`);
752
- if (rt.videoFramesDecoded != null) lines.push(`Video decoded: ${rt.videoFramesDecoded} Chunks fed: ${rt.videoChunksFed ?? "?"}`);
753
- if (rt.audioFramesDecoded != null) lines.push(`Audio decoded: ${rt.audioFramesDecoded}`);
754
- if (rt.packetsRead != null) lines.push(`Packets read: ${rt.packetsRead}`);
755
- if (rt.bsfApplied && (rt.bsfApplied as string[]).length > 0) lines.push(`BSF: ${(rt.bsfApplied as string[]).join(", ")}`);
756
- 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
+
757
855
  if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
856
+
758
857
  this._statsEl.textContent = lines.join("\n");
858
+ this._statsPrev = { ts: now, rt: { ...rt } };
759
859
  }
760
860
 
761
861
  // ── Controls: fullscreen ───────────────────────────────────────────────
@@ -286,6 +286,12 @@ export const PLAYER_STYLES = /* css */ `
286
286
  display: flex;
287
287
  align-items: center;
288
288
  cursor: pointer;
289
+ /* Claim all touch gestures on the seek bar. Without this, Android
290
+ * browsers (Chrome, Samsung Internet) treat horizontal drags as
291
+ * scroll candidates and cancel pointermove once the gesture
292
+ * resolves, breaking scrub. touch-action must be set in CSS —
293
+ * preventDefault() on pointerdown is too late. */
294
+ touch-action: none;
289
295
  }
290
296
 
291
297
  .avp-seek-track {
@@ -303,7 +309,13 @@ export const PLAYER_STYLES = /* css */ `
303
309
 
304
310
  .avp-seek-buffered {
305
311
  position: absolute;
306
- left: 0;
312
+ inset: 0;
313
+ pointer-events: none;
314
+ }
315
+
316
+ .avp-seek-buffered-range {
317
+ position: absolute;
318
+ top: 0;
307
319
  height: 100%;
308
320
  background: rgba(255, 255, 255, 0.35);
309
321
  border-radius: inherit;
package/src/player.ts CHANGED
@@ -132,9 +132,9 @@ export class UnifiedPlayer {
132
132
  private readonly options: CreatePlayerOptions,
133
133
  private readonly registry: PluginRegistry,
134
134
  ) {
135
- const { requestInit, fetchFn } = options;
136
- if (requestInit || fetchFn) {
137
- this.transport = { requestInit, fetchFn };
135
+ const { requestInit, fetchFn, cacheBytes } = options;
136
+ if (requestInit || fetchFn || cacheBytes !== undefined) {
137
+ this.transport = { requestInit, fetchFn, cacheBytes };
138
138
  }
139
139
  }
140
140
 
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