avbridge 2.1.1 → 2.2.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 (68) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/{avi-GNTV5ZOH.cjs → avi-6SJLWIWW.cjs} +19 -4
  3. package/dist/avi-6SJLWIWW.cjs.map +1 -0
  4. package/dist/{avi-V6HYQVR2.js → avi-GCGM7OJI.js} +18 -3
  5. package/dist/avi-GCGM7OJI.js.map +1 -0
  6. package/dist/{chunk-EJH67FXG.js → chunk-5DMTJVIU.js} +99 -3
  7. package/dist/chunk-5DMTJVIU.js.map +1 -0
  8. package/dist/{chunk-CUQD23WO.js → chunk-C5VA5U5O.js} +122 -23
  9. package/dist/chunk-C5VA5U5O.js.map +1 -0
  10. package/dist/{chunk-JQH6D4OE.cjs → chunk-G4APZMCP.cjs} +100 -3
  11. package/dist/chunk-G4APZMCP.cjs.map +1 -0
  12. package/dist/{chunk-Y5FYF5KG.cjs → chunk-HZLQNKFN.cjs} +5 -2
  13. package/dist/chunk-HZLQNKFN.cjs.map +1 -0
  14. package/dist/{chunk-PQTZS7OA.js → chunk-ILKDNBSE.js} +5 -2
  15. package/dist/chunk-ILKDNBSE.js.map +1 -0
  16. package/dist/{chunk-O34444ID.cjs → chunk-OE66B34H.cjs} +126 -27
  17. package/dist/chunk-OE66B34H.cjs.map +1 -0
  18. package/dist/element-browser.js +244 -19
  19. package/dist/element-browser.js.map +1 -1
  20. package/dist/element.cjs +9 -5
  21. package/dist/element.cjs.map +1 -1
  22. package/dist/element.d.cts +1 -1
  23. package/dist/element.d.ts +1 -1
  24. package/dist/element.js +8 -4
  25. package/dist/element.js.map +1 -1
  26. package/dist/index.cjs +18 -18
  27. package/dist/index.d.cts +12 -8
  28. package/dist/index.d.ts +12 -8
  29. package/dist/index.js +5 -5
  30. package/dist/libav-loader-27RDIN2I.js +3 -0
  31. package/dist/{libav-loader-XKH2TKUW.js.map → libav-loader-27RDIN2I.js.map} +1 -1
  32. package/dist/libav-loader-IV4AJ2HW.cjs +12 -0
  33. package/dist/{libav-loader-6APXVNIV.cjs.map → libav-loader-IV4AJ2HW.cjs.map} +1 -1
  34. package/dist/{player-BdtUG4rh.d.cts → player-DUyvltvy.d.cts} +3 -3
  35. package/dist/{player-BdtUG4rh.d.ts → player-DUyvltvy.d.ts} +3 -3
  36. package/dist/source-CN43EI7Z.cjs +28 -0
  37. package/dist/{source-SC6ZEQYR.cjs.map → source-CN43EI7Z.cjs.map} +1 -1
  38. package/dist/source-FFZ7TW2B.js +3 -0
  39. package/dist/{source-ZFS4H7J3.js.map → source-FFZ7TW2B.js.map} +1 -1
  40. package/package.json +1 -1
  41. package/src/classify/rules.ts +9 -2
  42. package/src/element/avbridge-video.ts +12 -1
  43. package/src/player.ts +22 -1
  44. package/src/probe/avi.ts +8 -1
  45. package/src/probe/index.ts +30 -9
  46. package/src/strategies/fallback/audio-output.ts +25 -3
  47. package/src/strategies/fallback/decoder.ts +96 -8
  48. package/src/strategies/fallback/index.ts +90 -6
  49. package/src/strategies/fallback/libav-loader.ts +12 -0
  50. package/src/strategies/fallback/video-renderer.ts +29 -4
  51. package/src/strategies/remux/pipeline.ts +10 -1
  52. package/src/types.ts +10 -1
  53. package/src/util/debug.ts +131 -0
  54. package/src/util/source.ts +4 -0
  55. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  56. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  57. package/dist/avi-GNTV5ZOH.cjs.map +0 -1
  58. package/dist/avi-V6HYQVR2.js.map +0 -1
  59. package/dist/chunk-CUQD23WO.js.map +0 -1
  60. package/dist/chunk-EJH67FXG.js.map +0 -1
  61. package/dist/chunk-JQH6D4OE.cjs.map +0 -1
  62. package/dist/chunk-O34444ID.cjs.map +0 -1
  63. package/dist/chunk-PQTZS7OA.js.map +0 -1
  64. package/dist/chunk-Y5FYF5KG.cjs.map +0 -1
  65. package/dist/libav-loader-6APXVNIV.cjs +0 -12
  66. package/dist/libav-loader-XKH2TKUW.js +0 -3
  67. package/dist/source-SC6ZEQYR.cjs +0 -28
  68. package/dist/source-ZFS4H7J3.js +0 -3
