buninu-browser 0.2.3 → 0.3.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 +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- 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 +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- 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-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- 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 +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- 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/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -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/raw-capture-benchmark.json +76 -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/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-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 +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -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 +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -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 +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -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
- package/ai-debug.log +0 -5537
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* array so `JSON.stringify(displayList)` stays a plain debug/reftest dump.
|
|
17
17
|
*/
|
|
18
18
|
import { backgroundLayers, gradientLayers, isGradient, resolveGradient, splitTopLevel } from "./gradient.js";
|
|
19
|
+
import { insetRadii, mapRadii, resolveCornerRadii as cornerRadii, scaleRadii } from "./rounded-rect.js";
|
|
19
20
|
|
|
20
21
|
const TRANSPARENT = "rgba(0, 0, 0, 0)";
|
|
21
22
|
const CSS_URL = /^url\(\s*(["']?)(.*?)\1\s*\)$/is;
|
|
@@ -126,9 +127,8 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
|
|
|
126
127
|
op: "drawShadow",
|
|
127
128
|
nodeId: box.nodeId,
|
|
128
129
|
rect: shadowRect,
|
|
129
|
-
radii: radii
|
|
130
|
-
?
|
|
131
|
-
: radii,
|
|
130
|
+
radii: mapRadii(radii, (x, y) => x > 0 || y > 0
|
|
131
|
+
? [Math.max(0, x + shadow.spread), Math.max(0, y + shadow.spread)] : [x, y]),
|
|
132
132
|
blur: shadow.blur,
|
|
133
133
|
color: shadow.color,
|
|
134
134
|
// The shadow is clipped out of the casting box's own border box.
|
|
@@ -240,6 +240,8 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
|
|
|
240
240
|
family: run.style?.fontFamily ?? ["sans-serif"],
|
|
241
241
|
size: run.style?.fontSize ?? 16,
|
|
242
242
|
weight: run.style?.fontWeight ?? 400,
|
|
243
|
+
style: run.style?.fontStyle ?? "normal",
|
|
244
|
+
stretch: run.style?.fontStretch ?? 100,
|
|
243
245
|
},
|
|
244
246
|
color: run.style?.color ?? "rgba(0, 0, 0, 1)",
|
|
245
247
|
...(run.style?.textDecorations ? { decoration: run.style.textDecorations } : {}),
|
|
@@ -404,11 +406,9 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
404
406
|
const positioned = style.position && style.position !== "static";
|
|
405
407
|
if (!escaping || positioned) {
|
|
406
408
|
const clip = overflowClip(node);
|
|
407
|
-
|
|
409
|
+
let radii = clip ? cornerRadii(style, boxes.get(node.id)) : null;
|
|
408
410
|
if (radii) {
|
|
409
|
-
|
|
410
|
-
radii.x = Math.max(0, radii.x - Math.max(left, right));
|
|
411
|
-
radii.y = Math.max(0, radii.y - Math.max(top, bottom));
|
|
411
|
+
radii = insetRadii(radii, style.borderWidths ?? [0, 0, 0, 0]);
|
|
412
412
|
}
|
|
413
413
|
clips.push([node, clip, radii]);
|
|
414
414
|
if (isScroller(style)) {
|
|
@@ -443,8 +443,7 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
443
443
|
const rect = clip && matrix ? transformRect(matrix, clip) : clip;
|
|
444
444
|
clipRect = intersectRects(clipRect, rect);
|
|
445
445
|
if (rect && radii && (radii.x > 0 || radii.y > 0)) {
|
|
446
|
-
roundedClips.push({ rect, radii: matrix
|
|
447
|
-
? { x: radii.x * Math.abs(matrix.a), y: radii.y * Math.abs(matrix.d) } : radii });
|
|
446
|
+
roundedClips.push({ rect, radii: matrix ? scaleRadii(radii, matrix.a, matrix.d) : radii });
|
|
448
447
|
}
|
|
449
448
|
}
|
|
450
449
|
// The owner's own box moves with every scroll container above it, not with its own.
|
|
@@ -531,25 +530,6 @@ export function visibleItems(displayList, rect, fixedOffset = { x: 0, y: 0 }) {
|
|
|
531
530
|
});
|
|
532
531
|
}
|
|
533
532
|
|
|
534
|
-
/**
|
|
535
|
-
* Resolves border-radius to one elliptical radius for every corner.
|
|
536
|
-
* Percentages refer to the border box's width (x) and height (y); radii that
|
|
537
|
-
* would overlap are scaled down together (CSS Backgrounds 3 §5.5).
|
|
538
|
-
*/
|
|
539
|
-
function cornerRadii(style, rect) {
|
|
540
|
-
const value = style?.borderRadius;
|
|
541
|
-
let x = 0;
|
|
542
|
-
let y = 0;
|
|
543
|
-
if (value?.unit === "%") {
|
|
544
|
-
x = rect.width * value.value / 100;
|
|
545
|
-
y = rect.height * value.value / 100;
|
|
546
|
-
} else if (value > 0) {
|
|
547
|
-
x = y = value;
|
|
548
|
-
}
|
|
549
|
-
const scale = Math.min(1, x > 0 ? rect.width / (2 * x) : 1, y > 0 ? rect.height / (2 * y) : 1);
|
|
550
|
-
return { x: x * scale, y: y * scale };
|
|
551
|
-
}
|
|
552
|
-
|
|
553
533
|
/**
|
|
554
534
|
* Where one background image layer's tiles go (CSS Backgrounds 3 §3): sized
|
|
555
535
|
* and positioned in the padding box, repeated per background-repeat, painted
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retains one encoded frame, including an encode still in progress. Multiple
|
|
3
|
+
* CDP clients asking for the same frame share that work. Older encodes may
|
|
4
|
+
* finish after a newer one, but must not replace its cache entry.
|
|
5
|
+
*/
|
|
6
|
+
export class FrameCaptureCache {
|
|
7
|
+
#entry = null;
|
|
8
|
+
|
|
9
|
+
get(key, capture) {
|
|
10
|
+
if (this.#entry?.key === key) return this.#entry.promise;
|
|
11
|
+
const entry = { key, promise: null };
|
|
12
|
+
this.#entry = entry;
|
|
13
|
+
try {
|
|
14
|
+
entry.promise = Promise.resolve(capture());
|
|
15
|
+
} catch (error) {
|
|
16
|
+
entry.promise = Promise.reject(error);
|
|
17
|
+
}
|
|
18
|
+
entry.promise = entry.promise.catch((error) => {
|
|
19
|
+
// A transient encoder failure must not poison subsequent captures.
|
|
20
|
+
if (this.#entry === entry) this.#entry = null;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
return entry.promise;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/paint/frame.js
CHANGED
|
@@ -11,21 +11,32 @@
|
|
|
11
11
|
* The viewport's device pixels at a scroll position. Parts of the viewport
|
|
12
12
|
* the tile does not cover show the page background, as Chrome's compositor
|
|
13
13
|
* shows a checkerboard until the missing tiles are rasterized.
|
|
14
|
+
* The returned pixels may be a view of the immutable tile. Callers must not
|
|
15
|
+
* modify them, including while an asynchronous image encoder is using them.
|
|
14
16
|
* @returns {{ pixels: Uint8Array, width: number, height: number }}
|
|
15
17
|
*/
|
|
16
|
-
export function cropViewport(tile, scrollX, scrollY, viewportWidth, viewportHeight) {
|
|
18
|
+
export function cropViewport(tile, scrollX, scrollY, viewportWidth, viewportHeight, exact = false) {
|
|
17
19
|
const scale = tile.scale;
|
|
18
|
-
const width = Math.max(1, Math.round(Math.floor(viewportWidth) * scale));
|
|
19
|
-
const height = Math.max(1, Math.round(Math.floor(viewportHeight) * scale));
|
|
20
|
-
const pixels = new Uint8Array(width * height * 4);
|
|
21
|
-
const [red, green, blue, alpha] = tile.background ?? [255, 255, 255, 255];
|
|
22
|
-
const fill = new Uint8Array([red, green, blue, alpha]);
|
|
20
|
+
const width = Math.max(1, Math.round((exact ? viewportWidth : Math.floor(viewportWidth)) * scale));
|
|
21
|
+
const height = Math.max(1, Math.round((exact ? viewportHeight : Math.floor(viewportHeight)) * scale));
|
|
23
22
|
const offsetX = Math.round((scrollX - tile.x) * scale);
|
|
24
23
|
const offsetY = Math.round((scrollY - tile.y) * scale);
|
|
24
|
+
// Raster tiles are normally viewport-wide. A full-width crop is already
|
|
25
|
+
// contiguous RGBA, including a viewport cut out of a taller prepaint tile.
|
|
26
|
+
if (offsetX === 0 && width === tile.pixelWidth && offsetY >= 0 && offsetY + height <= tile.pixelHeight) {
|
|
27
|
+
const start = offsetY * width * 4;
|
|
28
|
+
return { pixels: tile.pixels.subarray(start, start + width * height * 4), width, height };
|
|
29
|
+
}
|
|
30
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
25
31
|
const fromX = Math.max(0, -offsetX);
|
|
26
32
|
const toX = Math.min(width, tile.pixelWidth - offsetX);
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
// Background is only needed when the crop extends outside the raster tile.
|
|
34
|
+
let rowFill = null;
|
|
35
|
+
if (fromX > 0 || toX < width || offsetY < 0 || offsetY + height > tile.pixelHeight) {
|
|
36
|
+
const fill = new Uint8Array(tile.background ?? [255, 255, 255, 255]);
|
|
37
|
+
rowFill = new Uint8Array(width * 4);
|
|
38
|
+
new Uint32Array(rowFill.buffer).fill(new Uint32Array(fill.buffer)[0]);
|
|
39
|
+
}
|
|
29
40
|
for (let row = 0; row < height; row++) {
|
|
30
41
|
const sourceRow = row + offsetY;
|
|
31
42
|
const target = row * width * 4;
|
|
@@ -44,15 +55,33 @@ const IMAGE_FORMATS = new Set(["png", "jpeg", "webp"]);
|
|
|
44
55
|
|
|
45
56
|
/**
|
|
46
57
|
* Encodes RGBA pixels with Bun.Image. Bun.Image does not read raw pixels, so
|
|
47
|
-
* they are wrapped in an uncompressed BMP first
|
|
48
|
-
*
|
|
49
|
-
* @param {{ format?: "png" | "jpeg" | "webp", quality?: number }} options
|
|
58
|
+
* they are wrapped in an uncompressed BMP first. This copies the contiguous
|
|
59
|
+
* pixel bytes once to add the header, without converting channels or rows.
|
|
60
|
+
* @param {{ format?: "png" | "jpeg" | "webp", quality?: number, optimizeForSpeed?: boolean }} options
|
|
50
61
|
*/
|
|
51
|
-
export async function encodeImage(pixels, width, height, { format = "png", quality = 80 } = {}) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
export async function encodeImage(pixels, width, height, { format = "png", quality = 80, optimizeForSpeed = false } = {}) {
|
|
63
|
+
return encodePixels(pixels, width, height, format, quality, optimizeForSpeed, "bytes");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** CDP needs base64; let Bun produce it without copying encoded bytes into a Buffer. */
|
|
67
|
+
export async function encodeImageBase64(pixels, width, height, { format = "png", quality = 80, optimizeForSpeed = false } = {}) {
|
|
68
|
+
return encodePixels(pixels, width, height, format, quality, optimizeForSpeed, "toBase64");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function encodePixels(pixels, width, height, format, quality, optimizeForSpeed, terminal) {
|
|
72
|
+
const bitmap = rgbaBitmap(pixels, width, height);
|
|
73
|
+
try {
|
|
74
|
+
const image = new Bun.Image(bitmap);
|
|
75
|
+
const kind = IMAGE_FORMATS.has(format) ? format : "png";
|
|
76
|
+
const level = Math.min(100, Math.max(0, Number.isFinite(quality) ? quality : 80));
|
|
77
|
+
const pipeline = kind === "png" ? image.png(optimizeForSpeed ? { compressionLevel: 3 } : undefined) : image[kind]({ quality: level });
|
|
78
|
+
return await pipeline[terminal]();
|
|
79
|
+
} finally {
|
|
80
|
+
// Bun.Image borrows input until its terminal settles. Release only this
|
|
81
|
+
// owned temporary afterwards, instead of waiting for a large-heap GC.
|
|
82
|
+
// The caller's pixels and the encoded output remain independent.
|
|
83
|
+
bitmap.buffer.transfer(0);
|
|
84
|
+
}
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
/** A 32-bit BMP (BITMAPV4HEADER, BI_BITFIELDS in RGBA byte order, top-down rows) around RGBA pixels. */
|
|
@@ -86,3 +115,35 @@ export function backgroundBytes(color) {
|
|
|
86
115
|
if (!match) return [255, 255, 255, 255];
|
|
87
116
|
return [Number(match[1]), Number(match[2]), Number(match[3]), Math.round(Number(match[4] ?? 1) * 255)];
|
|
88
117
|
}
|
|
118
|
+
|
|
119
|
+
/** Raw screenshot payload, owned independently of a recyclable raster tile. */
|
|
120
|
+
export function rawFrame(pixels, width, height, format) {
|
|
121
|
+
if (!['rgba', 'bgra'].includes(format) || width * height > 16777216) throw Error('Invalid raw screenshot');
|
|
122
|
+
const data = new Uint8Array(pixels);
|
|
123
|
+
if (format === 'bgra') {
|
|
124
|
+
for (let i=0; i<data.length; i+=4) { const red=data[i]; data[i]=data[i+2]; data[i+2]=red; }
|
|
125
|
+
}
|
|
126
|
+
return {format, width, height, pixels:data};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// CDP clip coordinates are document CSS pixels, not viewport-relative pixels.
|
|
130
|
+
export function captureViewport(tile, x, y, width, height, clip) {
|
|
131
|
+
if (!clip) return cropViewport(tile,x,y,width,height);
|
|
132
|
+
if (![clip.x,clip.y,clip.width,clip.height,clip.scale].every(Number.isFinite)
|
|
133
|
+
|| clip.width<=0 || clip.height<=0 || clip.scale<=0 || clip.scale>8)
|
|
134
|
+
throw Error('Invalid screenshot clip');
|
|
135
|
+
const w=Math.max(1,Math.round(clip.width*tile.scale*clip.scale));
|
|
136
|
+
const h=Math.max(1,Math.round(clip.height*tile.scale*clip.scale));
|
|
137
|
+
const sourceW=Math.max(1,Math.round(clip.width*tile.scale));
|
|
138
|
+
const sourceH=Math.max(1,Math.round(clip.height*tile.scale));
|
|
139
|
+
if(Math.max(w,h,sourceW,sourceH)>8192 || w*h>16777216 || sourceW*sourceH>16777216)
|
|
140
|
+
throw Error('Invalid screenshot clip size');
|
|
141
|
+
const frame=cropViewport(tile,clip.x,clip.y,clip.width,clip.height,true);
|
|
142
|
+
if(clip.scale===1)return frame;
|
|
143
|
+
const pixels=new Uint8Array(w*h*4);
|
|
144
|
+
for(let row=0;row<h;row++)for(let col=0;col<w;col++) {
|
|
145
|
+
const from=(Math.min(frame.height-1,Math.floor(row/clip.scale))*frame.width+Math.min(frame.width-1,Math.floor(col/clip.scale)))*4;
|
|
146
|
+
pixels.set(frame.pixels.subarray(from,from+4),(row*w+col)*4);
|
|
147
|
+
}
|
|
148
|
+
return {pixels,width:w,height:h};
|
|
149
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SkParagraph can shape a whole text run in one call. Only emoji need a
|
|
2
|
+
// different fallback order; ordinary text must stay together for shaping.
|
|
3
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
4
|
+
const hasEmojiPresentation = /\p{Emoji_Presentation}|\uFE0F/u;
|
|
5
|
+
const startsEmojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
6
|
+
|
|
7
|
+
/** Append text with the emoji font preference applied to entire graphemes. */
|
|
8
|
+
export function appendParagraphText(builder, text, emojiStyle) {
|
|
9
|
+
text ||= " ";
|
|
10
|
+
if (!hasEmojiPresentation.test(text)) {
|
|
11
|
+
builder.addText(text);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
let start = 0;
|
|
15
|
+
for (const { segment, index } of graphemes.segment(text)) {
|
|
16
|
+
if (!startsEmojiPresentation.test(segment) && !segment.includes("\uFE0F")) continue;
|
|
17
|
+
if (index > start) builder.addText(text.slice(start, index));
|
|
18
|
+
builder.pushStyle(emojiStyle());
|
|
19
|
+
builder.addText(segment);
|
|
20
|
+
builder.pop();
|
|
21
|
+
start = index + segment.length;
|
|
22
|
+
}
|
|
23
|
+
if (start < text.length) builder.addText(text.slice(start));
|
|
24
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reusable native readback buffer and immutable JavaScript pixel snapshots.
|
|
3
|
+
* Shared snapshots cross the renderer/compositor worker boundary without
|
|
4
|
+
* copying another full raster tile. Each snapshot owns its buffer: encoding
|
|
5
|
+
* an old frame can safely overlap painting the next one.
|
|
6
|
+
*/
|
|
7
|
+
export class RasterReadback {
|
|
8
|
+
#kit;
|
|
9
|
+
#shared;
|
|
10
|
+
#scratch = null;
|
|
11
|
+
#allocate;
|
|
12
|
+
|
|
13
|
+
constructor(kit, { shared = false, allocate = null } = {}) {
|
|
14
|
+
this.#kit = kit;
|
|
15
|
+
this.#shared = shared;
|
|
16
|
+
this.#allocate = allocate;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
read(image, width, height) {
|
|
20
|
+
const kit = this.#kit;
|
|
21
|
+
const bytes = width * height * 4;
|
|
22
|
+
if (!this.#scratch || this.#scratch.length < bytes) {
|
|
23
|
+
this.dispose();
|
|
24
|
+
this.#scratch = kit.Malloc(Uint8Array, bytes);
|
|
25
|
+
}
|
|
26
|
+
const native = image.readPixels(0, 0, {
|
|
27
|
+
width, height, colorType: kit.ColorType.RGBA_8888,
|
|
28
|
+
alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB,
|
|
29
|
+
}, this.#scratch, width * 4);
|
|
30
|
+
if (!native) throw new Error("Unable to read raster pixels");
|
|
31
|
+
const pixels = this.#allocate ? this.#allocate(bytes)
|
|
32
|
+
: new Uint8Array(this.#shared ? new SharedArrayBuffer(bytes) : new ArrayBuffer(bytes));
|
|
33
|
+
pixels.set(native.subarray(0, bytes));
|
|
34
|
+
return pixels;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
dispose() {
|
|
38
|
+
if (this.#scratch) this.#kit.Free(this.#scratch);
|
|
39
|
+
this.#scratch = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {mkdtempSync,writeFileSync,readdirSync,statSync,unlinkSync,rmSync,realpathSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
|
|
5
|
+
// The producer owns the directory; Kitty t=t consumers unlink individual frames.
|
|
6
|
+
// A worker borrows a directory owned by its parent, so terminating the worker
|
|
7
|
+
// cannot skip cleanup. No pixel data is encoded into the CDP response.
|
|
8
|
+
export class RawFrames {
|
|
9
|
+
constructor({directory,ownsDirectory=true}={}) {
|
|
10
|
+
this.externalDirectory=directory;this.ownsDirectory=ownsDirectory;
|
|
11
|
+
this.sequence=0;this.closed=false;
|
|
12
|
+
this.onExit=()=>this.close();
|
|
13
|
+
}
|
|
14
|
+
get directory() {
|
|
15
|
+
if(this.closed)throw Error('Raw frame store is closed');
|
|
16
|
+
if(!this.path){
|
|
17
|
+
this.path=this.externalDirectory ? this.externalDirectory() : realpathSync(mkdtempSync(join(tmpdir(),'tty-graphics-protocol-browser-')));
|
|
18
|
+
if(!this.path)throw Error('Missing raw frame directory');
|
|
19
|
+
if(this.ownsDirectory)process.once('exit',this.onExit);
|
|
20
|
+
}
|
|
21
|
+
return this.path;
|
|
22
|
+
}
|
|
23
|
+
write(bytes,width,height,format) {
|
|
24
|
+
if (!['rgba','bgra'].includes(format) || !Number.isInteger(width) || !Number.isInteger(height)
|
|
25
|
+
|| width<1 || height<1 || width*height>16777216 || !(bytes instanceof Uint8Array)
|
|
26
|
+
|| bytes.byteLength!==width*height*4) throw Error('Invalid raw frame');
|
|
27
|
+
const directory=this.directory;
|
|
28
|
+
let count=0,total=0;
|
|
29
|
+
for(const name of readdirSync(directory)){
|
|
30
|
+
const path=join(directory,name);
|
|
31
|
+
try{
|
|
32
|
+
const stat=statSync(path);
|
|
33
|
+
if(Date.now()-stat.mtimeMs>60000){unlinkSync(path);continue;}
|
|
34
|
+
count++;total+=stat.size;
|
|
35
|
+
}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
36
|
+
}
|
|
37
|
+
if(count>=16||total+bytes.byteLength>128*1024*1024)throw Error('Raw frame consumer stalled');
|
|
38
|
+
const path=join(directory,`${++this.sequence}.${format}`);
|
|
39
|
+
writeFileSync(path,bytes,{flag:'wx',mode:0o600});
|
|
40
|
+
return JSON.stringify({version:1,transport:'file',path,width,height,stride:width*4,format,bytes:bytes.byteLength});
|
|
41
|
+
}
|
|
42
|
+
close(){
|
|
43
|
+
if(this.closed)return;this.closed=true;
|
|
44
|
+
process.removeListener('exit',this.onExit);
|
|
45
|
+
if(this.ownsDirectory&&this.path)rmSync(this.path,{recursive:true,force:true});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const FIELDS = ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius"];
|
|
2
|
+
const SQUARE = Object.freeze({ x: 0, y: 0 });
|
|
3
|
+
|
|
4
|
+
/** Clockwise corner pairs, starting at the top left. */
|
|
5
|
+
export function radiusPairs(radii) {
|
|
6
|
+
return radii?.corners ?? Array.from({ length: 4 }, () => [radii?.x ?? 0, radii?.y ?? 0]);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function compact(pairs) {
|
|
10
|
+
const [x, y] = pairs[0];
|
|
11
|
+
return pairs.every(pair => pair[0] === x && pair[1] === y) ? { x, y }
|
|
12
|
+
: { x: Math.max(...pairs.map(pair => pair[0])), y: Math.max(...pairs.map(pair => pair[1])), corners: pairs };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function mapRadii(radii, map) {
|
|
16
|
+
return compact(radiusPairs(radii).map(([x, y], index) => map(x, y, index)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function insetRadii(radii, [top, right, bottom, left]) {
|
|
20
|
+
if (!radii || !(radii.x > 0 || radii.y > 0)) return SQUARE;
|
|
21
|
+
const horizontal = [left, right, right, left], vertical = [top, top, bottom, bottom];
|
|
22
|
+
return mapRadii(radii, (x, y, index) => [Math.max(0, x - horizontal[index]),
|
|
23
|
+
Math.max(0, y - vertical[index])]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function scaleRadii(radii, x, y) {
|
|
27
|
+
let pairs = radiusPairs(radii).map(pair => [pair[0] * Math.abs(x), pair[1] * Math.abs(y)]);
|
|
28
|
+
if (x < 0) pairs = [pairs[1], pairs[0], pairs[3], pairs[2]];
|
|
29
|
+
if (y < 0) pairs = [pairs[3], pairs[2], pairs[1], pairs[0]];
|
|
30
|
+
return compact(pairs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** CanvasKit's twelve-float rounded rectangle, with independent elliptic corners. */
|
|
34
|
+
export function roundedRect(rect, radii, scale = 1) {
|
|
35
|
+
const result = new Float32Array(12);
|
|
36
|
+
result.set(rect);
|
|
37
|
+
if (!radii?.corners) {
|
|
38
|
+
const x = (radii?.x ?? 0) * scale, y = (radii?.y ?? 0) * scale;
|
|
39
|
+
for (let i = 4; i < 12; i += 2) { result[i] = x; result[i + 1] = y; }
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
const pairs = radii.corners;
|
|
43
|
+
for (let i = 0; i < 4; i++) {
|
|
44
|
+
result[4 + i * 2] = pairs[i][0] * scale;
|
|
45
|
+
result[5 + i * 2] = pairs[i][1] * scale;
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** CSS Backgrounds §5.5: all radii share one overlap-reduction factor. */
|
|
51
|
+
export function resolveCornerRadii(style, rect) {
|
|
52
|
+
const first = style?.[FIELDS[0]];
|
|
53
|
+
if (first && FIELDS.every(field => style[field] === first)) {
|
|
54
|
+
const x = Math.max(0, length(first[0], rect.width));
|
|
55
|
+
const y = Math.max(0, length(first[1], rect.height));
|
|
56
|
+
if (!(x > 0 || y > 0)) return SQUARE;
|
|
57
|
+
const factor = Math.min(1, ratio(rect.width, x * 2), ratio(rect.height, y * 2));
|
|
58
|
+
return { x: x * factor, y: y * factor };
|
|
59
|
+
}
|
|
60
|
+
const pairs = FIELDS.map(field => {
|
|
61
|
+
const [x, y] = style?.[field] ?? [style?.borderRadius ?? 0, style?.borderRadius ?? 0];
|
|
62
|
+
return [Math.max(0, length(x, rect.width)), Math.max(0, length(y, rect.height))];
|
|
63
|
+
});
|
|
64
|
+
const [tl, tr, br, bl] = pairs;
|
|
65
|
+
const factor = Math.min(1, ratio(rect.width, tl[0] + tr[0]), ratio(rect.width, bl[0] + br[0]),
|
|
66
|
+
ratio(rect.height, tl[1] + bl[1]), ratio(rect.height, tr[1] + br[1]));
|
|
67
|
+
return compact(pairs.map(pair => pair.map(value => value * factor)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ratio = (size, sum) => sum > 0 ? Math.max(0, size) / sum : 1;
|
|
71
|
+
function length(value, reference) {
|
|
72
|
+
if (typeof value === "number") return value;
|
|
73
|
+
if (value?.unit === "%") return reference * value.value / 100;
|
|
74
|
+
if (value?.unit === "math") {
|
|
75
|
+
if (value.kind === "calc") return value.px + reference * value.percent / 100;
|
|
76
|
+
const values = value.values.map(part => length(part, reference));
|
|
77
|
+
if (value.kind === "min") return Math.min(...values);
|
|
78
|
+
if (value.kind === "max") return Math.max(...values);
|
|
79
|
+
if (value.kind === "clamp") return Math.max(values[0], Math.min(values[1], values[2]));
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel buffers may be reused only after both the renderer's current tile and
|
|
3
|
+
* every compositor lease have let go. The idle cache is bounded; in-flight
|
|
4
|
+
* screenshots keep their own leases until their asynchronous encoder finishes.
|
|
5
|
+
*/
|
|
6
|
+
export class SharedFramePool {
|
|
7
|
+
#records = new WeakMap();
|
|
8
|
+
#leases = new Map();
|
|
9
|
+
#current = null;
|
|
10
|
+
#idle = [];
|
|
11
|
+
#idleBytes = 0;
|
|
12
|
+
#maxBuffers;
|
|
13
|
+
#maxBytes;
|
|
14
|
+
|
|
15
|
+
constructor({ maxBuffers = 3, maxBytes = 64 * 1024 * 1024 } = {}) {
|
|
16
|
+
this.#maxBuffers = maxBuffers;
|
|
17
|
+
this.#maxBytes = maxBytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
acquire(bytes) {
|
|
21
|
+
let index = this.#idle.findIndex(record => record.buffer.byteLength === bytes);
|
|
22
|
+
if (index < 0) index = this.#idle.findIndex(record => record.buffer.byteLength >= bytes && record.buffer.byteLength <= bytes * 2);
|
|
23
|
+
let record;
|
|
24
|
+
if (index >= 0) {
|
|
25
|
+
[record] = this.#idle.splice(index, 1);
|
|
26
|
+
this.#idleBytes -= record.buffer.byteLength;
|
|
27
|
+
record.idle = false;
|
|
28
|
+
} else {
|
|
29
|
+
record = { buffer: new SharedArrayBuffer(bytes), leases: 0, idle: false };
|
|
30
|
+
this.#records.set(record.buffer, record);
|
|
31
|
+
}
|
|
32
|
+
return new Uint8Array(record.buffer, 0, bytes);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
publish(serial, pixels) {
|
|
36
|
+
const record = this.#records.get(pixels.buffer);
|
|
37
|
+
if (!record) throw new Error("Published pixels do not belong to the shared frame pool");
|
|
38
|
+
const previous = this.#current;
|
|
39
|
+
this.#current = record;
|
|
40
|
+
record.leases++;
|
|
41
|
+
this.#leases.set(serial, record);
|
|
42
|
+
if (previous !== record) this.#recycle(previous);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
release(serial) {
|
|
46
|
+
const record = this.#leases.get(serial);
|
|
47
|
+
if (!record) return;
|
|
48
|
+
this.#leases.delete(serial);
|
|
49
|
+
record.leases--;
|
|
50
|
+
this.#recycle(record);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#recycle(record) {
|
|
54
|
+
if (!record || record === this.#current || record.leases || record.idle) return;
|
|
55
|
+
if (this.#maxBuffers <= 0 || record.buffer.byteLength > this.#maxBytes) return;
|
|
56
|
+
// Keep the sizes the renderer is using now. Startup/small viewport
|
|
57
|
+
// buffers must not permanently occupy every slot after a resize.
|
|
58
|
+
while (this.#idle.length >= this.#maxBuffers || this.#idleBytes + record.buffer.byteLength > this.#maxBytes) {
|
|
59
|
+
const old = this.#idle.shift();
|
|
60
|
+
old.idle = false;
|
|
61
|
+
this.#idleBytes -= old.buffer.byteLength;
|
|
62
|
+
}
|
|
63
|
+
record.idle = true;
|
|
64
|
+
this.#idle.push(record);
|
|
65
|
+
this.#idleBytes += record.buffer.byteLength;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
clear() {
|
|
69
|
+
// Dropping an owner never makes outstanding pixels available for reuse.
|
|
70
|
+
this.#records = new WeakMap();
|
|
71
|
+
this.#leases.clear();
|
|
72
|
+
this.#current = null;
|
|
73
|
+
this.#idle = [];
|
|
74
|
+
this.#idleBytes = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compositor-side leases, independent of renderer frame acknowledgement. */
|
|
79
|
+
export class FrameLeaseTracker {
|
|
80
|
+
#current = null;
|
|
81
|
+
#released;
|
|
82
|
+
|
|
83
|
+
constructor(released) { this.#released = released; }
|
|
84
|
+
|
|
85
|
+
replace(serial, frame) {
|
|
86
|
+
const previous = this.#current;
|
|
87
|
+
this.#current = { serial, frame, readers: 0, retired: false };
|
|
88
|
+
if (previous) { previous.retired = true; this.#release(previous); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async capture(read) {
|
|
92
|
+
const lease = this.#current;
|
|
93
|
+
if (!lease) return read(null);
|
|
94
|
+
lease.readers++;
|
|
95
|
+
try { return await read(lease.frame); }
|
|
96
|
+
finally { lease.readers--; this.#release(lease); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#release(lease) {
|
|
100
|
+
if (lease.retired && !lease.readers) {
|
|
101
|
+
lease.retired = false;
|
|
102
|
+
this.#released(lease.serial);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/lib/render-tree/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { extractSvg } from "./svg.js";
|
|
2
|
-
import { controlKind } from "../style/computed-style.js";
|
|
2
|
+
import { computeElementStyle, controlKind } from "../style/computed-style.js";
|
|
3
3
|
|
|
4
4
|
const PLACEHOLDER_COLOR = "rgba(117, 117, 117, 1)";
|
|
5
5
|
const CHECKED_COLOR = "rgba(0, 117, 255, 1)";
|
|
6
|
+
const DEFAULT_SUMMARY = Object.freeze({ tagName: "SUMMARY", getAttribute: () => null, hasAttribute: () => false });
|
|
7
|
+
const CONTENTS_HIDDEN_ELEMENTS = new Set(["BR", "IMG", "CANVAS", "VIDEO", "AUDIO", "IFRAME", "OBJECT", "EMBED", "INPUT", "TEXTAREA", "SELECT"]);
|
|
6
8
|
const NON_RENDERED_ELEMENTS = new Set([
|
|
7
9
|
"BASE",
|
|
8
10
|
"BASEFONT",
|
|
@@ -168,9 +170,10 @@ export class RenderTreeBuilder {
|
|
|
168
170
|
|
|
169
171
|
#element(element, nodesById, isRoot = false, styleEngine = null, resources = null) {
|
|
170
172
|
const style = styleEngine?.get(element) ?? null;
|
|
171
|
-
if (!isRoot && (style?.display === "none" || NON_RENDERED_ELEMENTS.has(element.tagName) || element.hasAttribute("hidden"))) {
|
|
173
|
+
if (!isRoot && (style?.display === "none" || NON_RENDERED_ELEMENTS.has(element.tagName) || (!style && element.hasAttribute("hidden")))) {
|
|
172
174
|
return null;
|
|
173
175
|
}
|
|
176
|
+
if (style?.display === "contents" && CONTENTS_HIDDEN_ELEMENTS.has(element.tagName)) return null;
|
|
174
177
|
if (element.tagName === "BR") {
|
|
175
178
|
return this.#node(element, "line-break", nodesById, {
|
|
176
179
|
tagName: "BR",
|
|
@@ -180,7 +183,7 @@ export class RenderTreeBuilder {
|
|
|
180
183
|
}
|
|
181
184
|
if (element.localName === "svg") {
|
|
182
185
|
// Inline SVG is a replaced element; its shapes are drawn into its box.
|
|
183
|
-
const svg = extractSvg(element, style?.color ?? "rgba(0, 0, 0, 1)");
|
|
186
|
+
const svg = extractSvg(element, style?.color ?? "rgba(0, 0, 0, 1)", styleEngine);
|
|
184
187
|
const [, , viewWidth, viewHeight] = svg.viewBox ?? [];
|
|
185
188
|
return this.#node(element, "image", nodesById, {
|
|
186
189
|
tagName: "SVG",
|
|
@@ -220,13 +223,13 @@ export class RenderTreeBuilder {
|
|
|
220
223
|
children: Object.freeze([]),
|
|
221
224
|
});
|
|
222
225
|
}
|
|
223
|
-
if (element.tagName === "IMG" || element.tagName === "VIDEO") {
|
|
226
|
+
if (element.tagName === "IMG" || element.tagName === "VIDEO" || element.tagName === "CANVAS") {
|
|
224
227
|
return this.#node(element, "image", nodesById, {
|
|
225
228
|
tagName: element.tagName,
|
|
226
229
|
style,
|
|
227
230
|
resource: resources?.get(element) ?? null,
|
|
228
|
-
widthAttribute: positiveDimension(element.getAttribute("width")),
|
|
229
|
-
heightAttribute: positiveDimension(element.getAttribute("height")),
|
|
231
|
+
widthAttribute: element.tagName === "CANVAS" ? element.width : positiveDimension(element.getAttribute("width")),
|
|
232
|
+
heightAttribute: element.tagName === "CANVAS" ? element.height : positiveDimension(element.getAttribute("height")),
|
|
230
233
|
children: Object.freeze([]),
|
|
231
234
|
});
|
|
232
235
|
}
|
|
@@ -237,9 +240,37 @@ export class RenderTreeBuilder {
|
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
const children = [];
|
|
243
|
+
if (element.tagName === "SUMMARY" && style?.display === "list-item"
|
|
244
|
+
&& ["disclosure-open", "disclosure-closed"].includes(style.listStyleType)) {
|
|
245
|
+
let keys = this.#pseudoKeys.get(element);
|
|
246
|
+
if (!keys) this.#pseudoKeys.set(element, keys = { before: {}, after: {}, beforeText: {}, afterText: {} });
|
|
247
|
+
keys.marker ??= {};
|
|
248
|
+
children.push(this.#node(element, "text", nodesById, {
|
|
249
|
+
text: style.listStyleType === "disclosure-open" ? "▼ " : "▶ ",
|
|
250
|
+
style, pseudo: "marker", children: Object.freeze([]),
|
|
251
|
+
}, keys.marker));
|
|
252
|
+
}
|
|
240
253
|
const before = this.#pseudo(element, "before", styleEngine, nodesById);
|
|
241
254
|
if (before) children.push(before);
|
|
255
|
+
const details = element.tagName === "DETAILS";
|
|
256
|
+
const summary = details ? [...element.children].find((child) => child.tagName === "SUMMARY") : null;
|
|
257
|
+
const closed = details && !element.hasAttribute("open");
|
|
258
|
+
if (details && !summary) {
|
|
259
|
+
let keys = this.#pseudoKeys.get(element);
|
|
260
|
+
if (!keys) this.#pseudoKeys.set(element, keys = { before: {}, after: {}, beforeText: {}, afterText: {} });
|
|
261
|
+
keys.summary ??= {};
|
|
262
|
+
keys.summaryText ??= {};
|
|
263
|
+
const summaryStyle = Object.freeze({ ...computeElementStyle(DEFAULT_SUMMARY, style),
|
|
264
|
+
display: "list-item", listStyleType: closed ? "disclosure-closed" : "disclosure-open" });
|
|
265
|
+
const text = this.#node(element, "text", nodesById, {
|
|
266
|
+
text: closed ? "▶ Details" : "▼ Details", style: summaryStyle, pseudo: "summary", children: Object.freeze([]),
|
|
267
|
+
}, keys.summaryText);
|
|
268
|
+
children.push(this.#node(element, "block", nodesById, {
|
|
269
|
+
tagName: "SUMMARY", pseudo: "summary", style: summaryStyle, children: Object.freeze([text]),
|
|
270
|
+
}, keys.summary));
|
|
271
|
+
}
|
|
242
272
|
for (const child of element.childNodes) {
|
|
273
|
+
if (closed && child !== summary) continue;
|
|
243
274
|
let rendered = null;
|
|
244
275
|
if (child.nodeType === 3) {
|
|
245
276
|
rendered = this.#node(child, "text", nodesById, {
|
|
@@ -304,6 +335,7 @@ function sameSvg(left, right) {
|
|
|
304
335
|
|
|
305
336
|
function boxType(style, tagName = null) {
|
|
306
337
|
const display = style?.display;
|
|
338
|
+
if (display === "contents") return "contents";
|
|
307
339
|
if (display === "-webkit-box") return "block";
|
|
308
340
|
if (display === "-webkit-inline-box") return "inline-block";
|
|
309
341
|
if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
|