avbridge 2.2.0 → 2.3.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 (121) hide show
  1. package/CHANGELOG.md +125 -1
  2. package/NOTICE.md +2 -2
  3. package/README.md +100 -74
  4. package/THIRD_PARTY_LICENSES.md +2 -2
  5. package/dist/avi-2JPBSHGA.js +183 -0
  6. package/dist/avi-2JPBSHGA.js.map +1 -0
  7. package/dist/avi-F6WZJK5T.cjs +185 -0
  8. package/dist/avi-F6WZJK5T.cjs.map +1 -0
  9. package/dist/{avi-GCGM7OJI.js → avi-NJXAXUXK.js} +9 -3
  10. package/dist/avi-NJXAXUXK.js.map +1 -0
  11. package/dist/{avi-6SJLWIWW.cjs → avi-W6L3BTWU.cjs} +10 -4
  12. package/dist/avi-W6L3BTWU.cjs.map +1 -0
  13. package/dist/{chunk-ILKDNBSE.js → chunk-2PGRFCWB.js} +59 -10
  14. package/dist/chunk-2PGRFCWB.js.map +1 -0
  15. package/dist/chunk-5YAWWKA3.js +18 -0
  16. package/dist/chunk-5YAWWKA3.js.map +1 -0
  17. package/dist/chunk-6UUT4BEA.cjs +219 -0
  18. package/dist/chunk-6UUT4BEA.cjs.map +1 -0
  19. package/dist/{chunk-OE66B34H.cjs → chunk-7RGG6ME7.cjs} +562 -94
  20. package/dist/chunk-7RGG6ME7.cjs.map +1 -0
  21. package/dist/{chunk-WD2ZNQA7.js → chunk-DCSOQH2N.js} +7 -4
  22. package/dist/chunk-DCSOQH2N.js.map +1 -0
  23. package/dist/chunk-F3LQJKXK.cjs +20 -0
  24. package/dist/chunk-F3LQJKXK.cjs.map +1 -0
  25. package/dist/chunk-IAYKFGFG.js +200 -0
  26. package/dist/chunk-IAYKFGFG.js.map +1 -0
  27. package/dist/chunk-NNVOHKXJ.cjs +204 -0
  28. package/dist/chunk-NNVOHKXJ.cjs.map +1 -0
  29. package/dist/{chunk-C5VA5U5O.js → chunk-NV7ILLWH.js} +556 -92
  30. package/dist/chunk-NV7ILLWH.js.map +1 -0
  31. package/dist/{chunk-HZLQNKFN.cjs → chunk-QQXBPW72.js} +54 -15
  32. package/dist/chunk-QQXBPW72.js.map +1 -0
  33. package/dist/chunk-XKPSTC34.cjs +210 -0
  34. package/dist/chunk-XKPSTC34.cjs.map +1 -0
  35. package/dist/{chunk-L4NPOJ36.cjs → chunk-Z33SBWL5.cjs} +7 -4
  36. package/dist/chunk-Z33SBWL5.cjs.map +1 -0
  37. package/dist/element-browser.js +631 -103
  38. package/dist/element-browser.js.map +1 -1
  39. package/dist/element.cjs +4 -4
  40. package/dist/element.d.cts +1 -1
  41. package/dist/element.d.ts +1 -1
  42. package/dist/element.js +3 -3
  43. package/dist/index.cjs +174 -26
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.d.cts +48 -4
  46. package/dist/index.d.ts +48 -4
  47. package/dist/index.js +93 -12
  48. package/dist/index.js.map +1 -1
  49. package/dist/libav-http-reader-AZLE7YFS.cjs +16 -0
  50. package/dist/{libav-http-reader-FPYDBMYK.cjs.map → libav-http-reader-AZLE7YFS.cjs.map} +1 -1
  51. package/dist/libav-http-reader-WXG3Z7AI.js +3 -0
  52. package/dist/{libav-http-reader-NQJVY273.js.map → libav-http-reader-WXG3Z7AI.js.map} +1 -1
  53. package/dist/{player-DUyvltvy.d.cts → player-B6WB74RD.d.cts} +63 -3
  54. package/dist/{player-DUyvltvy.d.ts → player-B6WB74RD.d.ts} +63 -3
  55. package/dist/player.cjs +5500 -0
  56. package/dist/player.cjs.map +1 -0
  57. package/dist/player.d.cts +649 -0
  58. package/dist/player.d.ts +649 -0
  59. package/dist/player.js +5498 -0
  60. package/dist/player.js.map +1 -0
  61. package/dist/source-73CAH6HW.cjs +28 -0
  62. package/dist/{source-CN43EI7Z.cjs.map → source-73CAH6HW.cjs.map} +1 -1
  63. package/dist/source-F656KYYV.js +3 -0
  64. package/dist/{source-FFZ7TW2B.js.map → source-F656KYYV.js.map} +1 -1
  65. package/dist/source-QJR3OHTW.js +3 -0
  66. package/dist/source-QJR3OHTW.js.map +1 -0
  67. package/dist/source-VB74JQ7Z.cjs +28 -0
  68. package/dist/source-VB74JQ7Z.cjs.map +1 -0
  69. package/dist/variant-routing-434STYAB.js +3 -0
  70. package/dist/{variant-routing-JOBWXYKD.js.map → variant-routing-434STYAB.js.map} +1 -1
  71. package/dist/variant-routing-HONNAA6R.cjs +12 -0
  72. package/dist/{variant-routing-GOHB2RZN.cjs.map → variant-routing-HONNAA6R.cjs.map} +1 -1
  73. package/package.json +9 -1
  74. package/src/classify/rules.ts +27 -5
  75. package/src/convert/remux.ts +8 -0
  76. package/src/convert/transcode.ts +41 -8
  77. package/src/element/avbridge-player.ts +845 -0
  78. package/src/element/player-icons.ts +25 -0
  79. package/src/element/player-styles.ts +472 -0
  80. package/src/errors.ts +47 -0
  81. package/src/index.ts +23 -0
  82. package/src/player-element.ts +18 -0
  83. package/src/player.ts +127 -27
  84. package/src/plugins/builtin.ts +2 -2
  85. package/src/probe/avi.ts +4 -0
  86. package/src/probe/index.ts +40 -10
  87. package/src/strategies/fallback/audio-output.ts +31 -0
  88. package/src/strategies/fallback/decoder.ts +83 -2
  89. package/src/strategies/fallback/index.ts +34 -1
  90. package/src/strategies/fallback/variant-routing.ts +7 -13
  91. package/src/strategies/fallback/video-renderer.ts +129 -33
  92. package/src/strategies/hybrid/decoder.ts +131 -20
  93. package/src/strategies/hybrid/index.ts +36 -2
  94. package/src/strategies/remux/index.ts +13 -1
  95. package/src/strategies/remux/mse.ts +12 -2
  96. package/src/strategies/remux/pipeline.ts +6 -0
  97. package/src/subtitles/index.ts +7 -3
  98. package/src/types.ts +53 -1
  99. package/src/util/libav-http-reader.ts +5 -1
  100. package/src/util/source.ts +28 -8
  101. package/src/util/transport.ts +26 -0
  102. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  103. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  104. package/dist/avi-6SJLWIWW.cjs.map +0 -1
  105. package/dist/avi-GCGM7OJI.js.map +0 -1
  106. package/dist/chunk-C5VA5U5O.js.map +0 -1
  107. package/dist/chunk-HZLQNKFN.cjs.map +0 -1
  108. package/dist/chunk-ILKDNBSE.js.map +0 -1
  109. package/dist/chunk-J5MCMN3S.js +0 -27
  110. package/dist/chunk-J5MCMN3S.js.map +0 -1
  111. package/dist/chunk-L4NPOJ36.cjs.map +0 -1
  112. package/dist/chunk-NZU7W256.cjs +0 -29
  113. package/dist/chunk-NZU7W256.cjs.map +0 -1
  114. package/dist/chunk-OE66B34H.cjs.map +0 -1
  115. package/dist/chunk-WD2ZNQA7.js.map +0 -1
  116. package/dist/libav-http-reader-FPYDBMYK.cjs +0 -16
  117. package/dist/libav-http-reader-NQJVY273.js +0 -3
  118. package/dist/source-CN43EI7Z.cjs +0 -28
  119. package/dist/source-FFZ7TW2B.js +0 -3
  120. package/dist/variant-routing-GOHB2RZN.cjs +0 -12
  121. package/dist/variant-routing-JOBWXYKD.js +0 -3
