avbridge 2.2.0 → 2.2.1

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 CHANGED
@@ -4,6 +4,51 @@ All notable changes to **avbridge** are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.2.1]
8
+
9
+ ### Fixed
10
+
11
+ - **Canvas renderer no longer stretches non-stage-aspect video.** The
12
+ fallback + hybrid renderer's canvas sat at `width:100%;height:100%`
13
+ with no `object-fit`, so portrait or otherwise non-matching content
14
+ was stretched to fill the stage. Now uses `object-fit: contain` to
15
+ letterbox the bitmap inside the stage.
16
+ - **Strategy switch to `remux` while playing now resumes playback.**
17
+ `doSetStrategy` calls `session.seek()` before `session.play()`, so
18
+ the remux pipeline used to start with `pendingAutoPlay=false`; the
19
+ subsequent `video.play()` would then hit an element whose `src`
20
+ wasn't yet assigned (the MseSink constructs lazily on first write)
21
+ and silently reject. `RemuxPipeline` gained `setAutoPlay()` so
22
+ `session.play()` can flip `pendingAutoPlay=true` mid-flight; the
23
+ MseSink fires `video.play()` as soon as buffered data lands.
24
+ - **Strategy switch from `hybrid` / `fallback` to another backend now
25
+ preserves play state.** Those strategies hide the `<video>` and
26
+ drive playback from their own Web Audio clock, so the underlying
27
+ element's native `paused` was always `true`. `doSetStrategy` read
28
+ `!target.paused` and captured `wasPlaying=false`, skipping the
29
+ restore on the new session. Both strategies now patch a
30
+ configurable `paused` getter on the target that mirrors
31
+ `audio.isPlaying()`, and clean it up on `destroy()`.
32
+ - **`initialStrategy` no longer retries the same failing strategy.**
33
+ `buildInitialDecision` inherited `natural.fallbackChain` verbatim,
34
+ so for a `RISKY_NATIVE` file with `initialStrategy: "remux"` the
35
+ chain still contained `"remux"` — on failure, `startSession` would
36
+ shift it off and retry `remux` before escalating. The synthetic
37
+ decision now filters `initial` out of the inherited chain.
38
+ - **`UnifiedPlayer.destroy()` removes the `ended` listener it
39
+ attached during `bootstrap()`.** Previously the anonymous handler
40
+ leaked across player lifecycles on long-lived target elements
41
+ (e.g. `<avbridge-video>` swapping source), causing gradual
42
+ accumulation and duplicate `ended` events after source reloads.
43
+
44
+ ### Changed
45
+
46
+ - Bundle audit ceiling for the `element-only` scenario raised to
47
+ 20 KB eager gzip. The budget's purpose is catching
48
+ order-of-magnitude regressions (e.g. libav accidentally eager-
49
+ imported), not policing ±200 bytes; realistic first-play cost is
50
+ dominated by the multi-megabyte lazy wasm load.
51
+
7
52
  ## [2.2.0]
8
53
 
9
54
  ### Added
package/README.md CHANGED
@@ -8,9 +8,10 @@
8
8
  > **Play and convert arbitrary video files in the browser. Local files or remote URLs.**
9
9
 
10
10
  A media compatibility layer for the web. Drop in any file — MP4, MKV, AVI,
11
- WMV, FLV, MPEG-TS, DivX — and avbridge picks the best path: native `<video>`
12
- playback, mediabunny remux to fragmented MP4, libav.js demux + WebCodecs
13
- hardware decode, or full WASM software decode. Same API for all of them.
11
+ WMV, FLV, MPEG-TS, DivX, RMVB — and avbridge picks the best path: native
12
+ `<video>` playback, mediabunny remux to fragmented MP4, libav.js demux +
13
+ WebCodecs hardware decode, or full WASM software decode. Same API for all
14
+ of them.
14
15
 
15
16
  **Streaming-first.** Remote URLs are read via HTTP Range requests across all
16
17
  strategies — even AVI/WMV/FLV — so a 4 GB file plays without buffering 4 GB
