avbridge 2.11.0 → 2.12.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/{avi-B5CQYB7L.cjs → avi-32UABODO.cjs} +14 -6
  3. package/dist/avi-32UABODO.cjs.map +1 -0
  4. package/dist/{avi-2ILLBNPQ.cjs → avi-5BPR6QUX.cjs} +14 -6
  5. package/dist/avi-5BPR6QUX.cjs.map +1 -0
  6. package/dist/{avi-RWWPN2PR.js → avi-BLIH7KKV.js} +13 -5
  7. package/dist/avi-BLIH7KKV.js.map +1 -0
  8. package/dist/{avi-JXU4GQL2.js → avi-GX2H34IQ.js} +13 -5
  9. package/dist/avi-GX2H34IQ.js.map +1 -0
  10. package/dist/{chunk-DCSOQH2N.js → chunk-3AI5WFFN.js} +40 -16
  11. package/dist/chunk-3AI5WFFN.js.map +1 -0
  12. package/dist/{chunk-2NSOOMXW.js → chunk-3YKWU4FM.js} +3 -3
  13. package/dist/{chunk-2NSOOMXW.js.map → chunk-3YKWU4FM.js.map} +1 -1
  14. package/dist/{chunk-GYIJU44C.js → chunk-5CX7BVVV.js} +5 -5
  15. package/dist/{chunk-GYIJU44C.js.map → chunk-5CX7BVVV.js.map} +1 -1
  16. package/dist/{chunk-CL6UEUQF.js → chunk-B76QWPFM.js} +5 -5
  17. package/dist/{chunk-CL6UEUQF.js.map → chunk-B76QWPFM.js.map} +1 -1
  18. package/dist/{chunk-IHNHHEA2.js → chunk-BN7BRTLY.js} +143 -32
  19. package/dist/chunk-BN7BRTLY.js.map +1 -0
  20. package/dist/{chunk-OTFS7DC4.cjs → chunk-E5MAM2P4.cjs} +14 -14
  21. package/dist/{chunk-OTFS7DC4.cjs.map → chunk-E5MAM2P4.cjs.map} +1 -1
  22. package/dist/{chunk-L7A3ECI2.cjs → chunk-HZUVMXBN.cjs} +4 -4
  23. package/dist/{chunk-L7A3ECI2.cjs.map → chunk-HZUVMXBN.cjs.map} +1 -1
  24. package/dist/{chunk-37UOSAVI.cjs → chunk-UM6WCSGL.cjs} +157 -46
  25. package/dist/chunk-UM6WCSGL.cjs.map +1 -0
  26. package/dist/{chunk-BYGZN4Z5.cjs → chunk-VLI3Y6IJ.cjs} +5 -5
  27. package/dist/{chunk-BYGZN4Z5.cjs.map → chunk-VLI3Y6IJ.cjs.map} +1 -1
  28. package/dist/{chunk-Z33SBWL5.cjs → chunk-YPZFGJV3.cjs} +40 -16
  29. package/dist/chunk-YPZFGJV3.cjs.map +1 -0
  30. package/dist/element-browser.js +186 -43
  31. package/dist/element-browser.js.map +1 -1
  32. package/dist/element.cjs +5 -5
  33. package/dist/element.d.cts +1 -1
  34. package/dist/element.d.ts +1 -1
  35. package/dist/element.js +4 -4
  36. package/dist/index.cjs +21 -21
  37. package/dist/index.d.cts +2 -2
  38. package/dist/index.d.ts +2 -2
  39. package/dist/index.js +9 -9
  40. package/dist/{libav-demux-3N5Y3VQA.cjs → libav-demux-575OYCT2.cjs} +9 -9
  41. package/dist/{libav-demux-3N5Y3VQA.cjs.map → libav-demux-575OYCT2.cjs.map} +1 -1
  42. package/dist/{libav-demux-JXD4OTLM.js → libav-demux-SXZDLC7W.js} +4 -4
  43. package/dist/{libav-demux-JXD4OTLM.js.map → libav-demux-SXZDLC7W.js.map} +1 -1
  44. package/dist/libav-http-reader-2S5HAHW4.js +3 -0
  45. package/dist/{libav-http-reader-WXG3Z7AI.js.map → libav-http-reader-2S5HAHW4.js.map} +1 -1
  46. package/dist/libav-http-reader-Q356EO2K.cjs +16 -0
  47. package/dist/{libav-http-reader-AZLE7YFS.cjs.map → libav-http-reader-Q356EO2K.cjs.map} +1 -1
  48. package/dist/{player-DDdNVFDv.d.cts → player-bQ6n4hVp.d.cts} +15 -0
  49. package/dist/{player-DDdNVFDv.d.ts → player-bQ6n4hVp.d.ts} +15 -0
  50. package/dist/player.cjs +264 -53
  51. package/dist/player.cjs.map +1 -1
  52. package/dist/player.d.cts +22 -0
  53. package/dist/player.d.ts +22 -0
  54. package/dist/player.js +264 -53
  55. package/dist/player.js.map +1 -1
  56. package/dist/remux-NSBJFMLG.cjs +35 -0
  57. package/dist/{remux-KUS5GIL6.cjs.map → remux-NSBJFMLG.cjs.map} +1 -1
  58. package/dist/remux-PHUHO3VV.js +10 -0
  59. package/dist/{remux-56V7LDAD.js.map → remux-PHUHO3VV.js.map} +1 -1
  60. package/package.json +1 -1
  61. package/src/element/avbridge-player.ts +123 -23
  62. package/src/element/player-styles.ts +13 -1
  63. package/src/player.ts +3 -3
  64. package/src/probe/avi.ts +34 -2
  65. package/src/strategies/fallback/decoder.ts +148 -19
  66. package/src/strategies/fallback/video-renderer.ts +41 -3
  67. package/src/strategies/hybrid/decoder.ts +34 -9
  68. package/src/types.ts +15 -0
  69. package/src/util/libav-http-reader.ts +58 -19
  70. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.mjs +1 -1
  71. package/vendor/libav/avbridge/libav-6.8.8.0-avbridge.wasm.wasm +0 -0
  72. package/vendor/libav/avbridge/libav-avbridge.mjs +1 -1
  73. package/dist/avi-2ILLBNPQ.cjs.map +0 -1
  74. package/dist/avi-B5CQYB7L.cjs.map +0 -1
  75. package/dist/avi-JXU4GQL2.js.map +0 -1
  76. package/dist/avi-RWWPN2PR.js.map +0 -1
  77. package/dist/chunk-37UOSAVI.cjs.map +0 -1
  78. package/dist/chunk-DCSOQH2N.js.map +0 -1
  79. package/dist/chunk-IHNHHEA2.js.map +0 -1
  80. package/dist/chunk-Z33SBWL5.cjs.map +0 -1
  81. package/dist/libav-http-reader-AZLE7YFS.cjs +0 -16
  82. package/dist/libav-http-reader-WXG3Z7AI.js +0 -3
  83. package/dist/remux-56V7LDAD.js +0 -10
  84. package/dist/remux-KUS5GIL6.cjs +0 -35
