buninu-browser 0.2.2 → 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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Per-document layout observers. A layout/scroll notification schedules one
3
+ * measurement pass for all observers; page callbacks run only after every
4
+ * target has been measured, avoiding a layout flush for each lazy image.
5
+ * Geometry comes from the renderer, never from Happy DOM's zero-size boxes.
6
+ */
7
+ export function createLayoutObservers({ nodeOf, boundsOf, computedStyle, viewport, schedule, cancel, call, now }) {
8
+ const intersections = new Set();
9
+ const resizes = new Set();
10
+ let pending = null;
11
+ let measuring = false;
12
+ let disposed = false;
13
+ let lastGeometry = null;
14
+
15
+ const queue = () => {
16
+ if (disposed || measuring || pending !== null || (!intersections.size && !resizes.size)) return;
17
+ pending = schedule(flush);
18
+ };
19
+ function flush() {
20
+ pending = null;
21
+ if (disposed) return;
22
+ measuring = true;
23
+ const deliveries = [];
24
+ // Multiple observers commonly watch the same ancestor or target.
25
+ const bounds = new Map();
26
+ const styles = new Map();
27
+ const rectOf = (node) => {
28
+ if (!bounds.has(node)) bounds.set(node, node?.isConnected ? boundsOf(node) : null);
29
+ return bounds.get(node);
30
+ };
31
+ const styleOf = (node) => {
32
+ if (!styles.has(node)) styles.set(node, computedStyle(node));
33
+ return styles.get(node);
34
+ };
35
+ try {
36
+ for (const observer of resizes) {
37
+ const state = resizeStates.get(observer);
38
+ const entries = [];
39
+ for (const [node, target] of state.targets) {
40
+ const rect = rectOf(node);
41
+ const style = rect ? styleOf(node) : null;
42
+ const border = style?.borderWidths ?? ZERO_EDGES;
43
+ const padding = style?.padding ?? ZERO_EDGES;
44
+ const width = Math.max(0, (rect?.width ?? 0) - sum(border, 1, 3) - sum(padding, 1, 3));
45
+ const height = Math.max(0, (rect?.height ?? 0) - sum(border, 0, 2) - sum(padding, 0, 2));
46
+ const scale = viewport().devicePixelRatio || 1;
47
+ const entry = {
48
+ target: target.wrapper,
49
+ contentRect: rectangle({ left: number(padding[3]), top: number(padding[0]), width, height }),
50
+ borderBoxSize: [size(rect?.width ?? 0, rect?.height ?? 0)],
51
+ contentBoxSize: [size(width, height)],
52
+ devicePixelContentBoxSize: [size(Math.round(width * scale), Math.round(height * scale))],
53
+ };
54
+ const selected = target.box === "border-box" ? entry.borderBoxSize[0]
55
+ : target.box === "device-pixel-content-box" ? entry.devicePixelContentBoxSize[0] : entry.contentBoxSize[0];
56
+ if (!target.last || selected.inlineSize !== target.last.inlineSize || selected.blockSize !== target.last.blockSize) {
57
+ target.last = selected;
58
+ entries.push(entry);
59
+ }
60
+ }
61
+ if (entries.length) deliveries.push([state.callback, observer, entries]);
62
+ }
63
+ for (const observer of intersections) {
64
+ const state = intersectionStates.get(observer);
65
+ const rootNode = nodeOf(state.root);
66
+ const screen = viewport();
67
+ const rootRect = rootNode?.nodeType === 1 ? rectOf(rootNode) : { left: 0, top: 0, width: screen.width, height: screen.height };
68
+ const rootBounds = rootRect ? marginRect(rectangle(rootRect), state.margin) : rectangle(ZERO_RECT);
69
+ for (const [node, target] of state.targets) {
70
+ const raw = rectOf(node);
71
+ const boundingClientRect = rectangle(raw ?? ZERO_RECT);
72
+ let intersection = raw && rootRect && (rootNode?.nodeType !== 1 || rootNode.contains(node))
73
+ ? boundingClientRect : null;
74
+ // Clip through scrolling/overflow ancestors before the root.
75
+ for (let ancestor = node.parentElement; intersection && ancestor && ancestor !== rootNode; ancestor = ancestor.parentElement) {
76
+ const style = styleOf(ancestor);
77
+ if (style?.overflowX === "clip" || style?.overflowY === "clip") {
78
+ const clip = rectOf(ancestor);
79
+ intersection = clip ? intersect(intersection, rectangle(clip), style.overflowX === "clip", style.overflowY === "clip") : null;
80
+ }
81
+ }
82
+ if (intersection) intersection = intersect(intersection, rootBounds);
83
+ const isIntersecting = intersection !== null;
84
+ const area = boundingClientRect.width * boundingClientRect.height;
85
+ const ratio = area ? (intersection ? intersection.width * intersection.height / area : 0) : (isIntersecting ? 1 : 0);
86
+ const thresholdIndex = state.thresholds.findIndex((threshold) => threshold > ratio);
87
+ const index = thresholdIndex === -1 ? state.thresholds.length : thresholdIndex;
88
+ if (target.index !== index || target.intersecting !== isIntersecting) {
89
+ target.index = index;
90
+ target.intersecting = isIntersecting;
91
+ state.records.push({ target: target.wrapper, time: now(), isIntersecting, intersectionRatio: ratio,
92
+ boundingClientRect, intersectionRect: intersection ?? rectangle(ZERO_RECT), rootBounds });
93
+ }
94
+ }
95
+ if (state.records.length) deliveries.push([state.callback, observer, state.records.splice(0)]);
96
+ }
97
+ } finally {
98
+ measuring = false;
99
+ }
100
+ for (const [callback, observer, entries] of deliveries) call(callback, observer, [entries, observer]);
101
+ }
102
+
103
+ const element = (target) => {
104
+ const node = nodeOf(target);
105
+ if (node?.nodeType !== 1) throw new TypeError("The target must be an Element.");
106
+ return node;
107
+ };
108
+ const intersectionStates = new WeakMap();
109
+ class IntersectionObserver {
110
+ constructor(callback, options = {}) {
111
+ if (typeof callback !== "function") throw new TypeError("IntersectionObserver callback must be a function.");
112
+ const root = options?.root ?? null;
113
+ const rootNode = root === null ? null : nodeOf(root);
114
+ if (root !== null && rootNode?.nodeType !== 1 && rootNode?.nodeType !== 9) {
115
+ throw new TypeError("IntersectionObserver root must be an Element or Document.");
116
+ }
117
+ const margin = parseMargin(options?.rootMargin ?? "0px");
118
+ const raw = options?.threshold ?? 0;
119
+ const thresholds = (typeof raw === "object" && raw !== null ? Array.from(raw, Number) : [Number(raw)]).sort((a, b) => a - b);
120
+ if (thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
121
+ throw new RangeError("IntersectionObserver thresholds must be between 0 and 1.");
122
+ }
123
+ if (!thresholds.length) thresholds.push(0);
124
+ intersectionStates.set(this, { callback, root, margin, thresholds: Object.freeze(thresholds), targets: new Map(), records: [] });
125
+ }
126
+ get root() { return intersectionState(this).root; }
127
+ get rootMargin() { return intersectionState(this).margin.map(({ value, unit }) => `${value}${unit}`).join(" "); }
128
+ get thresholds() { return intersectionState(this).thresholds; }
129
+ observe(target) {
130
+ const state = intersectionState(this);
131
+ const node = element(target);
132
+ if (state.targets.has(node)) return;
133
+ state.targets.set(node, { wrapper: target, index: -1, intersecting: false });
134
+ intersections.add(this);
135
+ queue();
136
+ }
137
+ unobserve(target) {
138
+ const state = intersectionState(this);
139
+ state.targets.delete(element(target));
140
+ if (!state.targets.size) intersections.delete(this);
141
+ }
142
+ disconnect() {
143
+ intersectionState(this).targets.clear();
144
+ intersections.delete(this);
145
+ }
146
+ takeRecords() { return intersectionState(this).records.splice(0); }
147
+ }
148
+ const intersectionState = (observer) => {
149
+ const state = intersectionStates.get(observer);
150
+ if (!state) throw new TypeError("Illegal invocation");
151
+ return state;
152
+ };
153
+
154
+ const resizeStates = new WeakMap();
155
+ class ResizeObserver {
156
+ constructor(callback) {
157
+ if (typeof callback !== "function") throw new TypeError("ResizeObserver callback must be a function.");
158
+ resizeStates.set(this, { callback, targets: new Map() });
159
+ }
160
+ observe(target, options = {}) {
161
+ const state = resizeState(this);
162
+ const node = element(target);
163
+ const box = String(options?.box ?? "content-box");
164
+ if (!["content-box", "border-box", "device-pixel-content-box"].includes(box)) throw new TypeError("Invalid ResizeObserver box option.");
165
+ if (state.targets.get(node)?.box === box) return;
166
+ state.targets.set(node, { wrapper: target, box, last: size(0, 0) });
167
+ resizes.add(this);
168
+ queue();
169
+ }
170
+ unobserve(target) {
171
+ const state = resizeState(this);
172
+ state.targets.delete(element(target));
173
+ if (!state.targets.size) resizes.delete(this);
174
+ }
175
+ disconnect() {
176
+ resizeState(this).targets.clear();
177
+ resizes.delete(this);
178
+ }
179
+ }
180
+ const resizeState = (observer) => {
181
+ const state = resizeStates.get(observer);
182
+ if (!state) throw new TypeError("Illegal invocation");
183
+ return state;
184
+ };
185
+
186
+ return {
187
+ IntersectionObserver, ResizeObserver,
188
+ update(...geometry) {
189
+ // Video frames and screenshots can repaint without changing layout.
190
+ // They must not cause an O(targets × ancestors) observer pass each time.
191
+ if (geometry.length && lastGeometry && geometry.every((value, index) => value === lastGeometry[index])) return;
192
+ lastGeometry = geometry;
193
+ queue();
194
+ },
195
+ dispose() {
196
+ disposed = true;
197
+ if (pending !== null) cancel(pending);
198
+ pending = null;
199
+ intersections.clear();
200
+ resizes.clear();
201
+ },
202
+ };
203
+ }
204
+
205
+ const ZERO_RECT = Object.freeze({ left: 0, top: 0, width: 0, height: 0 });
206
+ const ZERO_EDGES = Object.freeze([0, 0, 0, 0]);
207
+ const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
208
+ const sum = (edges, a, b) => number(edges[a]) + number(edges[b]);
209
+ const size = (inlineSize, blockSize) => ({ inlineSize, blockSize });
210
+ function rectangle({ left, top, width, height }) {
211
+ return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height,
212
+ toJSON() { return { x: this.x, y: this.y, width: this.width, height: this.height,
213
+ top: this.top, right: this.right, bottom: this.bottom, left: this.left }; } };
214
+ }
215
+ function parseMargin(value) {
216
+ const tokens = String(value).trim().split(/\s+/);
217
+ if (!tokens.length || tokens.length > 4) throw new DOMException("Invalid rootMargin.", "SyntaxError");
218
+ const parts = tokens.map((token) => {
219
+ const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|%)$/.exec(token);
220
+ if (!match) throw new DOMException("rootMargin values must be pixels or percent.", "SyntaxError");
221
+ return { value: Number(match[1]), unit: match[2] };
222
+ });
223
+ const [top, right = top, bottom = top, left = right] = parts;
224
+ return [top, right, bottom, left];
225
+ }
226
+ function marginRect(rect, margin) {
227
+ const [top, right, bottom, left] = margin.map(({ value, unit }) => unit === "%" ? value * rect.width / 100 : value);
228
+ return rectangle({ left: rect.left - left, top: rect.top - top, width: rect.width + left + right, height: rect.height + top + bottom });
229
+ }
230
+ function intersect(a, b, x = true, y = true) {
231
+ const left = x ? Math.max(a.left, b.left) : a.left;
232
+ const right = x ? Math.min(a.right, b.right) : a.right;
233
+ const top = y ? Math.max(a.top, b.top) : a.top;
234
+ const bottom = y ? Math.min(a.bottom, b.bottom) : a.bottom;
235
+ return left <= right && top <= bottom ? rectangle({ left, top, width: right - left, height: bottom - top }) : null;
236
+ }
@@ -46,13 +46,14 @@ 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
 