@@ -46,6 +47,7 @@ MKV (H.264/AAC) → remux → fragmented MP4 via MSE
46
47
  MPEG-TS (H.264) → remux → fragmented MP4 via MSE
47
48
  AVI (H.264) → hybrid → libav demux + hardware decode
48
49
  AVI (DivX) → fallback → smooth software decode
50
+ RMVB (rv40/cook) → fallback → libav software decode
49
51
  ```
50
52
 
51
53
  ## Quick start
@@ -246,107 +248,132 @@ player.getDiagnostics();
246
248
  // reason: "avi container requires libav demux; codecs are hardware-decodable",
247
249
  // width: 1920, height: 1080, duration: 5400,
248
250
  // probedBy: "libav",
251
+ // transport: "http-range",
252
+ // rangeSupported: true,
253
+ // runtime: { decoderType: "webcodecs-hybrid", videoFramesDecoded: 5432, ... },
249
254
  // strategyHistory: [{ strategy: "hybrid", reason: "...", at: 1712764800000 }]
250
255
  // }
251
256
  ```
252
257
 
258
+ ### Debug logging
259
+
260
+ Enable verbose per-stage logging for hard-to-diagnose issues:
261
+
262
+ ```js
263
+ // In the browser console, or before avbridge loads:
264
+ globalThis.AVBRIDGE_DEBUG = true;
265
+ ```
266
+
267
+ The demo pages also accept `?avbridge_debug` in the URL. When enabled,
268
+ every decision point emits a `[avbridge:<tag>]` log covering probe,
269
+ classify, libav load, bootstrap, strategy execute, and cold-start gate
270
+ timings.
271
+
272
+ The following **unconditional diagnostics** also fire — even without the
273
+ flag — when something smells off:
274
+
275
+ - `[avbridge:bootstrap]` — bootstrap chain took >5 s end-to-end
276
+ - `[avbridge:probe]` — probe took >3 s
277
+ - `[avbridge:libav-load]` — libav variant load took >5 s (usually a
278
+ misconfigured base path or server MIME type)
279
+ - `[avbridge:cold-start]` — fallback cold-start gate timed out or
280
+ released on video-only grace after waiting for audio
281
+ - `[avbridge:decode-rate]` — fallback decoder is running under 60% of
282
+ realtime fps for more than 5 seconds (one-shot per session)
283
+ - `[avbridge:overflow-drop]` — renderer is dropping more than 10% of
284
+ decoded frames because the decoder is bursting faster than the
285
+ canvas can drain (one-shot per session)
286
+
287
+ These are designed so "it works on my machine but stutters on your
288
+ file" surfaces the specific reason in the console instead of requiring
289
+ a live debug session.
290
+
253
291
  ## Install
254
292
 
255
293
  ```bash
256
294
  npm install avbridge
257
295
  ```
258
296
 
259
- This gives you the **core package**: probe, classify, native playback, remux,
260
- transcode, and subtitles. No WASM. The full library is ~17 KB gzipped, but
261
- tree-shaking is aggressive what you actually pay for depends on which
262
- exports you import:
297
+ That's it. **No optional peers to install, no binaries to build, no static
298
+ file path to configure.** Both libav.js variants (the 5 MB webcodecs build
299
+ and the 6.5 MB custom avbridge build with AVI/WMV/DivX/rv40 decoders) ship
300
+ inside the tarball under `node_modules/avbridge/vendor/libav/` and are
301
+ lazy-loaded at runtime only if a file actually needs them.
263
302
 
264
- | Import | Eager (gzip) |
265
- |---|---|
266
- | `srtToVtt` | **0.5 KB** |
267
- | `probe`, `classify` | **3 KB** |
268
- | `transcode` | **3.3 KB** |
269
- | `remux` | **4.1 KB** |
270
- | `createPlayer` | **14 KB** |
271
- | `*` (everything) | **17 KB** |
303
+ Packed tarball is **~4 MB**, unpacked **~15 MB** (mostly the two WASM
304
+ binaries). If you only ever play native MP4, you never download a single
305
+ byte of the libav WASM — the loader is behind a dynamic `import()` that
306
+ never fires.
272
307
 
