buninu-browser 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -0,0 +1,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 { escapeSesHtmlComments, rewriteClassicScriptSource } from "./script-rewrite.js";
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
- const classic = rewriteClassicScriptSource(String(source));
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
- for (const edit of innerEdits) {
122
- if (edit.start < start || edit.end > end) continue;
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
+ }
@@ -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), documentUrl());
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
  }