buninu-browser 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  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 +17 -4
  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 +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. package/ai-debug.log +0 -5537
@@ -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
+ }
@@ -28,7 +28,7 @@
28
28
  * closed with sentBytes: the PCM that reached the output before audio-close)
29
29
  *
30
30
  * With --compositor, a compositor Worker (compositor.js) answers screenshots
31
- * and scrolling on fd 3 while this thread is busy; frames go to it with
31
+ * and scrolling on a dedicated socket while this thread is busy; frames go to it with
32
32
  * postMessage.
33
33
  *
34
34
  * Startup order matters for the sandbox:
@@ -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();
@@ -163,13 +172,23 @@ function compositorCaughtUp() {
163
172
 
164
173
  async function startCompositor() {
165
174
  const worker = new Worker(new URL("./compositor.js", import.meta.url).href);
175
+ const endpoint = JSON.parse(process.argv.find(arg=>arg.startsWith('--compositor-endpoint=')).slice('--compositor-endpoint='.length));
176
+ worker.postMessage({type:'connect',endpoint});
166
177
  await new Promise((resolve, reject) => {
167
178
  worker.addEventListener("message", resolve, { once: true });
168
179
  worker.addEventListener("error", reject, { once: true });
169
180
  });
181
+ worker.addEventListener("error", (event) => {
182
+ frameBuffers.clear();
183
+ report(`buninu-browser: compositor worker failed: ${String(event?.message ?? "unknown error").slice(0, 500)}`);
184
+ // Its dedicated controller channel and outstanding frame leases cannot
185
+ // recover in place. Normal renderer exit rejects pending controller calls.
186
+ process.exit(1);
187
+ });
170
188
  worker.addEventListener("message", ({ data }) => {
171
189
  if (data?.type === "scroll") renderer.applyCompositorScroll(data.x, data.y);
172
190
  else if (data?.type === "video-report") renderer.videoReport(data);
191
+ else if (data?.type === "frame-release" && Number.isInteger(data.serial)) frameBuffers.release(data.serial);
173
192
  else if (data?.type === "frame-ack" && Number.isInteger(data.serial)) {
174
193
  framesAcknowledged = Math.max(framesAcknowledged, data.serial);
175
194
  frameWaiters = frameWaiters.filter((waiter) => {
@@ -220,19 +239,36 @@ async function startScriptRewriter() {
220
239
  });
221
240
  }
222
241
 
242
+ let nextClipboardId=1;
243
+ const clipboardWrites=new Map();
244
+ function clipboardWrite(text) {
245
+ const id=nextClipboardId++;
246
+ return new Promise((resolve,reject)=>{
247
+ const timer=setTimeout(()=>{clipboardWrites.delete(id);reject(Error('Clipboard timed out'));},5000);
248
+ clipboardWrites.set(id,{resolve,reject,timer});
249
+ process.send({type:'clipboard-write',id,text});
250
+ });
251
+ }
252
+
223
253
  const renderer = await createPageRenderer({
224
254
  fetchResource,
225
255
  rewriteScript,
226
256
  requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
227
257
  requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
228
258
  pageFetch,
259
+ clipboardWrite,
229
260
  webSockets,
230
261
  audio,
231
262
  setCookie: (value) => process.send({ type: "cookie", value }),
232
263
  reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
233
264
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
234
265
  trace: logging ? (text) => report(`timing: ${text}`) : null,
235
- onFrame: compositor ? (frame) => compositor.postMessage({ type: "frame", serial: ++framesSent, ...frame }) : null,
266
+ allocateFramePixels: compositor ? bytes => frameBuffers.acquire(bytes) : null,
267
+ onFrame: compositor ? (frame) => {
268
+ const serial = ++framesSent;
269
+ frameBuffers.publish(serial, frame.tile.pixels);
270
+ compositor.postMessage({ type: "frame", serial, ...frame });
271
+ } : null,
236
272
  onVideo: compositor ? (message) => compositor.postMessage(message) : null,
237
273
  // Whoever waits for load (a screenshot, say) sees the frame painted at load.
238
274
  onLifecycle: (name, generation) => {
@@ -301,6 +337,13 @@ async function warmUp() {
301
337
  }
302
338
 
303
339
  process.on("message", async (message) => {
340
+ if(message?.type==='clipboard-result') {
341
+ const pending=clipboardWrites.get(message.id);if(!pending)return;
342
+ clipboardWrites.delete(message.id);clearTimeout(pending.timer);
343
+ if(message.error)pending.reject(Error(String(message.error)));else pending.resolve();
344
+ return;
345
+ }
346
+
304
347
  if (message?.type === "page-fetch-head") {
305
348
  const pending = pendingFetches.get(message.id);
306
349
  if (!pending?.page || pending.head) return;
@@ -317,6 +360,7 @@ process.on("message", async (message) => {
317
360
  if (!pending?.page) return;
318
361
  pendingFetches.delete(message.id);
319
362
  pending.streamController.close();
363
+ pending.finish?.();
320
364
  return;
321
365
  }
322
366
  if (message?.type === "page-fetch-error") {
@@ -326,6 +370,7 @@ process.on("message", async (message) => {
326
370
  const error = new Error(message.error || "fetch failed");
327
371
  if (pending.head) pending.streamController.error(error);
328
372
  else pending.reject(error);
373
+ pending.finish?.();
329
374
  return;
330
375
  }
331
376
  if (message?.type === "fetch-chunk") {
@@ -357,6 +402,7 @@ process.on("message", async (message) => {
357
402
  const error = new Error(message.error);
358
403
  pending.pipe?.fail(error);
359
404
  if (!pending.head) pending.reject(error);
405
+ pending.finish?.();
360
406
  return;
361
407
  }
362
408
  const result = message.result ?? {};
@@ -374,6 +420,7 @@ process.on("message", async (message) => {
374
420
  return;
375
421
  }
376
422
  pending.resolve(result);
423
+ pending.finish?.();
377
424
  return;
378
425
  }
379
426
  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));