buninu-browser 0.6.0 → 0.9.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 (144) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +94 -4
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/index.js +10 -6
  16. package/lib/audio/biquad.js +25 -0
  17. package/lib/audio/piano.js +6 -3
  18. package/lib/audio/playback-buffer.js +5 -5
  19. package/lib/audio/pulse-client.js +24 -5
  20. package/lib/audio/web-audio.js +570 -0
  21. package/lib/browser-dialog.js +116 -0
  22. package/lib/cdp-server.js +128 -72
  23. package/lib/file-picker.js +48 -0
  24. package/lib/headless-shell.js +34 -12
  25. package/lib/layout/text-layout.js +112 -19
  26. package/lib/media/mp4-frame-times.js +93 -0
  27. package/lib/paint/direct-canvas.ts +39 -0
  28. package/lib/paint/display-list.js +37 -12
  29. package/lib/paint/raster-readback.js +16 -7
  30. package/lib/renderer/animation-frames.js +10 -5
  31. package/lib/renderer/bindings.js +69 -16
  32. package/lib/renderer/canvas-2d.js +265 -15
  33. package/lib/renderer/canvas-round-rect.js +36 -0
  34. package/lib/renderer/css-animations.js +173 -0
  35. package/lib/renderer/dialog-client.js +30 -0
  36. package/lib/renderer/dialog-worker.js +35 -0
  37. package/lib/renderer/eval-environment.js +28 -0
  38. package/lib/renderer/file-input.js +49 -0
  39. package/lib/renderer/host.js +33 -4
  40. package/lib/renderer/html-drag.js +77 -0
  41. package/lib/renderer/image-loading.js +45 -2
  42. package/lib/renderer/main.js +4 -0
  43. package/lib/renderer/module-transform.js +5 -3
  44. package/lib/renderer/page-builtins.js +59 -0
  45. package/lib/renderer/page-realm.js +177 -40
  46. package/lib/renderer/page-renderer.js +397 -49
  47. package/lib/renderer/piano-input.js +3 -1
  48. package/lib/renderer/raster-surface.js +18 -0
  49. package/lib/renderer/script-rewrite.js +165 -9
  50. package/lib/renderer/video-presentation.js +5 -4
  51. package/lib/renderer/webgl/clip.js +24 -0
  52. package/lib/renderer/webgl/context.js +158 -0
  53. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  54. package/lib/renderer/webgl/draw-cache.js +22 -0
  55. package/lib/renderer/webgl/pipeline.js +70 -0
  56. package/lib/renderer/webgl/shaders.js +58 -0
  57. package/lib/renderer/webgl/textures.js +43 -0
  58. package/lib/renderer/window-named-properties.js +46 -0
  59. package/lib/screenshot-cli.js +4 -7
  60. package/lib/style/animation-values.js +29 -0
  61. package/lib/style/computed-style.js +109 -13
  62. package/lib/style/transform.js +44 -0
  63. package/lib/window-size.js +10 -0
  64. package/package.json +1 -1
  65. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  66. package/patches/happy-dom/README.md +16 -0
  67. package/test/animation-frames.test.js +15 -0
  68. package/test/canvas-2d.test.js +252 -4
  69. package/test/canvas-frame-pacing.test.js +7 -0
  70. package/test/cdp-viewers.test.js +79 -0
  71. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  72. package/test/classic-games-patches/README.md +156 -0
  73. package/test/classic-games-scenarios.js +133 -0
  74. package/test/clipboard.test.js +16 -0
  75. package/test/computed-style.test.js +18 -0
  76. package/test/css-animation-lifecycle.test.js +28 -0
  77. package/test/css-animations.test.js +83 -0
  78. package/test/direct-canvas.test.js +39 -0
  79. package/test/dynamic-inline-handlers.test.js +25 -0
  80. package/test/eval-declarations.test.js +22 -0
  81. package/test/file-picker.test.js +26 -0
  82. package/test/fixtures/canvas-damage.js +23 -0
  83. package/test/fixtures/canvas-frame-pacing.js +35 -0
  84. package/test/fixtures/direct-canvas.js +96 -0
  85. package/test/fixtures/javascript-dialogs.html +8 -0
  86. package/test/fixtures/media-audio-lifetime.js +65 -0
  87. package/test/fixtures/renderer-memory.js +7 -5
  88. package/test/fixtures/video-black-replay.js +8 -6
  89. package/test/fixtures/video-slow-end.js +39 -0
  90. package/test/fixtures/web-audio-renderer.js +45 -0
  91. package/test/flex.test.js +26 -0
  92. package/test/grid.test.js +13 -0
  93. package/test/headless-shell.test.js +9 -0
  94. package/test/headless-window-size.test.js +28 -0
  95. package/test/html-rcdata.test.js +35 -0
  96. package/test/image-decode.test.js +18 -0
  97. package/test/index.test.js +1 -0
  98. package/test/javascript-dialog.test.js +83 -0
  99. package/test/manual-breakout-paddle.js +42 -0
  100. package/test/manual-breakout-static.js +35 -0
  101. package/test/manual-classic-games.js +41 -0
  102. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  103. package/test/manual-clumsy-bird.js +52 -0
  104. package/test/manual-hextris.js +30 -0
  105. package/test/manual-numbered-music.js +24 -0
  106. package/test/manual-t-rex-runner.js +49 -0
  107. package/test/manual-threejs-scene.js +64 -0
  108. package/test/manual-webgl-benchmark.js +27 -0
  109. package/test/manual-webgl-cpu.html +26 -0
  110. package/test/media-audio-lifetime.test.js +8 -0
  111. package/test/mp4-frame-times.test.js +33 -0
  112. package/test/page-scripts.test.js +96 -1
  113. package/test/piano-input.test.js +15 -2
  114. package/test/piano.test.js +11 -2
  115. package/test/playback-buffer.test.js +11 -0
  116. package/test/pointer-drag.test.js +33 -0
  117. package/test/positioning.test.js +82 -0
  118. package/test/pulse-client.test.js +51 -2
  119. package/test/raster-readback.test.js +14 -0
  120. package/test/script-rewrite.test.js +61 -3
  121. package/test/video-slow-end.test.js +6 -0
  122. package/test/viewport-keyboard-overflow.test.js +25 -0
  123. package/test/wayland-win32-resources.test.js +47 -0
  124. package/test/wayland-win32-wire.test.js +59 -0
  125. package/test/web-audio-renderer.test.js +8 -0
  126. package/test/web-audio.test.js +264 -0
  127. package/test/webgl-clip.test.js +9 -0
  128. package/test/webgl-cpu-fragment.test.js +43 -0
  129. package/test/window-named-properties.test.js +41 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  131. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  132. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  133. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  134. package/vendor/land-renderer/LICENSE +21 -0
  135. package/vendor/land-renderer/README.md +14 -0
  136. package/vendor/land-renderer/direct-compositor.ts +443 -0
  137. package/vendor/land-renderer/frame.js +88 -0
  138. package/vendor/land-renderer/png-fast.js +85 -0
  139. package/vendor/land-renderer/raw-frames.js +66 -0
  140. package/vendor/land-renderer/render-types.ts +7 -0
  141. package/vendor/land-renderer/renderer.ts +134 -0
  142. package/vendor/land-renderer/snapshot.js +37 -0
  143. package/vendor/land-renderer/viewport.js +18 -0
  144. package/vendor/ses/dist/ses.mjs +1 -1