@@ -135,6 +135,20 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
135
135
  let videoFramesDecoded = 0;
136
136
  let audioFramesDecoded = 0;
137
137
 
138
+ // Decode-rate watchdog. Samples framesDecoded every second and
139
+ // compares against realtime expected frames for the source fps. If
140
+ // the decoder sustains less than 60% of realtime for more than
141
+ // 5 seconds (counting only time since the first frame emerged),
142
+ // emits a one-shot diagnostic so users know why playback is
143
+ // stuttering instead of guessing. A second one-shot fires if the
144
+ // renderer's overflow-drop rate exceeds 10% of decoded frames —
145
+ // that symptom means the decoder is BURSTING faster than the
146
+ // renderer can drain, which is a different bug from "decoder slow".
147
+ let watchdogFirstFrameMs = 0;
148
+ let watchdogSlowSinceMs = 0;
149
+ let watchdogSlowWarned = false;
150
+ let watchdogOverflowWarned = false;
151
+
138
152
  // Synthetic timestamp counters. Reset on seek.
139
153
  let syntheticVideoUs = 0;
140
154
  let syntheticAudioUs = 0;
@@ -150,10 +164,18 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
150
164
  let readErr: number;
151
165
  let packets: Record<number, LibavPacket[]>;
152
166
  try {
153
- // Smaller batch = fewer frames per decode round = less queue burst.
154
- // 16 KB 4 video packets + ~12 audio packets at typical DivX
155
- // bitrates. The renderer drains ~1 frame per 33ms rAF tick, so
156
- // keeping bursts 4-6 frames prevents queue overflow.
167
+ // Batch size tunes the tradeoff between JS↔WASM call overhead
168
+ // (small = more crossings per second) and queue burstiness
169
+ // (large = decoder hands the renderer big bursts at once that
170
+ // can blow past the renderer's 64-frame hard cap before the
171
+ // per-batch `queueHighWater` throttle runs).
172
+ //
173
+ // We tried 64 KB and saw ~30% overflow drops on RMVB:rv40 at
174
+ // 1024x768 because one decode batch regularly produced >30
175
+ // frames. 16 KB keeps each batch ≈ 4-6 video packets at
176
+ // typical bitrates, so the worst-case queue spike stays under
177
+ // `queueHighWater` and the throttle has a chance to apply
178
+ // backpressure *between* batches rather than within one.
157
179
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
158
180
  limit: 16 * 1024,
159
181
  });
@@ -167,16 +189,82 @@ export async function startDecoder(opts: StartDecoderOptions): Promise<DecoderHa
167
189
  const videoPackets = videoStream ? packets[videoStream.index] : undefined;
168
190
  const audioPackets = audioStream ? packets[audioStream.index] : undefined;
169
191
 
