avbridge 1.0.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 (103) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +415 -0
  4. package/dist/avi-M5B4SHRM.cjs +164 -0
  5. package/dist/avi-M5B4SHRM.cjs.map +1 -0
  6. package/dist/avi-POCGZ4JX.js +162 -0
  7. package/dist/avi-POCGZ4JX.js.map +1 -0
  8. package/dist/chunk-5ISVAODK.js +80 -0
  9. package/dist/chunk-5ISVAODK.js.map +1 -0
  10. package/dist/chunk-F7YS2XOA.cjs +2966 -0
  11. package/dist/chunk-F7YS2XOA.cjs.map +1 -0
  12. package/dist/chunk-FKM7QBZU.js +2957 -0
  13. package/dist/chunk-FKM7QBZU.js.map +1 -0
  14. package/dist/chunk-J5MCMN3S.js +27 -0
  15. package/dist/chunk-J5MCMN3S.js.map +1 -0
  16. package/dist/chunk-L4NPOJ36.cjs +180 -0
  17. package/dist/chunk-L4NPOJ36.cjs.map +1 -0
  18. package/dist/chunk-NZU7W256.cjs +29 -0
  19. package/dist/chunk-NZU7W256.cjs.map +1 -0
  20. package/dist/chunk-PQTZS7OA.js +147 -0
  21. package/dist/chunk-PQTZS7OA.js.map +1 -0
  22. package/dist/chunk-WD2ZNQA7.js +177 -0
  23. package/dist/chunk-WD2ZNQA7.js.map +1 -0
  24. package/dist/chunk-Y5FYF5KG.cjs +153 -0
  25. package/dist/chunk-Y5FYF5KG.cjs.map +1 -0
  26. package/dist/chunk-Z2FJ5TJC.cjs +82 -0
  27. package/dist/chunk-Z2FJ5TJC.cjs.map +1 -0
  28. package/dist/element.cjs +433 -0
  29. package/dist/element.cjs.map +1 -0
  30. package/dist/element.d.cts +158 -0
  31. package/dist/element.d.ts +158 -0
  32. package/dist/element.js +431 -0
  33. package/dist/element.js.map +1 -0
  34. package/dist/index.cjs +576 -0
  35. package/dist/index.cjs.map +1 -0
  36. package/dist/index.d.cts +80 -0
  37. package/dist/index.d.ts +80 -0
  38. package/dist/index.js +554 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/libav-http-reader-FPYDBMYK.cjs +16 -0
  41. package/dist/libav-http-reader-FPYDBMYK.cjs.map +1 -0
  42. package/dist/libav-http-reader-NQJVY273.js +3 -0
  43. package/dist/libav-http-reader-NQJVY273.js.map +1 -0
  44. package/dist/libav-import-2JURFHEW.js +8 -0
  45. package/dist/libav-import-2JURFHEW.js.map +1 -0
  46. package/dist/libav-import-GST2AMPL.cjs +30 -0
  47. package/dist/libav-import-GST2AMPL.cjs.map +1 -0
  48. package/dist/libav-loader-KA2MAWLM.js +3 -0
  49. package/dist/libav-loader-KA2MAWLM.js.map +1 -0
  50. package/dist/libav-loader-ZHOERPHW.cjs +12 -0
  51. package/dist/libav-loader-ZHOERPHW.cjs.map +1 -0
  52. package/dist/player-BBwbCkdL.d.cts +365 -0
  53. package/dist/player-BBwbCkdL.d.ts +365 -0
  54. package/dist/source-SC6ZEQYR.cjs +28 -0
  55. package/dist/source-SC6ZEQYR.cjs.map +1 -0
  56. package/dist/source-ZFS4H7J3.js +3 -0
  57. package/dist/source-ZFS4H7J3.js.map +1 -0
  58. package/dist/variant-routing-GOHB2RZN.cjs +12 -0
  59. package/dist/variant-routing-GOHB2RZN.cjs.map +1 -0
  60. package/dist/variant-routing-JOBWXYKD.js +3 -0
  61. package/dist/variant-routing-JOBWXYKD.js.map +1 -0
  62. package/package.json +95 -0
  63. package/src/classify/index.ts +1 -0
  64. package/src/classify/rules.ts +214 -0
  65. package/src/convert/index.ts +2 -0
  66. package/src/convert/remux.ts +522 -0
  67. package/src/convert/transcode.ts +329 -0
  68. package/src/diagnostics.ts +99 -0
  69. package/src/element/avbridge-player.ts +576 -0
  70. package/src/element.ts +19 -0
  71. package/src/events.ts +71 -0
  72. package/src/index.ts +42 -0
  73. package/src/libav-stubs.d.ts +24 -0
  74. package/src/player.ts +455 -0
  75. package/src/plugins/builtin.ts +37 -0
  76. package/src/plugins/registry.ts +32 -0
  77. package/src/probe/avi.ts +242 -0
  78. package/src/probe/index.ts +59 -0
  79. package/src/probe/mediabunny.ts +194 -0
  80. package/src/strategies/fallback/audio-output.ts +293 -0
  81. package/src/strategies/fallback/clock.ts +7 -0
  82. package/src/strategies/fallback/decoder.ts +660 -0
  83. package/src/strategies/fallback/index.ts +170 -0
  84. package/src/strategies/fallback/libav-import.ts +27 -0
  85. package/src/strategies/fallback/libav-loader.ts +190 -0
  86. package/src/strategies/fallback/variant-routing.ts +43 -0
  87. package/src/strategies/fallback/video-renderer.ts +216 -0
  88. package/src/strategies/hybrid/decoder.ts +641 -0
  89. package/src/strategies/hybrid/index.ts +139 -0
  90. package/src/strategies/native.ts +107 -0
  91. package/src/strategies/remux/annexb.ts +112 -0
  92. package/src/strategies/remux/index.ts +79 -0
  93. package/src/strategies/remux/mse.ts +234 -0
  94. package/src/strategies/remux/pipeline.ts +254 -0
  95. package/src/subtitles/index.ts +91 -0
  96. package/src/subtitles/render.ts +62 -0
  97. package/src/subtitles/srt.ts +62 -0
  98. package/src/subtitles/vtt.ts +5 -0
  99. package/src/types-shim.d.ts +3 -0
  100. package/src/types.ts +360 -0
  101. package/src/util/codec-strings.ts +86 -0
  102. package/src/util/libav-http-reader.ts +315 -0
  103. package/src/util/source.ts +274 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * libav.js HTTP block reader.