@@ -1,3 +1,4 @@
1
+ import {BrowserDialog} from "./browser-dialog.js";
1
2
  // qml handle begin: select a dedicated local application renderer
2
3
  import {isQmlDocument} from './qml/format.js';
3
4
  // Local QML uses a dedicated host renderer.
@@ -15,6 +16,7 @@ import {isQmlDocument} from './qml/format.js';
15
16
  * always passes it) but ignored. `--dangerously-allow-host-js` relaxes
16
17
  * the renderer sandbox for every page.
17
18
  */
19
+ import { parseWindowSize } from "./window-size.js";
18
20
  import { CdpServer } from "./cdp-server.js";
19
21
  import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
20
22
  import { audioDemoDocument, displayDocument } from "./document-types.js";
@@ -31,6 +33,7 @@ export const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
31
33
 
32
34
  export async function runHeadlessShell(args = Bun.argv.slice(2)) {
33
35
  installLog(logFileFromEnv());
36
+ const initialViewport = parseWindowSize(args);
34
37
  const allowHostJs = args.includes(DANGEROUSLY_ALLOW_HOST_JS);
35
38
  if (args.includes("--no-sandbox")) {
36
39
  console.error(`buninu-browser: --no-sandbox is ignored; use ${DANGEROUSLY_ALLOW_HOST_JS} to disable the page sandbox`);
@@ -44,7 +47,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
44
47
 
45
48
  // The endpoint opens while the first renderer starts: a client's first
46
49
  // navigation fetches its document meanwhile.
47
- const browser = await createBrowser({ allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
50
+ const browser = await createBrowser({ initialViewport, allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
48
51
  const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
49
52
  const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
50
53
  const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
@@ -85,14 +88,14 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
85
88
  * anything that needs a renderer waits for it.
86
89
  *
87
90
  * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
88
- * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean,
91
+ * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean, initialViewport?: {width: number, height: number},
89
92
  * clipboard?: {read: Function, write: Function} }} options
90
93
  * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
91
94
  */
92
95
  export async function createBrowser({
93
96
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 0, mobile = false,
94
97
  waitForRenderer = true, webkit = true,
95
- clipboard = null,
98
+ clipboard = null, initialViewport = { width: 800, height: 600 },
96
99
  } = {}) {
97
100
  // Explicit presentation profiles fix the identity for requests and scripts.
98
101
  const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
@@ -111,12 +114,18 @@ export async function createBrowser({
111
114
  let rendererReady = null;
112
115
  const documentReady = new WeakMap();
113
116
  // Renderer-independent state replayed into every new renderer.
114
- const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
117
+ const viewport = { ...initialViewport, deviceScaleFactor: 1 };
115
118
  let userAgent = webkit ? initialUserAgent : null;
116
119
  // Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
117
120
  const initScripts = new Map();
118
121
  let nextInitScript = 1;
119
122
  // Listeners for frame and lifecycle notifications (the CDP server).
123
+ let dialogHandler = null;
124
+ const dialogs = new BrowserDialog({viewport:()=>({...viewport}),
125
+ background:()=>current?.composite('screenshot',{format:'png'}),
126
+ changed:()=>notify(frameListeners),
127
+ opened:request=>dialogHandler?.(request),
128
+ closed:result=>dialogHandler?.(null,result),clipboard});
120
129
  const frameListeners = new Set();
121
130
  const lifecycleListeners = new Set();
122
131
  const downloadListeners = new Set();
@@ -173,6 +182,7 @@ export async function createBrowser({
173
182
  // qml handle end
174
183
 
175
184
  const host = new RendererHost({
185
+ onDialog: (request,signal) => host===current ? dialogs.open({...request,url:host.documentUrl},signal) : {accept:false},
176
186
  // Native frontends supply one clipboard for page APIs and input defaults.
177
187
  ...(clipboard?{clipboardRead:()=>clipboard.read(),clipboardWrite:text=>clipboard.write(text)}:{}),
178
188
  network,
@@ -327,6 +337,7 @@ export async function createBrowser({
327
337
  * restarted renderer).
328
338
  */
329
339
  async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
340
+ dialogs.close();
330
341
  const started = performance.now();
331
342
  if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
332
343
  return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
@@ -428,6 +439,7 @@ export async function createBrowser({
428
439
  await call("runJavaScriptURL", resolved);
429
440
  return;
430
441
  }
442
+ dialogs.close();
431
443
  await rememberScroll();
432
444
  let shown = resolved;
433
445
  if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
@@ -458,6 +470,7 @@ export async function createBrowser({
458
470
 
459
471
  async function traverseHistory(index) {
460
472
  if (index < 0 || index >= history.length || index === historyIndex) return;
473
+ dialogs.close();
461
474
  await rememberScroll();
462
475
  historyIndex = index;
463
476
  const entry = history[index];
@@ -466,6 +479,10 @@ export async function createBrowser({
466
479
  }
467
480
 
468
481
  const context = {
482
+ onDialog(handler) { dialogHandler = handler; },
483
+ get dialogState() { return dialogs.active?.request ?? null; },
484
+ handleDialog(result) { dialogs.finish(result); },
485
+ get viewport() { return { ...viewport }; },
469
486
  get url() { return currentUrl; },
470
487
  navigate,
471
488
  get userAgent() { return userAgent ?? initialUserAgent; },
@@ -482,6 +499,7 @@ export async function createBrowser({
482
499
  goForward: () => traverseHistory(historyIndex + 1),
483
500
  resize(width, height, deviceScaleFactor = 1) {
484
501
  Object.assign(viewport, { width, height, deviceScaleFactor });
502
+ if(dialogs.active){Promise.resolve(applyState("resize",resizeArguments)).catch(()=>{});return dialogs.resize(width,height,deviceScaleFactor);}
485
503
  return applyState("resize", resizeArguments);
486
504
  },
487
505
  setUserAgent(value) {
@@ -501,13 +519,13 @@ export async function createBrowser({
501
519
  if (!initScripts.delete(String(identifier))) return;
502
520
  applyState("setInitScripts", () => [[...initScripts.values()]]);
503
521
  },
504
- screenshot: (options = {}) => composite("screenshot", {
522
+ screenshot: (options = {}) => (dialogs.active ? dialogs.invoke.bind(dialogs) : composite)("screenshot", {
505
523
  format: ["jpeg", "webp", "rgba", "bgra"].includes(options.format) ? options.format : "png",
506
524
  quality: Number.isFinite(options.quality) ? options.quality : 80,
507
525
  optimizeForSpeed: options.optimizeForSpeed === true,
508
- clip: options.clip ? {...options.clip,x:options.clip.x/pageScale(),y:options.clip.y/pageScale(),width:options.clip.width/pageScale(),height:options.clip.height/pageScale()} : undefined,
526
+ clip: options.clip ? {...options.clip,x:options.clip.x/(dialogs.active?1:pageScale()),y:options.clip.y/(dialogs.active?1:pageScale()),width:options.clip.width/(dialogs.active?1:pageScale()),height:options.clip.height/(dialogs.active?1:pageScale())} : undefined,
509
527
  }),
510
- title: () => call("title"),
528
+ title: () => dialogs.active ? currentUrl : call("title"),
511
529
  /**
512
530
  * The document's response body as it arrived (CDP Page.getResourceContent),
513
531
  * for the current document's URL with or without its fragment; else null.
@@ -530,6 +548,7 @@ export async function createBrowser({
530
548
  * first; the page scrolls when nothing there takes it.
531
549
  */
532
550
  async scroll(deltaX, deltaY, at = null) {
551
+ if(dialogs.active)return at?dialogs.invoke("wheel",at.x,at.y,deltaX,deltaY):dialogs.invoke("scroll",deltaX,deltaY);
533
552
  const scale = pageScale();
534
553
  let left = { x: deltaX / scale, y: deltaY / scale };
535
554
  if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
@@ -538,9 +557,9 @@ export async function createBrowser({
538
557
  }
539
558
  return composite("scroll", left.x, left.y);
540
559
  },
541
- press: (key, options) => call("press", key, options),
542
- type: (text) => call("type", text),
543
- scrollOffset: () => composite("scrollOffset"),
560
+ press: (key, options) => dialogs.active ? dialogs.invoke("press",key,options) : call("press", key, options),
561
+ type: (text) => dialogs.active ? dialogs.invoke("type",text) : call("type", text),
562
+ scrollOffset: () => dialogs.active ? Promise.resolve({x:0,y:0}) : composite("scrollOffset"),
544
563
  /** Calls `listener(frame?)` when the viewport changes, with its measured scroll offset when available. */
545
564
  onFrame(listener) {
546
565
  frameListeners.add(listener);
@@ -556,12 +575,14 @@ export async function createBrowser({
556
575
  return [...reachedLifecycle(committed.host, committed.generation)];
557
576
  },
558
577
  move(x, y, options = {}) {
578
+ if(dialogs.active)return dialogs.invoke("move",x,y,options);
559
579
  return call("move", x / pageScale(), y / pageScale(), options);
560
580
  },
561
581
  pointerButton(x, y, button, state, options = {}) {
562
582
  return this.click(x, y, {...options, button:button===273?'right':button===274?'middle':'left', phase:state?'down':'up'});
563
583
  },
564
584
  async click(x, y, options = {}) {
585
+ if(dialogs.active){await dialogs.invoke("click",x,y,options);return null;}
565
586
  const clicked = await call("click", x / pageScale(), y / pageScale(), options);
566
587
  const href = typeof clicked === "string" ? clicked : clicked?.url;
567
588
  if (typeof href !== "string") return null;
@@ -573,10 +594,10 @@ export async function createBrowser({
573
594
  referrer: current.documentUrl });
574
595
  return result === null ? null : { url: href, title: await call("title") };
575
596
  },
576
- evaluate: (expression) => call("evaluate", String(expression)),
597
+ evaluate: (expression) => dialogs.active ? dialogs.invoke("evaluate",String(expression)) : call("evaluate", String(expression)),
577
598
  async cdp(method) {
578
599
  if(method==='Page.getLayoutMetrics') {
579
- const offset=await composite('scrollOffset');
600
+ const offset=dialogs.active?{x:0,y:0}:await composite('scrollOffset');
580
601
  const visual={pageX:offset.x*pageScale(),pageY:offset.y*pageScale(),offsetX:0,offsetY:0,clientWidth:viewport.width,clientHeight:viewport.height,scale:1,zoom:1};
581
602
  return {cssVisualViewport:visual,cssLayoutViewport:visual,visualViewport:visual,layoutViewport:visual};
582
603
  }
@@ -612,6 +633,7 @@ export async function createBrowser({
612
633
  },
613
634
  close() {
614
635
  closed = true;
636
+ dialogs.close();
615
637
  cancelSparePreparation();
616
638
  current?.close();
617
639
  spare?.close();
@@ -179,7 +179,7 @@ function buildBox(node, flags, blockified = false) {
179
179
  // by its display: body { display: grid } or flex is common.
180
180
  const display = node.type === "root" ? style?.display : node.type;
181
181
  const flex = ["flex", "inline-flex"].includes(display)
182
- ? style?.flexDirection === "column" ? "column" : "row"
182
+ ? style?.flexDirection?.startsWith("column") ? "column" : "row"
183
183
  : null;
184
184
  const grid = ["grid", "inline-grid"].includes(display);
185
185
  const table = ["table", "inline-table"].includes(node.type);
@@ -358,6 +358,7 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
358
358
  const context = Number.isInteger(style.zIndex);
359
359
  ctx.layer = Object.freeze({
360
360
  z: context ? style.zIndex : 0,
361
+ nodeId: box.node.id,
361
362
  order: ctx.layerCount++,
362
363
  context,
363
364
  parent: ctx.stackingContext,
@@ -430,6 +431,7 @@ function cachedBlockResult(ctx, box, key, y, x) {
430
431
  for (const layer of cached.layers) {
431
432
  layers.push(Object.freeze({
432
433
  z: layer.z,
434
+ nodeId: layer.nodeId,
433
435
  order: ctx.layerCount++,
434
436
  context: layer.context,
435
437
  parent: layer.parent === -1 ? ctx.stackingContext : layers[layer.parent],
@@ -485,7 +487,7 @@ function cacheBlockResult(ctx, box, key, x, y, result, start) {
485
487
  for (const layer of ordered) {
486
488
  const parent = indices.get(layer.parent) ?? (layer.parent === start.context ? -1 : null);
487
489
  if (parent === null) return;
488
- layers.push({ z: layer.z, context: layer.context, parent });
490
+ layers.push({ z: layer.z, nodeId: layer.nodeId, context: layer.context, parent });
489
491
  }
490
492
  const indexOf = (layer) => layer === start.layer ? -1 : indices.get(layer) ?? null;
491
493
  const boxLayers = boxes.map((record) => indexOf(record.layer));
@@ -538,7 +540,7 @@ function layoutAbsolutes(ctx, box, record) {
538
540
  const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
539
541
  .map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
540
542
  const margin = style?.margin ?? ZERO_BOX;
541
- const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
543
+ let [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
542
544
  const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
543
545
  let width;
544
546
  if (specifiedWidth == null && left != null && right != null) {
@@ -546,6 +548,19 @@ function layoutAbsolutes(ctx, box, record) {
546
548
  } else {
547
549
  width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
548
550
  }
551
+ // CSS 2 §10.3.7: with both insets and width resolved, auto margins
552
+ // absorb the remaining space. Normal-flow margin resolution above is
553
+ // only used to size the box; its x is not the absolute position.
554
+ if (specifiedWidth != null && left != null && right != null) {
555
+ const remaining = containing.width-left-right-width-marginLeft-marginRight;
556
+ const autoLeft=margin[3]==="auto",autoRight=margin[1]==="auto";
557
+ if(autoLeft&&autoRight) {
558
+ if(remaining>=0)marginLeft=marginRight=remaining/2;
559
+ else if(style.direction==="rtl"){marginLeft=remaining;marginRight=0;}
560
+ else {marginLeft=0;marginRight=remaining;}
561
+ }else if(autoLeft)marginLeft=remaining;
562
+ else if(autoRight)marginRight=remaining;
563
+ }
549
564
  const staticPosition = absolute.staticPosition ?? containing;
550
565
  const x = left != null ? containing.x + left + marginLeft
551
566
  : right != null ? containing.x + containing.width - right - marginRight - width
@@ -566,6 +581,14 @@ function layoutAbsolutes(ctx, box, record) {
566
581
  }
567
582
  }
568
583
 
584
+ /** CSS Sizing 4: a degenerate ratio behaves as auto; replaced boxes can prefer their natural ratio. */
585
+ function preferredAspectRatio(style, natural = null) {
586
+ const value = style?.aspectRatio;
587
+ if (!value || value.auto && natural != null) return natural;
588
+ const ratio = value.numerator / value.denominator;
589
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : natural;
590
+ }
591
+
569
592
  /**
570
593
  * justify-content in a column flex container (Flexbox §8.2): `free` is the
571
594
  * height left over once the items are stacked, as with a min-height or height
@@ -602,12 +625,12 @@ function justifyColumn(ctx, items, justify = "normal", free) {
602
625
  }
603
626
 
604
627
  /** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
605
- function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
606
- for (let index = boxStart; index < ctx.boxes.length; index++) {
628
+ function shiftOutput(ctx, boxStart, fragmentStart, dx, dy, boxEnd=ctx.boxes.length, fragmentEnd=ctx.fragments.length) {
629
+ for (let index = boxStart; index < boxEnd; index++) {
607
630
  const record = ctx.boxes[index];
608
631
  ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
609
632
  }
610
- for (let index = fragmentStart; index < ctx.fragments.length; index++) {
633
+ for (let index = fragmentStart; index < fragmentEnd; index++) {
611
634
  ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
612
635
  }
613
636
  }
@@ -674,14 +697,24 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
674
697
  const containingHeight = ctx.containing?.height;
675
698
  const resolveHeight = (value) => numberOrNull(resolveLength(value, containingHeight ?? NaN));
676
699
  const specifiedHeight = resolveHeight(style?.height);
700
+ const ratio = preferredAspectRatio(style);
701
+ // The transferred height uses the box selected by box-sizing. It is
702
+ // definite for descendants (including percentage heights and grid tracks).
703
+ let ratioHeight = specifiedHeight == null && forcedHeight == null && ratio != null
704
+ ? style.boxSizing === "border-box" ? toContentHeight(width / ratio) : contentWidth / ratio
705
+ : null;
677
706
  const minHeight = resolveHeight(style?.minHeight);
678
707
  const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
708
+ if (ratioHeight != null) {
709
+ if (maxHeight != null) ratioHeight = Math.min(ratioHeight, toContentHeight(maxHeight));
710
+ if (minHeight != null) ratioHeight = Math.max(ratioHeight, toContentHeight(minHeight));
711
+ }
679
712
  const previousContaining = ctx.containing;
680
713
  ctx.containing = {
681
714
  width: contentWidth,
682
715
  height: forcedHeight != null
683
716
  ? Math.max(0, forcedHeight - border[0] - border[2] - padding[0] - padding[2])
684
- : specifiedHeight != null ? toContentHeight(specifiedHeight) : null,
717
+ : specifiedHeight != null ? toContentHeight(specifiedHeight) : ratioHeight,
685
718
  };
686
719
 
687
720
  if (box.table) {
@@ -743,10 +776,25 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
743
776
  ctx.containing = previousContaining;
744
777
  let contentHeight = Math.max(0, contentBottom - contentTop);
745
778
  if (specifiedHeight != null) contentHeight = toContentHeight(specifiedHeight);
779
+ else if (ratioHeight != null) contentHeight = style.minHeight === "auto" && style.overflowY === "visible"
780
+ ? Math.max(contentHeight, ratioHeight) : ratioHeight;
746
781
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
747
782
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
748
783
  if (forcedHeight != null) contentHeight = Math.max(0, forcedHeight - padding[0] - padding[2] - border[0] - border[2]);
749
- if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
784
+ if (columnItems?.length) {
785
+ justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
786
+ if(style?.flexDirection === "column-reverse") {
787
+ for(const [index,item] of columnItems.entries()){
788
+ const record=ctx.boxes[item.boxStart];
789
+ const position=item.mainY+record.y-item.initialY;
790
+ const delta=2*contentTop+contentHeight-2*position-item.mainHeight;
791
+ if(index===0&&baseline!=null)baseline+=delta;
792
+ const next=columnItems[index+1];
793
+ shiftOutput(ctx,item.boxStart,item.fragmentStart,0,delta,
794
+ next?.boxStart??ctx.boxes.length,next?.fragmentStart??ctx.fragments.length);
795
+ }
796
+ }
797
+ }
750
798
  if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
751
799
  record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
752
800
  // Summary's disclosure marker participates in its inline content.
@@ -840,6 +888,8 @@ function roman(value) {
840
888
 
841
889
  function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
842
890
  const style = box.style;
891
+ const reverse=style?.flexDirection === "column-reverse";
892
+ const margins=child=>{const m=child.style?.margin??ZERO_BOX;return reverse?[m[2],m[1],m[0],m[3]]:m;};
843
893
  const gap = style?.rowGap ?? style?.gap ?? 0;
844
894
  const definiteHeight = ctx.containing?.height;
845
895
  // Most column containers have auto height. Without a main-axis constraint
@@ -854,10 +904,11 @@ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
854
904
  for (const [index, child] of box.children.entries()) {
855
905
  if (index) cursor += gap;
856
906
  const horizontal = columnHorizontal(ctx, child, style, x, width);
857
- const margin = child.style?.margin ?? ZERO_BOX;
907
+ const margin = margins(child);
858
908
  items.push({boxStart:ctx.boxes.length,fragmentStart:ctx.fragments.length,
859
909
  autoTop:margin[0] === "auto",autoBottom:margin[2] === "auto"});
860
910
  const result = layoutBlock(ctx, child, horizontal.x, horizontal.width, cursor + numberOrZero(margin[0]));
911
+ Object.assign(items.at(-1),{mainY:cursor+numberOrZero(margin[0]),mainHeight:result.height,initialY:ctx.boxes[items.at(-1).boxStart].y});
861
912
  cursor += numberOrZero(margin[0]) + result.height + numberOrZero(margin[2]);
862
913
  baseline ??= result.baseline;
863
914
  }
@@ -866,7 +917,7 @@ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
866
917
  const items = box.children.map(child => {
867
918
  const childStyle = child.style;
868
919
  const horizontal = columnHorizontal(ctx, child, style, x, width);
869
- const margin = childStyle?.margin ?? ZERO_BOX;
920
+ const margin = margins(child);
870
921
  const padding = paddingBox(childStyle, width);
871
922
  const border = borderWidths(childStyle);
872
923
  const extra = padding[0] + padding[2] + border[0] + border[2];
@@ -908,6 +959,7 @@ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
908
959
  const forcedHeight = item.definite || Math.abs(targetHeight - item.naturalHeight) > 1e-9 ? targetHeight : null;
909
960
  const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width,
910
961
  cursor + item.marginLeft, false, forcedHeight);
962
+ Object.assign(item,{mainY:cursor+item.marginLeft,mainHeight:result.height,initialY:ctx.boxes[item.boxStart].y});
911
963
  cursor += item.marginLeft + result.height + item.marginRight;
912
964
  baseline ??= result.baseline;
913
965
  }
@@ -932,7 +984,14 @@ function layoutFlexRow(ctx, box, x, width, y) {
932
984
  const style = box.style;
933
985
  const columnGap = style?.columnGap ?? style?.gap ?? 0;
934
986
  const rowGap = style?.rowGap ?? style?.gap ?? 0;
935
- const items = box.children.map((child) => flexItem(ctx, child, width));
987
+ const items = box.children.map((child) => {
988
+ const item=flexItem(ctx,child,width);
989
+ if(style?.flexDirection === "row-reverse") {
990
+ [item.marginLeft,item.marginRight]=[item.marginRight,item.marginLeft];
991
+ [item.autoLeft,item.autoRight]=[item.autoRight,item.autoLeft];
992
+ }
993
+ return item;
994
+ });
936
995
 
937
996
  const lines = [];
938
997
  let line = [];
@@ -1007,7 +1066,8 @@ function layoutFlexRow(ctx, box, x, width, y) {
1007
1066
  } else if (alignment === "end") {
1008
1067
  itemY += crossSize - outerHeight;
1009
1068
  }
1010
- const result = layoutBlock(ctx, item.box, mainX, item.target, itemY, false, forcedHeight);
1069
+ const itemX=style?.flexDirection === "row-reverse"?x+width-(mainX-x)-item.target:mainX;
1070
+ const result = layoutBlock(ctx, item.box, itemX, item.target, itemY, false, forcedHeight);
1011
1071
  baseline ??= result.baseline;
1012
1072
  mainX += item.target + item.marginRight + (item.autoRight ? autoShare : 0) + columnGap + between;
1013
1073
  }
@@ -1147,7 +1207,7 @@ function layoutGrid(ctx, box, x, width, y) {
1147
1207
  const measured = dryLayout(ctx, item.box, horizontal.width);
1148
1208
  item.height = measured.height + numberOrZero(margin[0]) + numberOrZero(margin[2]);
1149
1209
  }
1150
- const rows = sizeGridRows(grid, rowGap);
1210
+ const rows = sizeGridRows(grid, rowGap, ctx.containing?.height, style?.alignContent);
1151
1211
  const rowStarts = trackStarts(rows, [], rowGap, y);
1152
1212
 
1153
1213
  let baseline = null;
@@ -1164,7 +1224,8 @@ function layoutGrid(ctx, box, x, width, y) {
1164
1224
  const contentHeight = item.height - margin.top - margin.bottom;
1165
1225
  let itemY = areaY + margin.top;
1166
1226
  let forcedHeight = null;
1167
- if (["normal", "stretch"].includes(alignment) && numberOrNull(itemStyle?.height) == null) {
1227
+ if (["normal", "stretch"].includes(alignment) && numberOrNull(itemStyle?.height) == null
1228
+ && (alignment === "stretch" || preferredAspectRatio(itemStyle) == null)) {
1168
1229
  forcedHeight = Math.max(contentHeight, areaHeight - margin.top - margin.bottom);
1169
1230
  } else if (alignment === "center") {
1170
1231
  itemY += (areaHeight - item.height) / 2;
@@ -1756,10 +1817,10 @@ function updateGrowth(track, value) {
1756
1817
  }
1757
1818
 
1758
1819
  /** Row sizing: explicit lengths are fixed; other rows fit their tallest item. */
1759
- function sizeGridRows(grid, gap) {
1820
+ function sizeGridRows(grid, gap, available = null, alignment = "normal") {
1760
1821
  const rows = grid.rowTracks.map((track) => {
1761
- const fixed = numberOrNull(resolveBreadth(track.min, null));
1762
- const fixedMax = numberOrNull(resolveBreadth(track.max, null));
1822
+ const fixed = numberOrNull(resolveBreadth(track.min, available));
1823
+ const fixedMax = numberOrNull(resolveBreadth(track.max, available));
1763
1824
  return fixed != null && fixedMax != null ? { size: fixedMax, fixed: true } : { size: fixed ?? 0, fixed: false };
1764
1825
  });
1765
1826
  for (const item of grid.items.filter((candidate) => candidate.rowSpan === 1)) {
@@ -1772,6 +1833,34 @@ function sizeGridRows(grid, gap) {
1772
1833
  const flexible = spanned.filter((row) => !row.fixed);
1773
1834
  if (item.height > current && flexible.length) flexible.at(-1).size += item.height - current;
1774
1835
  }
1836
+ // Fractional row tracks consume the definite block-axis space, just as
1837
+ // column fr tracks consume inline space. Empty grid items still stretch
1838
+ // into those tracks (e.g. a 2x2 game board sized by aspect-ratio).
1839
+ const flexible=grid.rowTracks.flatMap((track,index)=>track.max?.fr>0?[index]:[]);
1840
+ if(flexible.length){
1841
+ let fraction=0;
1842
+ if(available!=null){
1843
+ const leftover=available-gap*Math.max(0,rows.length-1)-rows.reduce((sum,row,index)=>sum+(flexible.includes(index)?0:row.size),0);
1844
+ const frozen=new Set();
1845
+ for(let guard=0;guard<=flexible.length;guard++){
1846
+ const active=flexible.filter(index=>!frozen.has(index));
1847
+ const factors=Math.max(1,active.reduce((sum,index)=>sum+grid.rowTracks[index].max.fr,0));
1848
+ const space=leftover-[...frozen].reduce((sum,index)=>sum+rows[index].size,0);
1849
+ fraction=Math.max(0,space/factors);
1850
+ const violators=active.filter(index=>rows[index].size>fraction*grid.rowTracks[index].max.fr);
1851
+ if(!violators.length)break;
1852
+ for(const index of violators)frozen.add(index);
1853
+ }
1854
+ }else fraction=Math.max(...flexible.map(index=>rows[index].size/Math.max(1,grid.rowTracks[index].max.fr)));
1855
+ for(const index of flexible)rows[index].size=Math.max(rows[index].size,fraction*grid.rowTracks[index].max.fr);
1856
+ }
1857
+ // Normal grid content alignment stretches auto tracks to fill a definite
1858
+ // height, so place-items:center centers inside the entire grid area.
1859
+ if (available != null && ["normal", "stretch", undefined].includes(alignment)) {
1860
+ const free = available - rows.reduce((sum, row) => sum + row.size, 0) - gap * Math.max(0, rows.length - 1);
1861
+ const stretchy = rows.filter((row, index) => grid.rowTracks[index].max === "auto");
1862
+ if (free > 0 && stretchy.length) for (const row of stretchy) row.size += free / stretchy.length;
1863
+ }
1775
1864
  return rows.map((row) => row.size);
1776
1865
  }
1777
1866
 
@@ -1838,6 +1927,10 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
1838
1927
  let width;
1839
1928
  if (box.replaced) width = imageDimensions(box.node, containingWidth).width + extra;
1840
1929
  else if (specified != null) width = borderBox(specified);
1930
+ else if (preferredAspectRatio(style) != null && numberOrNull(resolveLength(style?.height, ctx.containing?.height ?? NaN)) != null) {
1931
+ const height = resolveLength(style.height, ctx.containing?.height ?? NaN);
1932
+ width = borderBox(height * preferredAspectRatio(style));
1933
+ }
1841
1934
  else if (shrink) width = Math.min(maxContentWidth(ctx, box), available);
1842
1935
  else width = available;
1843
1936
  const maxWidth = numberOrNull(resolveLength(style?.maxWidth, percentageBasis));
@@ -2735,7 +2828,7 @@ function imageDimensions(node, referenceWidth, referenceHeight = null) {
2735
2828
  const intrinsicWidth = (canvas ? node.widthAttribute : node.resource?.width) ?? node.widthAttribute ?? 300;
2736
2829
  const intrinsicHeight = (canvas ? node.heightAttribute : node.resource?.height) ?? node.heightAttribute ?? 150;
2737
2830
  const emptyCanvas = node.tagName === "CANVAS" && !(intrinsicWidth > 0 && intrinsicHeight > 0);
2738
- const ratio = emptyCanvas ? null : intrinsicWidth / intrinsicHeight;
2831
+ const ratio = preferredAspectRatio(node.style, emptyCanvas ? null : intrinsicWidth / intrinsicHeight);
2739
2832
  const cssWidth = node.style?.width;
2740
2833
  const cssHeight = node.style?.height;
2741
2834
  let width = cssWidth !== "auto" && cssWidth != null
@@ -2746,7 +2839,7 @@ function imageDimensions(node, referenceWidth, referenceHeight = null) {
2746
2839
  : null;
2747
2840
  if (!Number.isFinite(width)) width = null;
2748
2841
  if (!Number.isFinite(height)) height = null;
2749
- if (width == null && height == null) [width, height] = [intrinsicWidth, intrinsicHeight];
2842
+ if (width == null && height == null) [width, height] = [intrinsicWidth, ratio == null ? intrinsicHeight : intrinsicWidth / ratio];
2750
2843
  else if (width == null) width = ratio == null ? intrinsicWidth : height * ratio;
2751
2844
  else if (height == null) height = ratio == null ? intrinsicHeight : width / ratio;
2752
2845
  if (node.style?.maxWidth !== "none" && node.style?.maxWidth != null) {
@@ -0,0 +1,93 @@
1
+ // Presentation timestamps for ordinary (nonfragmented) MP4 video. FFmpeg
2
+ // returns decoded pictures in presentation order; average FPS cannot time VFR.
3
+ // Unsupported tables/edits retain the existing decoder clock.
4
+ export function mp4FrameTimes(bytes) {
5
+ if (!(bytes instanceof Uint8Array)) return null;
6
+ try {
7
+ const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
8
+ let seen=0;
9
+ const u=(p,end)=>{if(p<0||p+4>end)throw Error('short field');return v.getUint32(p)};
10
+ const boxes=(start,end)=>{
11
+ const out=[];
12
+ for(let p=start;p<end;){
13
+ if(++seen>32768)throw Error('box limit');
14
+ let size=u(p,end),header=8;const type=u(p+4,end);
15
+ if(size===1){size=u(p+8,end)*4294967296+u(p+12,end);header=16}
16
+ if(size===0)size=end-p;
17
+ // Progressive files may end inside mdat; timing tables before it
18
+ // are already complete and do not depend on the arriving payload.
19
+ if(size>end-p && type===0x6d646174 && start===0)break;
20
+ if(!Number.isSafeInteger(size)||size<header||size>end-p)throw Error('bad box');
21
+ out.push({type,p:p+header,end:p+size});p+=size;
22
+ }return out;
23
+ };
24
+ const tag=s=>[...s].reduce((n,c)=>n*256+c.charCodeAt(0),0);
25
+ const find=(list,name)=>list.find(b=>b.type===tag(name));
26
+ const children=b=>boxes(b.p,b.end);
27
+ const top=boxes(0,bytes.length),moov=find(top,'moov');if(!moov)return null;
28
+ const movie=children(moov);if(find(movie,'mvex'))return null;
29
+ const tracks=movie.filter(b=>b.type===tag('trak'));
30
+ let result=null;
31
+ for(const trak of tracks){
32
+ const nodes=children(trak),mdia=find(nodes,'mdia');if(!mdia)continue;
33
+ const media=children(mdia),hdlr=find(media,'hdlr');
34
+ if(!hdlr||u(hdlr.p+8,hdlr.end)!==tag('vide'))continue;
35
+ if(result)return null;
36
+ const mdhd=find(media,'mdhd');if(!mdhd||bytes[mdhd.p]>1)return null;
37
+ const scale=u(mdhd.p+(bytes[mdhd.p]===1?20:12),mdhd.end);if(!scale)return null;
38
+ let offset=0;const edts=find(nodes,'edts');
39
+ if(edts){
40
+ const edit=find(children(edts),'elst');
41
+ if(!edit||bytes[edit.p]!==0||u(edit.p+4,edit.end)!==1||u(edit.p+16,edit.end)!==65536)return null;
42
+ offset=u(edit.p+12,edit.end);if(offset>=2147483648)return null;
43
+ }
44
+ const minf=find(media,'minf');if(!minf)return null;
45
+ const stbl=find(children(minf),'stbl');if(!stbl)return null;
46
+ const tables=children(stbl),stts=find(tables,'stts'),ctts=find(tables,'ctts');if(!stts||bytes[stts.p]!==0)return null;
47
+ const times=[],durations=[];let time=0;
48
+ const runs=u(stts.p+4,stts.end);if(runs>250000)return null;
49
+ for(let i=0;i<runs;i++){
50
+ const count=u(stts.p+8+i*8,stts.end),delta=u(stts.p+12+i*8,stts.end);
51
+ if(!delta||times.length+count>250000)return null;
52
+ for(let j=0;j<count;j++){times.push(time);durations.push(delta);time+=delta;if(!Number.isSafeInteger(time))return null;}
53
+ }
54
+ if(ctts){
55
+ if(bytes[ctts.p]>1)return null;let index=0;
56
+ const runs=u(ctts.p+4,ctts.end);if(runs>250000)return null;
57
+ for(let i=0;i<runs;i++){
58
+ const count=u(ctts.p+8+i*8,ctts.end);let delta=u(ctts.p+12+i*8,ctts.end);
59
+ if(bytes[ctts.p]===1)delta|=0;
60
+ if(index+count>times.length)return null;
61
+ for(let j=0;j<count;j++)times[index++]+=delta;
62
+ }if(index!==times.length)return null;
63
+ }
64
+ const keys=[];const stss=find(tables,'stss');
65
+ if(stss){const count=u(stss.p+4,stss.end);if(count>times.length)return null;
66
+ for(let i=0;i<count;i++){const n=u(stss.p+8+i*4,stss.end);if(!n||n>times.length)return null;keys.push((times[n-1]-offset)/scale)}
67
+ }else for(const t of times)keys.push((t-offset)/scale);
68
+ const frames=Float64Array.from(times,t=>(t-offset)/scale);frames.sort();keys.sort((a,b)=>a-b);
69
+ if(!frames.length||frames[0]<-1e-6)return null;
70
+ let end=0;for(let i=0;i<times.length;i++)end=Math.max(end,(times[i]+durations[i]-offset)/scale);
71
+ result={frames,keys,end};
72
+ }return result;
73
+ }catch{return null}
74
+ }
75
+
76
+ export function timeVideoDecoder(decoder,bytes) {
77
+ const timeline=mp4FrameTimes(bytes);if(!timeline)return decoder;
78
+ let index=0;
79
+ return {
80
+ width:decoder.width,height:decoder.height,fps:decoder.fps,
81
+ get frameTime(){return timeline.frames[Math.max(0,index-1)]},
82
+ get nextFrameTime(){return timeline.frames[index]??timeline.end},
83
+ nextFrame(...args){const pixels=decoder.nextFrame(...args);if(pixels)index++;return pixels},
84
+ seek(ms){
85
+ decoder.seek(ms);const target=ms/1000;let key=timeline.keys[0]??0;
86
+ for(const t of timeline.keys){if(t>target+1e-6)break;key=t}
87
+ let low=0,high=timeline.frames.length;
88
+ while(low<high){const mid=(low+high)>>>1;if(timeline.frames[mid]<key-1e-6)low=mid+1;else high=mid}
89
+ index=low;
90
+ },
91
+ close(){decoder.close()},
92
+ };
93
+ }
@@ -0,0 +1,39 @@
1
+ // Browser adapter only. All pixel composition and sampling use Land unchanged.
2
+ import {createDirectComposer, type DirectSurface} from '../../vendor/land-renderer/direct-compositor.ts';
3
+ export interface CanvasLayer {
4
+ pixels: Uint8Array; width: number; height: number;
5
+ x: number; y: number; drawWidth: number; drawHeight: number;
6
+ }
7
+ export class CanvasComposer {
8
+ private surfaces: DirectSurface[] = [];
9
+ private positions = new WeakMap<DirectSurface,{x:number;y:number}>();
10
+ private buffers = new WeakMap<Uint8Array,NonNullable<DirectSurface["pixels"]>>();
11
+ private base: {key:string;surface:DirectSurface} | null = null;
12
+ private readonly direct = createDirectComposer({input:{x:0,y:0,pointer:false},wm:{
13
+ renderSurfaces:()=>this.surfaces,origin:surface=>this.positions.get(surface)!,
14
+ }});
15
+ private surface(layer: CanvasLayer): DirectSurface {
16
+ let pixels=this.buffers.get(layer.pixels);
17
+ if(!pixels||pixels.width!==layer.width||pixels.height!==layer.height){pixels={bytes:layer.pixels,width:layer.width,height:layer.height,stride:layer.width*4,format:0};this.buffers.set(layer.pixels,pixels);}
18
+ const surface:DirectSurface={pixels,
19
+ scale:1,transform:0,viewportDestination:[layer.drawWidth,layer.drawHeight]};
20
+ this.positions.set(surface,{x:layer.x,y:layer.y});
21
+ return surface;
22
+ }
23
+ compose(width:number,height:number,background:readonly number[],layers:CanvasLayer[]):Uint8Array {
24
+ const key=[width,height,...background].join('/');
25
+ if(this.base?.key!==key){
26
+ const pixels=new Uint8Array(width*height*4);
27
+ // Browser RGBA and Land BGRA use identical alpha and blend operations.
28
+ // Supplying an opaque RGBA base also hides Land's default desktop color.
29
+ const words=new Uint32Array(pixels.buffer);
30
+ const color=new Uint8Array([background[0]!,background[1]!,background[2]!,255]);
31
+ words.fill(new Uint32Array(color.buffer)[0]!);
32
+ this.base={key,surface:this.surface({pixels,width,height,x:0,y:0,drawWidth:width,drawHeight:height})};
33
+ }
34
+ this.surfaces=[this.base.surface,...layers.map(layer=>this.surface(layer))];
35
+ const out=this.direct.compose(width,height);
36
+ return new Uint8Array(out.buffer,out.byteOffset,out.byteLength);
37
+ }
38
+ close():void {this.direct.close();this.surfaces=[];this.base=null;this.positions=new WeakMap();this.buffers=new WeakMap();}
39
+ }