buninu-browser 0.2.3 → 0.2.4

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 (105) hide show
  1. package/lib/cdp-server.js +47 -14
  2. package/lib/happy-dom/document-base.js +33 -0
  3. package/lib/happy-dom/parser.js +15 -6
  4. package/lib/happy-dom/selector-matcher.js +2 -2
  5. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  6. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  7. package/lib/headless-shell.js +9 -6
  8. package/lib/input/hit-test.js +67 -19
  9. package/lib/layout/text-layout.js +213 -91
  10. package/lib/media/byte-pipe.js +2 -2
  11. package/lib/media/position-audio.js +10 -1
  12. package/lib/media/source-snapshots.js +126 -0
  13. package/lib/media/video-keyframe.js +17 -4
  14. package/lib/media/webm-stream.js +106 -0
  15. package/lib/network/network-service.js +268 -120
  16. package/lib/paint/display-list.js +8 -28
  17. package/lib/paint/frame-cache.js +25 -0
  18. package/lib/paint/frame.js +42 -13
  19. package/lib/paint/paragraph-text.js +24 -0
  20. package/lib/paint/raster-readback.js +41 -0
  21. package/lib/paint/rounded-rect.js +82 -0
  22. package/lib/paint/shared-frame-pool.js +105 -0
  23. package/lib/render-tree/index.js +38 -6
  24. package/lib/render-tree/svg.js +15 -7
  25. package/lib/renderer/bindings.js +233 -80
  26. package/lib/renderer/broadcast-channel.js +76 -0
  27. package/lib/renderer/canvas-2d.js +257 -14
  28. package/lib/renderer/canvas-text.js +120 -0
  29. package/lib/renderer/compositor.js +13 -11
  30. package/lib/renderer/custom-element-reactions.js +25 -1
  31. package/lib/renderer/font-loading.js +227 -0
  32. package/lib/renderer/host.js +28 -4
  33. package/lib/renderer/image-resources.js +65 -0
  34. package/lib/renderer/interfaces.js +28 -0
  35. package/lib/renderer/layout-observers.js +236 -0
  36. package/lib/renderer/main.js +30 -4
  37. package/lib/renderer/media-source.js +126 -55
  38. package/lib/renderer/page-realm.js +270 -98
  39. package/lib/renderer/page-renderer.js +647 -235
  40. package/lib/renderer/prepared-script-cache.js +50 -0
  41. package/lib/renderer/rendered-text.js +108 -0
  42. package/lib/renderer/rewrite-worker.js +4 -3
  43. package/lib/renderer/script-rewrite.js +13 -2
  44. package/lib/renderer/scroll-into-view.js +28 -0
  45. package/lib/renderer/slot-assignment.js +60 -0
  46. package/lib/renderer/video-presentation.js +23 -4
  47. package/lib/renderer/workers.js +2 -2
  48. package/lib/style/computed-style.js +611 -104
  49. package/lib/style/selector-traits.js +49 -5
  50. package/package.json +1 -1
  51. package/test/broadcast-channel.test.js +26 -0
  52. package/test/canvas-2d.test.js +83 -2
  53. package/test/canvas-paint.test.js +9 -0
  54. package/test/canvas-text.test.js +41 -0
  55. package/test/cdp-stream.test.js +56 -0
  56. package/test/computed-style-binding.test.js +151 -0
  57. package/test/computed-style.test.js +258 -1
  58. package/test/display-list.test.js +12 -0
  59. package/test/document-base.test.js +87 -0
  60. package/test/dom-platform.test.js +461 -0
  61. package/test/fetch-lifecycle.test.js +161 -0
  62. package/test/fixtures/canvas-paint.js +117 -0
  63. package/test/fixtures/canvas-text.js +42 -0
  64. package/test/fixtures/image-lifetime.js +90 -0
  65. package/test/fixtures/media-seek-snapshot.js +77 -0
  66. package/test/fixtures/mse-audio-chirp.webm +0 -0
  67. package/test/fixtures/mse-switch-large.mp4 +0 -0
  68. package/test/fixtures/mse-switch-small.mp4 +0 -0
  69. package/test/fixtures/renderer-memory.js +110 -0
  70. package/test/fixtures/summary-style.js +28 -0
  71. package/test/font-loading.test.js +97 -0
  72. package/test/grid.test.js +58 -0
  73. package/test/hit-test.test.js +21 -1
  74. package/test/image-lifetime.test.js +12 -0
  75. package/test/image-resources.test.js +94 -0
  76. package/test/local-media.test.js +84 -0
  77. package/test/media-seek-snapshot.test.js +11 -0
  78. package/test/media-source-config.test.js +98 -0
  79. package/test/media-source-webm-accounting.test.js +98 -0
  80. package/test/media-source-webm.test.js +108 -0
  81. package/test/navigation.test.js +1 -0
  82. package/test/network-service.test.js +388 -0
  83. package/test/page-scripts.test.js +1 -1
  84. package/test/paint-frame.test.js +103 -0
  85. package/test/paint-paragraph-cache.test.js +87 -0
  86. package/test/position-audio.test.js +24 -0
  87. package/test/positioning.test.js +22 -0
  88. package/test/prepared-script-cache.test.js +45 -0
  89. package/test/pulse-client.test.js +10 -3
  90. package/test/raster-readback.test.js +40 -0
  91. package/test/render-tree.test.js +28 -0
  92. package/test/renderer-memory.test.js +11 -0
  93. package/test/selector-traits.test.js +24 -0
  94. package/test/shared-frame-pool.test.js +63 -0
  95. package/test/style-sheets.test.js +51 -0
  96. package/test/svg.test.js +29 -0
  97. package/test/text-layout.test.js +106 -0
  98. package/test/video-keyframe.test.js +61 -0
  99. package/test/video-presentation.test.js +69 -0
  100. package/tools/browser-lab.js +78 -0
  101. package/tools/browser-soak.js +75 -0
  102. package/tools/network-reference.js +49 -0
  103. package/tools/process-memory.js +38 -0
  104. package/tools/script-benchmark.js +25 -0
  105. package/ai-debug.log +0 -5537