170
- if (videoDec && videoPackets && videoPackets.length > 0) {
171
- await decodeVideoBatch(videoPackets, myToken);
172
- }
173
- if (myToken !== pumpToken || destroyed) return;
192
+ // Decode audio BEFORE video. On software-decode-bound content
193
+ // (rv40/mpeg4/wmv3 @ 720p+) a single video batch can take
194
+ // 200-400 ms of wall time; if the scheduler hasn't been fed
195
+ // during that window, audio output runs dry and the user hears
196
+ // clicks/gaps. Audio is time-critical; video can drop a frame
197
+ // and nobody notices. Audio decode is also typically <1 ms per
198
+ // packet for cook/mp3/aac, so doing it first barely delays
199
+ // video decoding at all.
174
200
  if (audioDec && audioPackets && audioPackets.length > 0) {
175
201
  await decodeAudioBatch(audioPackets, myToken);
176
202
  }
203
+ if (myToken !== pumpToken || destroyed) return;
204
+ if (videoDec && videoPackets && videoPackets.length > 0) {
205
+ await decodeVideoBatch(videoPackets, myToken);
206
+ }
177
207
 
178
208
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
179
209
 
210
+ // ── Decode-rate watchdog ──────────────────────────────────────
211
+ if (videoFramesDecoded > 0) {
212
+ if (watchdogFirstFrameMs === 0) {
213
+ watchdogFirstFrameMs = performance.now();
214
+ }
215
+ const elapsedSinceFirst = (performance.now() - watchdogFirstFrameMs) / 1000;
216
+
217
+ // 1. Slow-decode detection (sustained <60% of realtime fps).
218
+ if (elapsedSinceFirst > 1 && !watchdogSlowWarned) {
219
+ const expectedFrames = elapsedSinceFirst * videoFps;
220
+ const ratio = videoFramesDecoded / expectedFrames;
221
+ if (ratio < 0.6) {
222
+ if (watchdogSlowSinceMs === 0) watchdogSlowSinceMs = performance.now();
223
+ if ((performance.now() - watchdogSlowSinceMs) / 1000 > 5) {
224
+ watchdogSlowWarned = true;
225
+ console.warn(
226
+ "[avbridge:decode-rate]",
227
+ `decoder is running slower than realtime: ` +
228
+ `${videoFramesDecoded} frames in ${elapsedSinceFirst.toFixed(1)}s ` +
229
+ `(${(videoFramesDecoded / elapsedSinceFirst).toFixed(1)} fps vs ${videoFps} fps source — ` +
230
+ `${(ratio * 100).toFixed(0)}% of realtime). ` +
231
+ `Playback will stutter. Typical causes: software decode of a codec with no WebCodecs support ` +
232
+ `(rv40, mpeg4 @ 720p+, wmv3), or a resolution too large for single-threaded WASM to keep up with.`,
233
+ );
234
+ }
235
+ } else {
236
+ watchdogSlowSinceMs = 0;
237
+ }
238
+ }
239
+
240
+ // 2. Overflow-drop detection (>10% of decoded frames dropped
241
+ // by the renderer's hard cap). This means the decoder
242
+ // produces BURSTS — it's fast enough on average but one
243
+ // batch delivers >30 frames at a time, overflowing before
244
+ // the queueHighWater throttle can apply backpressure.
245
+ // Symptom is different from "decoder slow": here the fps
246
+ // ratio looks fine but the user sees choppy playback.
247
+ if (
248
+ !watchdogOverflowWarned &&
249
+ videoFramesDecoded > 100 // wait for a meaningful sample
250
+ ) {
251
+ const rendererStats = opts.renderer.stats() as { framesDroppedOverflow?: number };
252
+ const overflow = rendererStats.framesDroppedOverflow ?? 0;
253
+ if (overflow / videoFramesDecoded > 0.1) {
254
+ watchdogOverflowWarned = true;
255
+ console.warn(
256
+ "[avbridge:overflow-drop]",
257
+ `renderer is dropping ${overflow}/${videoFramesDecoded} frames ` +
258
+ `(${((overflow / videoFramesDecoded) * 100).toFixed(0)}%) because the decoder ` +
259
+ `is producing bursts faster than the canvas can drain. Symptom: choppy ` +
260
+ `playback despite decoder keeping up on average. Fix would be smaller ` +
261
+ `read batches in the pump loop or a lower queueHighWater cap — see ` +
262
+ `src/strategies/fallback/decoder.ts.`,
263
+ );
264
+ }
265
+ }
266
+ }
267
+
180
268
  // Throttle: don't run too far ahead of playback. Two backpressure