52
53
  /** Methods the controller may invoke; anything else is ignored. */
53
54
  const METHODS = new Set([
54
55
  "loadDocument", "showFragment", "wheel", "runJavaScriptURL", "url", "scrollTo", "resize", "screenshot", "title",
55
- "scroll", "press", "type", "scrollOffset", "click", "evaluate", "setUserAgent", "setInitScripts", "consoleMessages", "whenLoaded",
56
+ "scroll", "press", "type", "scrollOffset", "click", "move", "evaluate", "setUserAgent", "setInitScripts", "consoleMessages", "whenLoaded",
56
57
  "applyCompositorScroll",
57
58
  ]);
58
59
 
@@ -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,8 +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);
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);
172
190
  else if (data?.type === "frame-ack" && Number.isInteger(data.serial)) {
173
191
  framesAcknowledged = Math.max(framesAcknowledged, data.serial);
174
192
  frameWaiters = frameWaiters.filter((waiter) => {
@@ -231,7 +249,13 @@ const renderer = await createPageRenderer({
231
249
  reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
232
250
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
233
251
  trace: logging ? (text) => report(`timing: ${text}`) : null,
234
- 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,
258
+ onVideo: compositor ? (message) => compositor.postMessage(message) : null,
235
259
  // Whoever waits for load (a screenshot, say) sees the frame painted at load.
236
260
  onLifecycle: (name, generation) => {
237
261
  if (name !== "load") process.send({ type: "lifecycle", name, generation });
@@ -315,6 +339,7 @@ process.on("message", async (message) => {
315
339
  if (!pending?.page) return;
316
340
  pendingFetches.delete(message.id);
317
341
  pending.streamController.close();
342
+ pending.finish?.();
318
343
  return;
319
344
  }
320
345
  if (message?.type === "page-fetch-error") {
@@ -324,6 +349,7 @@ process.on("message", async (message) => {
324
349
  const error = new Error(message.error || "fetch failed");
325
350
  if (pending.head) pending.streamController.error(error);
326
351
  else pending.reject(error);
352
+ pending.finish?.();
327
353
  return;
328
354
  }
329
355
  if (message?.type === "fetch-chunk") {
@@ -355,6 +381,7 @@ process.on("message", async (message) => {
355
381
  const error = new Error(message.error);
356
382
  pending.pipe?.fail(error);
357
383
  if (!pending.head) pending.reject(error);
384
+ pending.finish?.();
358
385
  return;
359
386
  }
360
387
  const result = message.result ?? {};
@@ -372,6 +399,7 @@ process.on("message", async (message) => {
372
399
  return;
373
400
  }
374
401
  pending.resolve(result);
402
+ pending.finish?.();
375
403
  return;
376
404
  }
377
405
  if (message?.type === "audio-event") {
@@ -0,0 +1,13 @@
1
+ // `play` means unpaused; `playing` means playback can actually advance.
2
+ // Keep the distinction across pauses and network starvation.
3
+ const phases = new WeakMap();
4
+ export function playbackEvent(state, phase, dispatch) {
5
+ const previous = phases.get(state);
6
+ if (previous === phase) return;
7
+ phases.set(state, phase);
8
+ if (phase === 'playing' && previous === 'waiting') {
9
+ dispatch(state, 'canplay');
10
+ if (phases.get(state) !== phase || !state.playing) return;
11
+ }
12
+ dispatch(state, phase);
13
+ }
@@ -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));