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.
Files changed (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +17 -4
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. 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.x > 0 || radii.y > 0
130
- ? { x: Math.max(0, radii.x + shadow.spread), y: Math.max(0, radii.y + shadow.spread) }
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
- const radii = clip ? cornerRadii(style, boxes.get(node.id)) : null;
409
+ let radii = clip ? cornerRadii(style, boxes.get(node.id)) : null;
408
410
  if (radii) {
409
- const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
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
+ }
@@ -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
- const rowFill = new Uint8Array(width * 4);
28
- for (let index = 0; index < width; index++) rowFill.set(fill, index * 4);
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 (header only, no copying of
48
- * rows or channels).
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
- const image = new Bun.Image(rgbaBitmap(pixels, width, height));
53
- const kind = IMAGE_FORMATS.has(format) ? format : "png";
54
- const level = Math.min(100, Math.max(0, Number.isFinite(quality) ? quality : 80));
55
- return kind === "png" ? image.png().bytes() : image[kind]({ quality: level }).bytes();
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
+ }
@@ -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;