@@ -141,11 +141,22 @@ export class VideoRenderer {
141
141
  this.rafHandle = requestAnimationFrame(this.tick);
142
142
  }
143
143
 
144
- /** True once at least one frame has been enqueued. */
144
+ /**
145
+ * True once at least one frame has been enqueued *since the last flush*.
146
+ * Used by `readyState` — initial cold-start reports HAVE_NOTHING until
147
+ * any frame has arrived, and after a seek we want the same semantics
148
+ * (HAVE_NOTHING until post-seek frames arrive), so the cumulative
149
+ * `framesPainted > 0` that used to live here was wrong: it kept the
150
+ * state "true forever" after the first frame ever, so post-seek
151
+ * `waitForBuffer()` would exit immediately with an empty queue and
152
+ * leave video frozen while audio kept going.
153
+ */
145
154
  hasFrames(): boolean {
146
- return this.queue.length > 0 || this.framesPainted > 0;
155
+ return this.queue.length > 0 || this.hasEverEnqueuedSinceFlush;
147
156
  }
148
157
 
158
+ private hasEverEnqueuedSinceFlush = false;
159
+
149
160
  /** Current depth of the frame queue. Used by the decoder for backpressure. */
150
161
  queueDepth(): number {
151
162
  return this.queue.length;
@@ -166,6 +177,7 @@ export class VideoRenderer {
166
177
  return;
167
178
  }
168
179
  this.queue.push(frame);
180
+ this.hasEverEnqueuedSinceFlush = true;
169
181
  if (this.queue.length === 1 && this.framesPainted === 0) {
170
182
  this.resolveFirstFrame();
171
183
  }
@@ -342,7 +354,16 @@ export class VideoRenderer {
342
354
  }
343
355
 
344
356
  // Only drop frames that are more than 2 frame-durations behind.
345
- const dropThresholdUs = audioNowUs - frameDurationUs * 2;
357
+ // Diagnostic escape hatch: `globalThis.AVBRIDGE_RELAX_DROP = true`
358
+ // pushes the threshold so far back that frames are effectively
359
+ // never dropped as late. The display will run behind the audio
360
+ // clock but won't stutter from drop bursts. Useful for isolating
361
+ // "is the problem decode throughput or drop policy?".
362
+ const _relaxDrop =
363
+ (globalThis as { AVBRIDGE_RELAX_DROP?: boolean }).AVBRIDGE_RELAX_DROP === true;
364
+ const dropThresholdUs = _relaxDrop
365
+ ? audioNowUs - 60 * 1_000_000 /* 60 s */
366
+ : audioNowUs - frameDurationUs * 2;
346
367
  let dropped = 0;
347
368
  while (bestIdx > 0) {
348
369
  const ts = this.queue[0].timestamp ?? 0;
@@ -419,6 +440,7 @@ export class VideoRenderer {
419
440
  while (this.queue.length > 0) this.queue.shift()?.close();
420
441
  this.prerolled = false;
421
442
  this.ptsCalibrated = false; // recalibrate at new seek position
443
+ this.hasEverEnqueuedSinceFlush = false; // so waitForBuffer() waits for post-flush frames
422
444
  if (isDebug() && count > 0) {
423
445
  // eslint-disable-next-line no-console
424
446
  console.log(`[avbridge:renderer] FLUSH discarded=${count} painted=${this.framesPainted} drops=${this.framesDroppedLate}`);
@@ -426,11 +448,27 @@ export class VideoRenderer {
426
448
  }
427
449
 
428
450
  stats(): Record<string, unknown> {
451
+ // Queue span — the gap between the oldest and newest queued frame's
452
+ // PTS, in ms. If this collapses while audio keeps advancing, the
453
+ // producer has stalled. If it stays wide with stale head, the
454
+ // producer is bursting faster than realtime but the renderer can't
455
+ // catch up.
456
+ let queueSpanMs = 0;
457
+ let queueHeadMs = 0;
458
+ let queueTailMs = 0;
459
+ if (this.queue.length > 0) {
460
+ queueHeadMs = Math.round((this.queue[0].timestamp ?? 0) / 1000);
461
+ queueTailMs = Math.round((this.queue[this.queue.length - 1].timestamp ?? 0) / 1000);
462
+ queueSpanMs = Math.max(0, queueTailMs - queueHeadMs);
463
+ }
429
464
  return {
430
465
  framesPainted: this.framesPainted,
431
466
  framesDroppedLate: this.framesDroppedLate,
432
467
  framesDroppedOverflow: this.framesDroppedOverflow,
433
468
  queueDepth: this.queue.length,
469
+ queueHeadMs,
470
+ queueTailMs,
471
+ queueSpanMs,
434
472
  };
435
473
  }
436
474
 
@@ -165,6 +165,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
165
165
  // ── Bitstream filter for MPEG-4 Part 2 packed B-frames ───────────────
166
166
  let bsfCtx: number | null = null;
167
167
  let bsfPkt: number | null = null;
168
+ let bsfRequiredButMissing = false;
168
169
  if (videoStream && opts.context.videoTracks[0]?.codec === "mpeg4") {
169
170
  try {
170
171
  bsfCtx = await libav.av_bsf_list_parse_str_js("mpeg4_unpack_bframes");
@@ -175,15 +176,23 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
175
176
  bsfPkt = await libav.av_packet_alloc();
176
177
  dbg.info("bsf", "mpeg4_unpack_bframes BSF active (hybrid)");
177
178
  } else {
178
- // eslint-disable-next-line no-console
179
- console.warn("[avbridge] mpeg4_unpack_bframes BSF not available in hybrid decoder");
179
+ bsfRequiredButMissing = true;
180
180
  bsfCtx = null;
181
181
  }
182
182
  } catch (err) {
183
- // eslint-disable-next-line no-console
184
- console.warn("[avbridge] hybrid: failed to init BSF:", (err as Error).message);
183
+ bsfRequiredButMissing = true;
185
184
  bsfCtx = null;
186
185
  bsfPkt = null;
186
+ dbg.warn("bsf", `hybrid: mpeg4_unpack_bframes BSF init failed: ${(err as Error).message}`);
187
+ }
188
+ if (bsfRequiredButMissing) {
189
+ // eslint-disable-next-line no-console
190
+ console.error(
191
+ "[avbridge] MPEG-4 Part 2 (DivX/Xvid) detected but mpeg4_unpack_bframes " +
192
+ "BSF is unavailable in this libav variant. Files with packed B-frames " +
193
+ "will play with incorrect frame ordering. Rebuild the libav variant " +
194
+ "with the `avbsf` fragment included.",
195
+ );
187
196
  }
188
197
  }
189
198
 
@@ -193,7 +202,13 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
193
202
  for (const pkt of packets) {
194
203
  await libav.ff_copyin_packet(bsfPkt, pkt);
195
204
  const sendErr = await libav.av_bsf_send_packet(bsfCtx, bsfPkt);
196
- if (sendErr < 0) { out.push(pkt); continue; }
205
+ if (sendErr < 0) {
206
+ // BSF rejected — DON'T pass the original through. Its buffer may
207
+ // have been transferred into the worker by ff_copyin_packet, so
208
+ // re-posting it would throw DataCloneError on a detached
209
+ // ArrayBuffer. See fallback/decoder.ts for the full explanation.
210
+ continue;
211
+ }
197
212
  while (true) {
198
213
  const recvErr = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
199
214
  if (recvErr < 0) break;
@@ -206,10 +221,18 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
206
221
  async function flushBSF(): Promise<void> {
207
222
  if (!bsfCtx || !bsfPkt) return;
208
223
  try {
209
- await libav.av_bsf_send_packet(bsfCtx, 0);
210
- while (true) {
211
- const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
212
- if (err < 0) break;
224
+ // Use av_bsf_flush to reset the BSF without putting it in EOF mode.
225
+ // See the matching comment in src/strategies/fallback/decoder.ts —
226
+ // sending NULL as the flush signal puts the BSF into EOF state so
227
+ // subsequent sends fail, which corrupts the post-seek pipeline with
228
+ // detached-buffer DataCloneErrors.
229
+ if (libav.av_bsf_flush) {
230
+ await libav.av_bsf_flush(bsfCtx);
231
+ } else {
232
+ while (true) {
233
+ const err = await libav.av_bsf_receive_packet(bsfCtx, bsfPkt);
234
+ if (err < 0) break;
235
+ }
213
236
  }
214
237
  } catch { /* ignore */ }
215
238
  }
@@ -562,6 +585,7 @@ export async function startHybridDecoder(opts: StartHybridDecoderOptions): Promi
562
585
  videoChunksFed,
563
586
  audioFramesDecoded,
564
587
  bsfApplied: bsfCtx ? ["mpeg4_unpack_bframes"] : [],
588
+ bsfMissing: bsfRequiredButMissing ? ["mpeg4_unpack_bframes"] : [],
565
589
  videoDecodeQueueSize: videoDecoder?.decodeQueueSize ?? 0,
566
590
  // Confirmed transport info — see fallback decoder for the pattern.
567
591
  _transport: inputHandle.transport === "http-range" ? "http-range" : "memory",
@@ -687,6 +711,7 @@ interface LibavRuntime {
687
711
  av_bsf_init(ctx: number): Promise<number>;
688
712
  av_bsf_send_packet(ctx: number, pkt: number): Promise<number>;
689
713
  av_bsf_receive_packet(ctx: number, pkt: number): Promise<number>;
714
+ av_bsf_flush?(ctx: number): Promise<void>;
690
715
  av_bsf_free(ctx: number): Promise<void>;
691
716
  ff_copyin_packet(pktPtr: number, packet: LibavPacket): Promise<void>;
692
717
  ff_copyout_packet(pkt: number): Promise<LibavPacket>;
package/src/types.ts CHANGED
@@ -287,6 +287,14 @@ export interface CreatePlayerOptions {
287
287
  * for interceptors, logging, or environments without a global fetch.
288
288
  */
289
289
  fetchFn?: FetchFn;
290
+ /**
291
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
292
+ * Defaults to 8 MB. Set to `0` to disable caching. Raise this when the
293
+ * app plays seek-heavy legacy-container media from URLs — hot regions
294
+ * (header/moov, tail index, current window) stay resident instead of
295
+ * being re-fetched on every bounce.
296
+ */
297
+ cacheBytes?: number;
290
298
  }
291
299
 
292
300
  /** Signature-compatible with `globalThis.fetch`. */
@@ -296,6 +304,13 @@ export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<
296
304
  export interface TransportConfig {
297
305
  requestInit?: RequestInit;
298
306
  fetchFn?: FetchFn;
307
+ /**
308
+ * Byte budget for the libav HTTP reader's LRU cache of fetched ranges.
309
+ * Defaults to 8 MB. Set to `0` to disable caching entirely. Higher
310
+ * values help seek-heavy network playback keep hot regions
311
+ * (header/moov, tail index, current read) resident.
312
+ */
313
+ cacheBytes?: number;
299
314
  }
300
315
 
301
316
  /** Events emitted by {@link UnifiedPlayer}. Strongly typed. */
@@ -32,8 +32,13 @@
32
32
  * clamped to `[256 KB, 1 MB]`. Small reads get amortized; pathological
33
33
  * large requests don't OOM us.
34
34
  *
35
- * - **Last-block cache.** Only the most-recent fetched block is kept.
36
- * Re-fetches via Range are cheap; an LRU cache is post-1.0.
35
+ * - **LRU block cache.** Fetched blocks are kept in a Map keyed by start
36
+ * position, bounded by a byte budget (default 8 MB, configurable via
37
+ * `cacheBytes`). Map insertion order doubles as recency; re-accessing
38
+ * a block promotes it via delete+set. Eviction walks oldest-first
39
+ * until total bytes fit the budget. Typical seek pattern has three
40
+ * hot regions — header/moov at the front, index at the tail, current
41
+ * read position — all of which fit comfortably under the default.
37
42
  *
38
43
  * - **Safe detach.** `detach()` clears `libav.onblockread`, sets a
39
44
  * destroyed flag, and ignores any in-flight fetch resolutions so we
@@ -42,6 +47,7 @@
42
47
 
43
48
  const MIN_READ = 256 * 1024;
44
49
  const MAX_READ = 1 * 1024 * 1024;
50
+ const DEFAULT_CACHE_BYTES = 8 * 1024 * 1024;
45
51
 
46
52
  interface LibavLike {
47
53
  mkblockreaderdev(name: string, size: number): Promise<void>;
@@ -69,6 +75,12 @@ export interface AttachLibavHttpReaderOptions {
69
75
  requestInit?: RequestInit;
70
76
  /** Override fetch (for testing). Defaults to globalThis.fetch. */
71
77
  fetchFn?: typeof fetch;
78
+ /**
79
+ * Byte budget for the LRU block cache. Defaults to 8 MB. Set to `0`
80
+ * to disable caching. Raise this (e.g. 32 MB) for apps that play
81
+ * seek-heavy legacy-container media over the network.
82
+ */
83
+ cacheBytes?: number;
72
84
  }
73
85
 
74
86
  /**
@@ -108,6 +120,7 @@ export async function prepareLibavInput(
108
120
  const handle = await attachLibavHttpReader(libav, filename, source.url, {
109
121
  requestInit: transport?.requestInit,
110
122
  fetchFn: transport?.fetchFn,
123
+ cacheBytes: transport?.cacheBytes,
111
124
  });
112
125
  return {
113
126
  filename,
@@ -192,9 +205,12 @@ export async function attachLibavHttpReader(
192
205
  // ── State ───────────────────────────────────────────────────────────────
193
206
 
194
207
  let detached = false;
195
- // Most-recently fetched block. Cached so re-reads of the same region
196
- // (e.g. demuxer re-walks the header) don't issue another HTTP request.
197
- let cached: { pos: number; bytes: Uint8Array } | null = null;
208
+ // LRU cache of fetched blocks, keyed by start position. Map insertion
209
+ // order = recency. Bounded by `cacheBudget` bytes; evicts oldest-first
210
+ // on overflow. Set budget to 0 to disable caching.
211
+ const cache = new Map<number, Uint8Array>();
212
+ let cacheBytes = 0;
213
+ const cacheBudget = Math.max(0, options.cacheBytes ?? DEFAULT_CACHE_BYTES);
198
214
  // The currently in-flight fetch, if any. Used both for serialization
199
215
  // (we await this before starting another) and for in-flight dedup.
200
216
  let inflight: Promise<void> | null = null;
@@ -206,17 +222,39 @@ export async function attachLibavHttpReader(
206
222
  return doubled;
207
223
  }
208
224
 
209
- /** True if the cached block fully covers `[pos, pos+length)`. */
210
- function cacheCovers(pos: number, length: number): boolean {
211
- if (!cached) return false;
212
- return pos >= cached.pos && pos + length <= cached.pos + cached.bytes.byteLength;
225
+ /**
226
+ * Look up a cached block that fully covers `[pos, pos+length)`. On hit,
227
+ * promote the block to most-recent and return the slice. On miss, null.
228
+ */
229
+ function cacheLookup(pos: number, length: number): Uint8Array | null {
230
+ for (const [blockPos, bytes] of cache) {
231
+ if (pos >= blockPos && pos + length <= blockPos + bytes.byteLength) {
232
+ cache.delete(blockPos);
233
+ cache.set(blockPos, bytes);
234
+ const offset = pos - blockPos;
235
+ return bytes.subarray(offset, offset + length);
236
+ }
237
+ }
238
+ return null;
213
239
  }
214
240
 
215
- /** Slice the requested window out of the cached block. */
216
- function sliceFromCache(pos: number, length: number): Uint8Array {
217
- if (!cached) throw new Error("sliceFromCache called with no cache");
218
- const offset = pos - cached.pos;
219
- return cached.bytes.subarray(offset, offset + length);
241
+ /** Insert a fetched block; evict least-recently-used until under budget. */
242
+ function cacheInsert(pos: number, bytes: Uint8Array): void {
243
+ const existing = cache.get(pos);
244
+ if (existing) {
245
+ cacheBytes -= existing.byteLength;
246
+ cache.delete(pos);
247
+ }
248
+ cache.set(pos, bytes);
249
+ cacheBytes += bytes.byteLength;
250
+ while (cacheBytes > cacheBudget && cache.size > 0) {
251
+ const oldestKey = cache.keys().next().value as number | undefined;
252
+ if (oldestKey === undefined) break;
253
+ const oldest = cache.get(oldestKey);
254
+ if (!oldest) break;
255
+ cache.delete(oldestKey);
256
+ cacheBytes -= oldest.byteLength;
257
+ }
220
258
  }
221
259
 
222
260
  /** Fetch one Range and update the cache. */
@@ -235,7 +273,7 @@ export async function attachLibavHttpReader(
235
273
  );
236
274
  }
237
275
  const buf = new Uint8Array(await res.arrayBuffer());
238
- cached = { pos, bytes: buf };
276
+ cacheInsert(pos, buf);
239
277
  return buf;
240
278
  }
241
279
 
@@ -252,9 +290,9 @@ export async function attachLibavHttpReader(
252
290
  if (detached) return;
253
291
 
254
292
  // Cache hit — reply directly without a network round-trip.
255
- if (cacheCovers(pos, length)) {
256
- const data = sliceFromCache(pos, length);
257
- try { await libav.ff_block_reader_dev_send(name, pos, data); } catch { /* ignore — libav may have torn down */ }
293
+ const hit = cacheLookup(pos, length);
294
+ if (hit) {
295
+ try { await libav.ff_block_reader_dev_send(name, pos, hit); } catch { /* ignore — libav may have torn down */ }
258
296
  return;
259
297
  }
260
298
 
@@ -312,7 +350,8 @@ export async function attachLibavHttpReader(
312
350
  try { await inflight; } catch { /* ignore */ }
313
351
  }
314
352
  // Drop the cache and unlink the virtual file.
315
- cached = null;
353
+ cache.clear();
354
+ cacheBytes = 0;
316
355
  try { await libav.unlinkreadaheadfile(filename); } catch { /* ignore */ }
317
356
  },
318
357
  };