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.
- package/README.md +13 -6
- package/index.js +10 -6
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +94 -46
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/cdp-viewers.test.js +43 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/index.test.js +1 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- 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("-->")
|
|
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
|
}
|
package/lib/renderer/seccomp.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|