buninu-browser 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,88 @@
1
+ const SELECTORS = Object.freeze({
2
+ media: "video, audio",
3
+ images: "img",
4
+ sheets: "style, link[rel~='stylesheet']",
5
+ frames: "iframe",
6
+ });
7
+
8
+ /**
9
+ * Cache discovery, never resource state. Tag membership changes with DOM
10
+ * structure; only the stylesheet list also depends on an attribute (rel).
11
+ * Callers still read src/href, credentials, media/type/disabled, text and the
12
+ * document base URL on every pass. Ordinary data/ARIA/class mutations therefore
13
+ * do not cause four whole-document queries during a geometry flush.
14
+ */
15
+ export function createResourceElements(document) {
16
+ const lists = new Map();
17
+ const ancestors = new Map();
18
+ let disposed = false;
19
+ const discard = kind => { lists.delete(kind); ancestors.delete(kind); };
20
+ const invalidate = records => {
21
+ let visited;
22
+ for (const record of records) {
23
+ if (!lists.size) return;
24
+ if (record.type === "attributes") discard("sheets");
25
+ if (record.type !== "childList") continue;
26
+ for (const node of record.removedNodes) {
27
+ if (node.nodeType !== 1) {
28
+ if (node.nodeType !== 3 && node.nodeType !== 8) { lists.clear(); ancestors.clear(); return; }
29
+ continue;
30
+ }
31
+ // Snapshot ancestry survives detached subtrees being emptied or moved
32
+ // before observer delivery, without scanning every cached resource.
33
+ for (const [kind, parents] of ancestors) if (parents.has(node)) discard(kind);
34
+ }
35
+ const pending = [...record.addedNodes];
36
+ while (pending.length && lists.size) {
37
+ const node = pending.pop();
38
+ if (node.nodeType !== 1) {
39
+ if (node.nodeType !== 3 && node.nodeType !== 8) { lists.clear(); ancestors.clear(); return; }
40
+ continue;
41
+ }
42
+ // Nested insertion records can name the same subtree many times.
43
+ visited ??= new Set();
44
+ if (visited.has(node)) continue;
45
+ visited.add(node);
46
+ // Match the selector engine's case folding, including foreign tags.
47
+ const tag = node.tagName.toUpperCase();
48
+ if (tag === "VIDEO" || tag === "AUDIO") discard("media");
49
+ else if (tag === "IMG") discard("images");
50
+ else if (tag === "IFRAME") discard("frames");
51
+ else if (tag === "STYLE" || (tag === "LINK" && node.matches("link[rel~='stylesheet']"))) discard("sheets");
52
+ if (lists.size) for (const child of node.children) pending.push(child);
53
+ }
54
+ }
55
+ };
56
+ // Use an independent observer: discovery precedes style computation and
57
+ // must not consume or reorder the CSS invalidation observer's records.
58
+ const observer = new document.defaultView.MutationObserver(invalidate);
59
+ observer.observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ["rel"] });
60
+ return {
61
+ get(kind) {
62
+ const selector = SELECTORS[kind];
63
+ if (!selector) throw new TypeError(`Unknown resource element kind: ${kind}`);
64
+ if (disposed) throw new Error("Resource element discovery is disposed");
65
+ // Script may insert an image/link and synchronously read geometry in
66
+ // the same task, before observer callbacks have been delivered.
67
+ invalidate(observer.takeRecords());
68
+ let list = lists.get(kind);
69
+ if (!list) {
70
+ list = Object.freeze(Array.from(document.querySelectorAll(selector)));
71
+ lists.set(kind, list);
72
+ const parents = new WeakSet();
73
+ for (const element of list) {
74
+ for (let node = element; node && !parents.has(node); node = node.parentNode) parents.add(node);
75
+ }
76
+ ancestors.set(kind, parents);
77
+ }
78
+ return list;
79
+ },
80
+ dispose() {
81
+ if (disposed) return;
82
+ disposed = true;
83
+ observer.disconnect();
84
+ lists.clear();
85
+ ancestors.clear();
86
+ },
87
+ };
88
+ }
@@ -378,14 +378,22 @@ export function rewriteConstructorReads(source, sourceType = "script", parsed =
378
378
 
379
379
  /** Separates the tokens of `-->` and `<!--` in code (see escapeSesHtmlComments). */
380
380
  const HTML_COMMENT_MARK = "/*$buninu$h*/";
381
+ // An escaped IdentifierName still denotes the property "import", but SES's
382
+ // conservative import-expression regexp cannot mistake it for import().
383
+ const IMPORT_NAME_REWRITE = "\\u0069mport/*$buninu$import*/";
381
384
 
382
385
  /**
383
386
  * SES rejects HTML comment delimiters anywhere in source, including inside
384
387
  * strings and regular expressions where browsers treat them as ordinary
385
388
  * data. Escape only those lexical values; their evaluated value is unchanged.
389
+ * Also protect property/method names named `import`: SES otherwise renames
390
+ * `static import()` and `object. import()` to `__import__`. Acorn distinguishes
391
+ * these IdentifierNames from actual ImportExpressions, which keep the existing
392
+ * dynamic-import routing through the page module loader.
386
393
  */
387
394
  export function escapeSesHtmlComments(source, sourceType = "script") {
388
- if (!source.includes("<!--") && !source.includes("-->")) return source;
395
+ if (!source.includes("<!--") && !source.includes("-->")
396
+ && !/\bimport\s*(?:\(|\/[/*])/.test(source)) return source;
389
397
  let program;
390
398
  const tokens = [];
391
399
  try {
@@ -409,6 +417,14 @@ export function escapeSesHtmlComments(source, sourceType = "script") {
409
417
  }
410
418
  const visit = (node) => {
411
419
  if (!node || typeof node.type !== "string") return;
420
+ // `import.meta` is grammar, not an IdentifierName: escaping its keyword
421
+ // would make a valid module fail to parse (and new.target needs no edit).
422
+ if (node.type === "MetaProperty") return;
423
+ if (node.type === "Identifier" && node.name === "import"
424
+ && source.slice(node.start, node.end) === "import") {
425
+ edits.push({ start: node.start, end: node.end, text: IMPORT_NAME_REWRITE });
426
+ return;
427
+ }
412
428
  if ((node.type === "Literal" && (typeof node.value === "string" || node.regex))
413
429
  || node.type === "TemplateElement") {
414
430
  const text = source.slice(node.start, node.end);
@@ -986,6 +1002,7 @@ export function restoreSourceText(text) {
986
1002
  }
987
1003
  return code.replace(DIRECT_EVAL_CALL, "eval(")
988
1004
  .replaceAll(HTML_COMMENT_MARK, "")
1005
+ .replaceAll(IMPORT_NAME_REWRITE, "import")
989
1006
  .replaceAll("<\\x21--", "<!--").replaceAll("-\\x2d>", "-->")
990
1007
  .replace(/\b__import__(?=\s*(?:\(|\/[/*]))/g, "import");
991
1008
  }
@@ -24,7 +24,7 @@ import { SYSCALL_NUMBERS } from "./syscall-numbers.js";
24
24
  * rest are syscalls Chromium allows with argument checks, allowed here
25
25
  * without them for now, plus what a strace of the renderer workload (load,
26
26
  * resize, raster, scroll, hints, hit testing) showed Bun/JSC need.
27
- * clone, clone3, kill, tgkill and prctl are handled separately with argument
27
+ * clone, clone3, kill, tkill, tgkill and prctl are handled separately with argument
28
28
  * checks.
29
29
  */
30
30
  export const ALLOWED_SYSCALLS = Object.freeze([
@@ -114,8 +114,10 @@ export function buildFilter(arch, pid, allowed = ALLOWED_SYSCALLS) {
114
114
  [LD_W_ABS, 0, 0, DATA_NR],
115
115
  );
116
116
  program.push([JEQ_K, 0, 1, nr("clone3")], [RET_K, 0, 0, retErrno(ENOSYS)]);
117
- // kill(pid, ...) and tgkill(tgid, ...): only this process.
118
- for (const name of ["kill", "tgkill"]) {
117
+ // kill(pid, ...) and tgkill(tgid, ...): only this process. tkill(tid, ...)
118
+ // has no thread-group argument, so only allow its main thread (tid == pid).
119
+ // Bun/JSC uses this to interrupt the main thread for profiling.
120
+ for (const name of ["kill", "tkill", "tgkill"]) {
119
121
  program.push(
120
122
  [JEQ_K, 0, 4, nr(name)],
121
123
  [LD_W_ABS, 0, 0, DATA_ARG0_LOW],
@@ -135,7 +137,7 @@ export function buildFilter(arch, pid, allowed = ALLOWED_SYSCALLS) {
135
137
  [LD_W_ABS, 0, 0, DATA_NR],
136
138
  );
137
139
  for (const name of new Set(allowed)) {
138
- if (["clone", "clone3", "kill", "tgkill", "prctl"].includes(name)) continue;
140
+ if (["clone", "clone3", "kill", "tkill", "tgkill", "prctl"].includes(name)) continue;
139
141
  // Some syscalls exist on only one architecture (e.g. x86-64 open, poll).
140
142
  if (numbers[name] == null) continue;
141
143
  program.push([JEQ_K, 0, 1, numbers[name]], [RET_K, 0, 0, RET_ALLOW]);
@@ -0,0 +1,71 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ // Promise continuations within a timer's microtask checkpoint inherit its
4
+ // nesting, but a later task must not inherit a long-finished timer callback.
5
+ const taskContext = new AsyncLocalStorage();
6
+ const hostSetTimer = globalThis.setTimeout, hostClearTimer = globalThis.clearTimeout;
7
+ const hostQueueTask = globalThis.setImmediate, hostClearTask = globalThis.clearImmediate;
8
+
9
+ /** Window/Worker timers: one Web IDL long conversion and the HTML nesting clamp. */
10
+ export function createTimers({ invoke, nextId, setTimer = hostSetTimer, clearTimer = hostClearTimer,
11
+ queueTask = hostQueueTask, clearTask = hostClearTask }) {
12
+ const records = new Map(), checkpoints = new Set(), owner = {};
13
+ let disposed = false, checkpointTask = null;
14
+
15
+ function run(record, nesting) {
16
+ if (!nesting) return taskContext.run(null, () => invoke(record.callback));
17
+ const context = { owner, level: nesting, active: true };
18
+ checkpoints.add(context);
19
+ if (checkpointTask === null) checkpointTask = queueTask(() => {
20
+ checkpointTask = null;
21
+ for (const context of checkpoints) context.active = false;
22
+ checkpoints.clear();
23
+ });
24
+ return taskContext.run(context, () => invoke(record.callback));
25
+ }
26
+ function arm(record) {
27
+ const delay = record.nesting > 6 ? Math.max(4, record.delay) : record.delay;
28
+ record.handle = setTimer(() => {
29
+ if (disposed || records.get(record.id) !== record) return;
30
+ const nesting = record.nesting;
31
+ if (record.repeat) {
32
+ // Preserve interval cadence and its handle while advancing nesting;
33
+ // cancellation from inside the callback removes this next wakeup too.
34
+ record.nesting = Math.min(7, nesting + 1);
35
+ arm(record);
36
+ } else records.delete(record.id);
37
+ run(record, nesting);
38
+ }, delay);
39
+ }
40
+ return {
41
+ schedule(callback, delay, repeat = false, timerTask = true) {
42
+ // Unary + performs Web IDL ToNumber (unlike Number, it rejects BigInt),
43
+ // and |0 converts to a signed 32-bit long before negative delays clamp.
44
+ const milliseconds = Math.max(0, +delay | 0);
45
+ const id = nextId();
46
+ if (disposed) return id;
47
+ const current = taskContext.getStore();
48
+ const parent = current?.active && current.owner === owner ? current.level : 0;
49
+ const record = { id, callback, delay: milliseconds, repeat,
50
+ nesting: timerTask ? Math.min(7, parent + 1) : 0, handle: null };
51
+ records.set(id, record);
52
+ arm(record);
53
+ return id;
54
+ },
55
+ cancel(id) {
56
+ const key = Number(id), record = records.get(key);
57
+ if (!record) return;
58
+ records.delete(key);
59
+ clearTimer(record.handle);
60
+ },
61
+ dispose() {
62
+ disposed = true;
63
+ for (const record of records.values()) clearTimer(record.handle);
64
+ records.clear();
65
+ if (checkpointTask !== null) clearTask(checkpointTask);
66
+ checkpointTask = null;
67
+ for (const context of checkpoints) context.active = false;
68
+ checkpoints.clear();
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,94 @@
1
+ function workerClosedError(event) {
2
+ return new Error(`Video worker closed${Number.isInteger(event?.code) ? ` (${event.code})` : ''}`);
3
+ }
4
+
5
+ /** Compositor-owned endpoint for the independent media decoder. */
6
+ export class VideoChannel {
7
+ constructor(worker, changed, report) {
8
+ this.worker = worker;
9
+ this.changed = changed;
10
+ this.report = report;
11
+ this.config = null;
12
+ this.frame = null;
13
+ worker.addEventListener('message', ({data}) => {
14
+ if (this.failure) return;
15
+ if (data.type === 'frame') {
16
+ try {
17
+ if (this.config?.id === data.id) {
18
+ this.frame = data.frame;
19
+ changed();
20
+ }
21
+ } finally {
22
+ // ACK discarded generations too; an old frame may still be in
23
+ // flight when open/stop reaches the independent decoder.
24
+ if (Number.isSafeInteger(data.delivery))
25
+ this.send({type:'frame-ack', id:data.id, delivery:data.delivery});
26
+ }
27
+ } else if (data.type === 'report' && this.config?.id === data.report.id) report(data.report);
28
+ });
29
+ worker.addEventListener('error', error => this.fail(error));
30
+ // Bun silently ignores postMessage after a worker exits. Catch close too,
31
+ // or a dead decoder would retain its last picture without invoking fallback.
32
+ worker.addEventListener('close', event => this.fail(workerClosedError(event)));
33
+ }
34
+ send(message) {
35
+ if (this.failure) return;
36
+ try { this.worker.postMessage(message); }
37
+ catch (error) { this.fail(error); }
38
+ }
39
+ fail(error) {
40
+ if (this.failure) return;
41
+ const id = this.config?.id;
42
+ this.failure = String(error?.message ?? error) || 'Video worker failed';
43
+ this.config = null;
44
+ this.frame = null;
45
+ this.worker.terminate();
46
+ if (id !== undefined) this.report({id,error:this.failure});
47
+ }
48
+ open(config) {
49
+ this.config = config;
50
+ this.frame = null;
51
+ if (this.failure) { this.report({id:config.id,error:this.failure}); return; }
52
+ this.send({type:'open', config});
53
+ }
54
+ extend(request) {
55
+ if (this.config?.id !== request.id || this.failure) return;
56
+ // Existing pixels and their outstanding ACK belong to the same session.
57
+ this.send({ ...request, type: 'extend' });
58
+ }
59
+ stop(id) {
60
+ if (id !== undefined && this.config?.id !== id) return;
61
+ this.config = null;
62
+ this.frame = null;
63
+ this.send({type:'stop', id});
64
+ }
65
+ bind(id, audioId, audioStart, time, endTime) {
66
+ if (this.config?.id !== id || this.failure) return;
67
+ this.send({type:'bind', id, audioId, audioStart, time, endTime});
68
+ }
69
+ timing(id, seconds) {
70
+ this.send({type:'timing', id, seconds});
71
+ }
72
+ }
73
+
74
+ export async function startVideoChannel(changed, report) {
75
+ const worker = new Worker(new URL('./video-worker.js', import.meta.url).href);
76
+ const channel = new VideoChannel(worker, changed, report);
77
+ try {
78
+ await new Promise((resolve, reject) => {
79
+ const ready = ({data}) => { if(data.type === 'ready') {cleanup(); resolve();} };
80
+ const failed = error => {cleanup(); reject(error);};
81
+ const closed = event => failed(new Error(channel.failure || workerClosedError(event).message));
82
+ // Slow WASM initialization is not a failure; wait for ready or exit/error.
83
+ const cleanup = () => {
84
+ worker.removeEventListener('message', ready);
85
+ worker.removeEventListener('error', failed);
86
+ worker.removeEventListener('close', closed);
87
+ };
88
+ worker.addEventListener('message', ready);
89
+ worker.addEventListener('error', failed);
90
+ worker.addEventListener('close', closed);
91
+ });
92
+ } catch (error) { worker.terminate(); throw error; }
93
+ return channel;
94
+ }
@@ -0,0 +1,115 @@
1
+ import {visibleItems} from "../paint/display-list.js";
2
+
3
+ /** The compositor's rounded device rect and the stricter geometry needed for replay. */
4
+ export function videoLayerGeometry(item, region, scale) {
5
+ const source = item.rect, transformed = {...source};
6
+ const transform = item.transform;
7
+ if (transform) {
8
+ transformed.x = transformed.x * transform.a + transform.e;
9
+ transformed.y = transformed.y * transform.d + transform.f;
10
+ transformed.width *= transform.a;
11
+ transformed.height *= transform.d;
12
+ }
13
+ const rect = {
14
+ x:Math.round((transformed.x-region.x)*scale), y:Math.round((transformed.y-region.y)*scale),
15
+ width:Math.round(transformed.width*scale), height:Math.round(transformed.height*scale),
16
+ };
17
+ // Keep the existing layer rect for fallback painting. Black replay also
18
+ // needs finite painter intermediates: equivalent arithmetic can overflow
19
+ // in different orders, so check the final compositor coordinates directly.
20
+ const {a=1,b=0,c=0,d=1,e=0,f=0} = transform ?? {};
21
+ const x = Math.round((source?.x-region.x)*scale), y = Math.round((source?.y-region.y)*scale);
22
+ const width = Math.round(source?.width*scale), height = Math.round(source?.height*scale);
23
+ const tx = ((a-1)*region.x+e)*scale, ty = ((d-1)*region.y+f)*scale;
24
+ const replayable = !item.fixed && a>0 && d>0 && !b && !c && scale>0 && source?.width>0 && source?.height>0
25
+ && width>0 && height>0 && rect.width>0 && rect.height>0
26
+ && [source?.x,source?.y,source?.width,source?.height,a,b,c,d,e,f,scale,x,y,width,height,tx,ty,
27
+ x*a+tx,y*d+ty,(x+width)*a+tx,(y+height)*d+ty,rect.x,rect.y,rect.width,rect.height].every(Number.isFinite);
28
+ return {rect,replayable};
29
+ }
30
+
31
+ /** Device-space coverage of the scale/translate video draw used by the painter. */
32
+ export function videoMaskBounds(item, region, scale, width, height) {
33
+ const rect = item?.rect, transform = item?.transform;
34
+ if (!rect || item.fixed || ![scale, rect.x, rect.y, rect.width, rect.height, region.x, region.y].every(Number.isFinite) || scale <= 0
35
+ || rect.width <= 0 || rect.height <= 0) return null;
36
+ const {a = 1, d = 1, e = 0, f = 0, b = 0, c = 0} = transform ?? {};
37
+ if (![a,d,e,f,b,c].every(Number.isFinite) || a <= 0 || d <= 0 || b || c) return null;
38
+ // Match deviceRect's separate rounding of origin and extent, then canvas.concat.
39
+ const x = Math.round((rect.x - region.x) * scale) * a + ((a - 1) * region.x + e) * scale;
40
+ const y = Math.round((rect.y - region.y) * scale) * d + ((d - 1) * region.y + f) * scale;
41
+ const right = x + Math.round(rect.width * scale) * a;
42
+ const bottom = y + Math.round(rect.height * scale) * d;
43
+ const left = Math.max(0, Math.min(width, Math.floor(x) - 1));
44
+ const top = Math.max(0, Math.min(height, Math.floor(y) - 1));
45
+ return {x:left, y:top, width:Math.max(0,Math.min(width,Math.ceil(right)+1)-left),
46
+ height:Math.max(0,Math.min(height,Math.ceil(bottom)+1)-top)};
47
+ }
48
+
49
+ /**
50
+ * Black/white passes need only replay the video's coverage. The ordinary
51
+ * raster is immutable and already supplies every unaffected background pixel.
52
+ * Unknown or full-tile bounds retain the original full-tile path. This leaves
53
+ * the scratch surface changed, like the original black/white passes; callers
54
+ * must not reuse it as an ordinary raster (canvasDamage excludes videos).
55
+ */
56
+ export function rasterVideoMask({kit, canvas, surface, width, height, pixels, bounds, region, scale,
57
+ background, paint, hints, cullPadding = 2, blackAlreadyRasterized = false}) {
58
+ const cropped = bounds && pixels?.length === width * height * 4
59
+ && (bounds.x !== 0 || bounds.y !== 0 || bounds.width !== width || bounds.height !== height);
60
+ const area = cropped ? bounds : {x:0,y:0,width,height};
61
+ const base = cropped ? new Uint8Array(pixels) : null;
62
+ const mask = cropped ? new Uint8Array(width * height * 4) : null;
63
+ if (!area.width || !area.height) return {base, mask};
64
+ const dirtyView = cropped && Number.isFinite(cullPadding) ? {
65
+ x:region.x+(area.x-cullPadding)/scale, y:region.y+(area.y-cullPadding)/scale,
66
+ width:(area.width+cullPadding*2)/scale, height:(area.height+cullPadding*2)/scale,
67
+ } : region;
68
+ function raster(white) {
69
+ canvas.save();
70
+ try {
71
+ // Keep the canvas clip identical to the ordinary paint. Adding even an
72
+ // enclosing integer clip can change Skia's rounded-clip AA edge coverage.
73
+ // Cull replay items and crop readback instead; clear remains a cheap fill.
74
+ canvas.clear(background);
75
+ paint(white, dirtyView);
76
+ hints?.();
77
+ } finally { canvas.restore(); }
78
+ surface.flush();
79
+ const image = surface.makeImageSnapshot();
80
+ try {
81
+ const data = image.readPixels(area.x,area.y,{width:area.width,height:area.height,
82
+ colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB});
83
+ if (!data) throw new Error('Unable to read video mask pixels');
84
+ return data;
85
+ } finally { image.delete(); }
86
+ }
87
+ let black;
88
+ if (blackAlreadyRasterized && pixels?.length === width * height * 4) {
89
+ black = new Uint8Array(area.width * area.height * 4);
90
+ for (let row = 0; row < area.height; row++) {
91
+ const start = ((area.y + row) * width + area.x) * 4;
92
+ black.set(pixels.subarray(start,start + area.width * 4),row * area.width * 4);
93
+ }
94
+ } else black = raster(false);
95
+ const white = raster(true);
96
+ if (!cropped) {
97
+ for(let i=0;i<white.length;i++) white[i]=Math.max(0,white[i]-black[i]);
98
+ return {base:black,mask:white};
99
+ }
100
+ const stride = area.width * 4;
101
+ for(let row=0;row<area.height;row++) {
102
+ const source=row*stride, target=((area.y+row)*width+area.x)*4;
103
+ base.set(black.subarray(source,source+stride),target);
104
+ for(let i=0;i<stride;i++) mask[target+i]=Math.max(0,white[source+i]-black[source+i]);
105
+ }
106
+ return {base,mask};
107
+ }
108
+
109
+ /** Preserve shadow tails beyond nominal display-list bounds during partial replay. */
110
+ export function videoMaskItems(displayList, view, dirtyView, viewport) {
111
+ const nearby = new Set(visibleItems(displayList, dirtyView, viewport));
112
+ // Keep the full pass's ordering and membership. Gaussian shadows can reach
113
+ // farther than their CSS blur-radius bounds, so do not cull those again.
114
+ return visibleItems(displayList, view, viewport).filter(item => item.op === "drawShadow" || nearby.has(item));
115
+ }