181
269
  // signals:
182
270
  // - Audio buffer (mediaTimeOfNext - now()) > 2 sec — we have
@@ -2,6 +2,7 @@ import type { MediaContext, PlaybackSession } 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";
5
+ import { dbg } from "../../util/debug.js";
5
6
 
6
7
  /**
7
8
  * Fallback strategy session.
@@ -30,8 +31,27 @@ import { startDecoder, type DecoderHandles } from "./decoder.js";
30
31
  * seek(t)` — none of the buffering choreography leaks out.
31
32
  */
32
33
 
33
- const READY_AUDIO_BUFFER_SECONDS = 0.3;
34
- const READY_TIMEOUT_SECONDS = 10;
34
+ // Gate for cold-start playback. We want to start playing as soon as
35
+ // there's any decoded output — the decoder will keep pumping during
36
+ // playback, so more-is-better buffering only helps for fast decoders.
37
+ //
38
+ // For software-decode-bound content (rv40 / wmv3 / mpeg4 @ 720p+ on
39
+ // single-threaded WASM), the decoder may run *slower* than realtime.
40
+ // Waiting for a large audio-buffer threshold is actively wrong in that
41
+ // case: it will never be reached, so the old gate would sit out its
42
+ // full 10-second timeout before playing anything. An aggressive gate
43
+ // ships the first frame to the screen fast, at the cost of the audio
44
+ // clock racing a little ahead of video in the first few seconds —
45
+ // which is the same situation we'd have been in after the timeout
46
+ // anyway.
47
+ //
48
+ // READY_AUDIO_BUFFER_SECONDS: minimum audio queued before start. Set
49
+ // low enough that a slow decoder still reaches it before the user
50
+ // loses patience; 40 ms ≈ 2 cook packets or ~2 AAC packets.
51
+ // READY_TIMEOUT_SECONDS: hard safety. If even 40 ms of audio can't be
52
+ // produced in 3 s, give up and play whatever we have.
53
+ const READY_AUDIO_BUFFER_SECONDS = 0.04;
54
+ const READY_TIMEOUT_SECONDS = 3;
35
55
 
