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.
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +53 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +20 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +12 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +289 -131
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +42 -0
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +22 -27
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +40 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +244 -82
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +27 -10
- package/lib/renderer/custom-element-reactions.js +42 -0
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +36 -5
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +33 -5
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +278 -147
- package/lib/renderer/page-renderer.js +840 -301
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +145 -0
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +632 -108
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/compositor.test.js +38 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +279 -1
- package/test/display-list.test.js +26 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/download.test.js +8 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/headless-shell.test.js +22 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-playback-events.test.js +22 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +160 -2
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +27 -5
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +168 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +136 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- 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
|
+
}
|
package/lib/renderer/main.js
CHANGED
|
@@ -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.
|
|
104
|
-
|
|
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
|
-
|
|
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()
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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));
|