273
- The libav-loader path is split into a lazy chunk (~5 KB extra) that only
274
- loads when a consumer actually invokes the AVI/ASF/FLV remux path.
308
+ ### Two ways to consume
275
309
 
276
- Run `npm run audit:bundle` to verify these numbers in your fork.
310
+ **Bundler (Vite, webpack, Rollup, esbuild):**
277
311
 
278
- ### Optional: fallback / hybrid strategies
312
+ ```ts
313
+ import { createPlayer, remux, transcode, probe, classify } from "avbridge";
314
+ // or
315
+ import "avbridge/element"; // registers <avbridge-video> custom element
316
+ ```
279
317
 
280
- For files that need software decode or libav.js demux (AVI, WMV, FLV,
281
- legacy codecs):
318
+ The tree-shaking budgets below apply to this path. Your bundler resolves
319
+ `mediabunny` and `libavjs-webcodecs-bridge` through normal dependency
320
+ resolution. libav.js binaries live at
321
+ `node_modules/avbridge/vendor/libav/` — the loader finds them
322
+ automatically via `import.meta.url` in the generated chunk.
282
323
 
283
- ```bash
284
- npm install @libav.js/variant-webcodecs libavjs-webcodecs-bridge
285
- ```
324
+ **Plain `<script type="module">` (no bundler):**
286
325
 
287
- This handles MKV/WebM/MP4 containers via the hybrid/fallback strategies.
326
+ ```html
327
+ <script type="module"
328
+ src="/node_modules/avbridge/dist/element-browser.js"></script>
288
329
 
289
- ### Optional: AVI, WMV3, DivX, and other legacy formats
330
+ <avbridge-video src="/video.mkv" autoplay playsinline></avbridge-video>
331
+ ```
290
332
 
291
- For **AVI, WMV3, MPEG-4 Part 2, DivX**, and other legacy formats, you need
292
- a custom libav.js build see [`vendor/libav/README.md`](./vendor/libav/README.md)
293
- for the build recipe.
333
+ This is a second tsup entry (`dist/element-browser.js`) that inlines
334
+ mediabunny + libavjs-webcodecs-bridge into a single ~1.3 MB file with
335
+ zero bare specifiers at runtime. Perfect for self-hosted tools or static
336
+ sites that don't want a build step. It loads libav.js from the same
337
+ co-located `vendor/libav/` tree.
294
338
 
295
- ### Package boundary summary
339
+ ### Bundle sizes (bundler path)
296
340
 
297
- | What you need | What to install |
341
+ | Import | Eager (gzip) |
298
342
  |---|---|