@@ -1,4 +1,4 @@
1
- import type { MediaContext, PlaybackSession } from "../../types.js";
1
+ import type { MediaContext, PlaybackSession, TransportConfig } from "../../types.js";
2
2
  import { VideoRenderer } from "./video-renderer.js";
3
3
  import { AudioOutput } from "./audio-output.js";
4
4
  import { startDecoder, type DecoderHandles } from "./decoder.js";
@@ -56,6 +56,7 @@ const READY_TIMEOUT_SECONDS = 3;
56
56
  export async function createFallbackSession(
57
57
  ctx: MediaContext,
58
58
  target: HTMLVideoElement,
59
+ transport?: TransportConfig,
59
60
  ): Promise<PlaybackSession> {
60
61
  // Normalize the source so URL inputs go through the libav HTTP block
61
62
  // reader instead of being buffered into memory.
@@ -74,6 +75,7 @@ export async function createFallbackSession(
74
75
  context: ctx,
75
76
  renderer,
76
77
  audio,
78
+ transport,
77
79
  });
78
80
  } catch (err) {
79
81
  audio.destroy();
@@ -93,6 +95,31 @@ export async function createFallbackSession(
93
95
  void doSeek(v);
94
96
  },
95
97
  });
98
+ // Mirror `paused` / `volume` / `muted` from the audio output — the
99
+ // underlying <video> never has its own src, so its native state is
100
+ // meaningless. This lets HTMLMediaElement consumers (<avbridge-player>
101
+ // controls) see the real values and control volume through the audio
102
+ // output's GainNode.
103
+ Object.defineProperty(target, "paused", {
104
+ configurable: true,
105
+ get: () => !audio.isPlaying(),
106
+ });
107
+ Object.defineProperty(target, "volume", {
108
+ configurable: true,
109
+ get: () => audio.getVolume(),
110
+ set: (v: number) => {
111
+ audio.setVolume(v);
112
+ target.dispatchEvent(new Event("volumechange"));
113
+ },
114
+ });
115
+ Object.defineProperty(target, "muted", {
116
+ configurable: true,
117
+ get: () => audio.getMuted(),
118
+ set: (m: boolean) => {
119
+ audio.setMuted(m);
120
+ target.dispatchEvent(new Event("volumechange"));
121
+ },
122
+ });
96
123
  // Mirror duration so the demo's controls can use target.duration too.
