avbridge 2.8.3 → 2.8.4
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 +32 -0
- package/dist/{chunk-JSQOBUQB.js → chunk-KBWQRGHS.js} +62 -11
- package/dist/chunk-KBWQRGHS.js.map +1 -0
- package/dist/{chunk-IUSFLVLJ.cjs → chunk-YX4AGLNF.cjs} +62 -11
- package/dist/chunk-YX4AGLNF.cjs.map +1 -0
- package/dist/element-browser.js +60 -9
- package/dist/element-browser.js.map +1 -1
- package/dist/element.cjs +2 -2
- package/dist/element.d.cts +1 -1
- package/dist/element.d.ts +1 -1
- package/dist/element.js +1 -1
- package/dist/index.cjs +8 -8
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{player-DXEKOky8.d.cts → player-BptSJPfn.d.cts} +7 -0
- package/dist/{player-DXEKOky8.d.ts → player-BptSJPfn.d.ts} +7 -0
- package/dist/player.cjs +79 -13
- package/dist/player.cjs.map +1 -1
- package/dist/player.d.cts +20 -0
- package/dist/player.d.ts +20 -0
- package/dist/player.js +79 -13
- package/dist/player.js.map +1 -1
- package/package.json +1 -1
- package/src/classify/rules.ts +9 -0
- package/src/player.ts +96 -8
- package/dist/chunk-IUSFLVLJ.cjs.map +0 -1
- package/dist/chunk-JSQOBUQB.js.map +0 -1
package/package.json
CHANGED
package/src/classify/rules.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
Classification,
|
|
5
5
|
ContainerKind,
|
|
6
6
|
MediaContext,
|
|
7
|
+
StrategyName,
|
|
7
8
|
VideoCodec,
|
|
8
9
|
VideoTrackInfo,
|
|
9
10
|
} from "../types.js";
|
|
@@ -218,10 +219,18 @@ export function classifyContext(ctx: MediaContext): Classification {
|
|
|
218
219
|
reason: `${ctx.container} container with ${video.codec}${audio ? "/" + audio.codec : ""}; MSE rejects the remux target mime and WebCodecs is unavailable — falling back to WASM decode`,
|
|
219
220
|
};
|
|
220
221
|
}
|
|
222
|
+
// Give REMUX_CANDIDATE a fallback chain so the runtime stall / decode
|
|
223
|
+
// supervisors have somewhere to escalate to when MSE lies about codec
|
|
224
|
+
// support (the Firefox HEVC case — audio plays, video never paints).
|
|
225
|
+
// The initial pick is still remux; these only engage on stall.
|
|
226
|
+
const fallbackChain: StrategyName[] = webCodecsAvailable()
|
|
227
|
+
? ["hybrid", "fallback"]
|
|
228
|
+
: ["fallback"];
|
|
221
229
|
return {
|
|
222
230
|
class: "REMUX_CANDIDATE",
|
|
223
231
|
strategy: "remux",
|
|
224
232
|
reason: `${ctx.container} container with native-supported codecs — remux to fragmented MP4 for reliable playback`,
|
|
233
|
+
fallbackChain,
|
|
225
234
|
};
|
|
226
235
|
}
|
|
227
236
|
|
package/src/player.ts
CHANGED
|
@@ -20,6 +20,62 @@ import type {
|
|
|
20
20
|
} from "./types.js";
|
|
21
21
|
import { AvbridgeError, ERR_PLAYER_NOT_READY, ERR_ALL_STRATEGIES_EXHAUSTED } from "./errors.js";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Decoded-video-frame counter reader. Prefers the standard
|
|
25
|
+
* `getVideoPlaybackQuality().totalVideoFrames` (all evergreen browsers);
|
|
26
|
+
* falls back to the WebKit-prefixed `webkitDecodedFrameCount` for older
|
|
27
|
+
* Safari. Returns 0 for non-video elements or when nothing exposes the
|
|
28
|
+
* count — the caller treats 0 as "no signal" (constant across samples,
|
|
29
|
+
* which is fine).
|
|
30
|
+
*/
|
|
31
|
+
export function readDecodedFrameCount(target: HTMLMediaElement): number {
|
|
32
|
+
if (typeof HTMLVideoElement === "undefined" || !(target instanceof HTMLVideoElement)) return 0;
|
|
33
|
+
const vq = (target as HTMLVideoElement & { getVideoPlaybackQuality?: () => { totalVideoFrames: number } }).getVideoPlaybackQuality;
|
|
34
|
+
if (typeof vq === "function") {
|
|
35
|
+
try { return vq.call(target).totalVideoFrames; } catch { /* fall through */ }
|
|
36
|
+
}
|
|
37
|
+
const legacy = (target as HTMLVideoElement & { webkitDecodedFrameCount?: number }).webkitDecodedFrameCount;
|
|
38
|
+
return typeof legacy === "number" ? legacy : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pure decision function for the stall supervisor. Takes a snapshot of
|
|
43
|
+
* the observable state and returns whether to escalate. Extracted so it
|
|
44
|
+
* can be unit-tested without spinning up a real player / media element.
|
|
45
|
+
*
|
|
46
|
+
* - `time-stall`: `currentTime` hasn't moved for `timeStallThresholdMs`
|
|
47
|
+
* despite the element being in a state where it should be playing.
|
|
48
|
+
* - `silent-video`: the media has a video track, `currentTime` is
|
|
49
|
+
* advancing (audio is playing), but the decoder has produced no new
|
|
50
|
+
* frames for `frameStallThresholdMs`. Catches Firefox-style "MSE
|
|
51
|
+
* reports codec supported but the decoder can't actually decode it".
|
|
52
|
+
*/
|
|
53
|
+
export function evaluateDecodeHealth(input: {
|
|
54
|
+
hasVideoTrack: boolean;
|
|
55
|
+
timeAdvanced: boolean;
|
|
56
|
+
framesAdvanced: boolean;
|
|
57
|
+
now: number;
|
|
58
|
+
lastProgressTime: number;
|
|
59
|
+
lastFrameProgressTime: number;
|
|
60
|
+
timeStallThresholdMs?: number;
|
|
61
|
+
frameStallThresholdMs?: number;
|
|
62
|
+
}): { escalate: false } | { escalate: true; kind: "time-stall" | "silent-video" } {
|
|
63
|
+
const timeThreshold = input.timeStallThresholdMs ?? 5000;
|
|
64
|
+
const frameThreshold = input.frameStallThresholdMs ?? 3000;
|
|
65
|
+
if (!input.timeAdvanced && input.now - input.lastProgressTime > timeThreshold) {
|
|
66
|
+
return { escalate: true, kind: "time-stall" };
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
input.hasVideoTrack &&
|
|
70
|
+
input.timeAdvanced &&
|
|
71
|
+
!input.framesAdvanced &&
|
|
72
|
+
input.now - input.lastFrameProgressTime > frameThreshold
|
|
73
|
+
) {
|
|
74
|
+
return { escalate: true, kind: "silent-video" };
|
|
75
|
+
}
|
|
76
|
+
return { escalate: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
23
79
|
export class UnifiedPlayer {
|
|
24
80
|
private emitter = new TypedEmitter<PlayerEventMap>();
|
|
25
81
|
private session: PlaybackSession | null = null;
|
|
@@ -34,6 +90,13 @@ export class UnifiedPlayer {
|
|
|
34
90
|
private stallTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
91
|
private lastProgressTime = 0;
|
|
36
92
|
private lastProgressPosition = -1;
|
|
93
|
+
/** Last observed `HTMLVideoElement.getVideoPlaybackQuality().totalVideoFrames`
|
|
94
|
+
* (or `webkitDecodedFrameCount` fallback). Used by the silent-video
|
|
95
|
+
* watchdog — catches cases where `currentTime` advances (audio plays)
|
|
96
|
+
* but the decoder produces no frames, e.g. Firefox claiming `hev1.*`
|
|
97
|
+
* via MSE when the decoder actually can't decode HEVC. */
|
|
98
|
+
private lastVideoFrameCount = 0;
|
|
99
|
+
private lastVideoFrameProgressTime = 0;
|
|
37
100
|
private errorListener: (() => void) | null = null;
|
|
38
101
|
|
|
39
102
|
// Bound so we can removeEventListener in destroy(); without this the
|
|
@@ -351,23 +414,48 @@ export class UnifiedPlayer {
|
|
|
351
414
|
// Monitor currentTime progress
|
|
352
415
|
this.lastProgressPosition = this.options.target.currentTime;
|
|
353
416
|
this.lastProgressTime = performance.now();
|
|
417
|
+
this.lastVideoFrameCount = readDecodedFrameCount(this.options.target);
|
|
418
|
+
this.lastVideoFrameProgressTime = performance.now();
|
|
419
|
+
|
|
420
|
+
const hasVideoTrack = (this.mediaContext?.videoTracks.length ?? 0) > 0;
|
|
354
421
|
|
|
355
422
|
this.stallTimer = setInterval(() => {
|
|
356
423
|
const t = this.options.target;
|
|
424
|
+
const now = performance.now();
|
|
357
425
|
if (t.paused || t.ended || t.readyState < 2) {
|
|
358
426
|
this.lastProgressPosition = t.currentTime;
|
|
359
|
-
this.lastProgressTime =
|
|
427
|
+
this.lastProgressTime = now;
|
|
428
|
+
this.lastVideoFrameCount = readDecodedFrameCount(t);
|
|
429
|
+
this.lastVideoFrameProgressTime = now;
|
|
360
430
|
return;
|
|
361
431
|
}
|
|
362
|
-
|
|
432
|
+
const timeAdvanced = t.currentTime !== this.lastProgressPosition;
|
|
433
|
+
const frames = readDecodedFrameCount(t);
|
|
434
|
+
const framesAdvanced = frames > this.lastVideoFrameCount;
|
|
435
|
+
|
|
436
|
+
const health = evaluateDecodeHealth({
|
|
437
|
+
hasVideoTrack,
|
|
438
|
+
timeAdvanced,
|
|
439
|
+
framesAdvanced,
|
|
440
|
+
now,
|
|
441
|
+
lastProgressTime: this.lastProgressTime,
|
|
442
|
+
lastFrameProgressTime: this.lastVideoFrameProgressTime,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
if (timeAdvanced) {
|
|
363
446
|
this.lastProgressPosition = t.currentTime;
|
|
364
|
-
this.lastProgressTime =
|
|
365
|
-
return;
|
|
447
|
+
this.lastProgressTime = now;
|
|
366
448
|
}
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
449
|
+
if (framesAdvanced) {
|
|
450
|
+
this.lastVideoFrameCount = frames;
|
|
451
|
+
this.lastVideoFrameProgressTime = now;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (health.escalate) {
|
|
455
|
+
const reason = health.kind === "time-stall"
|
|
456
|
+
? `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`
|
|
457
|
+
: `${strategy} strategy: audio is advancing but the video decoder has produced no new frames for 3s — likely a silent codec failure`;
|
|
458
|
+
void this.escalate(reason);
|
|
371
459
|
}
|
|
372
460
|
}, 1000);
|
|
373
461
|
|