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,22 @@
|
|
|
1
|
+
import { pianoNote } from "../audio/piano.js";
|
|
2
|
+
|
|
3
|
+
// Keep a real editable input so mobile keyboards can open, but consume piano
|
|
4
|
+
// input before text editing. Terminals and IMEs may commit several keys at once.
|
|
5
|
+
export function handlePianoInput(element, input, play) {
|
|
6
|
+
if (element?.getAttribute?.("id") !== "buninu-piano-input" || element.disabled || element.readOnly) return false;
|
|
7
|
+
// CDP key events also carry text (often "" on rawKeyDown). Only a text-only
|
|
8
|
+
// commit belongs here; otherwise use key/code, including Space and F1–F12.
|
|
9
|
+
if (input.key === undefined && input.text !== undefined) {
|
|
10
|
+
for (const key of String(input.text)) {
|
|
11
|
+
const note = pianoNote(key);
|
|
12
|
+
if (note !== null) play?.(note);
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const note = pianoNote(String(input.key), String(input.code ?? ""));
|
|
17
|
+
if (note === null) return false;
|
|
18
|
+
// Suppressing text insertion must not depend on whether a note is retriggered
|
|
19
|
+
// or an audio backend is available. Shift still selects a physical piano key.
|
|
20
|
+
if (!input.repeat && !(Number(input.modifiers) & 7)) play?.(note);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const previousTargets = new WeakMap();
|
|
2
|
+
|
|
3
|
+
/** Deliver pointer/mouse boundary events before movement, as a real pointer does. */
|
|
4
|
+
export function dispatchPointerMove(window, realm, target, x, y) {
|
|
5
|
+
const document = window.document;
|
|
6
|
+
const previous = previousTargets.get(document) ?? null;
|
|
7
|
+
previousTargets.set(document, target);
|
|
8
|
+
const ancestors = node => { const list = []; for (; node?.nodeType === 1; node = node.parentElement) list.push(node); return list; };
|
|
9
|
+
const before = ancestors(previous), after = ancestors(target);
|
|
10
|
+
const send = (node, type, relatedTarget, bubbles = true) => {
|
|
11
|
+
if (!node) return;
|
|
12
|
+
const pointer = type.startsWith('pointer');
|
|
13
|
+
const Constructor = pointer ? window.PointerEvent : window.MouseEvent;
|
|
14
|
+
const event = new Constructor(type, { clientX: x, clientY: y, relatedTarget, bubbles,
|
|
15
|
+
composed: bubbles, cancelable: bubbles, button: pointer ? -1 : 0, buttons: 0,
|
|
16
|
+
pointerId: 1, pointerType: 'mouse', isPrimary: true });
|
|
17
|
+
if (realm) realm.dispatch(node, event); else node.dispatchEvent(event);
|
|
18
|
+
};
|
|
19
|
+
if (previous !== target) {
|
|
20
|
+
for (const prefix of ['pointer', 'mouse']) {
|
|
21
|
+
send(previous, `${prefix}out`, target);
|
|
22
|
+
for (const node of before) if (!after.includes(node)) send(node, `${prefix}leave`, target, false);
|
|
23
|
+
send(target, `${prefix}over`, previous);
|
|
24
|
+
for (const node of after.toReversed()) if (!before.includes(node)) send(node, `${prefix}enter`, previous, false);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
send(target, 'pointermove', null);
|
|
28
|
+
send(target, 'mousemove', null);
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { escapeSesHtmlComments, rewriteClassicScriptSource } from "./script-rewrite.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reuse deterministic source preparation across documents and repeated evals.
|
|
5
|
+
* Only strings/parse flags are retained, never a document, AST, or executable
|
|
6
|
+
* closure. Evaluation still happens in the current page's SES compartment.
|
|
7
|
+
*/
|
|
8
|
+
export class PreparedScriptCache {
|
|
9
|
+
#entries = new Map();
|
|
10
|
+
#bytes = 0;
|
|
11
|
+
#maxBytes;
|
|
12
|
+
#maxEntries;
|
|
13
|
+
#prepare;
|
|
14
|
+
|
|
15
|
+
constructor({ maxBytes = 4 * 1024 * 1024, maxEntries = 128, prepare = prepareSource } = {}) {
|
|
16
|
+
this.#maxBytes = Math.max(0, maxBytes);
|
|
17
|
+
this.#maxEntries = Math.max(0, maxEntries);
|
|
18
|
+
this.#prepare = prepare;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
prepare(source) {
|
|
22
|
+
const cached = this.#entries.get(source);
|
|
23
|
+
if (cached) {
|
|
24
|
+
this.#entries.delete(source);
|
|
25
|
+
this.#entries.set(source, cached);
|
|
26
|
+
return cached.result;
|
|
27
|
+
}
|
|
28
|
+
const result = Object.freeze(this.#prepare(source));
|
|
29
|
+
// Conservative UTF-16 accounting, including both the key and rewritten
|
|
30
|
+
// source, even when the engine can share their underlying string storage.
|
|
31
|
+
const bytes = (source.length + result.code.length) * 2;
|
|
32
|
+
if (!this.#maxEntries || bytes > this.#maxBytes) return result;
|
|
33
|
+
while (this.#entries.size && (this.#entries.size >= this.#maxEntries || this.#bytes + bytes > this.#maxBytes)) {
|
|
34
|
+
const key = this.#entries.keys().next().value;
|
|
35
|
+
this.#bytes -= this.#entries.get(key).bytes;
|
|
36
|
+
this.#entries.delete(key);
|
|
37
|
+
}
|
|
38
|
+
this.#entries.set(source, { result, bytes });
|
|
39
|
+
this.#bytes += bytes;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function prepareSource(source) {
|
|
45
|
+
const classic = rewriteClassicScriptSource(source);
|
|
46
|
+
return { code: escapeSesHtmlComments(classic.code), parsed: classic.parsed, strict: classic.strict };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mainThreadCache = new PreparedScriptCache();
|
|
50
|
+
export const prepareClassicScript = source => mainThreadCache.prepare(source);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** HTML innerText's rendered-text collection using the engine's computed styles. */
|
|
2
|
+
export function renderedText(element, computedStyle) {
|
|
3
|
+
if (!element.isConnected) return element.textContent;
|
|
4
|
+
const styles = new Map();
|
|
5
|
+
const styleOf = (node) => {
|
|
6
|
+
if (!styles.has(node)) styles.set(node, computedStyle(node));
|
|
7
|
+
return styles.get(node);
|
|
8
|
+
};
|
|
9
|
+
// An element outside rendering (including a display:none ancestor) returns
|
|
10
|
+
// raw descendant text. Hidden descendants of a rendered element do not.
|
|
11
|
+
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
|
|
12
|
+
if (styleOf(ancestor)?.display === "none") return element.textContent;
|
|
13
|
+
}
|
|
14
|
+
for (let child = element, ancestor = element.parentElement; ancestor; child = ancestor, ancestor = ancestor.parentElement) {
|
|
15
|
+
if (ancestor.tagName === "DETAILS" && !ancestor.open && child !== firstSummary(ancestor)) return "";
|
|
16
|
+
}
|
|
17
|
+
let output = "";
|
|
18
|
+
let breaks = 0;
|
|
19
|
+
let space = false;
|
|
20
|
+
const boundary = (count) => { breaks = Math.max(breaks, count); space = false; };
|
|
21
|
+
const beforeText = () => {
|
|
22
|
+
if (breaks && output) output += "\n".repeat(breaks);
|
|
23
|
+
else if (space && output && !output.endsWith("\n")) output += " ";
|
|
24
|
+
breaks = 0;
|
|
25
|
+
space = false;
|
|
26
|
+
};
|
|
27
|
+
const text = (value, style) => {
|
|
28
|
+
if (style?.visibility === "hidden" || style?.visibility === "collapse") return;
|
|
29
|
+
value = value.replace(/\r\n?/g, "\n");
|
|
30
|
+
if (style?.textTransform === "uppercase") value = value.toUpperCase();
|
|
31
|
+
else if (style?.textTransform === "lowercase") value = value.toLowerCase();
|
|
32
|
+
else if (style?.textTransform === "capitalize") value = value.replace(/(^|\s)\S/g, (part) => part.toUpperCase());
|
|
33
|
+
if (["pre", "pre-wrap", "break-spaces"].includes(style?.whiteSpace)) {
|
|
34
|
+
if (!value) return;
|
|
35
|
+
beforeText();
|
|
36
|
+
output += value;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Collapse across adjacent text nodes too: separate spans do not create
|
|
40
|
+
// extra spaces, and whitespace at block boundaries is not rendered.
|
|
41
|
+
const pattern = style?.whiteSpace === "pre-line" ? /([\t\f ]+|\n)/g : /([\t\f \n]+)/g;
|
|
42
|
+
for (const token of value.split(pattern)) {
|
|
43
|
+
if (!token) continue;
|
|
44
|
+
if (style?.whiteSpace === "pre-line" && token === "\n") {
|
|
45
|
+
beforeText();
|
|
46
|
+
output += "\n";
|
|
47
|
+
} else if (/^[\t\f \n]+$/.test(token)) space = !breaks;
|
|
48
|
+
else { beforeText(); output += token; }
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const stack = [{ node: element, root: true }];
|
|
52
|
+
while (stack.length) {
|
|
53
|
+
const item = stack.pop();
|
|
54
|
+
if (item.after) {
|
|
55
|
+
if (item.tab) { beforeText(); output += "\t"; }
|
|
56
|
+
if (item.breaks) boundary(item.breaks);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const node = item.node;
|
|
60
|
+
if (node.nodeType === 3) { text(node.data, styleOf(node.parentElement)); continue; }
|
|
61
|
+
if (node.nodeType !== 1) continue;
|
|
62
|
+
// SVG descriptive/resource elements never paint their own text, even
|
|
63
|
+
// though getComputedStyle(title).display is "inline" in browsers.
|
|
64
|
+
if (node.namespaceURI === SVG_NAMESPACE && NON_RENDERED_SVG.has(node.localName)) continue;
|
|
65
|
+
const style = styleOf(node);
|
|
66
|
+
if (style?.display === "none") continue;
|
|
67
|
+
if (node.tagName === "BR") {
|
|
68
|
+
if (style?.visibility !== "hidden" && style?.visibility !== "collapse") {
|
|
69
|
+
beforeText(); output += "\n";
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const count = item.root ? 0 : node.tagName === "P" ? 2
|
|
74
|
+
: BLOCK_DISPLAYS.has(style?.display) ? 1 : 0;
|
|
75
|
+
if (count) boundary(count);
|
|
76
|
+
const tab = style?.display === "table-cell" && node.nextElementSibling !== null;
|
|
77
|
+
if (count || tab) stack.push({ after: true, breaks: count, tab });
|
|
78
|
+
if (node.tagName === "DETAILS" && !node.open) {
|
|
79
|
+
const summary = firstSummary(node);
|
|
80
|
+
if (summary) stack.push({ node: summary });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (let child = node.lastChild; child; child = child.previousSibling) stack.push({ node: child });
|
|
84
|
+
}
|
|
85
|
+
return output;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function firstSummary(details) {
|
|
89
|
+
for (const child of details.children) if (child.tagName === "SUMMARY") return child;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const BLOCK_DISPLAYS = new Set(["block", "flow-root", "flex", "grid", "list-item", "table", "table-caption", "table-row"]);
|
|
94
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
95
|
+
const NON_RENDERED_SVG = new Set(["title", "desc", "metadata", "defs", "symbol", "clipPath", "mask", "pattern",
|
|
96
|
+
"linearGradient", "radialGradient", "filter"]);
|
|
97
|
+
|
|
98
|
+
/** innerText assignment turns CR/LF line boundaries into BR elements. */
|
|
99
|
+
export function setRenderedText(element, value) {
|
|
100
|
+
const document = element.ownerDocument;
|
|
101
|
+
const fragment = document.createDocumentFragment();
|
|
102
|
+
const lines = String(value ?? "").split(/\r\n|[\r\n]/);
|
|
103
|
+
for (let index = 0; index < lines.length; index++) {
|
|
104
|
+
if (index) fragment.appendChild(document.createElement("br"));
|
|
105
|
+
if (lines[index]) fragment.appendChild(document.createTextNode(lines[index]));
|
|
106
|
+
}
|
|
107
|
+
element.replaceChildren(fragment);
|
|
108
|
+
}
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* Messages: { id, source } → { id, result: { code, parsed, strict } } or
|
|
8
8
|
* { id, error }. The worker only transforms strings; page code never runs here.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { PreparedScriptCache } from "./prepared-script-cache.js";
|
|
11
|
+
|
|
12
|
+
const preparedScripts = new PreparedScriptCache({ maxBytes: 16 * 1024 * 1024, maxEntries: 64 });
|
|
11
13
|
|
|
12
14
|
self.addEventListener("message", ({ data }) => {
|
|
13
15
|
const { id, source } = data ?? {};
|
|
14
16
|
try {
|
|
15
|
-
|
|
16
|
-
self.postMessage({ id, result: { code: escapeSesHtmlComments(classic.code), parsed: classic.parsed, strict: classic.strict } });
|
|
17
|
+
self.postMessage({ id, result: preparedScripts.prepare(String(source)) });
|
|
17
18
|
} catch (error) {
|
|
18
19
|
self.postMessage({ id, error: String(error?.message ?? error) });
|
|
19
20
|
}
|
|
@@ -118,8 +118,19 @@ export function rewriteClassicScript(source, parsed = null) {
|
|
|
118
118
|
const text = (start, end) => {
|
|
119
119
|
let result = "";
|
|
120
120
|
let at = start;
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Function declarations and initializers usually span only a handful
|
|
122
|
+
// of edits. Locate that range instead of rescanning every edit in the
|
|
123
|
+
// bundle for each declaration (quadratic for many top-level functions).
|
|
124
|
+
let low = 0, high = innerEdits.length;
|
|
125
|
+
while (low < high) {
|
|
126
|
+
const middle = (low + high) >>> 1;
|
|
127
|
+
if (innerEdits[middle].start < start) low = middle + 1;
|
|
128
|
+
else high = middle;
|
|
129
|
+
}
|
|
130
|
+
for (let index = low; index < innerEdits.length; index++) {
|
|
131
|
+
const edit = innerEdits[index];
|
|
132
|
+
if (edit.start >= end) break;
|
|
133
|
+
if (edit.end > end) continue;
|
|
123
134
|
result += `${source.slice(at, edit.start)}${edit.text}`;
|
|
124
135
|
at = edit.end;
|
|
125
136
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** CSSOM View's boolean/dictionary overload and logical alignment options. */
|
|
2
|
+
export function scrollIntoViewOptions(value = undefined) {
|
|
3
|
+
const dictionary = value !== null && (typeof value === "object" || typeof value === "function") ? value : {};
|
|
4
|
+
const { block: requestedBlock = "start", inline = "nearest", behavior = "auto", container = "all" } = dictionary;
|
|
5
|
+
const block = value !== null && value !== undefined && typeof value !== "object" && typeof value !== "function"
|
|
6
|
+
? (Boolean(value) ? "start" : "end") : requestedBlock;
|
|
7
|
+
const options = {
|
|
8
|
+
block: String(block), inline: String(inline), behavior: String(behavior), container: String(container),
|
|
9
|
+
};
|
|
10
|
+
for (const key of ["block", "inline"]) {
|
|
11
|
+
if (!["start", "center", "end", "nearest"].includes(options[key])) throw new TypeError(`Invalid scrollIntoView ${key}`);
|
|
12
|
+
}
|
|
13
|
+
if (!["auto", "instant", "smooth"].includes(options.behavior)) throw new TypeError("Invalid scrollIntoView behavior");
|
|
14
|
+
if (!["all", "nearest"].includes(options.container)) throw new TypeError("Invalid scrollIntoView container");
|
|
15
|
+
return options;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Signed scroll distance for one axis, with the target relative to the scrollport. */
|
|
19
|
+
export function scrollAlignmentDelta(start, size, viewportSize, alignment) {
|
|
20
|
+
const end = start + size;
|
|
21
|
+
if (alignment === "start") return start;
|
|
22
|
+
if (alignment === "end") return end - viewportSize;
|
|
23
|
+
if (alignment === "center") return start + (size - viewportSize) / 2;
|
|
24
|
+
// Nearest leaves both fully visible and viewport-spanning boxes alone.
|
|
25
|
+
if ((start >= 0 && end <= viewportSize) || (start <= 0 && end >= viewportSize)) return 0;
|
|
26
|
+
const endDelta = end - viewportSize;
|
|
27
|
+
return Math.abs(start) < Math.abs(endDelta) ? start : endDelta;
|
|
28
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// DOM slot assignment, shared by the page bindings and flattened-tree users.
|
|
2
|
+
// Keep only manual assignments: named slots follow the current tree directly.
|
|
3
|
+
const manualNodes = new WeakMap();
|
|
4
|
+
const manualOwner = new WeakMap();
|
|
5
|
+
const isSlottable = node => node?.nodeType === 1 || node?.nodeType === 3;
|
|
6
|
+
|
|
7
|
+
export function assignedSlot(node) {
|
|
8
|
+
const root = node?.parentElement?.shadowRoot;
|
|
9
|
+
if (!root || !isSlottable(node)) return null;
|
|
10
|
+
if (root.slotAssignment === "manual") {
|
|
11
|
+
const slot = manualOwner.get(node);
|
|
12
|
+
return slot?.getRootNode() === root ? slot : null;
|
|
13
|
+
}
|
|
14
|
+
const name = node.nodeType === 1 ? node.getAttribute("slot") ?? "" : "";
|
|
15
|
+
return [...root.querySelectorAll("slot")].find(slot => (slot.getAttribute("name") ?? "") === name) ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function slotAssignedNodes(slot, flatten = false, visiting = new Set()) {
|
|
19
|
+
const root = slot.getRootNode();
|
|
20
|
+
if (!root.host || visiting.has(slot)) return [];
|
|
21
|
+
let nodes;
|
|
22
|
+
if (root.slotAssignment === "manual") {
|
|
23
|
+
nodes = (manualNodes.get(slot) ?? []).filter(node => node.parentNode === root.host);
|
|
24
|
+
} else {
|
|
25
|
+
const name = slot.getAttribute("name") ?? "";
|
|
26
|
+
const first = [...root.querySelectorAll("slot")].find(candidate => (candidate.getAttribute("name") ?? "") === name);
|
|
27
|
+
nodes = first !== slot ? [] : [...root.host.childNodes].filter(node => isSlottable(node)
|
|
28
|
+
&& (node.nodeType === 1 ? node.getAttribute("slot") ?? "" : "") === name);
|
|
29
|
+
}
|
|
30
|
+
if (!flatten) return nodes;
|
|
31
|
+
visiting.add(slot);
|
|
32
|
+
const flattened = [];
|
|
33
|
+
if (!nodes.length) nodes = [...slot.childNodes].filter(isSlottable);
|
|
34
|
+
for (const node of nodes) {
|
|
35
|
+
if (node.localName === "slot" && node.getRootNode().host) flattened.push(...slotAssignedNodes(node, true, visiting));
|
|
36
|
+
else flattened.push(node);
|
|
37
|
+
}
|
|
38
|
+
visiting.delete(slot);
|
|
39
|
+
return flattened;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assignSlotNodes(slot, nodes, notify) {
|
|
43
|
+
if (nodes.some(node => !isSlottable(node))) throw new TypeError("Assigned nodes must be elements or text nodes");
|
|
44
|
+
if (slot.getRootNode().slotAssignment !== "manual") return;
|
|
45
|
+
const unique = [...new Set(nodes)];
|
|
46
|
+
const previous = manualNodes.get(slot) ?? [];
|
|
47
|
+
const changed = unique.length !== previous.length || unique.some((node, index) => node !== previous[index]);
|
|
48
|
+
if (!changed) return;
|
|
49
|
+
for (const node of previous) if (manualOwner.get(node) === slot) manualOwner.delete(node);
|
|
50
|
+
for (const node of unique) {
|
|
51
|
+
const old = manualOwner.get(node);
|
|
52
|
+
if (old && old !== slot) {
|
|
53
|
+
manualNodes.set(old, (manualNodes.get(old) ?? []).filter(item => item !== node));
|
|
54
|
+
notify(old);
|
|
55
|
+
}
|
|
56
|
+
manualOwner.set(node, slot);
|
|
57
|
+
}
|
|
58
|
+
manualNodes.set(slot, unique);
|
|
59
|
+
notify(slot);
|
|
60
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { seekVideoKeyframe } from "../media/video-keyframe.js";
|
|
2
|
+
|
|
3
|
+
// Runs in the compositor Worker, never in the page's JavaScript turn. Both
|
|
4
|
+
// queue duration and decoded pixel memory are bounded. No audio extrapolation.
|
|
5
|
+
export class VideoPresentation {
|
|
6
|
+
constructor(engine, changed, report) {
|
|
7
|
+
this.engine = engine;
|
|
8
|
+
this.changed = changed;
|
|
9
|
+
this.report = report;
|
|
10
|
+
this.epoch = 0;
|
|
11
|
+
this.timer = null;
|
|
12
|
+
this.clocks = new Map();
|
|
13
|
+
}
|
|
14
|
+
async open(config) {
|
|
15
|
+
this.stop();
|
|
16
|
+
const epoch = this.epoch;
|
|
17
|
+
this.config = config;
|
|
18
|
+
config.started = performance.now();
|
|
19
|
+
try {
|
|
20
|
+
const decoder = await this.engine.decode(config.bytes, config.options);
|
|
21
|
+
if (epoch !== this.epoch) { decoder.close(); return; }
|
|
22
|
+
this.decoder = decoder;
|
|
23
|
+
const startTime = config.startTime ?? 0;
|
|
24
|
+
const endTime = config.endTime ?? Infinity;
|
|
25
|
+
this.nextTime = config.time >= endTime ? endTime
|
|
26
|
+
: config.time > startTime ? seekVideoKeyframe(decoder, config.time, startTime) : startTime;
|
|
27
|
+
this.width = Math.max(1, Math.min(480, decoder.width));
|
|
28
|
+
this.height = Math.max(1, Math.round(this.width * decoder.height / Math.max(1, decoder.width)));
|
|
29
|
+
if (this.height > 480) {
|
|
30
|
+
this.width = Math.max(1, Math.round(this.width * 480 / this.height));
|
|
31
|
+
this.height = 480;
|
|
32
|
+
}
|
|
33
|
+
this.limit = Math.max(2, Math.min(30, Math.floor(16 * 1024 * 1024 / (this.width * this.height * 4))));
|
|
34
|
+
this.step();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (epoch === this.epoch) this.report({ id: config.id, error: String(error) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
stop(id) {
|
|
40
|
+
if (id !== undefined && this.config?.id !== id) return;
|
|
41
|
+
this.epoch++;
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
this.decoder?.close();
|
|
44
|
+
this.decoder = null;
|
|
45
|
+
this.queue = [];
|
|
46
|
+
this.frame = null;
|
|
47
|
+
this.eof = false;
|
|
48
|
+
this.reportedEnd = false;
|
|
49
|
+
this.config = null;
|
|
50
|
+
}
|
|
51
|
+
timing(id, seconds) {
|
|
52
|
+
if (!Number.isFinite(seconds) || seconds < 0) return;
|
|
53
|
+
this.clocks.set(id, seconds);
|
|
54
|
+
// Audio stream identifiers increase monotonically within this renderer.
|
|
55
|
+
if (this.clocks.size > 32) this.clocks.delete(this.clocks.keys().next().value);
|
|
56
|
+
}
|
|
57
|
+
bind(id, audioId, audioStart, time, endTime) {
|
|
58
|
+
if (this.config?.id !== id) return;
|
|
59
|
+
if (audioId !== undefined) this.config.audioId = audioId;
|
|
60
|
+
if (audioStart !== undefined) this.config.audioStart = audioStart;
|
|
61
|
+
if (time !== undefined) Object.assign(this.config, { time, started: performance.now() });
|
|
62
|
+
if (endTime !== undefined) {
|
|
63
|
+
this.config.endTime = endTime;
|
|
64
|
+
// These frames may have been queued before the next init's first
|
|
65
|
+
// media segment established where the old configuration stops.
|
|
66
|
+
this.queue = this.queue.filter(frame => frame.time + 1e-6 < endTime);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
time() {
|
|
70
|
+
const c = this.config;
|
|
71
|
+
if (c.audioId != null) return c.audioStart + (this.clocks.get(c.audioId) ?? 0);
|
|
72
|
+
// Until its audio stream exists, a known audio track holds the image.
|
|
73
|
+
if (c.hasAudio) return c.time;
|
|
74
|
+
return c.time + (performance.now() - c.started) / 1000;
|
|
75
|
+
}
|
|
76
|
+
step() {
|
|
77
|
+
if (!this.decoder || !this.config) return;
|
|
78
|
+
const time = this.time();
|
|
79
|
+
const endTime = this.config.endTime ?? Infinity;
|
|
80
|
+
const deadline = performance.now() + 8;
|
|
81
|
+
try {
|
|
82
|
+
while (!this.eof && this.queue.length < this.limit && this.nextTime <= time + 0.75
|
|
83
|
+
&& this.nextTime + 1e-6 < endTime) {
|
|
84
|
+
const pixels = this.decoder.nextFrame(this.width, this.height);
|
|
85
|
+
if (!pixels) { this.eof = true; break; }
|
|
86
|
+
const frame = { pixels, time: this.nextTime, width: this.width, height: this.height };
|
|
87
|
+
this.nextTime += 1 / (this.decoder.fps || 30);
|
|
88
|
+
// A reopened decoder starts at an earlier keyframe. Decode through
|
|
89
|
+
// that prefix without flashing it over the last displayed picture.
|
|
90
|
+
if (frame.time + 1 / (this.decoder.fps || 30) > this.config.time) this.queue.push(frame);
|
|
91
|
+
if (performance.now() >= deadline) break;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Growing snapshots can throw at their current end. The main thread
|
|
95
|
+
// owns network/refill policy and decides whether this is EOF or an error.
|
|
96
|
+
this.eof = true;
|
|
97
|
+
}
|
|
98
|
+
// A later init can replace buffered frames before this snapshot's EOF.
|
|
99
|
+
// Finish at that media boundary; the renderer opens its next snapshot.
|
|
100
|
+
if (this.nextTime + 1e-6 >= endTime) {
|
|
101
|
+
this.eof = true;
|
|
102
|
+
this.nextTime = endTime;
|
|
103
|
+
}
|
|
104
|
+
let latest = null;
|
|
105
|
+
while (this.queue.length && this.queue[0].time <= time + 1e-6) latest = this.queue.shift();
|
|
106
|
+
if (latest) {
|
|
107
|
+
this.frame = latest;
|
|
108
|
+
if (this.config.shared) {
|
|
109
|
+
const meta = new Int32Array(this.config.shared, 0, 4);
|
|
110
|
+
Atomics.add(meta, 0, 1);
|
|
111
|
+
new Uint8Array(this.config.shared, 16, latest.pixels.length).set(latest.pixels);
|
|
112
|
+
Atomics.store(meta, 1, latest.width);
|
|
113
|
+
Atomics.store(meta, 2, latest.height);
|
|
114
|
+
Atomics.add(meta, 0, 1);
|
|
115
|
+
}
|
|
116
|
+
this.changed();
|
|
117
|
+
this.report({ id: this.config.id, time: latest.time });
|
|
118
|
+
}
|
|
119
|
+
if (this.eof && !this.queue.length && time >= this.nextTime && !this.reportedEnd) {
|
|
120
|
+
this.reportedEnd = true;
|
|
121
|
+
this.report({ id: this.config.id, end: this.nextTime });
|
|
122
|
+
}
|
|
123
|
+
this.timer = setTimeout(() => this.step(), 10);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Black/white raster passes capture the video's contribution after clipping,
|
|
128
|
+
// opacity, subtitles and controls. This preserves foreground pixels instead of
|
|
129
|
+
// pasting a rectangular video over the page's UI.
|
|
130
|
+
export function compositeVideo(tile, frame) {
|
|
131
|
+
const layer = tile.videoLayer;
|
|
132
|
+
if (!layer || !frame) return tile;
|
|
133
|
+
const pixels = new Uint8Array(layer.base);
|
|
134
|
+
const { x, y, width, height } = layer.rect;
|
|
135
|
+
for (let py = Math.max(0, Math.floor(y)); py < Math.min(tile.pixelHeight, Math.ceil(y + height)); py++) {
|
|
136
|
+
const sy = Math.max(0, Math.min(frame.height - 1, Math.floor((py - y) / height * frame.height)));
|
|
137
|
+
for (let px = Math.max(0, Math.floor(x)); px < Math.min(tile.pixelWidth, Math.ceil(x + width)); px++) {
|
|
138
|
+
const sx = Math.max(0, Math.min(frame.width - 1, Math.floor((px - x) / width * frame.width)));
|
|
139
|
+
const dst = (py * tile.pixelWidth + px) * 4, src = (sy * frame.width + sx) * 4;
|
|
140
|
+
for (let c = 0; c < 3; c++) pixels[dst + c] = Math.min(255,
|
|
141
|
+
layer.base[dst + c] + Math.round(frame.pixels[src + c] * layer.mask[dst + c] / 255));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { ...tile, pixels };
|
|
145
|
+
}
|
package/lib/renderer/workers.js
CHANGED
|
@@ -57,7 +57,7 @@ const hostClearTimeout = globalThis.clearTimeout;
|
|
|
57
57
|
* @returns {{ Worker: Function, dispose(): void }}
|
|
58
58
|
*/
|
|
59
59
|
export function createWorkers({
|
|
60
|
-
realm, documentUrl, fetchScript, runTask, callPage, reportError, DOMException, globals, constructorHelper, pagePorts,
|
|
60
|
+
realm, documentUrl, documentBaseUrl = documentUrl, fetchScript, runTask, callPage, reportError, DOMException, globals, constructorHelper, pagePorts,
|
|
61
61
|
}) {
|
|
62
62
|
const live = new Set();
|
|
63
63
|
|
|
@@ -94,7 +94,7 @@ export function createWorkers({
|
|
|
94
94
|
if (!new.target) throw new TypeError("Constructor Worker requires 'new'");
|
|
95
95
|
let url;
|
|
96
96
|
try {
|
|
97
|
-
url = new URL(String(scriptURL),
|
|
97
|
+
url = new URL(String(scriptURL), documentBaseUrl());
|
|
98
98
|
} catch {
|
|
99
99
|
throw new DOMException(`Failed to construct 'Worker': invalid URL '${scriptURL}'`, "SyntaxError");
|
|
100
100
|
}
|