avbridge 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +33 -0
  3. package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
  4. package/dist/avi-32UABODO.cjs.map +1 -0
  5. package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
  6. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  7. package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
  8. package/dist/avi-BLIH7KKV.js.map +1 -0
  9. package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
  10. package/dist/avi-GX2H34IQ.js.map +1 -0
  11. package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
  12. package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
  13. package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
  14. package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
  15. package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
  16. package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  17. package/dist/{chunk-7EF4VTUS.cjs → chunk-OFJYEITB.cjs} +489 -113
  18. package/dist/chunk-OFJYEITB.cjs.map +1 -0
  19. package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  20. package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  21. package/dist/{chunk-Z26PXRUY.js → chunk-VOC24LYF.js} +486 -110
  22. package/dist/chunk-VOC24LYF.js.map +1 -0
  23. package/dist/element-browser.js +492 -130
  24. package/dist/element-browser.js.map +1 -1
  25. package/dist/element.cjs +3 -3
  26. package/dist/element.js +2 -2
  27. package/dist/index.cjs +18 -18
  28. package/dist/index.js +6 -6
  29. package/dist/player.cjs +658 -170
  30. package/dist/player.cjs.map +1 -1
  31. package/dist/player.d.cts +36 -4
  32. package/dist/player.d.ts +36 -4
  33. package/dist/player.js +658 -170
  34. package/dist/player.js.map +1 -1
  35. package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
  36. package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  37. package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
  38. package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
  39. package/package.json +1 -1
  40. package/src/element/avbridge-player.ts +223 -43
  41. package/src/probe/avi.ts +34 -2
  42. package/src/strategies/fallback/audio-output.ts +164 -35
  43. package/src/strategies/fallback/decoder.ts +467 -60
  44. package/src/strategies/fallback/video-renderer.ts +209 -29
  45. package/src/strategies/hybrid/decoder.ts +56 -28
  46. package/src/strategies/remux/pipeline.ts +12 -3
  47. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  48. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  49. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  50. package/dist/avi-EQE6AR75.cjs.map +0 -1
  51. package/dist/avi-NNHH4AAA.js.map +0 -1
  52. package/dist/avi-S7EY54YA.js.map +0 -1
  53. package/dist/avi-Y3N325WZ.cjs.map +0 -1
  54. package/dist/chunk-7EF4VTUS.cjs.map +0 -1
  55. package/dist/chunk-Z26PXRUY.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  '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.13.0",
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",
@@ -72,6 +72,44 @@ type FitMode = (typeof FIT_MODES)[number];
72
72
  export class AvbridgePlayerElement extends HTMLElement {
73
73
  static readonly observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES];
74
74
 
