buninu-browser 0.2.1 → 0.2.2

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 (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. package/vendor/ses/dist/ses.mjs +52 -6
@@ -0,0 +1,819 @@
1
+ /**
2
+ * Media Source Extensions, enough for a page to append one container at a
3
+ * time and play it while later segments are still arriving.
4
+ *
5
+ * The event order follows the Media Source spec: appendBuffer returns with
6
+ * updating set, and a later task runs updatestart, the append, then update
7
+ * and updateend. sourceopen fires when an element attaches, not at
8
+ * construction. remove() and abort() do not cut bytes out of the
9
+ * concatenation — splicing an fMP4 or WebM would break the container, and
10
+ * the decoder reopens on the whole snapshot.
11
+ *
12
+ * Codec claims stay inside what this build can demux and decode. Types the
13
+ * engine cannot play are rejected so a page picks another one.
14
+ */
15
+ import { createBytePipe } from "../media/byte-pipe.js";
16
+
17
+ const BRAND = Symbol("MediaSource");
18
+ const BUFFER_BRAND = Symbol("SourceBuffer");
19
+ /** One page cannot grow the concatenated snapshot without bound. */
20
+ const APPEND_QUOTA = 256 * 1024 * 1024;
21
+
22
+ // Family → the token is fully specified only with a profile/level suffix.
23
+ const CODEC_FAMILIES = {
24
+ avc1: true, avc3: true, av01: true, vp09: true, mp4a: true,
25
+ vp8: false, vp9: false, opus: false, vorbis: false, flac: false, mp3: false,
26
+ };
27
+ const CONTAINERS = {
28
+ "video/mp4": ["avc1", "avc3", "av01", "vp09", "vp9", "mp4a"],
29
+ "audio/mp4": ["mp4a", "flac", "opus"],
30
+ "video/webm": ["vp8", "vp9", "vp09", "av01", "opus", "vorbis"],
31
+ "audio/webm": ["opus", "vorbis"],
32
+ "audio/mpeg": ["mp3", "mp4a"],
33
+ "audio/aac": ["mp4a"],
34
+ "audio/ogg": ["opus", "vorbis", "flac"],
35
+ "video/ogg": ["opus", "vorbis"],
36
+ "audio/wav": [],
37
+ "audio/wave": [],
38
+ "audio/x-wav": [],
39
+ "audio/flac": ["flac"],
40
+ };
41
+
42
+ /** Demuxer name for a MIME type, when the URL itself has no extension. */
43
+ export function containerFormat(mime) {
44
+ const base = String(mime ?? "").split(";")[0].trim().toLowerCase();
45
+ if (base === "video/mp4" || base === "audio/mp4" || base === "audio/aac") return "mp4";
46
+ if (base === "video/webm" || base === "audio/webm") return "webm";
47
+ if (base === "video/ogg" || base === "audio/ogg") return "ogg";
48
+ if (base === "audio/mpeg" || base === "audio/mp3") return "mp3";
49
+ if (base === "audio/wav" || base === "audio/wave" || base === "audio/x-wav") return "wav";
50
+ if (base === "audio/flac") return "flac";
51
+ return undefined;
52
+ }
53
+
54
+ function parseMime(type) {
55
+ const raw = String(type ?? "").trim().toLowerCase();
56
+ if (!raw || /[\u0000\r\n]/.test(raw)) return null;
57
+ const parts = raw.split(";").map((part) => part.trim()).filter(Boolean);
58
+ const base = parts[0];
59
+ if (!/^(video|audio)\/[a-z0-9.+-]+$/.test(base)) return null;
60
+ let codecs = null;
61
+ for (const part of parts.slice(1)) {
62
+ const match = part.match(/^codecs\s*=\s*"?([^"]+)"?$/);
63
+ if (match) codecs = match[1].split(",").map((token) => token.trim()).filter(Boolean);
64
+ }
65
+ return { base, codecs };
66
+ }
67
+
68
+ function codecKnown(token, families) {
69
+ const name = token.split(".")[0];
70
+ if (!Object.hasOwn(CODEC_FAMILIES, name)) return false;
71
+ if (families.length && !families.includes(name)) return false;
72
+ return true;
73
+ }
74
+
75
+ function codecFullySpecified(token) {
76
+ const name = token.split(".")[0];
77
+ if (!Object.hasOwn(CODEC_FAMILIES, name)) return false;
78
+ if (CODEC_FAMILIES[name]) return token.includes(".");
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * @param {string} type
84
+ * @param {{ requireCodecs?: boolean }} [options]
85
+ */
86
+ export function mimeSupported(type, { requireCodecs = false } = {}) {
87
+ const parsed = parseMime(type);
88
+ if (!parsed || !Object.hasOwn(CONTAINERS, parsed.base)) return false;
89
+ const families = CONTAINERS[parsed.base];
90
+ if (!parsed.codecs || !parsed.codecs.length) return !requireCodecs;
91
+ const check = requireCodecs ? codecFullySpecified : (token) => codecKnown(token, families);
92
+ if (requireCodecs && parsed.codecs.some((token) => !codecKnown(token, families))) return false;
93
+ return parsed.codecs.every((token) => check(token));
94
+ }
95
+
96
+ function copyBytes(data) {
97
+ if (data instanceof ArrayBuffer) return new Uint8Array(data.slice(0));
98
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
99
+ throw new TypeError("The provided value is not of type ArrayBuffer or ArrayBufferView.");
100
+ }
101
+
102
+ function timeRanges(start, end) {
103
+ const length = end > start ? 1 : 0;
104
+ return {
105
+ length,
106
+ start(index) {
107
+ if (index !== 0 || !length) throw new DOMException("The index provided is out of range.", "IndexSizeError");
108
+ return start;
109
+ },
110
+ end(index) {
111
+ if (index !== 0 || !length) throw new DOMException("The index provided is out of range.", "IndexSizeError");
112
+ return end;
113
+ },
114
+ };
115
+ }
116
+
117
+ function uint32(bytes, offset) {
118
+ return offset + 4 <= bytes.byteLength
119
+ ? new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0)
120
+ : null;
121
+ }
122
+
123
+ /** Reads an EBML variable-size integer. IDs retain their marker; sizes do not. */
124
+ function ebmlVint(bytes, offset, id = false) {
125
+ const first = bytes[offset];
126
+ if (first === undefined || first === 0) return null;
127
+ let length = 1;
128
+ while (length <= 8 && !(first & (0x80 >> (length - 1)))) length++;
129
+ if (length > 8 || offset + length > bytes.byteLength) return null;
130
+ let value = id ? first : first & ((1 << (8 - length)) - 1);
131
+ for (let index = 1; index < length; index++) value = value * 256 + bytes[offset + index];
132
+ return { value, length };
133
+ }
134
+
135
+ function ebmlUint(bytes, offset, length) {
136
+ if (length < 1 || length > 6 || offset + length > bytes.byteLength) return null;
137
+ let value = 0;
138
+ for (let index = 0; index < length; index++) value = value * 256 + bytes[offset + index];
139
+ return value;
140
+ }
141
+
142
+ /** Actual buffered end of WebM clusters (nanosecond TimecodeScale, millisecond default). */
143
+ function inspectWebMAppend(bytes, state) {
144
+ for (let offset = 0; offset + 4 < bytes.byteLength; offset++) {
145
+ if (bytes[offset] !== 0x2a || bytes[offset + 1] !== 0xd7 || bytes[offset + 2] !== 0xb1) continue;
146
+ const size = ebmlVint(bytes, offset + 3);
147
+ const value = size && ebmlUint(bytes, offset + 3 + size.length, size.value);
148
+ if (value) state.scale = value;
149
+ }
150
+ const scale = state.scale || 1_000_000;
151
+ let latest = null;
152
+ let first = null;
153
+ state.appendHasHeader = bytes.byteLength >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3;
154
+ for (let offset = 0; offset + 4 < bytes.byteLength;) {
155
+ if (!(bytes[offset] === 0x1f && bytes[offset + 1] === 0x43
156
+ && bytes[offset + 2] === 0xb6 && bytes[offset + 3] === 0x75)) {
157
+ offset++;
158
+ continue;
159
+ }
160
+ const size = ebmlVint(bytes, offset + 4);
161
+ if (!size) break;
162
+ let cursor = offset + 4 + size.length;
163
+ const limit = Math.min(bytes.byteLength, cursor + size.value);
164
+ let clusterTime = 0;
165
+ while (cursor < limit) {
166
+ const element = ebmlVint(bytes, cursor, true);
167
+ const length = element && ebmlVint(bytes, cursor + element.length);
168
+ if (!element || !length) break;
169
+ const data = cursor + element.length + length.length;
170
+ const end = Math.min(limit, data + length.value);
171
+ if (element.value === 0xe7) {
172
+ clusterTime = ebmlUint(bytes, data, length.value) ?? clusterTime;
173
+ first ??= clusterTime;
174
+ latest = Math.max(latest ?? 0, clusterTime);
175
+ } else if (element.value === 0xa3 || element.value === 0xa1) {
176
+ const track = ebmlVint(bytes, data);
177
+ const timeAt = track ? data + track.length : end;
178
+ if (timeAt + 2 <= end) {
179
+ let relative = (bytes[timeAt] << 8) | bytes[timeAt + 1];
180
+ if (relative & 0x8000) relative -= 0x10000;
181
+ // Opus packets are normally 20 ms; this small tail keeps buffered
182
+ // from ending at the start timestamp of its final packet.
183
+ latest = Math.max(latest ?? 0, clusterTime + relative + 20);
184
+ }
185
+ }
186
+ cursor = end;
187
+ }
188
+ offset = Math.max(offset + 4, limit);
189
+ }
190
+ state.appendStart = first == null ? null : first * scale / 1e9;
191
+ return latest == null ? null : latest * scale / 1e9;
192
+ }
193
+
194
+ // Segment times from different appends may differ by rounding.
195
+ const COVER_TOLERANCE = 0.002;
196
+
197
+ /** Whether the merged ranges in `covered` hold all of [start, end]. */
198
+ function covers(covered, start, end) {
199
+ return covered.some(([from, to]) => from <= start + COVER_TOLERANCE && end <= to + COVER_TOLERANCE);
200
+ }
201
+
202
+ /** Adds [start, end] to the sorted, merged ranges in `covered`. */
203
+ function addCovered(covered, start, end) {
204
+ covered.push([start, end]);
205
+ covered.sort((left, right) => left[0] - right[0]);
206
+ for (let index = covered.length - 1; index > 0; index--) {
207
+ const [from, to] = covered[index];
208
+ const previous = covered[index - 1];
209
+ if (from <= previous[1] + COVER_TOLERANCE) {
210
+ previous[1] = Math.max(previous[1], to);
211
+ covered.splice(index, 1);
212
+ }
213
+ }
214
+ }
215
+
216
+ /** Visits ISO BMFF boxes, including the containers needed by fragmented MP4. */
217
+ function mp4Boxes(bytes, start, end, visit) {
218
+ for (let offset = start; offset + 8 <= end;) {
219
+ let size = uint32(bytes, offset);
220
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
221
+ let header = 8;
222
+ if (size === 1) {
223
+ if (offset + 16 > end) return;
224
+ const high = uint32(bytes, offset + 8);
225
+ const low = uint32(bytes, offset + 12);
226
+ if (high == null || low == null || high > 0x1fffff) return;
227
+ size = high * 0x100000000 + low;
228
+ header = 16;
229
+ } else if (size === 0) {
230
+ size = end - offset;
231
+ }
232
+ if (!Number.isFinite(size) || size < header || offset + size > end) return;
233
+ const data = offset + header;
234
+ const boxEnd = offset + size;
235
+ visit(type, data, boxEnd);
236
+ if (["moov", "trak", "mdia", "mvex", "moof", "traf"].includes(type)) {
237
+ mp4Boxes(bytes, data, boxEnd, visit);
238
+ }
239
+ offset = boxEnd;
240
+ }
241
+ }
242
+
243
+ /** Updates one SourceBuffer's media time from an init or fMP4 media segment. */
244
+ function inspectMp4Append(bytes, state) {
245
+ let decodeTime = null;
246
+ let firstDecodeTime = null;
247
+ state.appendHasHeader = false;
248
+ let fragmentDuration = 0;
249
+ let fragmentSamples = 0;
250
+ let fragmentDefault = state.defaultDuration ?? 0;
251
+ mp4Boxes(bytes, 0, bytes.byteLength, (type, data, end) => {
252
+ if (type === "mdat" || type === "moof") state.hasMediaSegment = true;
253
+ if (type === "moov") {
254
+ state.hasMovie = true;
255
+ state.appendHasHeader = true;
256
+ }
257
+ if (type === "mvex") state.fragmented = true;
258
+ if (type === "mdhd" && data + 16 <= end) {
259
+ const version = bytes[data];
260
+ const timescale = uint32(bytes, data + (version === 1 ? 20 : 12));
261
+ if (timescale > 0) state.timescale = timescale;
262
+ } else if (type === "trex" && data + 16 <= end) {
263
+ const duration = uint32(bytes, data + 12);
264
+ if (duration > 0) state.defaultDuration = duration;
265
+ } else if (type === "tfhd" && data + 8 <= end) {
266
+ const flags = (bytes[data + 1] << 16) | (bytes[data + 2] << 8) | bytes[data + 3];
267
+ let cursor = data + 8;
268
+ if (flags & 0x000001) cursor += 8;
269
+ if (flags & 0x000002) cursor += 4;
270
+ if ((flags & 0x000008) && cursor + 4 <= end) fragmentDefault = uint32(bytes, cursor) ?? fragmentDefault;
271
+ } else if (type === "tfdt" && data + 8 <= end) {
272
+ if (bytes[data] === 1 && data + 12 <= end) {
273
+ const high = uint32(bytes, data + 4);
274
+ const low = uint32(bytes, data + 8);
275
+ if (high != null && low != null && high <= 0x1fffff) decodeTime = high * 0x100000000 + low;
276
+ } else {
277
+ decodeTime = uint32(bytes, data + 4);
278
+ }
279
+ firstDecodeTime ??= decodeTime;
280
+ } else if (type === "trun" && data + 8 <= end) {
281
+ const flags = (bytes[data + 1] << 16) | (bytes[data + 2] << 8) | bytes[data + 3];
282
+ const count = uint32(bytes, data + 4) ?? 0;
283
+ let cursor = data + 8;
284
+ if (flags & 0x000001) cursor += 4;
285
+ if (flags & 0x000004) cursor += 4;
286
+ let duration = 0;
287
+ for (let index = 0; index < count && cursor <= end; index++) {
288
+ if (flags & 0x000100) {
289
+ const value = uint32(bytes, cursor);
290
+ if (value == null) break;
291
+ duration += value;
292
+ cursor += 4;
293
+ }
294
+ if (flags & 0x000200) cursor += 4;
295
+ if (flags & 0x000400) cursor += 4;
296
+ if (flags & 0x000800) cursor += 4;
297
+ }
298
+ fragmentDuration += duration;
299
+ fragmentSamples += count;
300
+ }
301
+ });
302
+ if (!fragmentDuration && fragmentSamples && fragmentDefault) fragmentDuration = fragmentSamples * fragmentDefault;
303
+ state.appendStart = state.timescale > 0 && firstDecodeTime != null ? firstDecodeTime / state.timescale : null;
304
+ if (state.timescale > 0 && decodeTime != null && fragmentDuration > 0) {
305
+ return (decodeTime + fragmentDuration) / state.timescale;
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /**
311
+ * Per-document MediaSource / SourceBuffer / blob URL registry.
312
+ * `schedule` runs a function as a page task (so a listener added immediately
313
+ * after appendBuffer still observes updateend). `onAppend` and `onDuration`
314
+ * let the renderer probe and update the attached element.
315
+ */
316
+ export function createMediaSourceScope({ origin, schedule, DOMException, onAppend, onDuration, appendQuota = APPEND_QUOTA }) {
317
+ const urls = new Map();
318
+ let appended = 0;
319
+
320
+ /** Runs the listeners now. One throwing listener does not skip the rest. */
321
+ function emitNow(target, type) {
322
+ const event = { type, target };
323
+ const handler = target._handlers?.get(type);
324
+ if (typeof handler === "function") {
325
+ try {
326
+ handler.call(target, event);
327
+ } catch {}
328
+ }
329
+ const list = target._listeners?.get(type);
330
+ if (!list) return;
331
+ for (const entry of [...list]) {
332
+ try {
333
+ entry.fn.call(target, event);
334
+ } catch {}
335
+ if (entry.once) {
336
+ const index = list.indexOf(entry);
337
+ if (index >= 0) list.splice(index, 1);
338
+ }
339
+ }
340
+ }
341
+
342
+ /** A later page task, so a listener registered just after the call still runs. */
343
+ function queueEvent(target, type) {
344
+ schedule(() => emitNow(target, type));
345
+ }
346
+
347
+ function listenerApi(target) {
348
+ target._listeners = new Map();
349
+ target._handlers = new Map();
350
+ target.addEventListener = (type, fn, options) => {
351
+ if (typeof fn !== "function") return;
352
+ const name = String(type);
353
+ const list = target._listeners.get(name) ?? [];
354
+ list.push({ fn, once: options === true || Boolean(options?.once) });
355
+ target._listeners.set(name, list);
356
+ };
357
+ target.removeEventListener = (type, fn) => {
358
+ const list = target._listeners.get(String(type));
359
+ if (!list) return;
360
+ const index = list.findIndex((entry) => entry.fn === fn);
361
+ if (index >= 0) list.splice(index, 1);
362
+ };
363
+ target.dispatchEvent = (event) => {
364
+ queueEvent(target, event?.type);
365
+ return true;
366
+ };
367
+ }
368
+
369
+ function defineHandler(target, type) {
370
+ Object.defineProperty(target, `on${type}`, {
371
+ configurable: true,
372
+ enumerable: true,
373
+ get() {
374
+ return target._handlers.get(type) ?? null;
375
+ },
376
+ set(value) {
377
+ if (typeof value === "function") target._handlers.set(type, value);
378
+ else target._handlers.delete(type);
379
+ },
380
+ });
381
+ }
382
+
383
+ class SourceBufferList {
384
+ constructor() {
385
+ this._items = [];
386
+ listenerApi(this);
387
+ defineHandler(this, "addsourcebuffer");
388
+ defineHandler(this, "removesourcebuffer");
389
+ }
390
+
391
+ get length() {
392
+ return this._items.length;
393
+ }
394
+
395
+ item(index) {
396
+ return this._items[index] ?? null;
397
+ }
398
+
399
+ _add(buffer) {
400
+ this._items.push(buffer);
401
+ this[this._items.length - 1] = buffer;
402
+ queueEvent(this, "addsourcebuffer");
403
+ }
404
+
405
+ _remove(buffer) {
406
+ const index = this._items.indexOf(buffer);
407
+ if (index < 0) return;
408
+ this._items.splice(index, 1);
409
+ for (const key of Object.keys(this)) {
410
+ if (/^\d+$/.test(key)) delete this[key];
411
+ }
412
+ this._items.forEach((item, itemIndex) => {
413
+ this[itemIndex] = item;
414
+ });
415
+ queueEvent(this, "removesourcebuffer");
416
+ }
417
+ }
418
+
419
+ class SourceBuffer {
420
+ constructor(source, mime) {
421
+ if (!new.target) throw new TypeError("Failed to construct 'SourceBuffer': constructor requires 'new'");
422
+ Object.defineProperty(this, BUFFER_BRAND, { value: true });
423
+ this._source = source;
424
+ this._mime = String(mime);
425
+ this._pipe = createBytePipe();
426
+ this._updating = false;
427
+ this._operation = 0;
428
+ this._removed = false;
429
+ this._end = 0;
430
+ // Media time ranges appended so far, merged, to recognise a segment sent again.
431
+ this._covered = [];
432
+ this._quotaStart = 0;
433
+ this._accountedBytes = 0;
434
+ this._mp4 = {};
435
+ this._webm = {};
436
+ this._hasSegmentTimeline = false;
437
+ this._hasMediaSegment = false;
438
+ this._appendWindowStart = 0;
439
+ this._appendWindowEnd = Infinity;
440
+ this._timestampOffset = 0;
441
+ this.mode = /^audio\/(mpeg|aac)\b/.test(this._mime) ? "sequence" : "segments";
442
+ listenerApi(this);
443
+ for (const type of ["updatestart", "update", "updateend", "error", "abort"]) defineHandler(this, type);
444
+ }
445
+
446
+ get updating() {
447
+ return this._updating;
448
+ }
449
+
450
+ get buffered() {
451
+ return timeRanges(this._quotaStart, this._end);
452
+ }
453
+
454
+ get timestampOffset() {
455
+ return this._timestampOffset;
456
+ }
457
+
458
+ set timestampOffset(value) {
459
+ if (this._updating) throw new DOMException("SourceBuffer is updating.", "InvalidStateError");
460
+ const next = Number(value);
461
+ if (!Number.isFinite(next)) throw new TypeError("The provided double value is non-finite.");
462
+ this._timestampOffset = next;
463
+ }
464
+
465
+ get appendWindowStart() {
466
+ return this._appendWindowStart;
467
+ }
468
+
469
+ set appendWindowStart(value) {
470
+ const next = Number(value);
471
+ if (!Number.isFinite(next) || next < 0 || next >= this._appendWindowEnd) {
472
+ throw new TypeError("The append window start is out of range.");
473
+ }
474
+ this._appendWindowStart = next;
475
+ }
476
+
477
+ get appendWindowEnd() {
478
+ return this._appendWindowEnd;
479
+ }
480
+
481
+ set appendWindowEnd(value) {
482
+ const next = Number(value);
483
+ if (!Number.isNaN(next) && next <= this._appendWindowStart) throw new TypeError("The append window end is out of range.");
484
+ this._appendWindowEnd = next;
485
+ }
486
+
487
+ get mode() {
488
+ return this._mode;
489
+ }
490
+
491
+ set mode(value) {
492
+ if (this._updating) throw new DOMException("SourceBuffer is updating.", "InvalidStateError");
493
+ if (value !== "segments" && value !== "sequence") {
494
+ throw new TypeError("The provided value is not a valid enum value.");
495
+ }
496
+ this._mode = value;
497
+ }
498
+
499
+ appendBuffer(data) {
500
+ if (this._removed || this._source.readyState !== "open") {
501
+ throw new DOMException("This SourceBuffer is not attached to an open MediaSource.", "InvalidStateError");
502
+ }
503
+ if (this._updating) throw new DOMException("SourceBuffer is updating.", "InvalidStateError");
504
+ const bytes = copyBytes(data);
505
+ const inspected = /^video\/mp4|^audio\/mp4/i.test(this._mime) ? this._mp4
506
+ : /^video\/webm|^audio\/webm/i.test(this._mime) ? this._webm : null;
507
+ const segmentEnd = inspected === this._mp4 ? inspectMp4Append(bytes, this._mp4)
508
+ : inspected === this._webm ? inspectWebMAppend(bytes, this._webm) : null;
509
+ // Media the buffer already holds, sent again (players retry a segment
510
+ // on a slow network): the concatenation would play it twice, so it is
511
+ // left out. Its events fire as for any append.
512
+ const start = inspected && !inspected.appendHasHeader ? inspected.appendStart : null;
513
+ const duplicate = start != null && segmentEnd != null && covers(this._covered, start, segmentEnd);
514
+ const hasWebMCluster = /^video\/webm|^audio\/webm/i.test(this._mime)
515
+ && bytes.indexOf(0x1f) >= 0 && bytes.some((value, index) => value === 0x1f
516
+ && bytes[index + 1] === 0x43 && bytes[index + 2] === 0xb6 && bytes[index + 3] === 0x75);
517
+ if (appended + bytes.byteLength > appendQuota) {
518
+ throw new DOMException("The SourceBuffer is full.", "QuotaExceededError");
519
+ }
520
+ this._updating = true;
521
+ const operation = ++this._operation;
522
+ // One later task: updatestart, the append, then update and updateend.
523
+ // A listener added immediately after appendBuffer() returns is already
524
+ // registered when this task runs.
525
+ schedule(() => {
526
+ if (this._removed || operation !== this._operation) return;
527
+ emitNow(this, "updatestart");
528
+ if (!duplicate) {
529
+ this._pipe.append(bytes);
530
+ appended += bytes.byteLength;
531
+ this._accountedBytes += bytes.byteLength;
532
+ if (start != null && segmentEnd != null) addCovered(this._covered, start, segmentEnd);
533
+ }
534
+ if (this._mp4.hasMediaSegment || hasWebMCluster) this._hasMediaSegment = true;
535
+ if (segmentEnd != null) {
536
+ if (!this._hasSegmentTimeline) this._end = segmentEnd;
537
+ else this._end = Math.max(this._end, segmentEnd);
538
+ this._hasSegmentTimeline = true;
539
+ }
540
+ else if (this._end <= 0 && bytes.byteLength) this._end = 0.001;
541
+ this._updating = false;
542
+ if (!this._source._active._items.includes(this)) this._source._active._add(this);
543
+ emitNow(this, "update");
544
+ emitNow(this, "updateend");
545
+ try {
546
+ onAppend?.(this._source, this);
547
+ } catch {}
548
+ });
549
+ }
550
+
551
+ abort() {
552
+ if (this._source.readyState !== "open") {
553
+ throw new DOMException("MediaSource is not open.", "InvalidStateError");
554
+ }
555
+ if (!this._updating) return;
556
+ this._operation += 1;
557
+ this._updating = false;
558
+ queueEvent(this, "abort");
559
+ queueEvent(this, "updateend");
560
+ }
561
+
562
+ remove(start, end) {
563
+ if (this._removed || this._source.readyState !== "open") {
564
+ throw new DOMException("This SourceBuffer is not attached to an open MediaSource.", "InvalidStateError");
565
+ }
566
+ if (this._updating) throw new DOMException("SourceBuffer is updating.", "InvalidStateError");
567
+ const from = Number(start);
568
+ const to = Number(end);
569
+ if (!Number.isFinite(from) || from < 0 || from >= to) throw new TypeError("The removal range is invalid.");
570
+ this._updating = true;
571
+ const operation = ++this._operation;
572
+ schedule(() => {
573
+ if (this._removed || operation !== this._operation) return;
574
+ emitNow(this, "updatestart");
575
+ // The decoder still needs the concatenated snapshot, but bytes before
576
+ // the reported buffered range no longer count toward MSE's quota.
577
+ // Prefix eviction is the form adaptive players use while advancing.
578
+ if (from <= this._quotaStart && to > this._quotaStart && this._end > this._quotaStart) {
579
+ const nextStart = Math.min(to, this._end);
580
+ const fraction = (nextStart - this._quotaStart) / (this._end - this._quotaStart);
581
+ const released = Math.min(this._accountedBytes, Math.floor(this._accountedBytes * fraction));
582
+ this._accountedBytes -= released;
583
+ appended = Math.max(0, appended - released);
584
+ this._quotaStart = nextStart;
585
+ }
586
+ // Reported range only. The bytes stay so a later reopen can still seek.
587
+ if (from <= 0 && to >= this._end) this._end = 0;
588
+ this._updating = false;
589
+ emitNow(this, "update");
590
+ emitNow(this, "updateend");
591
+ });
592
+ }
593
+
594
+ changeType(type) {
595
+ if (this._updating) throw new DOMException("SourceBuffer is updating.", "InvalidStateError");
596
+ if (!mimeSupported(type)) throw new DOMException("The type is not supported.", "NotSupportedError");
597
+ this._mime = String(type);
598
+ }
599
+
600
+ noteEnd(seconds) {
601
+ if (Number.isFinite(seconds) && seconds > this._end) this._end = seconds;
602
+ }
603
+ }
604
+
605
+ class MediaSource {
606
+ constructor() {
607
+ if (!new.target) throw new TypeError("Failed to construct 'MediaSource': constructor requires 'new'");
608
+ Object.defineProperty(this, BRAND, { value: true });
609
+ this.readyState = "closed";
610
+ this._duration = NaN;
611
+ this._objectURL = null;
612
+ this._video = null;
613
+ this._audio = null;
614
+ this._buffers = [];
615
+ this.sourceBuffers = new SourceBufferList();
616
+ this.activeSourceBuffers = new SourceBufferList();
617
+ this._active = this.activeSourceBuffers;
618
+ this._live = null;
619
+ listenerApi(this);
620
+ for (const type of ["sourceopen", "sourceended", "sourceclose"]) defineHandler(this, type);
621
+ }
622
+
623
+ static isTypeSupported(type) {
624
+ return mimeSupported(type, { requireCodecs: true });
625
+ }
626
+
627
+ get duration() {
628
+ return this._duration;
629
+ }
630
+
631
+ set duration(value) {
632
+ if (this.readyState !== "open") throw new DOMException("MediaSource is not open.", "InvalidStateError");
633
+ const next = Number(value);
634
+ if (!Number.isFinite(next) || next < 0) throw new TypeError("The provided double value is non-finite.");
635
+ this._duration = next;
636
+ try {
637
+ onDuration?.(this, next);
638
+ } catch {}
639
+ }
640
+
641
+ get handle() {
642
+ return null;
643
+ }
644
+
645
+ addSourceBuffer(type) {
646
+ if (this.readyState !== "open") throw new DOMException("MediaSource is not open.", "InvalidStateError");
647
+ if (!mimeSupported(type)) throw new DOMException("The type is not supported.", "NotSupportedError");
648
+ const buffer = new SourceBuffer(this, type);
649
+ // audio/* feeds the audio decoder. video/* feeds video, and a lone
650
+ // muxed buffer is also the audio source (see streamPipes).
651
+ if (String(type).trim().toLowerCase().startsWith("audio/")) this._audio = buffer;
652
+ else this._video = buffer;
653
+ this._buffers.push(buffer);
654
+ this._pipesResolve?.();
655
+ this._pipesResolve = null;
656
+ this.sourceBuffers._add(buffer);
657
+ return buffer;
658
+ }
659
+
660
+ removeSourceBuffer(buffer) {
661
+ if (!buffer || buffer[BUFFER_BRAND] !== true || buffer._source !== this || buffer._removed) {
662
+ throw new DOMException("The SourceBuffer is not in this MediaSource.", "NotFoundError");
663
+ }
664
+ buffer._removed = true;
665
+ buffer._operation += 1;
666
+ buffer._updating = false;
667
+ appended = Math.max(0, appended - buffer._accountedBytes);
668
+ buffer._accountedBytes = 0;
669
+ if (this._video === buffer) this._video = null;
670
+ if (this._audio === buffer) this._audio = null;
671
+ this._buffers = this._buffers.filter((item) => item !== buffer);
672
+ this.sourceBuffers._remove(buffer);
673
+ this.activeSourceBuffers._remove(buffer);
674
+ }
675
+
676
+ endOfStream(error) {
677
+ if (this.readyState !== "open") throw new DOMException("MediaSource is not open.", "InvalidStateError");
678
+ if (this._buffers.some((buffer) => buffer._updating)) {
679
+ throw new DOMException("A SourceBuffer is still updating.", "InvalidStateError");
680
+ }
681
+ if (error !== undefined && error !== "network" && error !== "decode") {
682
+ throw new TypeError("The provided value is not a valid enum value.");
683
+ }
684
+ for (const buffer of this._buffers) {
685
+ if (error) buffer._pipe.fail(new Error(error));
686
+ else buffer._pipe.finish();
687
+ }
688
+ if (!Number.isFinite(this._duration)) {
689
+ const end = Math.max(0, ...this._buffers.map((buffer) => buffer._end));
690
+ if (end > 0) this._duration = end;
691
+ }
692
+ this.readyState = "ended";
693
+ this._pipesResolve?.();
694
+ this._pipesResolve = null;
695
+ queueEvent(this, "sourceended");
696
+ if (Number.isFinite(this._duration)) {
697
+ try {
698
+ onDuration?.(this, this._duration);
699
+ } catch {}
700
+ }
701
+ }
702
+
703
+ setLiveSeekableRange(start, end) {
704
+ if (this.readyState !== "open") throw new DOMException("MediaSource is not open.", "InvalidStateError");
705
+ const from = Number(start);
706
+ const to = Number(end);
707
+ if (!Number.isFinite(from) || !Number.isFinite(to) || from < 0 || from >= to) {
708
+ throw new TypeError("The live seekable range is invalid.");
709
+ }
710
+ this._live = { start: from, end: to };
711
+ }
712
+
713
+ clearLiveSeekableRange() {
714
+ if (this.readyState !== "open") throw new DOMException("MediaSource is not open.", "InvalidStateError");
715
+ this._live = null;
716
+ }
717
+
718
+ /** Closed → open. sourceopen is a task, so it runs after the src setter returns. */
719
+ attach(url) {
720
+ if (this.readyState !== "closed") throw new DOMException("MediaSource is already attached.", "InvalidStateError");
721
+ this._objectURL = url ?? this._objectURL;
722
+ this.readyState = "open";
723
+ queueEvent(this, "sourceopen");
724
+ }
725
+
726
+ detach() {
727
+ if (this.readyState === "closed") return;
728
+ for (const buffer of this._buffers) buffer._pipe.fail(new Error("closed"));
729
+ this.readyState = "closed";
730
+ this._pipesResolve?.();
731
+ this._pipesResolve = null;
732
+ queueEvent(this, "sourceclose");
733
+ }
734
+
735
+ /** Resolves once addSourceBuffer has created a pipe, or the source is no longer open. */
736
+ waitForPipes() {
737
+ if (this._video || this._audio || this.readyState !== "open") return Promise.resolve();
738
+ if (!this._pipesReady) {
739
+ this._pipesReady = new Promise((resolve) => {
740
+ this._pipesResolve = resolve;
741
+ });
742
+ }
743
+ return this._pipesReady;
744
+ }
745
+
746
+ /** Pipes the decoders read. A single muxed buffer serves both. */
747
+ streamPipes() {
748
+ const video = this._video?._pipe ?? this._audio?._pipe ?? null;
749
+ const audio = this._audio?._pipe ?? this._video?._pipe ?? null;
750
+ return {
751
+ video,
752
+ audio,
753
+ videoMime: this._video?._mime ?? this._audio?._mime ?? "",
754
+ audioMime: this._audio?._mime ?? this._video?._mime ?? "",
755
+ audioReady: Boolean((this._audio ?? this._video)?._hasMediaSegment),
756
+ };
757
+ }
758
+
759
+ noteBuffered(seconds) {
760
+ for (const buffer of this._buffers) {
761
+ // An ordinary MP4 may have its mdat header split across append calls;
762
+ // inspect the accumulated snapshot once probing succeeds.
763
+ if (!buffer._hasMediaSegment) {
764
+ const bytes = buffer._pipe.bytes();
765
+ if (/^video\/mp4|^audio\/mp4/i.test(buffer._mime)) inspectMp4Append(bytes, buffer._mp4);
766
+ else if (/^video\/webm|^audio\/webm/i.test(buffer._mime)) {
767
+ if (inspectWebMAppend(bytes, buffer._webm) != null) buffer._hasMediaSegment = true;
768
+ }
769
+ if (buffer._mp4.hasMediaSegment) buffer._hasMediaSegment = true;
770
+ }
771
+ // A probe can read the presentation duration from an init segment,
772
+ // but init bytes buffer no media. Only a non-fragmented buffer which
773
+ // has actual media payload may use that duration as its range.
774
+ const completeMp4 = /^video\/mp4|^audio\/mp4/i.test(buffer._mime)
775
+ && buffer._mp4.hasMovie && !buffer._mp4.fragmented;
776
+ if (!buffer._hasSegmentTimeline && (buffer._hasMediaSegment || completeMp4)) buffer.noteEnd(seconds);
777
+ }
778
+ }
779
+
780
+ bufferedEnd() {
781
+ const active = this._buffers.map((buffer) => buffer._end).filter((end) => end > 0);
782
+ return active.length ? Math.min(...active) : 0;
783
+ }
784
+ }
785
+
786
+ function isMediaSource(value) {
787
+ return Boolean(value && value[BRAND] === true);
788
+ }
789
+
790
+ return {
791
+ MediaSource,
792
+ SourceBuffer,
793
+ isMediaSource,
794
+ create(object) {
795
+ const id = crypto.randomUUID();
796
+ const url = `blob:${origin}/${id}`;
797
+ if (isMediaSource(object)) {
798
+ object._objectURL = url;
799
+ urls.set(url, { kind: "media-source", source: object });
800
+ return url;
801
+ }
802
+ if (typeof Blob === "function" && object instanceof Blob) {
803
+ urls.set(url, { kind: "blob", blob: object });
804
+ return url;
805
+ }
806
+ throw new TypeError("URL.createObjectURL: argument is not a Blob or MediaSource.");
807
+ },
808
+ revoke(url) {
809
+ urls.delete(String(url));
810
+ },
811
+ lookup(url) {
812
+ return urls.get(String(url)) ?? null;
813
+ },
814
+ mediaSourceAt(url) {
815
+ const entry = urls.get(String(url));
816
+ return entry?.kind === "media-source" ? entry.source : null;
817
+ },
818
+ };
819
+ }