3
+ *
4
+ * Wraps `libav.mkblockreaderdev` + `libav.onblockread` +
5
+ * `libav.ff_block_reader_dev_send` so that libav can demux a remote file
6
+ * via HTTP Range requests instead of needing the entire file in memory.
7
+ *
8
+ * Used by the AVI/ASF/FLV probe path and the libav-backed playback /
9
+ * conversion strategies whenever the source is a URL.
10
+ *
11
+ * Design notes:
12
+ *
13
+ * - **Range support detection** is done by issuing a `Range: bytes=0-0`
14
+ * probe request. We do NOT trust `Accept-Ranges` headers — some servers
15
+ * support ranges but don't advertise them, others advertise but don't.
16
+ * The probe request is the canonical signal: a `206 Partial Content`
17
+ * response means we can stream; anything else fails fast with a clear
18
+ * error. We never silently fall back to a full download.
19
+ *
20
+ * - **Sequential reads.** libav can issue overlapping `onblockread`
21
+ * callbacks. The reader serializes them through a single async queue
22
+ * so a) `ff_block_reader_dev_send` calls are well-ordered and b) we
23
+ * never have two in-flight fetches for unrelated reads. Throughput
24
+ * for v1 is "good enough"; correctness > parallelism.
25
+ *
26
+ * - **In-flight dedup.** If libav asks for `(pos=1000, len=4096)` twice
27
+ * in a row before the first request resolves, the second call awaits
28
+ * the first instead of issuing a duplicate fetch. This handles the
29
+ * "demuxer re-reads the same header" pattern cheaply.
30
+ *
31
+ * - **Read-ahead clamp.** libav's requested length is doubled, then
32
+ * clamped to `[256 KB, 1 MB]`. Small reads get amortized; pathological
33
+ * large requests don't OOM us.
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.
37
+ *
38
+ * - **Safe detach.** `detach()` clears `libav.onblockread`, sets a
39
+ * destroyed flag, and ignores any in-flight fetch resolutions so we
40
+ * never write into a torn-down demuxer.
41
+ */
42
+
43
+ const MIN_READ = 256 * 1024;
44
+ const MAX_READ = 1 * 1024 * 1024;
45
+
46
+ interface LibavLike {
47
+ mkblockreaderdev(name: string, size: number): Promise<void>;
48
+ unlinkreadaheadfile(name: string): Promise<void>;
49
+ ff_block_reader_dev_send(
50
+ name: string,
51
+ pos: number,
52
+ data: Uint8Array | null,
53
+ opts?: { errorCode?: number; error?: unknown },
54
+ ): Promise<void>;
55
+ onblockread?: (filename: string, pos: number, length: number) => void;
56
+ }
57
+
58
+ export interface LibavHttpReaderHandle {
59
+ /** Total file size (bytes) reported by the server. */
60
+ readonly size: number;
61
+ /** Always `"http-range"` for now. Reserved for future transports. */
62
+ readonly transport: "http-range";
63
+ /** Stop serving reads, clear the libav callback, and ignore late fetches. */
64
+ detach(): Promise<void>;
65
+ }
66
+
67
+ export interface AttachLibavHttpReaderOptions {
68
+ /** Optional `RequestInit` extras (mode, credentials, headers, etc.). */
69
+ requestInit?: RequestInit;
70
+ /** Override fetch (for testing). Defaults to globalThis.fetch. */
71
+ fetchFn?: typeof fetch;
72
+ }
73
+
74
+ /**
75
+ * Result of preparing a libav-readable file from a normalized source.
76
+ * Either an in-memory Blob (created via `mkreadaheadfile`) or a streaming
77
+ * HTTP reader (created via `attachLibavHttpReader`). Callers should
78
+ * `await detach()` when done so resources are cleaned up symmetrically.
79
+ */
80
+ export interface LibavInputHandle {
81
+ /** The virtual filename libav sees — pass to `ff_init_demuxer_file`. */
82
+ readonly filename: string;
83
+ /** "blob" for in-memory, "http-range" for streaming URL. */
84
+ readonly transport: "blob" | "http-range";
85
+ /** Total file size in bytes if known, otherwise undefined. */
86
+ readonly size: number | undefined;
87
+ /** Tear down the virtual file (and any HTTP reader state). */
88
+ detach(): Promise<void>;
89
+ }
90
+
91
+ interface LibavLikeWithBlob extends LibavLike {
92
+ mkreadaheadfile(name: string, blob: Blob): Promise<void>;
93
+ }
94
+
95
+ /**
96
+ * Convenience for the libav-backed strategies. Given a normalized source,
97
+ * either creates an in-memory readahead file (for Blob inputs) or attaches
98
+ * the HTTP block reader (for URL inputs). Returns a handle the caller
99
+ * should detach when done.
100
+ */
101
+ export async function prepareLibavInput(
102
+ libav: LibavLikeWithBlob,
103
+ filename: string,
104
+ source: import("./source.js").NormalizedSource,
105
+ ): Promise<LibavInputHandle> {
106
+ if (source.kind === "url") {
107
+ const handle = await attachLibavHttpReader(libav, filename, source.url);
108
+ return {
109
+ filename,
110
+ transport: "http-range",
111
+ size: handle.size,
112
+ detach: () => handle.detach(),
113
+ };
114
+ }
115
+ await libav.mkreadaheadfile(filename, source.blob);
116
+ return {
117
+ filename,
118
+ transport: "blob",
119
+ size: source.byteLength,
120
+ detach: async () => {
121
+ try { await libav.unlinkreadaheadfile(filename); } catch { /* ignore */ }
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Attach an HTTP block reader to a libav.js instance. After this resolves,
128
+ * libav can `ff_init_demuxer_file(filename)` and the demuxer will pull
129
+ * bytes via Range requests instead of needing a Blob.
130
+ *
131
+ * Fails fast (before any libav setup) if the server doesn't support
132
+ * Range requests.
133
+ */
134
+ export async function attachLibavHttpReader(
135
+ libav: LibavLike,
136
+ filename: string,
137
+ url: string,
138
+ options: AttachLibavHttpReaderOptions = {},
139
+ ): Promise<LibavHttpReaderHandle> {
140
+ const fetchFn = options.fetchFn ?? fetch;
141
+
142
+ // 1. Probe the server with a single-byte Range request.
143
+ let probeRes: Response;
144
+ try {
145
+ probeRes = await fetchFn(url, {
146
+ ...options.requestInit,
147
+ headers: {
148
+ ...(options.requestInit?.headers ?? {}),
149
+ Range: "bytes=0-0",
150
+ },
151
+ });
152
+ } catch (err) {
153
+ throw new Error(
154
+ `libav HTTP reader: failed to reach ${url}: ${(err as Error).message}`,
155
+ );
156
+ }
157
+ if (probeRes.status !== 206) {
158
+ // 200 means the server ignored Range and would have sent the whole
159
+ // file. We refuse to silently slurp gigabytes.
160
+ throw new Error(
161
+ `libav HTTP reader: ${url} does not support HTTP Range requests ` +
162
+ `(server returned ${probeRes.status} for a Range probe; need 206 Partial Content). ` +
163
+ `Remote AVI/ASF/FLV playback requires a server that honors byte-range requests.`,
164
+ );
165
+ }
166
+
167
+ // 2. Parse total file size from Content-Range: "bytes 0-0/12345678"
168
+ const contentRange = probeRes.headers.get("content-range") ?? "";
169
+ const sizeMatch = contentRange.match(/\/(\d+)$/);
170
+ if (!sizeMatch) {
171
+ throw new Error(
172
+ `libav HTTP reader: ${url} returned 206 but no parseable Content-Range header (got: "${contentRange}")`,
173
+ );
174
+ }
175
+ const size = parseInt(sizeMatch[1], 10);
176
+ if (!Number.isFinite(size) || size <= 0) {
177
+ throw new Error(
178
+ `libav HTTP reader: ${url} reported invalid file size ${size}`,
179
+ );
180
+ }
181
+
182
+ // Drain the probe body so the connection can be reused.
183
+ try { await probeRes.arrayBuffer(); } catch { /* ignore */ }
184
+
185
+ // 3. Create the virtual file libav will read from.
186
+ await libav.mkblockreaderdev(filename, size);
187
+
188
+ // ── State ───────────────────────────────────────────────────────────────
189
+
190
+ let detached = false;
191
+ // Most-recently fetched block. Cached so re-reads of the same region
192
+ // (e.g. demuxer re-walks the header) don't issue another HTTP request.
193
+ let cached: { pos: number; bytes: Uint8Array } | null = null;
194
+ // The currently in-flight fetch, if any. Used both for serialization
195
+ // (we await this before starting another) and for in-flight dedup.
196
+ let inflight: Promise<void> | null = null;
197
+
198
+ function clampReadLength(requested: number): number {
199
+ const doubled = requested * 2;
200
+ if (doubled < MIN_READ) return MIN_READ;
201
+ if (doubled > MAX_READ) return MAX_READ;
202
+ return doubled;
203
+ }
204
+
205
+ /** True if the cached block fully covers `[pos, pos+length)`. */
206
+ function cacheCovers(pos: number, length: number): boolean {
207
+ if (!cached) return false;
208
+ return pos >= cached.pos && pos + length <= cached.pos + cached.bytes.byteLength;
209
+ }
210
+
211
+ /** Slice the requested window out of the cached block. */
212
+ function sliceFromCache(pos: number, length: number): Uint8Array {
213
+ if (!cached) throw new Error("sliceFromCache called with no cache");
214
+ const offset = pos - cached.pos;
215
+ return cached.bytes.subarray(offset, offset + length);
216
+ }
217
+
218
+ /** Fetch one Range and update the cache. */
219
+ async function fetchRange(pos: number, length: number): Promise<Uint8Array> {
220
+ const end = Math.min(pos + length - 1, size - 1);
221
+ const res = await fetchFn(url, {
222
+ ...options.requestInit,
223
+ headers: {
224
+ ...(options.requestInit?.headers ?? {}),
225
+ Range: `bytes=${pos}-${end}`,
226
+ },
227
+ });
228
+ if (res.status !== 206 && res.status !== 200) {
229
+ throw new Error(
230
+ `libav HTTP reader: Range request bytes=${pos}-${end} returned ${res.status}`,
231
+ );
232
+ }
233
+ const buf = new Uint8Array(await res.arrayBuffer());
234
+ cached = { pos, bytes: buf };
235
+ return buf;
236
+ }
237
+
238
+ /**
239
+ * Handle a single libav read request. Serializes against any in-flight
240
+ * read by chaining off `inflight`. Honors `detached` at every async
241
+ * boundary so a torn-down reader never writes back into libav.
242
+ */
243
+ async function handleRead(name: string, pos: number, length: number): Promise<void> {
244
+ // Wait for any preceding read to finish so we don't interleave.
245
+ if (inflight) {
246
+ try { await inflight; } catch { /* ignore — that read's own caller handled it */ }
247
+ }
248
+ if (detached) return;
249
+
250
+ // Cache hit — reply directly without a network round-trip.
251
+ if (cacheCovers(pos, length)) {
252
+ const data = sliceFromCache(pos, length);
253
+ try { await libav.ff_block_reader_dev_send(name, pos, data); } catch { /* ignore — libav may have torn down */ }
254
+ return;
255
+ }
256
+
257
+ // Cache miss — fetch via Range. Read-ahead amortizes small reads.
258
+ const fetchLen = clampReadLength(length);
259
+ const fetched = (async () => {
260
+ try {
261
+ const buf = await fetchRange(pos, fetchLen);
262
+ if (detached) return;
263
+ // Slice exactly what libav asked for and send it back.
264
+ const reply = buf.subarray(0, Math.min(length, buf.byteLength));
265
+ try { await libav.ff_block_reader_dev_send(name, pos, reply); } catch { /* ignore */ }
266
+ } catch (err) {
267
+ if (detached) return;
268
+ // Signal EOF + error code to libav so the demuxer surfaces it.
269
+ try {
270
+ await libav.ff_block_reader_dev_send(name, pos, null, {
271
+ error: err,
272
+ });
273
+ } catch { /* ignore */ }
274
+ }
275
+ })();
276
+ inflight = fetched;
277
+ try { await fetched; } finally { if (inflight === fetched) inflight = null; }
278
+ }
279
+
280
+ // 4. Wire the callback. The signature accepts `(name, pos, length)` and
281
+ // we hand it to handleRead which does all the work asynchronously.
282
+ // Note: libav.js dispatches this synchronously from a worker message,
283
+ // so we kick off handleRead but don't await — the queue inside handleRead
284
+ // serializes things.
285
+ const previousCallback = libav.onblockread;
286
+ libav.onblockread = (name: string, pos: number, length: number) => {
287
+ if (detached || name !== filename) {
288
+ // Forward to any previous callback (e.g. another reader on the same
289
+ // libav instance). This is rare in practice but cheap to support.
290
+ previousCallback?.(name, pos, length);
291
+ return;
292
+ }
293
+ void handleRead(name, pos, length);
294
+ };
295
+
296
+ return {
297
+ size,
298
+ transport: "http-range",
299
+ async detach() {
300
+ if (detached) return;
301
+ detached = true;
302
+ // Restore the previous callback (if any) so we don't break unrelated
303
+ // readers on the same libav instance.
304
+ libav.onblockread = previousCallback;
305
+ // Wait for the last in-flight read to settle so we don't tear down
306
+ // the virtual file while libav is still expecting a response.
307
+ if (inflight) {
308
+ try { await inflight; } catch { /* ignore */ }
309
+ }
310
+ // Drop the cache and unlink the virtual file.
311
+ cached = null;
312
+ try { await libav.unlinkreadaheadfile(filename); } catch { /* ignore */ }
313
+ },
314
+ };
315
+ }
@@ -0,0 +1,274 @@
1
+ import type { ContainerKind, MediaInput } from "../types.js";
2
+
3
+ /**
4
+ * Bytes needed by the sniffer to identify every container we recognize.
5
+ * MPEG-TS needs the most: a sync byte at offset 0 *and* offset 188 (one TS
6
+ * packet apart). Allow a little extra for the M2TS variant (offset 4/192).
7
+ */
8
+ const SNIFF_BYTES_NEEDED = 380;
9
+
10
+ /**
11
+ * Bytes to fetch from a URL during the initial sniff. We grab a slightly
12
+ * larger range than `SNIFF_BYTES_NEEDED` so the cache has some headroom for
13
+ * the demuxer's first read after sniffing, in case it wants to look at
14
+ * a few extra bytes (e.g. mp4 ftyp + first moov box).
15
+ */
16
+ const URL_SNIFF_RANGE_BYTES = 32 * 1024;
17
+
18
+ /**
19
+ * `NormalizedSource` is a discriminated union: every consumer (probe,
20
+ * strategies) decides what to do based on `kind`. URL sources are NOT
21
+ * fetched eagerly; we only do a Range request for the first ~32 KB so the
22
+ * sniffer has bytes to look at. The strategies are then handed the URL
23
+ * directly so they can stream the rest via Range requests.
24
+ *
25
+ * For File / Blob / ArrayBuffer / Uint8Array sources, the bytes are
26
+ * already in memory, so we wrap them as a `blob` variant.
27
+ */
28
+ export type NormalizedSource =
29
+ | {
30
+ kind: "blob";
31
+ blob: Blob;
32
+ name?: string;
33
+ byteLength: number;
34
+ original: MediaInput;
35
+ }
36
+ | {
37
+ kind: "url";
38
+ url: string;
39
+ /** Bytes pulled via Range request for the sniffer. NOT the full file. */
40
+ sniffBytes: Uint8Array;
41
+ name?: string;
42
+ /** Total file size from Content-Length / Content-Range. May be undefined. */
43
+ byteLength: number | undefined;
44
+ original: MediaInput;
45
+ };
46
+
47
+ /** True if this source carries the entire file's bytes (vs. streaming). */
48
+ export function isInMemorySource(source: NormalizedSource): source is Extract<NormalizedSource, { kind: "blob" }> {
49
+ return source.kind === "blob";
50
+ }
51
+
52
+
53
+ /**
54
+ * Normalize a `MediaInput` for the probe + strategy layers. **Does not**
55
+ * download URL sources in full — only fetches the first ~32 KB via a
56
+ * Range request, which is enough for the sniffer to identify the
57
+ * container. The strategies are then expected to stream the rest via
58
+ * mediabunny's `UrlSource` (Range requests, prefetch, parallelism, cache).
59
+ *
60
+ * For non-URL inputs, the bytes are already in memory and we just wrap them.
61
+ */
62
+ export async function normalizeSource(source: MediaInput): Promise<NormalizedSource> {
63
+ if (source instanceof File) {
64
+ return {
65
+ kind: "blob",
66
+ blob: source,
67
+ name: source.name,
68
+ byteLength: source.size,
69
+ original: source,
70
+ };
71
+ }
72
+ if (source instanceof Blob) {
73
+ return { kind: "blob", blob: source, byteLength: source.size, original: source };
74
+ }
75
+ if (source instanceof ArrayBuffer) {
76
+ const blob = new Blob([source]);
77
+ return { kind: "blob", blob, byteLength: blob.size, original: source };
78
+ }
79
+ if (source instanceof Uint8Array) {
80
+ const blob = new Blob([source as BlobPart]);
81
+ return { kind: "blob", blob, byteLength: blob.size, original: source };
82
+ }
83
+ if (typeof source === "string" || source instanceof URL) {
84
+ const url = source instanceof URL ? source.toString() : source;
85
+ return await fetchUrlForSniff(url, source);
86
+ }
87
+ throw new TypeError("unsupported source type");
88
+ }
89
+
90
+ /**
91
+ * Fetch the first ~32 KB of a URL via a Range request. Falls back to a
92
+ * full GET if the server doesn't support range requests, but in that case
93
+ * we only read the first 32 KB and abort the rest of the response so we
94
+ * don't accidentally buffer a large file.
95
+ */
96
+ async function fetchUrlForSniff(url: string, originalSource: MediaInput): Promise<NormalizedSource> {
97
+ const name = url.split("/").pop()?.split("?")[0] ?? undefined;
98
+
99
+ // First attempt: Range request for the sniff window.
100
+ let res: Response;
101
+ try {
102
+ res = await fetch(url, {
103
+ headers: { Range: `bytes=0-${URL_SNIFF_RANGE_BYTES - 1}` },
104
+ });
105
+ } catch (err) {
106
+ throw new Error(`failed to fetch source ${url}: ${(err as Error).message}`);
107
+ }
108
+ if (!res.ok && res.status !== 206) {
109
+ throw new Error(`failed to fetch source ${url}: ${res.status} ${res.statusText}`);
110
+ }
111
+
112
+ // Determine the total file size from Content-Range (preferred) or Content-Length.
113
+ let byteLength: number | undefined;
114
+ const contentRange = res.headers.get("content-range");
115
+ if (contentRange) {
116
+ // "bytes 0-32767/12345678" — parse the part after the slash
117
+ const m = contentRange.match(/\/(\d+)$/);
118
+ if (m) byteLength = parseInt(m[1], 10);
119
+ }
120
+ if (byteLength === undefined) {
121
+ const cl = res.headers.get("content-length");
122
+ if (cl) {
123
+ const n = parseInt(cl, 10);
124
+ if (Number.isFinite(n)) {
125
+ // If the server returned 200 (full body), Content-Length is the
126
+ // FILE size. If 206 (partial), it's the chunk size — only use it
127
+ // as a total if no Content-Range was present (server doesn't do
128
+ // ranges) AND the full response is smaller than our sniff window.
129
+ if (res.status === 200) byteLength = n;
130
+ else if (res.status === 206 && !contentRange) byteLength = n;
131
+ }
132
+ }
133
+ }
134
+
135
+ // Read the sniff bytes. If the server ignored the Range header and is
136
+ // streaming the full file, only read the first window and let the rest
137
+ // be GC'd. We use a reader so we can stop early.
138
+ const reader = res.body?.getReader();
139
+ if (!reader) {
140
+ // No streamed body (some test environments). Fall back to .arrayBuffer()
141
+ // and slice — this might pull more than we wanted, but only for the
142
+ // initial sniff, not the full file.
143
+ const buf = new Uint8Array(await res.arrayBuffer());
144
+ const sniffBytes = buf.slice(0, URL_SNIFF_RANGE_BYTES);
145
+ return { kind: "url", url, sniffBytes, name, byteLength, original: originalSource };
146
+ }
147
+
148
+ const chunks: Uint8Array[] = [];
149
+ let collected = 0;
150
+ while (collected < URL_SNIFF_RANGE_BYTES) {
151
+ const { done, value } = await reader.read();
152
+ if (done) break;
153
+ chunks.push(value);
154
+ collected += value.byteLength;
155
+ }
156
+ // Cancel the response so we don't keep downloading.
157
+ await reader.cancel().catch(() => { /* ignore */ });
158
+
159
+ // Concatenate up to URL_SNIFF_RANGE_BYTES.
160
+ const total = Math.min(collected, URL_SNIFF_RANGE_BYTES);
161
+ const sniffBytes = new Uint8Array(total);
162
+ let offset = 0;
163
+ for (const chunk of chunks) {
164
+ if (offset >= total) break;
165
+ const room = total - offset;
166
+ sniffBytes.set(chunk.subarray(0, Math.min(chunk.byteLength, room)), offset);
167
+ offset += chunk.byteLength;
168
+ }
169
+
170
+ return { kind: "url", url, sniffBytes, name, byteLength, original: originalSource };
171
+ }
172
+
173
+ /**
174
+ * Identify the container family from a small byte buffer. Used by the
175
+ * probe layer for both file (Blob → first 380 bytes) and URL (Range
176
+ * request → first 32 KB) inputs.
177
+ *
178
+ * Sniffing intentionally does not trust file extensions.
179
+ */
180
+ export function sniffContainerFromBytes(head: Uint8Array): ContainerKind {
181
+ // MPEG-TS: sync byte 0x47 every 188 bytes. Verify at least two sync
182
+ // bytes in the right places to avoid false positives. Some captures
183
+ // start with a few junk bytes — also try offsets 4 and 192 (M2TS).
184
+ if (head.length >= 376 && head[0] === 0x47 && head[188] === 0x47) {
185
+ return "mpegts";
186
+ }
187
+ if (head.length >= 380 && head[4] === 0x47 && head[192] === 0x47) {
188
+ return "mpegts"; // M2TS — 4-byte timestamp prefix per packet
189
+ }
190
+ // RIFF....AVI → AVI
191
+ if (
192
+ head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46 &&
193
+ head[8] === 0x41 && head[9] === 0x56 && head[10] === 0x49
194
+ ) return "avi";
195
+ // RIFF....WAVE → WAV
196
+ if (
197
+ head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46 &&
198
+ head[8] === 0x57 && head[9] === 0x41 && head[10] === 0x56 && head[11] === 0x45
199
+ ) return "wav";
200
+ // EBML start: 1A 45 DF A3 → MKV/WebM. Distinguish later via DocType.
201
+ if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) {
202
+ return "mkv";
203
+ }
204
+ // ftyp at offset 4 → MP4 family
205
+ if (head[4] === 0x66 && head[5] === 0x74 && head[6] === 0x79 && head[7] === 0x70) {
206
+ // brand at bytes 8..11
207
+ const brand = String.fromCharCode(head[8], head[9], head[10], head[11]);
208
+ if (brand.startsWith("qt")) return "mov";
209
+ return "mp4";
210
+ }
211
+ // ASF / WMV: 30 26 B2 75 8E 66 CF 11
212
+ if (
213
+ head[0] === 0x30 && head[1] === 0x26 && head[2] === 0xb2 && head[3] === 0x75 &&
214
+ head[4] === 0x8e && head[5] === 0x66 && head[6] === 0xcf && head[7] === 0x11
215
+ ) return "asf";
216
+ // FLV: 46 4C 56
217
+ if (head[0] === 0x46 && head[1] === 0x4c && head[2] === 0x56) return "flv";
218
+ // OggS: 4F 67 67 53
219
+ if (head[0] === 0x4f && head[1] === 0x67 && head[2] === 0x67 && head[3] === 0x53) return "ogg";
220
+ // FLAC: 66 4C 61 43
221
+ if (head[0] === 0x66 && head[1] === 0x4c && head[2] === 0x61 && head[3] === 0x43) return "flac";
222
+ // ID3v2: 49 44 33 → MP3 (with id3)
223
+ if (head[0] === 0x49 && head[1] === 0x44 && head[2] === 0x33) return "mp3";
224
+ // MPEG audio frame sync: FF Fx
225
+ if (head[0] === 0xff && (head[1] & 0xe0) === 0xe0) {
226
+ // ADTS: FF F1 / FF F9
227
+ if ((head[1] & 0xf6) === 0xf0) return "adts";
228
+ return "mp3";
229
+ }
230
+ return "unknown";
231
+ }
232
+
233
+ /**
234
+ * Convenience: sniff a `NormalizedSource` regardless of kind. For URL
235
+ * sources, uses the pre-fetched `sniffBytes`. For blob sources, reads the
236
+ * first 380 bytes.
237
+ */
238
+ export async function sniffNormalizedSource(source: NormalizedSource): Promise<ContainerKind> {
239
+ if (source.kind === "url") {
240
+ return sniffContainerFromBytes(source.sniffBytes);
241
+ }
242
+ const buf = await readBlobBytes(source.blob, SNIFF_BYTES_NEEDED);
243
+ return sniffContainerFromBytes(new Uint8Array(buf));
244
+ }
245
+
246
+ /**
247
+ * Backwards-compatible wrapper for code that still passes a Blob directly.
248
+ * Prefer `sniffNormalizedSource` going forward.
249
+ */
250
+ export async function sniffContainer(blob: Blob): Promise<ContainerKind> {
251
+ const buf = await readBlobBytes(blob, SNIFF_BYTES_NEEDED);
252
+ return sniffContainerFromBytes(new Uint8Array(buf));
253
+ }
254
+
255
+ /**
256
+ * Read up to `limit` bytes from a Blob. Tries `Blob.arrayBuffer()` first
257
+ * (modern browsers), then falls back to `FileReader` (works under jsdom).
258
+ */
259
+ async function readBlobBytes(blob: Blob, limit: number): Promise<ArrayBuffer> {
260
+ const slice = blob.slice(0, limit);
261
+ if (typeof (slice as Blob & { arrayBuffer?: () => Promise<ArrayBuffer> }).arrayBuffer === "function") {
262
+ try {
263
+ return await (slice as Blob & { arrayBuffer: () => Promise<ArrayBuffer> }).arrayBuffer();
264
+ } catch {
265
+ /* fall through to FileReader */
266
+ }
267
+ }
268
+ return new Promise((resolve, reject) => {
269
+ const reader = new FileReader();
270
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
271
+ reader.onerror = () => reject(reader.error ?? new Error("FileReader failed"));
272
+ reader.readAsArrayBuffer(slice);
273
+ });
274
+ }