@@ -46,6 +46,7 @@ import { RENDERER_LOG_FLAG } from "../log.js";
46
46
  import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
47
47
  import { createBytePipe } from "../media/byte-pipe.js";
48
48
  import { notifyRejectionHandled, notifyUnhandledRejection } from "./rejections.js";
49
+ import { SharedFramePool } from "../paint/shared-frame-pool.js";
49
50
 
50
51
  const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
51
52
 
@@ -90,6 +91,7 @@ function fetchResource(url, kind) {
90
91
  function pageFetch(request) {
91
92
  let fetchId;
92
93
  let streamController;
94
+ const completed = Promise.withResolvers();
93
95
  const body = new ReadableStream({
94
96
  start(controller) { streamController = controller; },
95
97
  cancel() { promise.abort(); },
@@ -97,11 +99,17 @@ function pageFetch(request) {
97
99
  const promise = new Promise((resolve, reject) => {
98
100
  const id = nextFetchId++;
99
101
  fetchId = id;
100
- pendingFetches.set(id, { resolve, reject, page: true, body, streamController, head: false });
102
+ pendingFetches.set(id, { resolve, reject, page: true, body, streamController, head: false, finish: completed.resolve });
101
103
  process.send({ type: "page-fetch", id, request });
102
104
  });
103
- promise.abort = () => {
104
- if (!pendingFetches.delete(fetchId)) return;
105
+ promise.finished = completed.promise;
106
+ promise.abort = (reason = new DOMException("The operation was aborted.", "AbortError")) => {
107
+ const pending = pendingFetches.get(fetchId);
108
+ if (!pending) return;
109
+ pendingFetches.delete(fetchId);
110
+ if (pending.head) { try { streamController.error(reason); } catch {} }
111
+ else pending.reject(reason);
112
+ completed.resolve();
105
113
  process.send({ type: "page-fetch-abort", id: fetchId });
106
114
  };
107
115
  return promise;
@@ -145,6 +153,7 @@ const audio = {
145
153
  };
146
154
 
147
155
  // Workers load their module from disk, so this happens before seccomp.
156
+ const frameBuffers = new SharedFramePool();
148
157
  const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
149
158
  // Started before the sandbox closes: the worker loads its module from disk.
150
159
  const rewriteScript = await startScriptRewriter();
@@ -167,9 +176,17 @@ async function startCompositor() {
167
176
  worker.addEventListener("message", resolve, { once: true });
168
177
  worker.addEventListener("error", reject, { once: true });
169
178
  });
179
+ worker.addEventListener("error", (event) => {
180
+ frameBuffers.clear();
181
+ report(`buninu-browser: compositor worker failed: ${String(event?.message ?? "unknown error").slice(0, 500)}`);
182
+ // Its dedicated controller channel and outstanding frame leases cannot
183
+ // recover in place. Normal renderer exit rejects pending controller calls.
184
+ process.exit(1);
185
+ });
170
186
  worker.addEventListener("message", ({ data }) => {
171
187
  if (data?.type === "scroll") renderer.applyCompositorScroll(data.x, data.y);
172
188
  else if (data?.type === "video-report") renderer.videoReport(data);
189
+ else if (data?.type === "frame-release" && Number.isInteger(data.serial)) frameBuffers.release(data.serial);
173
190
  else if (data?.type === "frame-ack" && Number.isInteger(data.serial)) {
174
191
  framesAcknowledged = Math.max(framesAcknowledged, data.serial);
175
192
  frameWaiters = frameWaiters.filter((waiter) => {
@@ -232,7 +249,12 @@ const renderer = await createPageRenderer({
232
249
  reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
233
250
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
234
251
  trace: logging ? (text) => report(`timing: ${text}`) : null,
235
- onFrame: compositor ? (frame) => compositor.postMessage({ type: "frame", serial: ++framesSent, ...frame }) : null,
252
+ allocateFramePixels: compositor ? bytes => frameBuffers.acquire(bytes) : null,
253
+ onFrame: compositor ? (frame) => {
254
+ const serial = ++framesSent;
255
+ frameBuffers.publish(serial, frame.tile.pixels);
256
+ compositor.postMessage({ type: "frame", serial, ...frame });
257
+ } : null,
236
258
  onVideo: compositor ? (message) => compositor.postMessage(message) : null,
237
259
  // Whoever waits for load (a screenshot, say) sees the frame painted at load.
238
260
  onLifecycle: (name, generation) => {
@@ -317,6 +339,7 @@ process.on("message", async (message) => {
317
339
  if (!pending?.page) return;
318
340
  pendingFetches.delete(message.id);
319
341
  pending.streamController.close();
342
+ pending.finish?.();
320
343
  return;
321
344
  }
322
345
  if (message?.type === "page-fetch-error") {
@@ -326,6 +349,7 @@ process.on("message", async (message) => {
326
349
  const error = new Error(message.error || "fetch failed");
327
350
  if (pending.head) pending.streamController.error(error);
328
351
  else pending.reject(error);
352
+ pending.finish?.();
329
353
  return;
330
354
  }
331
355
  if (message?.type === "fetch-chunk") {
@@ -357,6 +381,7 @@ process.on("message", async (message) => {
357
381
  const error = new Error(message.error);
358
382
  pending.pipe?.fail(error);
359
383
  if (!pending.head) pending.reject(error);
384
+ pending.finish?.();
360
385
  return;
361
386
  }
362
387
  const result = message.result ?? {};
@@ -374,6 +399,7 @@ process.on("message", async (message) => {
374
399
  return;
375
400
  }
376
401
  pending.resolve(result);
402
+ pending.finish?.();
377
403
  return;
378
404
  }
379
405
  if (message?.type === "audio-event") {
@@ -5,14 +5,16 @@
5
5
  * The event order follows the Media Source spec: appendBuffer returns with
6
6
  * updating set, and a later task runs updatestart, the append, then update
7
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.
8
+ * construction. remove() and abort() retain original fragment history for
9
+ * decoder reopens and backward seeks. MP4 initialization changes have
10
+ * separate snapshots selected by media time, so configurations do not mix.
11
11
  *
12
12
  * Codec claims stay inside what this build can demux and decode. Types the
13
13
  * engine cannot play are rejected so a page picks another one.
14
14
  */
15
15
  import { createBytePipe } from "../media/byte-pipe.js";
16
+ import { createSourceSnapshots } from "../media/source-snapshots.js";
17
+ import { createWebMStream, ebmlHeader } from "../media/webm-stream.js";
16
18
 
17
19
  const BRAND = Symbol("MediaSource");
18
20
  const BUFFER_BRAND = Symbol("SourceBuffer");
@@ -140,53 +142,55 @@ function ebmlUint(bytes, offset, length) {
140
142
  }
141
143
 
142
144
  /** 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;
145
+ function inspectWebMAppend(bytes, state, clusterOrigin = null) {
151
146
  let latest = null;
152
147
  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);
148
+ const tracks = new Map();
149
+ const inspectCluster = (from, limit, origin = 0) => {
150
+ let clusterTime = origin;
151
+ const visit = (start, stop) => {
152
+ for (let cursor = start; cursor < stop;) {
153
+ const element = ebmlHeader(bytes, cursor);
154
+ if (!element) break;
155
+ const data = cursor + element.length, end = Math.min(stop, data + element.size);
156
+ if (element.id === 0xe7) {
157
+ clusterTime = ebmlUint(bytes, data, element.size) ?? clusterTime;
158
+ latest = Math.max(latest ?? 0, clusterTime);
159
+ } else if (element.id === 0xa0) visit(data, end); // BlockGroup
160
+ else if (element.id === 0xa3 || element.id === 0xa1) {
161
+ const track = ebmlVint(bytes, data);
162
+ const timeAt = track ? data + track.length : end;
163
+ if (timeAt + 2 <= end) {
164
+ let relative = (bytes[timeAt] << 8) | bytes[timeAt + 1];
165
+ if (relative & 0x8000) relative -= 0x10000;
166
+ first = Math.min(first ?? clusterTime + relative, clusterTime + relative);
167
+ // Opus packets are normally 20 ms; include the final packet.
168
+ latest = Math.max(latest ?? 0, clusterTime + relative + 20);
169
+ const range = tracks.get(track.value);
170
+ tracks.set(track.value, [Math.min(range?.[0] ?? Infinity, clusterTime + relative),
171
+ Math.max(range?.[1] ?? 0, clusterTime + relative + 20)]);
172
+ }
184
173
  }
174
+ cursor = end;
185
175
  }
186
- cursor = end;
176
+ };
177
+ visit(from, limit);
178
+ };
179
+ const visit = (from, limit) => {
180
+ for (let offset = from; offset < limit;) {
181
+ const element = ebmlHeader(bytes, offset);
182
+ if (!element) break;
183
+ const data = offset + element.length, end = Math.min(limit, data + element.size);
184
+ if (element.id === 0x18538067 || element.id === 0x1549a966) visit(data, end);
185
+ else if (element.id === 0x2ad7b1) state.scale = ebmlUint(bytes, data, element.size) || state.scale;
186
+ else if (element.id === 0x1f43b675) inspectCluster(data, end);
187
+ offset = end;
187
188
  }
188
- offset = Math.max(offset + 4, limit);
189
- }
189
+ };
190
+ if (clusterOrigin != null) inspectCluster(0, bytes.length, clusterOrigin);
191
+ else visit(0, bytes.length);
192
+ const scale = state.scale || 1_000_000;
193
+ state.appendTracks = new Map([...tracks].map(([track, [start, end]]) => [track, [start * scale / 1e9, end * scale / 1e9]]));
190
194
  state.appendStart = first == null ? null : first * scale / 1e9;
191
195
  return latest == null ? null : latest * scale / 1e9;
192
196
  }
@@ -422,7 +426,12 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
422
426
  Object.defineProperty(this, BUFFER_BRAND, { value: true });
423
427
  this._source = source;
424
428
  this._mime = String(mime);
425
- this._pipe = createBytePipe();
429
+ this._snapshots = /^(video|audio)\/mp4\b/i.test(this._mime) ? createSourceSnapshots(inspectMp4Append) : null;
430
+ this._pipe = createBytePipe({ retainBytes: !this._snapshots });
431
+ if (this._snapshots) {
432
+ this._pipe.snapshotAt = time => this._snapshots.at(time);
433
+ this._pipe.bytes = () => this._snapshots.at(0).bytes;
434
+ }
426
435
  this._updating = false;
427
436
  this._operation = 0;
428
437
  this._removed = false;
@@ -433,6 +442,8 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
433
442
  this._accountedBytes = 0;
434
443
  this._mp4 = {};
435
444
  this._webm = {};
445
+ this._webmCovered = new Map();
446
+ this._webmStream = /^(video|audio)\/webm\b/i.test(this._mime) ? createWebMStream() : null;
436
447
  this._hasSegmentTimeline = false;
437
448
  this._hasMediaSegment = false;
438
449
  this._appendWindowStart = 0;
@@ -504,16 +515,12 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
504
515
  const bytes = copyBytes(data);
505
516
  const inspected = /^video\/mp4|^audio\/mp4/i.test(this._mime) ? this._mp4
506
517
  : /^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;
518
+ let segmentEnd = inspected === this._mp4 ? inspectMp4Append(bytes, this._mp4) : null;
509
519
  // Media the buffer already holds, sent again (players retry a segment
510
520
  // on a slow network): the concatenation would play it twice, so it is
511
521
  // left out. Its events fire as for any append.
512
522
  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);
523
+ const duplicate = !this._snapshots && start != null && segmentEnd != null && covers(this._covered, start, segmentEnd);
517
524
  if (appended + bytes.byteLength > appendQuota) {
518
525
  throw new DOMException("The SourceBuffer is full.", "QuotaExceededError");
519
526
  }
@@ -525,13 +532,15 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
525
532
  schedule(() => {
526
533
  if (this._removed || operation !== this._operation) return;
527
534
  emitNow(this, "updatestart");
528
- if (!duplicate) {
535
+ if (this._webmStream) {
536
+ segmentEnd = this._appendWebM(this._webmStream.append(bytes), bytes.length);
537
+ } else if (!duplicate && (!this._snapshots || this._snapshots.append(bytes))) {
529
538
  this._pipe.append(bytes);
530
539
  appended += bytes.byteLength;
531
540
  this._accountedBytes += bytes.byteLength;
532
541
  if (start != null && segmentEnd != null) addCovered(this._covered, start, segmentEnd);
533
542
  }
534
- if (this._mp4.hasMediaSegment || hasWebMCluster) this._hasMediaSegment = true;
543
+ if (this._mp4.hasMediaSegment) this._hasMediaSegment = true;
535
544
  if (segmentEnd != null) {
536
545
  if (!this._hasSegmentTimeline) this._end = segmentEnd;
537
546
  else this._end = Math.max(this._end, segmentEnd);
@@ -548,10 +557,66 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
548
557
  });
549
558
  }
550
559
 
560
+ /** Complete finite Clusters, or complete Blocks from an unknown-size
561
+ * live Cluster, enter the decoder and contribute buffered ranges.
562
+ * Retries may be split differently or share an append with new media;
563
+ * comparing complete elements handles both cases.
564
+ */
565
+ _appendWebM(elements, incoming = 0) {
566
+ let retained = incoming, latest = null;
567
+ for (const { id, bytes, cluster, closedCluster } of elements) {
568
+ if (closedCluster) {
569
+ if (!closedCluster.accepted) retained -= closedCluster.prefix.reduce((sum, part) => sum + part.length, 0);
570
+ continue;
571
+ }
572
+ const end = inspectWebMAppend(bytes, this._webm, cluster?.time);
573
+ const start = this._webm.appendStart;
574
+ if (id === 0x1f43b675) {
575
+ this._hasMediaSegment = true;
576
+ if (end != null) latest = Math.max(latest ?? end, end);
577
+ if (start != null && end != null) {
578
+ // Different tracks can carry samples at the same timestamp.
579
+ // In particular, incremental unknown-size Clusters expose one
580
+ // Block at a time; an audio Block must not discard a video Block.
581
+ const tracks = this._webm.appendTracks;
582
+ if (tracks.size && [...tracks].every(([track, range]) => covers(this._webmCovered.get(track) ?? [], ...range))) {
583
+ retained -= bytes.length; continue;
584
+ }
585
+ for (const [track, range] of tracks) {
586
+ if (!this._webmCovered.has(track)) this._webmCovered.set(track, []);
587
+ addCovered(this._webmCovered.get(track), ...range);
588
+ }
589
+ addCovered(this._covered, start, end);
590
+ }
591
+ }
592
+ if (cluster && this._webmCluster !== cluster.token) {
593
+ for (const prefix of cluster.prefix) this._pipe.append(prefix);
594
+ this._webmCluster = cluster.token;
595
+ cluster.token.accepted = true;
596
+ }
597
+ this._pipe.append(bytes);
598
+ }
599
+ // A retry can span remove(), which may already have released the
600
+ // quota charged for its first pieces. Refund only this buffer's
601
+ // remaining charge, never another SourceBuffer's bytes.
602
+ retained = Math.max(-this._accountedBytes, retained);
603
+ appended += retained;
604
+ this._accountedBytes += retained;
605
+ return latest;
606
+ }
607
+
551
608
  abort() {
552
609
  if (this._source.readyState !== "open") {
553
610
  throw new DOMException("MediaSource is not open.", "InvalidStateError");
554
611
  }
612
+ // Reset parser input even between append operations: the next append
613
+ // may start a new initialization instead of completing this element.
614
+ if (this._webmStream) {
615
+ const released = Math.min(this._accountedBytes, this._webmStream.pendingSize);
616
+ this._webmStream.reset();
617
+ this._accountedBytes -= released;
618
+ appended -= released;
619
+ }
555
620
  if (!this._updating) return;
556
621
  this._operation += 1;
557
622
  this._updating = false;
@@ -683,7 +748,13 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
683
748
  }
684
749
  for (const buffer of this._buffers) {
685
750
  if (error) buffer._pipe.fail(new Error(error));
686
- else buffer._pipe.finish();
751
+ else {
752
+ if (buffer._webmStream) {
753
+ const end = buffer._appendWebM(buffer._webmStream.finish());
754
+ if (end != null) { buffer._end = Math.max(buffer._end, end); buffer._hasSegmentTimeline = true; }
755
+ }
756
+ buffer._pipe.finish();
757
+ }
687
758
  }
688
759
  if (!Number.isFinite(this._duration)) {
689
760
  const end = Math.max(0, ...this._buffers.map((buffer) => buffer._end));