299
- | Playback of MP4/MKV/WebM/**MPEG-TS** + remux/transcode export | `avbridge` (core, no WASM) |
300
- | Fallback/hybrid decode for modern codecs in legacy containers (AVI/ASF/FLV) | + `@libav.js/variant-webcodecs` + `libavjs-webcodecs-bridge` |
301
- | AVI, WMV3, DivX, MPEG-4 Part 2, VC-1 | + custom libav build (`scripts/build-libav.sh`) |
302
-
303
- ### Serving the libav.js binaries
343
+ | `srtToVtt` | **0.5 KB** |
344
+ | `probe`, `classify` | **2.5 KB** |
345
+ | `transcode` | **3 KB** |
346
+ | `remux` | **3.7 KB** |
347
+ | `createPlayer` | **15 KB** |
348
+ | `*` (everything) | **17.5 KB** |
349
+ | `avbridge/element` | **17 KB** |
304
350
 
305
- The optional libav variants ship as `.wasm` + `.mjs` files that need to be
306
- served by your app at a known URL. avbridge looks for them at
307
- `/libav/<variant>/libav-<variant>.mjs` (where `<variant>` is `webcodecs` or
308
- `avbridge`). You can override the base URL with
309
- `globalThis.AVBRIDGE_LIBAV_BASE = "/my-static-path"` before any avbridge
310
- code runs.
351
+ Run `npm run audit:bundle` to verify in your fork.
311
352
 
312
- #### Vite
353
+ ### Overriding the libav path (advanced)
313
354
 
314
- Copy the variant binaries into your `public/libav/` directory at build
315
- time. The avbridge demo does this via `scripts/copy-libav.mjs`:
355
+ If you want to host the libav binaries somewhere other than
356
+ `node_modules/avbridge/vendor/libav/` for example a CDN, a custom
357
+ libav build, or a patched version — set `AVBRIDGE_LIBAV_BASE` **before**
358
+ any avbridge code runs:
316
359
 
317
- ```bash
318
- # In your project, after npm install:
319
- mkdir -p public/libav/webcodecs
320
- cp node_modules/@libav.js/variant-webcodecs/dist/* public/libav/webcodecs/
360
+ ```html
361
+ <script>globalThis.AVBRIDGE_LIBAV_BASE = "https://cdn.example.com/libav";</script>
362
+ <script type="module" src="..."></script>
321
363
  ```
322
364
 
323
- For the custom `avbridge` variant, after running `./scripts/build-libav.sh`
324
- in the avbridge repo, copy `vendor/libav/*` into `public/libav/avbridge/`.
325
-
326
- #### Webpack
327
-
328
- Use `copy-webpack-plugin` to ship the binaries to your output directory at
329
- the same `libav/<variant>/` path.
330
-
331
- #### Plain `<script>` / no bundler
332
-
333
- Drop the variant directory anywhere on your origin and set
334
- `globalThis.AVBRIDGE_LIBAV_BASE` to the matching URL before importing
335
- avbridge.
336
-
337
- If a libav-backed strategy is selected and the binary isn't reachable,
338
- avbridge throws a clear error mentioning the URL it tried to load. The
339
- core (native + remux for modern containers) doesn't need any of this.
365
+ The loader will then fetch `<base>/<variant>/libav-<variant>.mjs` and its
366
+ sibling `.wasm` files. This is the documented replaceability hook for
367
+ LGPL compliance — see [`NOTICE.md`](./NOTICE.md) and
368
+ [`THIRD_PARTY_LICENSES.md`](./THIRD_PARTY_LICENSES.md).
340
369
 
341
370
  ## Known limitations
342
371
 
343
- - The **fallback strategy** uses WASM software decoding and is CPU-intensive, especially for HD video on mobile devices.
344
- - **Remux of AVI/ASF/FLV** requires libav.js — the core package cannot demux these containers.
372
+ - The **fallback strategy** uses WASM software decoding and is CPU-intensive, especially for HD video on mobile devices. The `[avbridge:decode-rate]` diagnostic fires if the decoder falls below 60% of realtime so you know that's what's happening. Codecs with no WebCodecs support (rv40, mpeg4 @ 720p+, wmv3, vc1 at high resolutions) are the usual suspects.
345
373
  - **Remote URL playback requires HTTP Range requests.** Servers that don't support `Range: bytes=...` will fail fast with a clear error rather than silently downloading the whole file. This applies to all strategies.
346
374
  - **H.264 + MP3 in MP4** is a best-effort combination that may produce playback issues in some browsers. Use `strict: true` to reject it, or re-encode audio to AAC via `transcode()`.
347
- - AVI files with **packed B-frames** (some DivX encodes) may have timing issues until the `mpeg4_unpack_bframes` BSF is wired in.
348
- - libav.js **threading is disabled** due to bugs in v6.8.8 decode runs single-threaded with SIMD acceleration.
349
- - `transcode()` v1 only accepts mediabunny-readable inputs (MP4/MKV/WebM/OGG/MOV/MP3/FLAC/WAV). AVI/ASF/FLV transcoding is planned for v1.1.
375
+ - libav.js **threading is disabled** due to known runtime bugs in the v6.8.8 pthreads build decode runs single-threaded with WASM SIMD acceleration.
376
+ - `transcode()` only accepts mediabunny-readable inputs (MP4/MKV/WebM/OGG/MOV/MP3/FLAC/WAV). AVI/ASF/FLV/RM transcoding means "play it first, record the output" — not yet plumbed.
350
377
  - `transcode()` uses **WebCodecs encoders only** — codec availability depends on the browser. AV1 encoding is not yet universal.
351
378
  - For the **hybrid and fallback strategies**, `<avbridge-video>.buffered` returns an empty `TimeRanges` because the canvas-based renderers don't track buffered ranges yet. Native and remux strategies expose the full `<video>.buffered` set as expected.
352
379
 
@@ -1070,6 +1070,10 @@ async function createRemuxPipeline(ctx, video) {
1070
1070
  console.error("[avbridge] remux pipeline reseek failed:", err);
1071
1071
  });
1072
1072
  },
1073
+ setAutoPlay(autoPlay) {
1074
+ pendingAutoPlay = autoPlay;
1075
+ if (sink) sink.setPlayOnSeek(autoPlay);
1076
+ },
1073
1077
  async destroy() {
1074
1078
  destroyed = true;
1075
1079
  pumpToken++;
@@ -1110,7 +1114,11 @@ async function createRemuxSession(context, video) {
1110
1114
  await pipeline.start(video.currentTime || 0, true);
1111
1115
  return;
1112
1116
  }
1113
- await video.play();
1117
+ pipeline.setAutoPlay(true);
1118
+ try {
1119
+ await video.play();
1120
+ } catch {
1121
+ }
1114
1122
  },
1115
1123
  pause() {
1116
1124
  wantPlay = false;
@@ -1158,7 +1166,7 @@ var VideoRenderer = class {
1158
1166
  this.resolveFirstFrame = resolve;
1159
1167
  });
1160
1168
  this.canvas = document.createElement("canvas");
1161
- this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;";
1169
+ this.canvas.style.cssText = "position:absolute;left:0;top:0;width:100%;height:100%;background:black;object-fit:contain;";
1162
1170
  const parent = target.parentElement ?? target.parentNode;
1163
1171
  if (parent && parent instanceof HTMLElement) {
1164
1172
  if (getComputedStyle(parent).position === "static") {
@@ -1400,9 +1408,13 @@ var AudioOutput = class {
1400
1408
  const node = this.ctx.createBufferSource();
1401
1409
  node.buffer = buffer;
1402
1410
  node.connect(this.gain);
1403
- const ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
1404
- const safeStart = Math.max(ctxStart, this.ctx.currentTime);
1405
- node.start(safeStart);
1411
+ let ctxStart = this.ctxTimeAtAnchor + (this.mediaTimeOfNext - this.mediaTimeOfAnchor);
1412
+ if (ctxStart < this.ctx.currentTime) {
1413
+ this.ctxTimeAtAnchor = this.ctx.currentTime;
1414
+ this.mediaTimeOfAnchor = this.mediaTimeOfNext;
1415
+ ctxStart = this.ctx.currentTime;
1416
+ }
1417
+ node.start(ctxStart);
1406
1418
  this.mediaTimeOfNext += frameCount / sampleRate;
1407
1419
  this.framesScheduled++;
1408
1420
  }
@@ -1970,6 +1982,10 @@ async function createHybridSession(ctx, target) {
1970
1982
  void doSeek(v);
1971
1983
  }
1972
1984
  });
1985
+ Object.defineProperty(target, "paused", {
1986
+ configurable: true,
1987
+ get: () => !audio.isPlaying()
1988
+ });
1973
1989
  if (ctx.duration && Number.isFinite(ctx.duration)) {
1974
1990
  Object.defineProperty(target, "duration", {
1975
1991
  configurable: true,
@@ -2034,6 +2050,7 @@ async function createHybridSession(ctx, target) {
2034
2050
  try {
2035
2051
  delete target.currentTime;
2036
2052
  delete target.duration;
2053
+ delete target.paused;
2037
2054
  } catch {
2038
2055
  }
2039
2056
  },
@@ -2113,7 +2130,8 @@ async function startDecoder(opts) {
2113
2130
  let audioFramesDecoded = 0;
2114
2131
  let watchdogFirstFrameMs = 0;
2115
2132
  let watchdogSlowSinceMs = 0;
2116
- let watchdogWarned = false;
2133
+ let watchdogSlowWarned = false;
2134
+ let watchdogOverflowWarned = false;
2117
2135
  let syntheticVideoUs = 0;
2118
2136
  let syntheticAudioUs = 0;
2119
2137
  const videoTrackInfo = opts.context.videoTracks.find((t) => t.id === videoStream?.index);
@@ -2125,7 +2143,7 @@ async function startDecoder(opts) {
2125
2143
  let packets;
2126
2144
  try {
2127
2145
  [readErr, packets] = await libav.ff_read_frame_multi(fmt_ctx, readPkt, {
2128
- limit: 64 * 1024
2146
+ limit: 16 * 1024
2129
2147
  });
2130
2148
  } catch (err) {
2131
2149
  console.error("[avbridge] ff_read_frame_multi failed:", err);
@@ -2134,26 +2152,26 @@ async function startDecoder(opts) {
2134
2152
  if (myToken !== pumpToken || destroyed) return;
2135
2153
  const videoPackets = videoStream ? packets[videoStream.index] : void 0;
2136
2154
  const audioPackets = audioStream ? packets[audioStream.index] : void 0;
2137
- if (videoDec && videoPackets && videoPackets.length > 0) {
2138
- await decodeVideoBatch(videoPackets, myToken);
2139
- }
2140
- if (myToken !== pumpToken || destroyed) return;
2141
2155
  if (audioDec && audioPackets && audioPackets.length > 0) {
2142
2156
  await decodeAudioBatch(audioPackets, myToken);
2143
2157
  }
2158
+ if (myToken !== pumpToken || destroyed) return;
2159
+ if (videoDec && videoPackets && videoPackets.length > 0) {
2160
+ await decodeVideoBatch(videoPackets, myToken);
2161
+ }
2144
2162
  packetsRead += (videoPackets?.length ?? 0) + (audioPackets?.length ?? 0);
2145
2163
  if (videoFramesDecoded > 0) {
2146
2164
  if (watchdogFirstFrameMs === 0) {
2147
2165
  watchdogFirstFrameMs = performance.now();
2148
2166
  }
2149
2167
  const elapsedSinceFirst = (performance.now() - watchdogFirstFrameMs) / 1e3;
2150
- if (elapsedSinceFirst > 1 && !watchdogWarned) {
2168
+ if (elapsedSinceFirst > 1 && !watchdogSlowWarned) {
2151
2169
  const expectedFrames = elapsedSinceFirst * videoFps;
2152
2170
  const ratio = videoFramesDecoded / expectedFrames;
2153
2171
  if (ratio < 0.6) {
2154
2172
  if (watchdogSlowSinceMs === 0) watchdogSlowSinceMs = performance.now();
2155
2173
  if ((performance.now() - watchdogSlowSinceMs) / 1e3 > 5) {
2156
- watchdogWarned = true;
2174
+ watchdogSlowWarned = true;
2157
2175
  console.warn(
2158
2176
  "[avbridge:decode-rate]",
2159
2177
  `decoder is running slower than realtime: ${videoFramesDecoded} frames in ${elapsedSinceFirst.toFixed(1)}s (${(videoFramesDecoded / elapsedSinceFirst).toFixed(1)} fps vs ${videoFps} fps source \u2014 ${(ratio * 100).toFixed(0)}% of realtime). Playback will stutter. Typical causes: software decode of a codec with no WebCodecs support (rv40, mpeg4 @ 720p+, wmv3), or a resolution too large for single-threaded WASM to keep up with.`
@@ -2163,6 +2181,17 @@ async function startDecoder(opts) {
2163
2181
  watchdogSlowSinceMs = 0;
2164
2182
  }
2165
2183
  }
2184
+ if (!watchdogOverflowWarned && videoFramesDecoded > 100) {
2185
+ const rendererStats = opts.renderer.stats();
2186
+ const overflow = rendererStats.framesDroppedOverflow ?? 0;
2187
+ if (overflow / videoFramesDecoded > 0.1) {
2188
+ watchdogOverflowWarned = true;
2189
+ console.warn(
2190
+ "[avbridge:overflow-drop]",
2191
+ `renderer is dropping ${overflow}/${videoFramesDecoded} frames (${(overflow / videoFramesDecoded * 100).toFixed(0)}%) because the decoder is producing bursts faster than the canvas can drain. Symptom: choppy playback despite decoder keeping up on average. Fix would be smaller read batches in the pump loop or a lower queueHighWater cap \u2014 see src/strategies/fallback/decoder.ts.`
2192
+ );
2193
+ }
2194
+ }
2166
2195
  }
2167
2196
  while (!destroyed && myToken === pumpToken && (opts.audio.bufferAhead() > 2 || opts.renderer.queueDepth() >= opts.renderer.queueHighWater)) {
2168
2197
  await new Promise((r) => setTimeout(r, 50));
@@ -2519,6 +2548,10 @@ async function createFallbackSession(ctx, target) {
2519
2548
  void doSeek(v);
2520
2549
  }
2521
2550
  });
2551
+ Object.defineProperty(target, "paused", {
2552
+ configurable: true,
2553
+ get: () => !audio.isPlaying()
2554
+ });
2522
2555
  if (ctx.duration && Number.isFinite(ctx.duration)) {
2523
2556
  Object.defineProperty(target, "duration", {
2524
2557
  configurable: true,
@@ -2527,25 +2560,35 @@ async function createFallbackSession(ctx, target) {
2527
2560
  }
2528
2561
  async function waitForBuffer() {
2529
2562
  const start = performance.now();
2563
+ let firstFrameAtMs = 0;
2530
2564
  dbg.info(
2531
2565
  "cold-start",
2532
- `gate entry: need audio >= ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms + 1 frame`
2566
+ `gate entry: want audio \u2265 ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms + 1 frame`
2533
2567
  );
2534
2568
  while (true) {
2535
2569
  const audioAhead = audio.isNoAudio() ? Infinity : audio.bufferAhead();
2536
2570
  const audioReady = audio.isNoAudio() || audioAhead >= READY_AUDIO_BUFFER_SECONDS2;
2537
2571
  const hasFrames = renderer.hasFrames();
2572
+ const nowMs = performance.now();
2573
+ if (hasFrames && firstFrameAtMs === 0) firstFrameAtMs = nowMs;
2538
2574
  if (audioReady && hasFrames) {
2539
2575
  dbg.info(
2540
2576
  "cold-start",
2541
- `gate satisfied in ${(performance.now() - start).toFixed(0)}ms (audio=${(audioAhead * 1e3).toFixed(0)}ms, frames=${renderer.queueDepth()})`
2577
+ `gate satisfied in ${(nowMs - start).toFixed(0)}ms (audio=${(audioAhead * 1e3).toFixed(0)}ms, frames=${renderer.queueDepth()})`
2542
2578
  );
2543
2579
  return;
2544
2580
  }
2545
- if ((performance.now() - start) / 1e3 > READY_TIMEOUT_SECONDS2) {
2581
+ if (hasFrames && firstFrameAtMs > 0 && nowMs - firstFrameAtMs >= 500) {
2582
+ dbg.info(
2583
+ "cold-start",
2584
+ `gate released on video-only grace at ${(nowMs - start).toFixed(0)}ms (frames=${renderer.queueDepth()}, audio=${(audioAhead * 1e3).toFixed(0)}ms \u2014 demuxer hasn't delivered audio packets yet, starting anyway and letting the audio scheduler catch up at its media-time anchor)`
2585
+ );
2586
+ return;
2587
+ }
2588
+ if ((nowMs - start) / 1e3 > READY_TIMEOUT_SECONDS2) {
2546
2589
  dbg.diag(
2547
2590
  "cold-start",
2548
- `gate TIMEOUT after ${READY_TIMEOUT_SECONDS2}s \u2014 audio=${(audioAhead * 1e3).toFixed(0)}ms (needed ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms), frames=${renderer.queueDepth()} (needed \u22651). Software decoder is producing output slower than realtime \u2014 playback will stutter. Check getDiagnostics().runtime for the decode rate.`
2591
+ `gate TIMEOUT after ${READY_TIMEOUT_SECONDS2}s \u2014 audio=${(audioAhead * 1e3).toFixed(0)}ms (needed ${READY_AUDIO_BUFFER_SECONDS2 * 1e3}ms), frames=${renderer.queueDepth()} (needed \u22651). Decoder produced nothing in ${READY_TIMEOUT_SECONDS2}s \u2014 either a corrupt source, a missing codec, or WASM is catastrophically slow on this file. Check getDiagnostics().runtime for decode counters.`
2549
2592
  );
2550
2593
  return;
2551
2594
  }
@@ -2594,6 +2637,7 @@ async function createFallbackSession(ctx, target) {
2594
2637
  try {
2595
2638
  delete target.currentTime;
2596
2639
  delete target.duration;
2640
+ delete target.paused;
2597
2641
  } catch {
2598
2642
  }
2599
2643
  },
@@ -2736,6 +2780,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
2736
2780
  lastProgressTime = 0;
2737
2781
  lastProgressPosition = -1;
2738
2782
  errorListener = null;
2783
+ // Bound so we can removeEventListener in destroy(); without this the
2784
+ // listener outlives the player and accumulates on elements that swap
2785
+ // source (e.g. <avbridge-video>).
2786
+ endedListener = null;
2739
2787
  // Serializes escalation / setStrategy calls
2740
2788
  switchingPromise = Promise.resolve();
2741
2789
  // Owns blob URLs created during sidecar discovery + SRT->VTT conversion.
@@ -2821,7 +2869,8 @@ var UnifiedPlayer = class _UnifiedPlayer {
2821
2869
  subtitle: ctx.subtitleTracks
2822
2870
  });
2823
2871
  this.startTimeupdateLoop();
2824
- this.options.target.addEventListener("ended", () => this.emitter.emit("ended", void 0));
2872
+ this.endedListener = () => this.emitter.emit("ended", void 0);
2873
+ this.options.target.addEventListener("ended", this.endedListener);
2825
2874
  this.emitter.emitSticky("ready", void 0);
2826
2875
  const bootstrapElapsed = performance.now() - bootstrapStart;
2827
2876
  dbg.info("bootstrap", `ready in ${bootstrapElapsed.toFixed(0)}ms`);
@@ -3107,6 +3156,10 @@ var UnifiedPlayer = class _UnifiedPlayer {
3107
3156
  this.timeupdateInterval = null;
3108
3157
  }
3109
3158
  this.clearSupervisor();
3159
+ if (this.endedListener) {
3160
+ this.options.target.removeEventListener("ended", this.endedListener);
3161
+ this.endedListener = null;
3162
+ }
3110
3163
  if (this.session) {
3111
3164
  await this.session.destroy();
3112
3165
  this.session = null;
@@ -3121,11 +3174,13 @@ async function createPlayer(options) {
3121
3174
  function buildInitialDecision(initial, ctx) {
3122
3175
  const natural = classifyContext(ctx);
3123
3176
  const cls = strategyToClass(initial, natural);
3177
+ const inherited = natural.fallbackChain ?? defaultFallbackChain(initial);
3178
+ const fallbackChain = inherited.filter((s) => s !== initial);
3124
3179
  return {
3125
3180
  class: cls,
3126
3181
  strategy: initial,
3127
3182
  reason: `initial strategy "${initial}" requested via options.initialStrategy`,
3128
- fallbackChain: natural.fallbackChain ?? defaultFallbackChain(initial)
3183
+ fallbackChain
3129
3184
  };
3130
3185
  }
3131
3186
  function strategyToClass(strategy, natural) {
@@ -3155,5 +3210,5 @@ function defaultFallbackChain(strategy) {
3155
3210
  }
3156
3211
 
3157
3212
  export { UnifiedPlayer, avbridgeAudioToMediabunny, avbridgeVideoToMediabunny, buildMediabunnySourceFromInput, classifyContext, createPlayer, probe, srtToVtt };
3158
- //# sourceMappingURL=chunk-C5VA5U5O.js.map
3159
- //# sourceMappingURL=chunk-C5VA5U5O.js.map
3213
+ //# sourceMappingURL=chunk-DMWARSEF.js.map
3214
+ //# sourceMappingURL=chunk-DMWARSEF.js.map