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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "avbridge",
3
- "version": "2.8.3",
3
+ "version": "2.8.4",
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",
@@ -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 = performance.now();
427
+ this.lastProgressTime = now;
428
+ this.lastVideoFrameCount = readDecodedFrameCount(t);
429
+ this.lastVideoFrameProgressTime = now;
360
430
  return;
361
431
  }
362
- if (t.currentTime !== this.lastProgressPosition) {
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 = performance.now();
365
- return;
447
+ this.lastProgressTime = now;
366
448
  }
367
- if (performance.now() - this.lastProgressTime > 5000) {
368
- void this.escalate(
369
- `${strategy} strategy stalled for 5s at ${t.currentTime.toFixed(1)}s`,
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