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.
- package/CHANGELOG.md +177 -0
- package/README.md +33 -0
- package/dist/{avi-EQE6AR75.cjs → avi-32UABODO.cjs} +12 -4
- package/dist/avi-32UABODO.cjs.map +1 -0
- package/dist/{avi-Y3N325WZ.cjs → avi-5BPR6QUX.cjs} +12 -4
- package/dist/avi-5BPR6QUX.cjs.map +1 -0
- package/dist/{avi-NNHH4AAA.js → avi-BLIH7KKV.js} +12 -4
- package/dist/avi-BLIH7KKV.js.map +1 -0
- package/dist/{avi-S7EY54YA.js → avi-GX2H34IQ.js} +12 -4
- package/dist/avi-GX2H34IQ.js.map +1 -0
- package/dist/{chunk-2LNXMGT6.js → chunk-5CX7BVVV.js} +5 -5
- package/dist/{chunk-2LNXMGT6.js.map → chunk-5CX7BVVV.js.map} +1 -1
- package/dist/{chunk-5Y5BTB5D.js → chunk-B76QWPFM.js} +3 -3
- package/dist/{chunk-5Y5BTB5D.js.map → chunk-B76QWPFM.js.map} +1 -1
- package/dist/{chunk-GJBNLPGI.cjs → chunk-E5MAM2P4.cjs} +9 -9
- package/dist/{chunk-GJBNLPGI.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
- package/dist/{chunk-7EF4VTUS.cjs → chunk-OFJYEITB.cjs} +489 -113
- package/dist/chunk-OFJYEITB.cjs.map +1 -0
- package/dist/{chunk-HBHSUGNI.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
- package/dist/{chunk-HBHSUGNI.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
- package/dist/{chunk-Z26PXRUY.js → chunk-VOC24LYF.js} +486 -110
- package/dist/chunk-VOC24LYF.js.map +1 -0
- package/dist/element-browser.js +492 -130
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +3 -3
- package/dist/element.js +2 -2
- package/dist/index.cjs +18 -18
- package/dist/index.js +6 -6
- package/dist/player.cjs +658 -170
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +36 -4
- package/dist/player.d.ts +36 -4
- package/dist/player.js +658 -170
- package/dist/player.js.map +1 -1
- package/dist/{remux-VPKCLHHM.cjs → remux-NSBJFMLG.cjs} +9 -9
- package/dist/{remux-VPKCLHHM.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
- package/dist/{remux-7TA4FKTY.js → remux-PHUHO3VV.js} +4 -4
- package/dist/{remux-7TA4FKTY.js.map → remux-PHUHO3VV.js.map} +1 -1
- package/package.json +1 -1
- package/src/element/avbridge-player.ts +223 -43
- package/src/probe/avi.ts +34 -2
- package/src/strategies/fallback/audio-output.ts +164 -35
- package/src/strategies/fallback/decoder.ts +467 -60
- package/src/strategies/fallback/video-renderer.ts +209 -29
- package/src/strategies/hybrid/decoder.ts +56 -28
- package/src/strategies/remux/pipeline.ts +12 -3
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
- package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
- package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
- package/dist/avi-EQE6AR75.cjs.map +0 -1
- package/dist/avi-NNHH4AAA.js.map +0 -1
- package/dist/avi-S7EY54YA.js.map +0 -1
- package/dist/avi-Y3N325WZ.cjs.map +0 -1
- package/dist/chunk-7EF4VTUS.cjs.map +0 -1
- package/dist/chunk-Z26PXRUY.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
require('./chunk-
|
|
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
|
|
16
|
+
get: function () { return chunkE5MAM2P4_cjs.createOutputFormat; }
|
|
17
17
|
});
|
|
18
18
|
Object.defineProperty(exports, "generateFilename", {
|
|
19
19
|
enumerable: true,
|
|
20
|
-
get: function () { return
|
|
20
|
+
get: function () { return chunkE5MAM2P4_cjs.generateFilename; }
|
|
21
21
|
});
|
|
22
22
|
Object.defineProperty(exports, "mimeForFormat", {
|
|
23
23
|
enumerable: true,
|
|
24
|
-
get: function () { return
|
|
24
|
+
get: function () { return chunkE5MAM2P4_cjs.mimeForFormat; }
|
|
25
25
|
});
|
|
26
26
|
Object.defineProperty(exports, "remux", {
|
|
27
27
|
enumerable: true,
|
|
28
|
-
get: function () { return
|
|
28
|
+
get: function () { return chunkE5MAM2P4_cjs.remux; }
|
|
29
29
|
});
|
|
30
30
|
Object.defineProperty(exports, "validateRemuxEligibility", {
|
|
31
31
|
enumerable: true,
|
|
32
|
-
get: function () { return
|
|
32
|
+
get: function () { return chunkE5MAM2P4_cjs.validateRemuxEligibility; }
|
|
33
33
|
});
|
|
34
|
-
//# sourceMappingURL=remux-
|
|
35
|
-
//# sourceMappingURL=remux-
|
|
34
|
+
//# sourceMappingURL=remux-NSBJFMLG.cjs.map
|
|
35
|
+
//# sourceMappingURL=remux-NSBJFMLG.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-NSBJFMLG.cjs"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { createOutputFormat, generateFilename, mimeForFormat, remux, validateRemuxEligibility } from './chunk-
|
|
2
|
-
import './chunk-
|
|
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-
|
|
10
|
-
//# sourceMappingURL=remux-
|
|
9
|
+
//# sourceMappingURL=remux-PHUHO3VV.js.map
|
|
10
|
+
//# sourceMappingURL=remux-PHUHO3VV.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"remux-PHUHO3VV.js"}
|
package/package.json
CHANGED
|
@@ -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", () =>
|
|
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
|
-
|
|
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
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
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
|
}
|