36
56
  export async function createFallbackSession(
37
57
  ctx: MediaContext,
@@ -86,16 +106,80 @@ export async function createFallbackSession(
86
106
  * playback smoothly. Returns early on timeout so we don't hang forever
87
107
  * if the decoder is producing nothing (e.g. immediately past EOF after
88
108
  * a seek to the end).
109
+ *
110
+ * The gate has three exit paths in order of preference:
111
+ *
112
+ * 1. **Fully ready** — audio buffer ≥ target AND ≥1 video frame.
113
+ * The happy path for fast decoders (native + remux never reach
114
+ * this function; this is fallback only).
115
+ *
116
+ * 2. **Video-ready, audio grace period elapsed** — we have video
117
+ * frames but the audio scheduler is still empty. RM/AVI
118
+ * containers commonly deliver a video GOP before their first
119
+ * audio packet, so "no audio yet" ≠ "no audio coming". We give
120
+ * the demuxer a 500 ms grace window from first-frame, then
121
+ * start regardless. Audio will be scheduled at its correct
122
+ * media time once its packets arrive.
123
+ *
124
+ * 3. **Hard timeout** — after {@link READY_TIMEOUT_SECONDS} seconds
125
+ * with neither condition met, start anyway and emit an
126
+ * unconditional diagnostic so the specific underflow is visible.
127
+ *
128
+ * Path #2 is what fixed the "RMVB sits on the play button for 10 s
129
+ * with audio=0ms, frames=N" case — the gate was waiting on audio
130
+ * packets that were several seconds behind in the file stream, and
131
+ * the timeout was the only way out.
89
132
  */
90
133
  async function waitForBuffer(): Promise<void> {
91
134
  const start = performance.now();
135
+ let firstFrameAtMs = 0;
136
+ dbg.info("cold-start",
137
+ `gate entry: want audio ≥ ${READY_AUDIO_BUFFER_SECONDS * 1000}ms + 1 frame`,
138
+ );
92
139
  while (true) {
93
- const audioReady = audio.isNoAudio() || audio.bufferAhead() >= READY_AUDIO_BUFFER_SECONDS;
94
- if (audioReady && renderer.hasFrames()) {
140
+ const audioAhead = audio.isNoAudio() ? Infinity : audio.bufferAhead();
141
+ const audioReady = audio.isNoAudio() || audioAhead >= READY_AUDIO_BUFFER_SECONDS;
142
+ const hasFrames = renderer.hasFrames();
143
+ const nowMs = performance.now();
144
+
145
+ if (hasFrames && firstFrameAtMs === 0) firstFrameAtMs = nowMs;
146
+
147
+ // Happy path: both ready.
148
+ if (audioReady && hasFrames) {
149
+ dbg.info("cold-start",
150
+ `gate satisfied in ${(nowMs - start).toFixed(0)}ms ` +
151
+ `(audio=${(audioAhead * 1000).toFixed(0)}ms, frames=${renderer.queueDepth()})`,
152
+ );
95
153
  return;
96
154
  }
97
- if ((performance.now() - start) / 1000 > READY_TIMEOUT_SECONDS) {
98
- // Give up waiting; play whatever we have.
155
+
156
+ // Grace path: have video, still waiting for audio that's
157
+ // on its way (first 500 ms after first-frame).
158
+ if (
159
+ hasFrames &&
160
+ firstFrameAtMs > 0 &&
161
+ nowMs - firstFrameAtMs >= 500
162
+ ) {
163
+ dbg.info("cold-start",
164
+ `gate released on video-only grace at ${(nowMs - start).toFixed(0)}ms ` +
165
+ `(frames=${renderer.queueDepth()}, audio=${(audioAhead * 1000).toFixed(0)}ms — ` +
166
+ `demuxer hasn't delivered audio packets yet, starting anyway and letting ` +
167
+ `the audio scheduler catch up at its media-time anchor)`,
168
+ );
169
+ return;
170
+ }
171
+
172
+ // Hard timeout.
173
+ if ((nowMs - start) / 1000 > READY_TIMEOUT_SECONDS) {
174
+ dbg.diag("cold-start",
175
+ `gate TIMEOUT after ${READY_TIMEOUT_SECONDS}s — ` +
176
+ `audio=${(audioAhead * 1000).toFixed(0)}ms ` +
177
+ `(needed ${READY_AUDIO_BUFFER_SECONDS * 1000}ms), ` +
178
+ `frames=${renderer.queueDepth()} (needed ≥1). ` +
179
+ `Decoder produced nothing in ${READY_TIMEOUT_SECONDS}s — either a corrupt source, ` +
180
+ `a missing codec, or WASM is catastrophically slow on this file. ` +
181
+ `Check getDiagnostics().runtime for decode counters.`,
182
+ );
99
183
  return;
100
184
  }
101
185
  await new Promise((r) => setTimeout(r, 50));
@@ -22,6 +22,8 @@
22
22
  * breaks libav's sibling-binary loading.
23
23
  */
24
24
 
25
+ import { dbg } from "../../util/debug.js";
26
+
25
27
  export type LibavVariant = "webcodecs" | "default" | "avbridge";
26
28
 
27
29
  export interface LoadLibavOptions {
@@ -85,12 +87,22 @@ export function loadLibav(
85
87
  async function loadVariant(
86
88
  variant: LibavVariant,
87
89
  wantThreads: boolean,
90
+ ): Promise<LibavInstance> {
91
+ return dbg.timed("libav-load", `load "${variant}" (threads=${wantThreads})`, 5000, () =>
92
+ loadVariantInner(variant, wantThreads),
93
+ );
94
+ }
95
+
96
+ async function loadVariantInner(
97
+ variant: LibavVariant,
98
+ wantThreads: boolean,
88
99
  ): Promise<LibavInstance> {
89
100
  const key = cacheKey(variant, wantThreads);
90
101
  const base = `${libavBaseUrl()}/${variant}`;
91
102
  // The custom variant is named `libav-avbridge.mjs`; the npm variants follow
92
103
  // the same convention (`libav-webcodecs.mjs`, `libav-default.mjs`).
93
104
  const variantUrl = `${base}/libav-${variant}.mjs`;
105
+ dbg.info("libav-load", `fetching ${variantUrl}`);
94
106
 
95
107
  // Preflight HEAD-ish check: issue a bytes=0-0 range request so a missing
96
108
  // file fails fast with a clear error instead of hanging deep inside the
@@ -53,11 +53,36 @@ export class VideoRenderer {
53
53
  this.canvas = document.createElement("canvas");
54
54
  this.canvas.style.cssText =
55
55
  "position:absolute;left:0;top:0;width:100%;height:100%;background:black;";
56
- const parent = target.parentElement;
57
- if (parent && getComputedStyle(parent).position === "static") {
58
- parent.style.position = "relative";
56
+
57
+ // Attach the canvas next to the video. When the video lives inside an
58
+ // `<avbridge-video>` shadow root, `target.parentElement` is the
59
+ // positioned `<div part="stage">` wrapper the element created
60
+ // precisely for this purpose. When the video is used standalone
61
+ // (legacy `createPlayer({ target: videoEl })` path), we fall back to
62
+ // `parentNode` — which handles plain Elements, and also ShadowRoots
63
+ // if someone inserts a bare <video> inside their own shadow DOM
64
+ // without a wrapper.
65
+ const parent: ParentNode | null =
66
+ (target.parentElement as ParentNode | null) ?? target.parentNode;
67
+ if (parent && parent instanceof HTMLElement) {
68
+ if (getComputedStyle(parent).position === "static") {
69
+ parent.style.position = "relative";
70
+ }
71
+ }
72
+ if (parent) {
73
+ parent.insertBefore(this.canvas, target);
74
+ } else {
75
+ // No parent at all — the target is detached. Fall back to appending
76
+ // the canvas to document.body so at least the frames are visible
77
+ // somewhere while the consumer fixes their DOM layout. This is a
78
+ // loud fallback: log a warning so the misuse is obvious.
79
+ // eslint-disable-next-line no-console
80
+ console.warn(
81
+ "[avbridge] fallback renderer: target <video> has no parent; " +
82
+ "appending canvas to document.body as a fallback.",
83
+ );
84
+ document.body.appendChild(this.canvas);
59
85
  }
60
- parent?.insertBefore(this.canvas, target);
61
86
  target.style.visibility = "hidden";
62
87
 
63
88
  const ctx = this.canvas.getContext("2d");
@@ -187,7 +187,16 @@ export async function createRemuxPipeline(
187
187
  const vTs = !vNext.done ? vNext.value.timestamp : Number.POSITIVE_INFINITY;
188
188
  const aTs = !aNext.done ? aNext.value.timestamp : Number.POSITIVE_INFINITY;
189
189
 
190
- if (!vNext.done && vTs <= aTs) {
190
+ // Mediabunny's muxer requires the first packet on a fresh Output to
191
+ // be a key packet. We fetched `startVideoPacket` via
192
+ // `videoSink.getKeyPacket(fromTime)` so the first video packet is
193
+ // guaranteed to be a keyframe — but a demuxer can hand us an audio
194
+ // packet with a lower timestamp, which mediabunny rejects with
195
+ // "First packet must be a key packet." Force the first video
196
+ // packet out before we let any audio through.
197
+ const forceVideoFirst = firstVideo && !vNext.done;
198
+
199
+ if (!vNext.done && (forceVideoFirst || vTs <= aTs)) {
191
200
  await videoSource.add(
192
201
  vNext.value,
193
202
  firstVideo && videoConfig ? { decoderConfig: videoConfig } : undefined,
package/src/types.ts CHANGED
@@ -23,6 +23,7 @@ export type ContainerKind =
23
23
  | "avi"
24
24
  | "asf"
25
25
  | "flv"
26
+ | "rm" // RealMedia (.rm / .rmvb)
26
27
  | "ogg"
27
28
  | "wav"
28
29
  | "mp3"
@@ -41,7 +42,10 @@ export type VideoCodec =
41
42
  | "mpeg4" // MPEG-4 Part 2 (DivX/Xvid)
42
43
  | "wmv3"
43
44
  | "vc1"
44
- | "rv40"
45
+ | "rv10" // RealVideo 1.0 (H.263-like)
46
+ | "rv20" // RealVideo G2
47
+ | "rv30" // RealVideo 8
48
+ | "rv40" // RealVideo 9/10
45
49
  | "mpeg2"
46
50
  | "mpeg1"
47
51
  | "theora"
@@ -60,6 +64,11 @@ export type AudioCodec =
60
64
  | "wmav2"
61
65
  | "wmapro"
62
66
  | "alac"
67
+ | "cook" // RealAudio Cooker (G2/RealAudio 8)
68
+ | "ra_144" // RealAudio 1.0 (14.4 kbps)
69
+ | "ra_288" // RealAudio 2.0 (28.8 kbps)
70
+ | "sipr" // RealAudio Sipr (voice codec)
71
+ | "atrac3" // Sony ATRAC3 (sometimes seen in .rm)
63
72
  | (string & {});
64
73
 
65
74
  export interface VideoTrackInfo {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Debug + self-diagnosis helper.
3
+ *
4
+ * avbridge has a lot of async stages (probe → classify → libav load →
5
+ * strategy.execute → decoder pump → cold-start gate → first paint) and
6
+ * when something's slow or wrong the symptom — "it hangs", "it stutters",
7
+ * "it plays audio without video" — is usually nowhere near the actual
8
+ * cause. This module gives us two things:
9
+ *
10
+ * 1. **Gated verbose logging.** `dbg.info(tag, ...)` etc. are no-ops
11
+ * unless the consumer sets `globalThis.AVBRIDGE_DEBUG = true` (or
12
+ * uses the matching `?avbridge_debug` URL search param at dev time).
13
+ * When enabled, every log is prefixed with `[avbridge:<tag>]` so the
14
+ * console is filterable.
15
+ *
16
+ * 2. **Unconditional self-diagnosis.** `dbg.warnIf(cond, tag, ...)`
17
+ * always fires when a suspicious condition is detected, even with
18
+ * debug off. These are the things we *know* mean something is
19
+ * broken or degraded and the user would want to know about — e.g.
20
+ * the cold-start gate timing out, the decoder running slower than
21
+ * realtime, a libav variant taking longer to load than any network
22
+ * should take, >20% of packets getting rejected.
23
+ *
24
+ * The guiding principle: **if a symptom caused more than 10 minutes of
25
+ * human debugging once, add a targeted warning so the next instance
26
+ * self-identifies in the console.** This module is where those
27
+ * warnings live.
28
+ */
29
+
30
+ /** Read the debug flag fresh on every call so it's runtime-toggleable. */
31
+ function isDebugEnabled(): boolean {
32
+ if (typeof globalThis === "undefined") return false;
33
+ const g = globalThis as { AVBRIDGE_DEBUG?: unknown };
34
+ if (g.AVBRIDGE_DEBUG === true) return true;
35
+ // Convenience: if running in a browser with a `?avbridge_debug` search
36
+ // param, flip the flag on automatically. Useful for demos and quick
37
+ // user reproduction without editing code.
38
+ if (typeof location !== "undefined" && typeof URLSearchParams !== "undefined") {
39
+ try {
40
+ const p = new URLSearchParams(location.search);
41
+ if (p.has("avbridge_debug")) {
42
+ g.AVBRIDGE_DEBUG = true;
43
+ return true;
44
+ }
45
+ } catch { /* ignore */ }
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function fmt(tag: string): string {
51
+ return `[avbridge:${tag}]`;
52
+ }
53
+
54
+ /* eslint-disable no-console */
55
+
56
+ export const dbg = {
57
+ /** Verbose — only when debug is enabled. The hot-path normal case. */
58
+ info(tag: string, ...args: unknown[]): void {
59
+ if (isDebugEnabled()) console.info(fmt(tag), ...args);
60
+ },
61
+
62
+ /** Warning — only when debug is enabled. Non-fatal oddities. */
63
+ warn(tag: string, ...args: unknown[]): void {
64
+ if (isDebugEnabled()) console.warn(fmt(tag), ...args);
65
+ },
66
+
67
+ /**
68
+ * Self-diagnosis warning. **Always** emits regardless of debug flag.
69
+ * Use this only for conditions that mean something is actually wrong
70
+ * or degraded — not for routine chatter.
71
+ */
72
+ diag(tag: string, ...args: unknown[]): void {
73
+ console.warn(fmt(tag), ...args);
74
+ },
75
+
76
+ /**
77
+ * Timing helper: wraps an async call and logs its elapsed time when
78
+ * debug is on. The callback runs whether debug is on or off — this is
79
+ * just for the `dbg.info` at the end.
80
+ *
81
+ * Also unconditionally fires `dbg.diag` if the elapsed time exceeds
82
+ * `slowMs`, so "the bootstrap took 8 seconds" shows up even without
83
+ * debug mode enabled.
84
+ */
85
+ async timed<T>(
86
+ tag: string,
87
+ label: string,
88
+ slowMs: number,
89
+ fn: () => Promise<T>,
90
+ ): Promise<T> {
91
+ const start = performance.now();
92
+ try {
93
+ const result = await fn();
94
+ const elapsed = performance.now() - start;
95
+ if (isDebugEnabled()) {
96
+ console.info(fmt(tag), `${label} ${elapsed.toFixed(0)}ms`);
97
+ }
98
+ if (elapsed > slowMs) {
99
+ console.warn(
100
+ fmt(tag),
101
+ `${label} took ${elapsed.toFixed(0)}ms (>${slowMs}ms expected) — ` +
102
+ `this is unusually slow; possible causes: ${hintForTag(tag)}`,
103
+ );
104
+ }
105
+ return result;
106
+ } catch (err) {
107
+ const elapsed = performance.now() - start;
108
+ console.warn(
109
+ fmt(tag),
110
+ `${label} FAILED after ${elapsed.toFixed(0)}ms:`,
111
+ err,
112
+ );
113
+ throw err;
114
+ }
115
+ },
116
+ };
117
+
118
+ function hintForTag(tag: string): string {
119
+ switch (tag) {
120
+ case "probe":
121
+ return "slow network (range request), large sniff window, or libav cold-start";
122
+ case "libav-load":
123
+ return "large .wasm download, misconfigured AVBRIDGE_LIBAV_BASE, or server-side MIME type";
124
+ case "bootstrap":
125
+ return "probe+classify+strategy-init chain; enable AVBRIDGE_DEBUG for a phase breakdown";
126
+ case "cold-start":
127
+ return "decoder is producing output slower than realtime — check framesDecoded in getDiagnostics()";
128
+ default:
129
+ return "unknown stage — enable globalThis.AVBRIDGE_DEBUG for more detail";
130
+ }
131
+ }
@@ -215,6 +215,10 @@ export function sniffContainerFromBytes(head: Uint8Array): ContainerKind {
215
215
  ) return "asf";
216
216
  // FLV: 46 4C 56
217
217
  if (head[0] === 0x46 && head[1] === 0x4c && head[2] === 0x56) return "flv";
218
+ // RealMedia (.rm / .rmvb): ".RMF" — 2E 52 4D 46
219
+ if (head[0] === 0x2e && head[1] === 0x52 && head[2] === 0x4d && head[3] === 0x46) {
220
+ return "rm";
221
+ }
218
222
  // OggS: 4F 67 67 53
219
223
  if (head[0] === 0x4f && head[1] === 0x67 && head[2] === 0x67 && head[3] === 0x53) return "ogg";
220
224
  // FLAC: 66 4C 61 43