75
+ /**
76
+ * Returns `true` if a DOM event originated from one of the player's
77
+ * **interactive chrome elements** (seek bar, control buttons, settings
78
+ * menu, overlay play button) rather than the bare video surface.
79
+ *
80
+ * This is the escape hatch for host pages that wrap the player in a
81
+ * gesture recognizer (e.g. TikTok-style vertical-swipe pager). For
82
+ * bubble-phase listeners the player's own handlers already call
83
+ * `stopPropagation()` on chrome interactions — but **capture-phase**
84
+ * listeners run *before* the player's handlers, so they need to check
85
+ * the event's path themselves and bail. This helper does that check
86
+ * via `composedPath()`, which traverses shadow boundaries correctly.
87
+ *
88
+ * Returns `false` for events on the bare video surface — host pages
89
+ * remain free to claim those for their own gestures (e.g. swipe-to-pan
90
+ * to the next video). Returns `false` for events that never hit a
91
+ * player at all.
92
+ *
93
+ * @example
94
+ * // TikTok-style vertical swipe on the document, capture phase:
95
+ * document.addEventListener("pointerdown", (e) => {
96
+ * if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return;
97
+ * startSwipeGesture(e);
98
+ * }, { capture: true });
99
+ */
100
+ static isPlayerChromeEvent(event: Event): boolean {
101
+ // Mirrors the selector used by the player's internal tap-target
102
+ // gate (see `_onPlayerSurfaceClick` and friends): anything inside
103
+ // these regions is "chrome", everything else is the bare video.
104
+ const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn";
105
+ for (const node of event.composedPath()) {
106
+ if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
75
113
  // ── Internal DOM refs ──────────────────────────────────────────────────
76
114
 
77
115
  private _video!: AvbridgeVideoElement;
@@ -104,6 +142,11 @@ export class AvbridgePlayerElement extends HTMLElement {
104
142
  private _activeAudioTrackId: number | null = null;
105
143
  private _activeSubtitleTrackId: number | null = null;
106
144
  private _userSeeking = false;
145
+ /** Last seek target the user committed. The thumb stays here (and
146
+ * `_updateTime` skips updating from `timeupdate`) until the underlying
147
+ * `currentTime` actually catches up — otherwise the thumb visibly snaps
148
+ * back to the pre-seek position while the remux pipeline rebuilds. */
149
+ private _pendingSeekTarget: number | null = null;
107
150
  private _holdTimer: ReturnType<typeof setTimeout> | null = null;
108
151
  private _holdSpeedActive = false;
109
152
  private _savedPlaybackRate = 1;
@@ -233,7 +276,10 @@ export class AvbridgePlayerElement extends HTMLElement {
233
276
  }
234
277
 
235
278
  // State tracking
236
- on(this._video, "loadstart", () => this._setState("loading"));
279
+ on(this._video, "loadstart", () => {
280
+ this._pendingSeekTarget = null;
281
+ this._setState("loading");
282
+ });
237
283
  on(this._video, "ready", () => {
238
284
  this._setState(this._video.paused ? "paused" : "playing");
239
285
  this._seekInput.max = String(this._video.duration || 0);
@@ -430,7 +476,9 @@ export class AvbridgePlayerElement extends HTMLElement {
430
476
  }
431
477
 
432
478
  private _onSeekCommit(): void {
433
- this._video.currentTime = Number(this._seekInput.value);
479
+ const target = Number(this._seekInput.value);
480
+ this._pendingSeekTarget = target;
481
+ this._video.currentTime = target;
434
482
  this._userSeeking = false;
435
483
  }
436
484
 
@@ -442,49 +490,87 @@ export class AvbridgePlayerElement extends HTMLElement {
442
490
  return frac * (this._video.duration || 0);
443
491
  }
444
492
 
445
- /** Seekbar width below which drag-to-scrub seeks in real-time (vs
446
- * preview-only). On narrow bars precise positioning is hard, so
447
- * immediate video feedback is more useful than a time tooltip. */
448
- private static readonly SCRUB_WIDTH_THRESHOLD = 400;
449
-
450
493
  private _onSeekPointerDown(e: PointerEvent): void {
451
494
  // Ignore synthetic clicks originating from the input's own handling
452
495
  if (e.button !== 0 && e.pointerType === "mouse") return;
453
496
  e.preventDefault();
497
+ // Consume the event so host pages can layer the player inside a
498
+ // swipe-driven UI (e.g. TikTok-style vertical pager) without the
499
+ // pointerdown bubbling out and latching their gesture recognizer.
500
+ // The seekbar's CSS sets `touch-action: none` to suppress native
501
+ // browser pan/zoom — this complements that on the JS side, since
502
+ // swipe handlers built on PointerEvents wouldn't honor touch-action.
503
+ e.stopPropagation();
454
504
  this._userSeeking = true;
455
505
  const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement;
456
506
  seekBar.setPointerCapture(e.pointerId);
457
507
  seekBar.setAttribute("data-seeking", "");
458
508
 
459
- // Decide scrub mode based on physical width.
460
- const scrubMode = seekBar.getBoundingClientRect().width < AvbridgePlayerElement.SCRUB_WIDTH_THRESHOLD;
461
- let lastScrubCommit = 0;
509
+ // Two seek modes, picked by `(pointer: coarse)`:
510
+ // - **fine** (mouse / trackpad / stylus): absolute mapping —
511
+ // pointer X maps directly to seek time, thumb jumps under
512
+ // cursor. Standard desktop YouTube behavior.
513
+ // - **coarse** (touch): relative drag — initial tap doesn't move
514
+ // the thumb; finger Δx maps to a Δt added to the time at
515
+ // pointerdown. Standard YouTube-mobile behavior; matters because
516
+ // finger positioning is too imprecise for absolute on a small bar.
517
+ // Both modes commit live during drag (throttled to ~4 Hz so we don't
518
+ // overwhelm the seek pipeline — every commit restarts the decoder
519
+ // pump on canvas strategies) and once more on pointerup.
520
+ const coarse = typeof matchMedia !== "undefined"
521
+ && matchMedia("(pointer: coarse)").matches;
522
+ const startTime = coarse ? (this._video.currentTime || 0) : 0;
523
+ const startClientX = e.clientX;
524
+ let lastCommit = 0;
525
+
526
+ const timeAt = (clientX: number): number => {
527
+ if (coarse) {
528
+ const rect = seekBar.getBoundingClientRect();
529
+ const dx = clientX - startClientX;
530
+ const dt = (dx / rect.width) * (this._video.duration || 0);
531
+ return Math.max(0, Math.min(this._video.duration || 0, startTime + dt));
532
+ }
533
+ return this._timeFromSeekPointer(clientX);
534
+ };
462
535
 
463
- const initial = this._timeFromSeekPointer(e.clientX);
464
- this._seekInput.value = String(initial);
465
- this._onSeekInput();
466
- this._updateSeekTooltip(e.clientX);
467
- if (scrubMode) this._onSeekCommit();
536
+ const showTooltip = (t: number, clientX: number): void => {
537
+ if (coarse) this._updateSeekTooltipAtTime(t);
538
+ else this._updateSeekTooltip(clientX);
539
+ };
540
+
541
+ // Fine mode: tap commits immediately (thumb jumps under pointer).
542
+ // Coarse mode: tap parks at the current time; only drag moves it.
543
+ if (!coarse) {
544
+ const initial = timeAt(e.clientX);
545
+ this._seekInput.value = String(initial);
546
+ this._onSeekInput();
547
+ showTooltip(initial, e.clientX);
548
+ this._onSeekCommit();
549
+ this._userSeeking = true; // commit clears it; we're still seeking
550
+ } else {
551
+ showTooltip(startTime, e.clientX);
552
+ }
468
553
 
469
554
  const onMove = (ev: PointerEvent) => {
470
- const t = this._timeFromSeekPointer(ev.clientX);
555
+ // Belt-and-suspenders for the host's swipe handler. Pointer capture
556
+ // changes the *target* of subsequent pointermove events to seekBar,
557
+ // but they still bubble through ancestors — a swipe listener
558
+ // attached to document/window would otherwise see every drag tick.
559
+ ev.stopPropagation();
560
+ const t = timeAt(ev.clientX);
471
561
  this._seekInput.value = String(t);
472
562
  this._onSeekInput();
473
- this._updateSeekTooltip(ev.clientX);
474
- // In scrub mode, commit seeks throttled to ~4 Hz so we don't
475
- // overwhelm the seek pipeline (especially on canvas strategies
476
- // where each seek restarts the decoder pump).
477
- if (scrubMode) {
478
- const now = performance.now();
479
- if (now - lastScrubCommit > 250) {
480
- lastScrubCommit = now;
481
- this._onSeekCommit();
482
- this._userSeeking = true; // keep seeking flag live
483
- }
563
+ showTooltip(t, ev.clientX);
564
+ const now = performance.now();
565
+ if (now - lastCommit > 250) {
566
+ lastCommit = now;
567
+ this._onSeekCommit();
568
+ this._userSeeking = true;
484
569
  }
485
570
  };
486
571
  const onUp = (ev: PointerEvent) => {
487
- const t = this._timeFromSeekPointer(ev.clientX);
572
+ ev.stopPropagation();
573
+ const t = timeAt(ev.clientX);
488
574
  this._seekInput.value = String(t);
489
575
  this._onSeekCommit();
490
576
  this._seekInput.focus();
@@ -511,6 +597,16 @@ export class AvbridgePlayerElement extends HTMLElement {
511
597
  this._seekTooltip.style.left = `${frac * 100}%`;
512
598
  }
513
599
 
600
+ /** Position the tooltip over a specific time (vs. pointer X). Used by
601
+ * relative-drag scrub on coarse pointers, where the displayed time
602
+ * is decoupled from the finger position. */
603
+ private _updateSeekTooltipAtTime(t: number): void {
604
+ const dur = this._video.duration || 0;
605
+ const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0;
606
+ this._seekTooltip.textContent = formatTime(t);
607
+ this._seekTooltip.style.left = `${frac * 100}%`;
608
+ }
609
+
514
610
  private _updateSeekVisuals(t: number): void {
515
611
  const dur = this._video.duration || 0;
516
612
  const pct = dur > 0 ? (t / dur) * 100 : 0;
@@ -526,6 +622,18 @@ export class AvbridgePlayerElement extends HTMLElement {
526
622
  if (this._userSeeking) return;
527
623
  const t = this._video.currentTime;
528
624
  const d = this._video.duration;
625
+ // While a committed seek is still settling, keep the thumb at the
626
+ // target so it doesn't snap back to the pre-seek position. Clear once
627
+ // currentTime has landed within 0.5s of the target.
628
+ if (this._pendingSeekTarget !== null) {
629
+ if (Math.abs(t - this._pendingSeekTarget) < 0.5) {
630
+ this._pendingSeekTarget = null;
631
+ } else {
632
+ this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`;
633
+ this._updateBuffered();
634
+ return;
635
+ }
636
+ }
529
637
  this._seekInput.value = String(t);
530
638
  this._updateSeekVisuals(t);
531
639
  this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`;
@@ -752,10 +860,13 @@ export class AvbridgePlayerElement extends HTMLElement {
752
860
 
753
861
  // ── Stats for nerds ────────────────────────────────────────────────────
754
862
 
863
+ private _statsPrev: { ts: number; rt: Record<string, unknown> } | null = null;
864
+
755
865
  private _toggleStats(): void {
756
866
  this._statsOpen = !this._statsOpen;
757
867
  this._statsEl.classList.toggle("open", this._statsOpen);
758
868
  if (this._statsOpen) {
869
+ this._statsPrev = null; // reset delta baseline
759
870
  this._updateStats();
760
871
  this._statsInterval = setInterval(() => this._updateStats(), 1000);
761
872
  } else {
@@ -767,23 +878,92 @@ export class AvbridgePlayerElement extends HTMLElement {
767
878
  const d = this._video.getDiagnostics() as Record<string, unknown> | null;
768
879
  if (!d) { this._statsEl.textContent = "No diagnostics"; return; }
769
880
  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 ?? "?"}`);
881
+ const now = performance.now();
882
+ const prev = this._statsPrev;
883
+ const dtSec = prev ? Math.max(0.001, (now - prev.ts) / 1000) : 0;
884
+ const delta = (key: string): number | null => {
885
+ if (!prev) return null;
886
+ const a = rt[key];
887
+ const b = prev.rt[key];
888
+ if (typeof a === "number" && typeof b === "number") return a - b;
889
+ return null;
890
+ };
891
+ const rate = (key: string): number | null => {
892
+ const d_ = delta(key);
893
+ return d_ != null ? d_ / dtSec : null;
894
+ };
895
+ const fmt = (n: number | null, digits = 1) => (n == null ? "?" : n.toFixed(digits));
896
+
897
+ const sourceFps = (typeof rt.sourceFps === "number" ? rt.sourceFps : d.fps) as number | undefined;
898
+ const lines: string[] = [];
899
+
900
+ // ── Identity ──────────────────────────────────────────────────────
901
+ lines.push(`Container: ${d.container ?? "?"} Strategy: ${d.strategy ?? "?"} (${d.strategyClass ?? "?"})`);
902
+ lines.push(`Video: ${d.videoCodec ?? "?"} ${d.width ?? "?"}×${d.height ?? "?"}${sourceFps ? ` @ ${sourceFps.toFixed(3)} fps` : ""}`);
903
+ lines.push(`Audio: ${d.audioCodec ?? "none"} Transport: ${d.transport ?? "?"}${d.rangeSupported === true ? "/range" : ""}`);
904
+ lines.push(`Duration: ${typeof d.duration === "number" ? d.duration.toFixed(1) + "s" : "?"} Playback rate: ${this._video.playbackRate.toFixed(2)}x`);
905
+
906
+ // ── Realtime rates (deltas per second) ─────────────────────────────
907
+ if (rt.videoFramesDecoded != null) {
908
+ const decFps = rate("videoFramesDecoded");
909
+ const paintFps = rate("framesPainted");
910
+ const dropLateFps = rate("framesDroppedLate");
911
+ const dropOverflowFps = rate("framesDroppedOverflow");
912
+ const pct = sourceFps && decFps != null ? ` (${((decFps / sourceFps) * 100).toFixed(0)}% of realtime)` : "";
913
+ lines.push(`Decode fps: ${fmt(decFps)}${pct} Paint fps: ${fmt(paintFps)}`);
914
+ lines.push(`Drops/sec: late=${fmt(dropLateFps)} overflow=${fmt(dropOverflowFps)} Totals: painted=${rt.framesPainted} dropped=${rt.framesDroppedLate}`);
915
+ }
916
+
917
+ // ── Decode-time breakdown (wall ms spent inside libav) ────────────
918
+ if (typeof rt.videoDecodeMsTotal === "number") {
919
+ const msDelta = delta("videoDecodeMsTotal");
920
+ const batchesDelta = delta("videoDecodeBatches");
921
+ const perBatch = msDelta != null && batchesDelta && batchesDelta > 0 ? msDelta / batchesDelta : null;
922
+ const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
923
+ lines.push(
924
+ `Video decode: ${fmt(perBatch)}ms/batch avg ${fmt(share)}% of wall slowest=${fmt(rt.slowestVideoBatchMs as number)}ms`,
925
+ );
926
+ }
927
+ if (typeof rt.audioDecodeMsTotal === "number") {
928
+ const msDelta = delta("audioDecodeMsTotal");
929
+ const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
930
+ lines.push(`Audio decode: ${fmt(share)}% of wall`);
931
+ }
932
+ if (typeof rt.pumpThrottleMsTotal === "number") {
933
+ const msDelta = delta("pumpThrottleMsTotal");
934
+ const share = msDelta != null && dtSec > 0 ? (msDelta / (dtSec * 1000)) * 100 : null;
935
+ lines.push(`Producer throttled: ${fmt(share)}% of wall`);
936
+ }
937
+
938
+ // ── Queue health ──────────────────────────────────────────────────
939
+ if (rt.queueDepth != null) {
940
+ lines.push(
941
+ `Queue: depth=${rt.queueDepth} span=${fmt(rt.queueSpanMs as number)}ms ` +
942
+ `head=${fmt(rt.queueHeadMs as number)}ms tail=${fmt(rt.queueTailMs as number)}ms`,
943
+ );
944
+ }
945
+ if (typeof rt.newestVideoPtsMs === "number") {
946
+ lines.push(`Newest decoded PTS: ${(rt.newestVideoPtsMs / 1000).toFixed(2)}s`);
947
+ }
948
+
949
+ // ── Audio ─────────────────────────────────────────────────────────
950
+ if (rt.audioState != null) {
951
+ lines.push(`Audio state: ${rt.audioState} bufferAhead=${fmt(rt.bufferAhead as number, 2)}s clock=${rt.clockMode ?? "?"}`);
952
+ }
953
+
954
+ // ── BSF + warnings ────────────────────────────────────────────────
955
+ if (typeof rt.ptsRegressions === "number" && rt.ptsRegressions > 0) {
956
+ lines.push(`PTS REGRESSIONS: ${rt.ptsRegressions} (worst=${fmt(rt.worstPtsRegressionMs as number)}ms) — decoder emitting out of order`);
957
+ }
958
+ if (rt.bsfApplied && (rt.bsfApplied as string[]).length > 0) lines.push(`BSF active: ${(rt.bsfApplied as string[]).join(", ")}`);
959
+ if (rt.bsfMissing && (rt.bsfMissing as string[]).length > 0) {
960
+ lines.push(`BSF MISSING: ${(rt.bsfMissing as string[]).join(", ")} (rebuild libav with avbsf)`);
961
+ }
962
+
785
963
  if (d.probedBy) lines.push(`Probed by: ${d.probedBy}`);
964
+
786
965
  this._statsEl.textContent = lines.join("\n");
966
+ this._statsPrev = { ts: now, rt: { ...rt } };
787
967
  }
788
968
 
789
969
  // ── 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
  }