97
124
  if (ctx.duration && Number.isFinite(ctx.duration)) {
98
125
  Object.defineProperty(target, "duration", {
@@ -215,11 +242,14 @@ export async function createFallbackSession(
215
242
  if (!audio.isPlaying()) {
216
243
  await waitForBuffer();
217
244
  await audio.start();
245
+ target.dispatchEvent(new Event("play"));
246
+ target.dispatchEvent(new Event("playing"));
218
247
  }
219
248
  },
220
249
 
221
250
  pause() {
222
251
  void audio.pause();
252
+ target.dispatchEvent(new Event("pause"));
223
253
  },
224
254
 
225
255
  async seek(time) {
@@ -244,6 +274,9 @@ export async function createFallbackSession(
244
274
  try {
245
275
  delete (target as unknown as Record<string, unknown>).currentTime;
246
276
  delete (target as unknown as Record<string, unknown>).duration;
277
+ delete (target as unknown as Record<string, unknown>).paused;
278
+ delete (target as unknown as Record<string, unknown>).volume;
279
+ delete (target as unknown as Record<string, unknown>).muted;
247
280
  } catch { /* ignore */ }
248
281
  },
249
282
 
@@ -19,25 +19,19 @@ import type { LibavVariant } from "./libav-loader.js";
19
19
 
20
20
  const LEGACY_CONTAINERS = new Set(["avi", "asf", "flv"]);
21
21
 
22
- const LEGACY_VIDEO_CODECS = new Set<VideoCodec>([
23
- "wmv3",
24
- "vc1",
25
- "mpeg4", // MPEG-4 Part 2 / DivX / Xvid
26
- "rv40",
27
- "mpeg2",
28
- "mpeg1",
29
- "theora",
30
- ]);
31
-
32
- const LEGACY_AUDIO_CODECS = new Set<AudioCodec>(["wmav2", "wmapro", "ac3", "eac3"]);
22
+ /** Codecs the webcodecs variant can handle (native browser codecs only).
23
+ * Anything not in these sets needs the custom avbridge variant. */
24
+ const WEBCODECS_AUDIO = new Set<AudioCodec>(["aac", "mp3", "opus", "vorbis", "flac"]);
25
+ const WEBCODECS_VIDEO = new Set<VideoCodec>(["h264", "h265", "vp8", "vp9", "av1"]);
33
26
 
34
27
  export function pickLibavVariant(ctx: MediaContext): LibavVariant {
35
28
  if (LEGACY_CONTAINERS.has(ctx.container)) return "avbridge";
36
29
  for (const v of ctx.videoTracks) {
37
- if (LEGACY_VIDEO_CODECS.has(v.codec)) return "avbridge";
30
+ // Any codec the webcodecs variant can't handle → need avbridge
31
+ if (!WEBCODECS_VIDEO.has(v.codec)) return "avbridge";
38
32
  }
39
33
  for (const a of ctx.audioTracks) {
40
- if (LEGACY_AUDIO_CODECS.has(a.codec)) return "avbridge";
34
+ if (!WEBCODECS_AUDIO.has(a.codec)) return "avbridge";
41
35
  }
42
36
  return "webcodecs";
43
37
  }
@@ -20,6 +20,13 @@ import type { ClockSource } from "./audio-output.js";
20
20
  * decoder was still warming up, and every frame was already in the past by
21
21
  * the time it landed in the queue.
22
22
  */
23
+ // Periodic debug log — throttled to once per second so it doesn't
24
+ // flood the console at 60Hz rAF rate.
25
+ function isDebug(): boolean {
26
+ return typeof globalThis !== "undefined" && !!(globalThis as Record<string, unknown>).AVBRIDGE_DEBUG;
27
+ }
28
+ let lastDebugLog = 0;
29
+
23
30
  export class VideoRenderer {
24
31
  private canvas: HTMLCanvasElement;
25
32
  private ctx: CanvasRenderingContext2D;
@@ -35,6 +42,21 @@ export class VideoRenderer {
35
42
  private lastPaintWall = 0;
36
43
  /** Minimum ms between paints — paces video at roughly source fps. */
37
44
  private paintIntervalMs: number;
45
+ /** Cumulative count of frames skipped because all PTS are in the future. */
46
+ private ticksWaiting = 0;
47
+ /** Cumulative count of ticks where PTS mode painted a frame. */
48
+ private ticksPainted = 0;
49
+
50
+ /**
51
+ * Calibration offset (microseconds) between video PTS and audio clock.
52
+ * Video PTS and AudioContext.currentTime can drift ~0.1% relative to
53
+ * each other (different clock domains). Over 45 minutes that's 2.6s.
54
+ * We measure the offset on the first painted frame and update it
55
+ * periodically so the PTS comparison stays calibrated.
56
+ */
57
+ private ptsCalibrationUs = 0;
58
+ private ptsCalibrated = false;
59
+ private lastCalibrationWall = 0;
38
60
 
39
61
  /** Resolves once the first decoded frame has been enqueued. */
40
62
  readonly firstFrameReady: Promise<void>;
@@ -51,8 +73,12 @@ export class VideoRenderer {
51
73
  });
52
74
 
53
75
  this.canvas = document.createElement("canvas");
76
+ // object-fit:contain letterboxes the canvas bitmap (sized to
77
+ // frame.displayWidth × displayHeight in paint()) inside the stage so
78
+ // portrait / non-stage-aspect content isn't stretched. Canvas is a
79
+ // replaced element, so object-fit applies.
54
80
  this.canvas.style.cssText =
55
- "position:absolute;left:0;top:0;width:100%;height:100%;background:black;";
81
+ "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
56
82
 
57
83
  // Attach the canvas next to the video. When the video lives inside an
58
84
  // `<avbridge-video>` shadow root, `target.parentElement` is the
@@ -150,45 +176,109 @@ export class VideoRenderer {
150
176
  return;
151
177
  }
152
178
 
153
- // Wall-clock-paced painting with coarse A/V drift correction.
154
- //
155
- // Base policy: paint one frame every `paintIntervalMs` of wall time,
156
- // regardless of the frame's synthetic timestamp. This avoids the old
157
- // per-frame audio-gate that caused massive overflow during decode bursts.
158
- //
159
- // Drift correction (runs every ~1 sec):
160
- // - Video > 150 ms behind audio → drop one frame (catch up)
161
- // - Video > 150 ms ahead of audio → skip one paint (let audio catch up)
179
+ // PTS-based painting: find the latest frame whose presentation time
180
+ // has arrived (timestamp ≤ audio clock), paint it, and discard any
181
+ // older frames. This produces correct cadence at any display refresh
182
+ // rate and any source fps no 3:2 pulldown artifacts.
162
183
  //
163
- // This keeps long-run sync robust even for legacy AVI/DivX with messy
164
- // timestamps, packed B-frames, and odd frame durations. The correction
165
- // is deliberately gentle (one frame at a time) so it doesn't cause
166
- // visible stuttering.
167
- const wallNow = performance.now();
168
- if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
184
+ // Fallback: if frame timestamps are unreliable (all zero, synthetic),
185
+ // fall back to wall-clock pacing as before.
186
+ const rawAudioNowUs = this.clock.now() * 1_000_000;
187
+ const headTs = this.queue[0].timestamp ?? 0;
188
+ const hasPts = headTs > 0 || this.queue.length > 1;
169
189
 
170
- if (this.queue.length === 0) return;
190
+ if (hasPts) {
191
+ // Calibration: video PTS and audio clock (AudioContext.currentTime)
192
+ // live in different clock domains with a fixed offset (different epoch)
193
+ // plus a small rate drift (~7ms/s). We snap the offset on first paint
194
+ // and re-snap every 10 seconds. Between snaps, max drift is ~70ms
195
+ // (under 2 frames at 24fps, below lip-sync perception threshold).
196
+ const wallNow = performance.now();
197
+ if (!this.ptsCalibrated || wallNow - this.lastCalibrationWall > 10_000) {
198
+ this.ptsCalibrationUs = headTs - rawAudioNowUs;
199
+ this.ptsCalibrated = true;
200
+ this.lastCalibrationWall = wallNow;
201
+ }
202
+
203
+ const audioNowUs = rawAudioNowUs + this.ptsCalibrationUs;
204
+ const frameDurationUs = this.paintIntervalMs * 1000;
205
+ const deadlineUs = audioNowUs + frameDurationUs;
206
+
207
+ let bestIdx = -1;
208
+ for (let i = 0; i < this.queue.length; i++) {
209
+ const ts = this.queue[i].timestamp ?? 0;
210
+ if (ts <= deadlineUs) {
211
+ bestIdx = i;
212
+ } else {
213
+ break;
214
+ }
215
+ }
171
216
 
172
- // Coarse drift correction: compare the head frame's timestamp to
173
- // audio.now() every ~1 sec (every 30 frames at 30fps). The frame ts
174
- // and audio.now() are both in seconds of media time. Drift beyond
175
- // 150ms triggers gentle correction — one frame per check, not a burst.
176
- if (this.framesPainted > 0 && this.framesPainted % 30 === 0) {
177
- const audioNowUs = this.clock.now() * 1_000_000;
178
- const headTs = this.queue[0].timestamp ?? 0;
179
- const driftUs = headTs - audioNowUs;
180
-
181
- if (driftUs < -150_000) {
182
- // Video behind audio by > 150ms — drop one frame to catch up.
183
- this.queue.shift()?.close();
184
- this.framesDroppedLate++;
185
- if (this.queue.length === 0) return;
186
- } else if (driftUs > 150_000) {
187
- // Video ahead of audio by > 150ms — skip this paint cycle.
217
+ if (bestIdx < 0) {
218
+ this.ticksWaiting++;
219
+ if (isDebug()) {
220
+ const now = performance.now();
221
+ if (now - lastDebugLog > 1000) {
222
+ const headPtsMs = (headTs / 1000).toFixed(1);
223
+ const audioMs = (audioNowUs / 1000).toFixed(1);
224
+ const rawDriftMs = ((headTs - rawAudioNowUs) / 1000).toFixed(1);
225
+ const calibMs = (this.ptsCalibrationUs / 1000).toFixed(1);
226
+ // eslint-disable-next-line no-console
227
+ console.log(
228
+ `[avbridge:renderer] WAIT q=${this.queue.length} headPTS=${headPtsMs}ms calibAudio=${audioMs}ms ` +
229
+ `rawDrift=${rawDriftMs}ms calib=${calibMs}ms painted=${this.framesPainted} dropped=${this.framesDroppedLate}`,
230
+ );
231
+ lastDebugLog = now;
232
+ }
233
+ }
188
234
  return;
189
235
  }
236
+
237
+ // Only drop frames that are more than 2 frame-durations behind.
238
+ const dropThresholdUs = audioNowUs - frameDurationUs * 2;
239
+ let dropped = 0;
240
+ while (bestIdx > 0) {
241
+ const ts = this.queue[0].timestamp ?? 0;
242
+ if (ts < dropThresholdUs) {
243
+ this.queue.shift()?.close();
244
+ this.framesDroppedLate++;
245
+ bestIdx--;
246
+ dropped++;
247
+ } else {
248
+ break;
249
+ }
250
+ }
251
+
252
+ this.ticksPainted++;
253
+
254
+ if (isDebug()) {
255
+ const now = performance.now();
256
+ if (now - lastDebugLog > 1000) {
257
+ const paintedTs = (this.queue[0]?.timestamp ?? 0);
258
+ const audioMs = (audioNowUs / 1000).toFixed(1);
259
+ const ptsMs = (paintedTs / 1000).toFixed(1);
260
+ const rawDriftMs = ((paintedTs - rawAudioNowUs) / 1000).toFixed(1);
261
+ const calibMs = (this.ptsCalibrationUs / 1000).toFixed(1);
262
+ // eslint-disable-next-line no-console
263
+ console.log(
264
+ `[avbridge:renderer] PAINT q=${this.queue.length} calibAudio=${audioMs}ms nextPTS=${ptsMs}ms ` +
265
+ `rawDrift=${rawDriftMs}ms calib=${calibMs}ms dropped=${dropped} total_drops=${this.framesDroppedLate} painted=${this.framesPainted}`,
266
+ );
267
+ lastDebugLog = now;
268
+ }
269
+ }
270
+
271
+ const frame = this.queue.shift()!;
272
+ this.paint(frame);
273
+ frame.close();
274
+ this.lastPaintWall = performance.now();
275
+ return;
190
276
  }
191
277
 
278
+ // Wall-clock fallback: used when timestamps are unreliable (all zero).
279
+ const wallNow = performance.now();
280
+ if (wallNow - this.lastPaintWall < this.paintIntervalMs - 2) return;
281
+
192
282
  const frame = this.queue.shift()!;
193
283
  this.paint(frame);
194
284
  frame.close();
@@ -218,8 +308,14 @@ export class VideoRenderer {
218
308
 
219
309
  /** Discard all queued frames. Used by seek to drop stale buffers. */
220
310
  flush(): void {
311
+ const count = this.queue.length;
221
312
  while (this.queue.length > 0) this.queue.shift()?.close();
222
313
  this.prerolled = false;
314
+ this.ptsCalibrated = false; // recalibrate at new seek position
315
+ if (isDebug() && count > 0) {
316
+ // eslint-disable-next-line no-console
317
+ console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
318
+ }
223
319
  }
224
320
 
225
321
  stats(): Record<string, unknown> {
@@ -18,6 +18,7 @@ import { loadLibav, type LibavVariant } from "../fallback/libav-loader.js";
18
18
  import { VideoRenderer } from "../fallback/video-renderer.js";
19
19
  import { AudioOutput } from "../fallback/audio-output.js";
20
20
  import type { MediaContext } from "../../types.js";
21
+ import { dbg } from "../../util/debug.js";
21
22
  import { pickLibavVariant } from "../fallback/variant-routing.js";
22
23
 
23
24
  export interface HybridDecoderHandles {
@@ -34,6 +35,7 @@ export interface StartHybridDecoderOptions {
34
35
  context: MediaContext;
35
36
  renderer: VideoRenderer;
36
37
  audio: AudioOutput;
38
+ transport?: import("../../types.js").TransportConfig;
37
39
  }
38
40
 
39
41
  export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promise<HybridDecoderHandles> {
@@ -45,7 +47,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
45
47
  // libav demuxes via Range requests. For Blob sources, it falls back to
46
48
  // mkreadaheadfile (in-memory). The returned handle owns cleanup.
47
49
  const { prepareLibavInput } = await import("../../util/libav-http-reader.js");
48
- const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source);
50
+ const inputHandle = await prepareLibavInput(libav as unknown as Parameters<typeof prepareLibavInput>[0], opts.filename, opts.source, opts.transport);
49
51
 
50
52
  const readPkt = await libav.av_packet_alloc();
51
53
  const [fmt_ctx, streams] = await libav.ff_init_demuxer_file(opts.filename);
@@ -134,6 +136,58 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
134
136
  throw new Error("hybrid decoder: could not initialize any decoders");
135
137
  }
136
138
 
139
+ // ── Bitstream filter for MPEG-4 Part 2 packed B-frames ───────────────
140
+ let bsfCtx: number | null = null;
141
+ let bsfPkt: number | null = null;
142
+ if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
143
+ try {
144
+ bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
145
+ if (bsfCtx != null && bsfCtx >= 0) {
146
+ const parIn = await libav.AVBSFContext_par_in(bsfCtx);
147
+ await libav.avcodec_parameters_copy(parIn, videoStream.codecpar);
148
+ await libav.av_bsf_init(bsfCtx);
149
+ bsfPkt = await libav.av_packet_alloc();
150
+ dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
151
+ } else {
152
+ // eslint-disable-next-line no-console
153
+ console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
154
+ bsfCtx = null;
155
+ }
156
+ } catch (err) {
157
+ // eslint-disable-next-line no-console
158
+ console.warn("[avbridge] hybrid: failed to init BSF:", (err as Error).message);
159
+ bsfCtx = null;
160
+ bsfPkt = null;
161
+ }
162
+ }
163
+
164
+ async function applyBSF(packets: LibavPacket[]): Promise<LibavPacket[]> {
165
+ if (!bsfCtx || !bsfPkt) return packets;
166
+ const out: LibavPacket[] = [];
167
+ for (const pkt of packets) {
168
+ await libav.ff_copyin_packet(bsfPkt, pkt);
169
+ const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
170
+ if (sendErr < 0) { out.push(pkt); continue; }
171
+ while (true) {
172
+ const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
173
+ if (recvErr < 0) break;
174
+ out.push(await libav.ff_copyout_packet(bsfPkt));
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ async function flushBSF(): Promise<void> {
181
+ if (!bsfCtx || !bsfPkt) return;
182
+ try {
183
+ await libav.av_bsf_send_packet(bsfCtx, 0);
184
+ while (true) {
185
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
186
+ if (err < 0) break;
187
+ }
188
+ } catch { /* ignore */ }
189
+ }
190
+
137
191
  // ── Mutable state ─────────────────────────────────────────────────────
138
192
  let destroyed = false;
139
193
  let pumpToken = 0;
@@ -171,9 +225,27 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
171
225
  const videoPackets = videoStream ? packets[videoStream.index] : undefined;
172
226
  const audioPackets = audioStream ? packets[audioStream.index] : undefined;
173
227
 
174
- // Feed video packets to WebCodecs VideoDecoder
228
+ // Decode audio BEFORE video. Same rationale as fallback decoder
229
+ // (POSTMORTEMS.md entry 1, fix #2): audio decode via libav's
230
+ // ff_decode_multi is a blocking WASM call that prevents rAF from
231
+ // firing. For heavy codecs like DTS, a single batch can take
232
+ // 10-50 ms. Processing audio first ensures the audio scheduler is
233
+ // fed before video decode starts, reducing perceived stutter.
234
+ if (audioDec && audioPackets && audioPackets.length > 0) {
235
+ await decodeAudioBatch(audioPackets, myToken);
236
+ }
237
+ if (myToken !== pumpToken || destroyed) return;
238
+
239
+ // Yield to the event loop so the video renderer's rAF callback
240
+ // can fire between the audio decode (blocking) and the video feed
241
+ // (async). Without this, the renderer starves during DTS decode.
242
+ await new Promise((r) => setTimeout(r, 0));
243
+ if (myToken !== pumpToken || destroyed) return;
244
+
245
+ // Feed video packets to WebCodecs VideoDecoder (after BSF if applicable)
175
246
  if (videoDecoder && videoPackets && videoPackets.length > 0) {
176
- for (const pkt of videoPackets) {
247
+ const processed = await applyBSF(videoPackets);
248
+ for (const pkt of processed) {
177
249
  if (myToken !== pumpToken || destroyed) return;
178
250
  sanitizePacketTimestamp(pkt, () => {
179
251
  const ts = syntheticVideoUs;
@@ -194,11 +266,6 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
194
266
  }
195
267
  }
196
268
 
197
- // Decode audio with libav software decoder
198
- if (audioDec && audioPackets && audioPackets.length > 0) {
199
- await decodeAudioBatch(audioPackets, myToken);
200
- }
201
-
202
269
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
203
270
 
204
271
  // Backpressure: WebCodecs decodeQueueSize + audio buffer + renderer queue
@@ -230,20 +297,49 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
230
297
 
231
298
  async function decodeAudioBatch(pkts: LibavPacket[], myToken: number, flush = false) {
232
299
  if (!audioDec || destroyed || myToken !== pumpToken) return;
233
- let frames: LibavFrame[];
234
- try {
235
- frames = await libav.ff_decode_multi(
236
- audioDec.c,
237
- audioDec.pkt,
238
- audioDec.frame,
239
- pkts,
240
- flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true },
241
- );
242
- } catch (err) {
243
- console.error("[avbridge] hybrid audio decode failed:", err);
244
- return;
300
+
301
+ // For heavy codecs (DTS, AC3), decode in small sub-batches and yield
302
+ // between them so the event loop can run rAF for video painting.
303
+ // Each ff_decode_multi call is a blocking WASM invocation.
304
+ const AUDIO_SUB_BATCH = 4; // packets per sub-batch
305
+ let allFrames: LibavFrame[] = [];
306
+
307
+ for (let i = 0; i < pkts.length; i += AUDIO_SUB_BATCH) {
308
+ if (myToken !== pumpToken || destroyed) return;
309
+ const slice = pkts.slice(i, i + AUDIO_SUB_BATCH);
310
+ const isLast = i + AUDIO_SUB_BATCH >= pkts.length;
311
+ try {
312
+ const frames = await libav.ff_decode_multi(
313
+ audioDec.c,
314
+ audioDec.pkt,
315
+ audioDec.frame,
316
+ slice,
317
+ isLast && flush ? { fin: true, ignoreErrors: true } : { ignoreErrors: true },
318
+ );
319
+ allFrames = allFrames.concat(frames);
320
+ } catch (err) {
321
+ console.error("[avbridge] hybrid audio decode failed:", err);
322
+ return;
323
+ }
324
+ // Yield between sub-batches so rAF can fire
325
+ if (!isLast) await new Promise((r) => setTimeout(r, 0));
245
326
  }
327
+
328
+ // Handle flush-only call (empty pkts array)
329
+ if (pkts.length === 0 && flush) {
330
+ try {
331
+ allFrames = await libav.ff_decode_multi(
332
+ audioDec.c, audioDec.pkt, audioDec.frame, [],
333
+ { fin: true, ignoreErrors: true },
334
+ );
335
+ } catch (err) {
336
+ console.error("[avbridge] hybrid audio flush failed:", err);
337
+ return;
338
+ }
339
+ }
340
+
246
341
  if (myToken !== pumpToken || destroyed) return;
342
+ const frames = allFrames;
247
343
 
248
344
  for (const f of frames) {
249
345
  if (myToken !== pumpToken || destroyed) return;
@@ -283,6 +379,8 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
283
379
  destroyed = true;
284
380
  pumpToken++;
285
381
  try { await pumpRunning; } catch { /* ignore */ }
382
+ try { if (bsfCtx) await libav.av_bsf_free(bsfCtx); } catch { /* ignore */ }
383
+ try { if (bsfPkt) await libav.av_packet_free?.(bsfPkt); } catch { /* ignore */ }
286
384
  try { if (videoDecoder && videoDecoder.state !== "closed") videoDecoder.close(); } catch { /* ignore */ }
287
385
  try { if (audioDec) await libav.ff_free_decoder?.(audioDec.c, audioDec.pkt, audioDec.frame); } catch { /* ignore */ }
288
386
  try { await libav.av_packet_free?.(readPkt); } catch { /* ignore */ }
@@ -324,6 +422,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
324
422
  try {
325
423
  if (audioDec) await libav.avcodec_flush_buffers?.(audioDec.c);
326
424
  } catch { /* ignore */ }
425
+ await flushBSF();
327
426
 
328
427
  syntheticVideoUs = Math.round(timeSec * 1_000_000);
329
428
  syntheticAudioUs = Math.round(timeSec * 1_000_000);
@@ -340,6 +439,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
340
439
  videoFramesDecoded,
341
440
  videoChunksFed,
342
441
  audioFramesDecoded,
442
+ bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
343
443
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
344
444
  // Confirmed transport info — see fallback decoder for the pattern.
345
445
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -636,6 +736,17 @@ interface LibavRuntime {
636
736
  avcodec_flush_buffers?(c: number): Promise<void>;
637
737
  avformat_close_input_js(ctx: number): Promise<void>;
638
738
  f64toi64?(val: number): [number, number];
739
+
740
+ // BSF methods
741
+ av_bsf_list_parse_str_js(str: string): Promise<number>;
742
+ AVBSFContext_par_in(ctx: number): Promise<number>;
743
+ avcodec_parameters_copy(dst: number, src: number): Promise<number>;
744
+ av_bsf_init(ctx: number): Promise<number>;
745
+ av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
746
+ av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
747
+ av_bsf_free(ctx: number): Promise<void>;
748
+ ff_copyin_packet(pktPtr: number, packet: LibavPacket): Promise<void>;
749
+ ff_copyout_packet(pkt: number): Promise<LibavPacket>;
639
750
  }
640
751
 
641
752
  interface BridgeModule {
@@ -1,4 +1,4 @@
1
- import type { MediaContext, PlaybackSession } from "../../types.js";
1
+ import type { MediaContext, PlaybackSession, TransportConfig } from "../../types.js";
2
2
  import { VideoRenderer } from "../fallback/video-renderer.js";
3
3
  import { AudioOutput } from "../fallback/audio-output.js";
4
4
  import { startHybridDecoder, type HybridDecoderHandles } from "./decoder.js";
@@ -20,6 +20,7 @@ const READY_TIMEOUT_SECONDS = 10;
20
20
  export async function createHybridSession(
21
21
  ctx: MediaContext,
22
22
  target: HTMLVideoElement,
23
+ transport?: TransportConfig,
23
24
  ): Promise<PlaybackSession> {
24
25
  // Normalize the source so URL inputs go through the libav HTTP block
25
26
  // reader instead of being buffered into memory.
@@ -38,6 +39,7 @@ export async function createHybridSession(
38
39
  context: ctx,
39
40
  renderer,
40
41
  audio,
42
+ transport,
41
43
  });
42
44
  } catch (err) {
43
45
  audio.destroy();
@@ -45,12 +47,36 @@ export async function createHybridSession(
45
47
  throw err;
46
48
  }
47
49
 
48
- // Patch <video> element for the unified player layer
50
+ // Patch <video> element for the unified player layer. The underlying
51
+ // <video> never has its own src; all playback state lives in the audio
52
+ // clock + canvas renderer. We expose that state via property getters
53
+ // so standard HTMLMediaElement consumers (like <avbridge-player>'s
54
+ // controls UI) see the real values.
49
55
  Object.defineProperty(target, "currentTime", {
50
56
  configurable: true,
51
57
  get: () => audio.now(),
52
58
  set: (v: number) => { void doSeek(v); },
53
59
  });
60
+ Object.defineProperty(target, "paused", {
61
+ configurable: true,
62
+ get: () => !audio.isPlaying(),
63
+ });
64
+ Object.defineProperty(target, "volume", {
65
+ configurable: true,
66
+ get: () => audio.getVolume(),
67
+ set: (v: number) => {
68
+ audio.setVolume(v);
69
+ target.dispatchEvent(new Event("volumechange"));
70
+ },
71
+ });
72
+ Object.defineProperty(target, "muted", {
73
+ configurable: true,
74
+ get: () => audio.getMuted(),
75
+ set: (m: boolean) => {
76
+ audio.setMuted(m);
77
+ target.dispatchEvent(new Event("volumechange"));
78
+ },
79
+ });
54
80
  if (ctx.duration && Number.isFinite(ctx.duration)) {
55
81
  Object.defineProperty(target, "duration", {
56
82
  configurable: true,
@@ -95,11 +121,16 @@ export async function createHybridSession(
95
121
  if (!audio.isPlaying()) {
96
122
  await waitForBuffer();
97
123
  await audio.start();
124
+ // Dispatch play/playing events so HTMLMediaElement consumers
125
+ // (e.g. <avbridge-player>'s controls UI) update their state.
126
+ target.dispatchEvent(new Event("play"));
127
+ target.dispatchEvent(new Event("playing"));
98
128
  }
99
129
  },
100
130
 
101
131
  pause() {
102
132
  void audio.pause();
133
+ target.dispatchEvent(new Event("pause"));
103
134
  },
104
135
 
105
136
  async seek(time) {
@@ -129,6 +160,9 @@ export async function createHybridSession(
129
160
  try {
130
161
  delete (target as unknown as Record<string, unknown>).currentTime;
131
162
  delete (target as unknown as Record<string, unknown>).duration;
163
+ delete (target as unknown as Record<string, unknown>).paused;
164
+ delete (target as unknown as Record<string, unknown>).volume;
165
+ delete (target as unknown as Record<string, unknown>).muted;
132
166
  } catch { /* ignore */ }
133
167
  },
134
168
 
@@ -36,7 +36,19 @@ export async function createRemuxSession(
36
36
  await pipeline.start(video.currentTime || 0, true);
37
37
  return;
38
38
  }
39
- await video.play();
39
+ // seek() may have already started the pump with autoPlay=false
40
+ // (strategy-switch flow calls seek before play). Flip the pipeline's
41
+ // pending autoPlay so the MseSink fires video.play() once buffered
42
+ // data lands, and also attempt an immediate video.play() in case the
43
+ // sink is already wired up. The immediate call can reject when
44
+ // video.src hasn't been set yet — that's fine, the deferred path will
45
+ // catch it.
46
+ pipeline.setAutoPlay(true);
47
+ try {
48
+ await video.play();
49
+ } catch {
50
+ /* sink not ready yet; setAutoPlay will handle playback on first buffered write */
51
+ }
40
52
  },
41
53
  pause() {
42
54
  wantPlay = false;