buninu-browser 0.7.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 (142) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +90 -1
  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/lib/audio/biquad.js +25 -0
  16. package/lib/audio/piano.js +6 -3
  17. package/lib/audio/playback-buffer.js +5 -5
  18. package/lib/audio/pulse-client.js +24 -5
  19. package/lib/audio/web-audio.js +570 -0
  20. package/lib/browser-dialog.js +116 -0
  21. package/lib/cdp-server.js +43 -11
  22. package/lib/file-picker.js +48 -0
  23. package/lib/headless-shell.js +34 -12
  24. package/lib/layout/text-layout.js +112 -19
  25. package/lib/media/mp4-frame-times.js +93 -0
  26. package/lib/paint/direct-canvas.ts +39 -0
  27. package/lib/paint/display-list.js +37 -12
  28. package/lib/paint/raster-readback.js +16 -7
  29. package/lib/renderer/animation-frames.js +10 -5
  30. package/lib/renderer/bindings.js +69 -16
  31. package/lib/renderer/canvas-2d.js +265 -15
  32. package/lib/renderer/canvas-round-rect.js +36 -0
  33. package/lib/renderer/css-animations.js +173 -0
  34. package/lib/renderer/dialog-client.js +30 -0
  35. package/lib/renderer/dialog-worker.js +35 -0
  36. package/lib/renderer/eval-environment.js +28 -0
  37. package/lib/renderer/file-input.js +49 -0
  38. package/lib/renderer/host.js +33 -4
  39. package/lib/renderer/html-drag.js +77 -0
  40. package/lib/renderer/image-loading.js +45 -2
  41. package/lib/renderer/main.js +4 -0
  42. package/lib/renderer/module-transform.js +5 -3
  43. package/lib/renderer/page-builtins.js +59 -0
  44. package/lib/renderer/page-realm.js +177 -40
  45. package/lib/renderer/page-renderer.js +397 -49
  46. package/lib/renderer/piano-input.js +3 -1
  47. package/lib/renderer/raster-surface.js +18 -0
  48. package/lib/renderer/script-rewrite.js +165 -9
  49. package/lib/renderer/video-presentation.js +5 -4
  50. package/lib/renderer/webgl/clip.js +24 -0
  51. package/lib/renderer/webgl/context.js +158 -0
  52. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  53. package/lib/renderer/webgl/draw-cache.js +22 -0
  54. package/lib/renderer/webgl/pipeline.js +70 -0
  55. package/lib/renderer/webgl/shaders.js +58 -0
  56. package/lib/renderer/webgl/textures.js +43 -0
  57. package/lib/renderer/window-named-properties.js +46 -0
  58. package/lib/screenshot-cli.js +4 -7
  59. package/lib/style/animation-values.js +29 -0
  60. package/lib/style/computed-style.js +109 -13
  61. package/lib/style/transform.js +44 -0
  62. package/lib/window-size.js +10 -0
  63. package/package.json +1 -1
  64. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  65. package/patches/happy-dom/README.md +16 -0
  66. package/test/animation-frames.test.js +15 -0
  67. package/test/canvas-2d.test.js +252 -4
  68. package/test/canvas-frame-pacing.test.js +7 -0
  69. package/test/cdp-viewers.test.js +36 -0
  70. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  71. package/test/classic-games-patches/README.md +156 -0
  72. package/test/classic-games-scenarios.js +133 -0
  73. package/test/clipboard.test.js +16 -0
  74. package/test/computed-style.test.js +18 -0
  75. package/test/css-animation-lifecycle.test.js +28 -0
  76. package/test/css-animations.test.js +83 -0
  77. package/test/direct-canvas.test.js +39 -0
  78. package/test/dynamic-inline-handlers.test.js +25 -0
  79. package/test/eval-declarations.test.js +22 -0
  80. package/test/file-picker.test.js +26 -0
  81. package/test/fixtures/canvas-damage.js +23 -0
  82. package/test/fixtures/canvas-frame-pacing.js +35 -0
  83. package/test/fixtures/direct-canvas.js +96 -0
  84. package/test/fixtures/javascript-dialogs.html +8 -0
  85. package/test/fixtures/media-audio-lifetime.js +65 -0
  86. package/test/fixtures/renderer-memory.js +7 -5
  87. package/test/fixtures/video-black-replay.js +8 -6
  88. package/test/fixtures/video-slow-end.js +39 -0
  89. package/test/fixtures/web-audio-renderer.js +45 -0
  90. package/test/flex.test.js +26 -0
  91. package/test/grid.test.js +13 -0
  92. package/test/headless-shell.test.js +9 -0
  93. package/test/headless-window-size.test.js +28 -0
  94. package/test/html-rcdata.test.js +35 -0
  95. package/test/image-decode.test.js +18 -0
  96. package/test/javascript-dialog.test.js +83 -0
  97. package/test/manual-breakout-paddle.js +42 -0
  98. package/test/manual-breakout-static.js +35 -0
  99. package/test/manual-classic-games.js +41 -0
  100. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  101. package/test/manual-clumsy-bird.js +52 -0
  102. package/test/manual-hextris.js +30 -0
  103. package/test/manual-numbered-music.js +24 -0
  104. package/test/manual-t-rex-runner.js +49 -0
  105. package/test/manual-threejs-scene.js +64 -0
  106. package/test/manual-webgl-benchmark.js +27 -0
  107. package/test/manual-webgl-cpu.html +26 -0
  108. package/test/media-audio-lifetime.test.js +8 -0
  109. package/test/mp4-frame-times.test.js +33 -0
  110. package/test/page-scripts.test.js +96 -1
  111. package/test/piano-input.test.js +15 -2
  112. package/test/piano.test.js +11 -2
  113. package/test/playback-buffer.test.js +11 -0
  114. package/test/pointer-drag.test.js +33 -0
  115. package/test/positioning.test.js +82 -0
  116. package/test/pulse-client.test.js +51 -2
  117. package/test/raster-readback.test.js +14 -0
  118. package/test/script-rewrite.test.js +61 -3
  119. package/test/video-slow-end.test.js +6 -0
  120. package/test/viewport-keyboard-overflow.test.js +25 -0
  121. package/test/wayland-win32-resources.test.js +47 -0
  122. package/test/wayland-win32-wire.test.js +59 -0
  123. package/test/web-audio-renderer.test.js +8 -0
  124. package/test/web-audio.test.js +264 -0
  125. package/test/webgl-clip.test.js +9 -0
  126. package/test/webgl-cpu-fragment.test.js +43 -0
  127. package/test/window-named-properties.test.js +41 -0
  128. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  129. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  131. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  132. package/vendor/land-renderer/LICENSE +21 -0
  133. package/vendor/land-renderer/README.md +14 -0
  134. package/vendor/land-renderer/direct-compositor.ts +443 -0
  135. package/vendor/land-renderer/frame.js +88 -0
  136. package/vendor/land-renderer/png-fast.js +85 -0
  137. package/vendor/land-renderer/raw-frames.js +66 -0
  138. package/vendor/land-renderer/render-types.ts +7 -0
  139. package/vendor/land-renderer/renderer.ts +134 -0
  140. package/vendor/land-renderer/snapshot.js +37 -0
  141. package/vendor/land-renderer/viewport.js +18 -0
  142. package/vendor/ses/dist/ses.mjs +1 -1
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import {rotationMatrix3D,multiplyRotation3D} from '../style/transform.js';
2
+ import {resolveAffine,multiplyTransform as composeTransforms} from '../style/transform.js';
1
3
  /**
2
4
  * Converts layout output into an immutable, serializable display list.
3
5
  *
@@ -94,8 +96,13 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
94
96
  for (const box of boxesByLayer.get(layer.order) ?? []) paintBox(box);
95
97
  for (const fragment of fragmentsByLayer.get(layer.order) ?? []) paintFragment(fragment);
96
98
  };
99
+ // Absolute layout is deferred until its containing block's dimensions are
100
+ // known. Its allocation order is therefore not CSS tree/paint order.
101
+ const treeOrder = new Map();
102
+ const indexTree = node => { treeOrder.set(node.id, treeOrder.size); for (const child of node.children ?? []) indexTree(child); };
103
+ if (tree?.root) indexTree(tree.root);
97
104
  const paintContext = (context) => {
98
- const members = (children.get(context.order) ?? []).sort((left, right) => left.z - right.z || left.order - right.order);
105
+ const members = (children.get(context.order) ?? []).sort((left, right) => left.z - right.z || (treeOrder.get(left.nodeId) ?? left.order) - (treeOrder.get(right.nodeId) ?? right.order));
99
106
  for (const layer of members) if (layer.z < 0) paintMember(layer);
100
107
  paintLayerContent(context);
101
108
  for (const layer of members) if (layer.z >= 0) paintMember(layer);
@@ -111,7 +118,7 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
111
118
  const start = items.length;
112
119
  const rect = { x: box.x, y: box.y, width: box.width, height: box.height };
113
120
  hits.push({
114
- nodeId: box.nodeId, rect: effect.transform ? transformRect(effect.transform, rect) : rect, clipRect: effect.clipRect, fixed: effect.fixed,
121
+ nodeId: box.nodeId, localRect:rect, transform:effect.transform, rect: effect.transform ? transformRect(effect.transform, rect) : rect, clipRect: effect.clipRect, fixed: effect.fixed,
115
122
  });
116
123
  reach(effect, rect);
117
124
  const radii = cornerRadii(style, rect);
@@ -189,7 +196,7 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
189
196
  height: run.type === "image" ? run.height : fragment.height,
190
197
  };
191
198
  hits.push({
192
- nodeId: run.nodeId,
199
+ nodeId: run.nodeId, localRect:hitRect, transform:effect.transform,
193
200
  rect: effect.transform ? transformRect(effect.transform, hitRect) : hitRect,
194
201
  clipRect: effect.clipRect,
195
202
  fixed: effect.fixed,
@@ -296,6 +303,8 @@ export function hitTarget(displayList, x, y, scroll, skip = () => false) {
296
303
  if (pointX < rect.x || pointX >= rect.x + rect.width || pointY < rect.y || pointY >= rect.y + rect.height) continue;
297
304
  if (clipRect && (pointX < clipRect.x || pointX >= clipRect.x + clipRect.width
298
305
  || pointY < clipRect.y || pointY >= clipRect.y + clipRect.height)) continue;
306
+ const {transform,localRect}=hits[index];
307
+ if(transform&&localRect){const p=inverseTransformPoint(transform,pointX,pointY);if(p.x<localRect.x||p.x>=localRect.x+localRect.width||p.y<localRect.y||p.y>=localRect.y+localRect.height)continue;}
299
308
  if (skip(nodeId)) continue;
300
309
  return nodeId;
301
310
  }
@@ -366,7 +375,11 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
366
375
  const ownTransform = memo((node) => {
367
376
  const transform = node.style?.transform;
368
377
  const box = boxes.get(node.id);
369
- if (!box || !transform?.scaleX || (transform.scaleX === 1 && transform.scaleY === 1)) return null;
378
+ if (!box || !transform || (!transform.affine && (transform.scaleX == null || (transform.scaleX === 1 && transform.scaleY === 1)))) return null;
379
+ if (transform.affine) {
380
+ const {a,b,c,d,e,f}=resolveAffine(transform.affine,box.width,box.height), origin=transformOrigin(node.style.transformOrigin,box);
381
+ return {a,b,c,d,e:origin.x*(1-a)-c*origin.y+e,f:origin.y*(1-d)-b*origin.x+f};
382
+ }
370
383
  const origin = transformOrigin(node.style.transformOrigin, box);
371
384
  return {
372
385
  a: transform.scaleX,
@@ -443,6 +456,17 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
443
456
  transform = transform ? composeTransforms(transform, scrolled) : scrolled;
444
457
  }
445
458
  }
459
+ // Backface-hidden descendants share their ancestor's rotation only
460
+ // inside preserve-3d. Flattened groups begin a new local 3D context.
461
+ if(chain.some(node=>node.style?.backfaceVisibility==='hidden')){
462
+ const identity=[1,0,0,0,1,0,0,0,1];let rotation=identity;
463
+ for(let index=chain.length-1;index>=0;index--){
464
+ const style=chain[index].style;
465
+ rotation=multiplyRotation3D(rotation,rotationMatrix3D(style?.transform));
466
+ if(style?.backfaceVisibility==='hidden'&&rotation[8]<-1e-10){cache.set(key,null);return null;}
467
+ if(style?.transformStyle!=='preserve-3d')rotation=identity;
468
+ }
469
+ }
446
470
  const roundedClips = [];
447
471
  for (const [node, clip, radii] of clips) {
448
472
  const matrix = transforms.get(node);
@@ -495,16 +519,17 @@ function transformOrigin(value, box) {
495
519
  }
496
520
 
497
521
  /** `outer` after `inner`: x -> outer(inner(x)), for scales with translation. */
498
- function composeTransforms(outer, inner) {
499
- return { a: outer.a * inner.a, d: outer.d * inner.d, e: outer.a * inner.e + outer.e, f: outer.d * inner.f + outer.f };
522
+ export function inverseTransformPoint(matrix,x,y) {
523
+ const {a,b=0,c=0,d,e,f}=matrix,det=a*d-b*c;
524
+ if(!det)return {x:Infinity,y:Infinity};
525
+ x-=e;y-=f;return {x:(d*x-c*y)/det,y:(a*y-b*x)/det};
500
526
  }
501
527
 
502
- function transformRect(matrix, rect) {
503
- const x0 = matrix.a * rect.x + matrix.e;
504
- const y0 = matrix.d * rect.y + matrix.f;
505
- const x1 = matrix.a * (rect.x + rect.width) + matrix.e;
506
- const y1 = matrix.d * (rect.y + rect.height) + matrix.f;
507
- return { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y1 - y0) };
528
+ export function transformRect(matrix, rect) {
529
+ const {a,b=0,c=0,d,e,f}=matrix;
530
+ const points=[[rect.x,rect.y],[rect.x+rect.width,rect.y],[rect.x,rect.y+rect.height],[rect.x+rect.width,rect.y+rect.height]].map(([x,y])=>({x:a*x+c*y+e,y:b*x+d*y+f}));
531
+ const x=Math.min(...points.map(p=>p.x)),y=Math.min(...points.map(p=>p.y));
532
+ return {x,y,width:Math.max(...points.map(p=>p.x))-x,height:Math.max(...points.map(p=>p.y))-y};
508
533
  }
509
534
 
510
535
  function applyEffect(items, start, effect) {
@@ -16,21 +16,30 @@ export class RasterReadback {
16
16
  this.#allocate = allocate;
17
17
  }
18
18
 
19
- read(image, width, height) {
19
+ read(image, width, height, { premultiplied = false, previous = null, region = null, allocate = this.#allocate } = {}) {
20
20
  const kit = this.#kit;
21
21
  const bytes = width * height * 4;
22
22
  if (!this.#scratch || this.#scratch.length < bytes) {
23
23
  this.dispose();
24
24
  this.#scratch = kit.Malloc(Uint8Array, bytes);
25
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);
26
+ const partial = previous?.length === bytes && region
27
+ && [region.x,region.y,region.width,region.height].every(Number.isInteger)
28
+ && region.x >= 0 && region.y >= 0 && region.width > 0 && region.height > 0
29
+ && region.x + region.width <= width && region.y + region.height <= height;
30
+ const x=partial?region.x:0,y=partial?region.y:0;
31
+ const rw=partial?region.width:width,rh=partial?region.height:height;
32
+ const native = image.readPixels(x, y, {
33
+ width:rw, height:rh, colorType: kit.ColorType.RGBA_8888,
34
+ alphaType: premultiplied ? kit.AlphaType.Premul : kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB,
35
+ }, this.#scratch, rw * 4);
30
36
  if (!native) throw new Error("Unable to read raster pixels");
31
- const pixels = this.#allocate ? this.#allocate(bytes)
37
+ const pixels = allocate ? allocate(bytes)
32
38
  : new Uint8Array(this.#shared ? new SharedArrayBuffer(bytes) : new ArrayBuffer(bytes));
33
- pixels.set(native.subarray(0, bytes));
39
+ if(partial){
40
+ pixels.set(previous);
41
+ for(let row=0;row<rh;row++)pixels.set(native.subarray(row*rw*4,(row+1)*rw*4),((y+row)*width+x)*4);
42
+ }else pixels.set(native.subarray(0, bytes));
34
43
  return pixels;
35
44
  }
36
45
 
@@ -12,7 +12,7 @@ export function supportsFrameCheckpoint(version = Bun.version) {
12
12
 
13
13
  /** One callback collection per document, with a checkpoint between callbacks. */
14
14
  export function createAnimationFrames({ invoke, now, checkpoint = drainMicrotasks,
15
- frameTimers = null }) {
15
+ frameTimers = null, afterFrame = null }) {
16
16
  let nextWakeId = 1;
17
17
  // A private timer collection keeps frame handles separate from Window timers.
18
18
  // timerTask=false also prevents a frame inheriting a timer's nesting clamp.
@@ -42,10 +42,15 @@ export function createAnimationFrames({ invoke, now, checkpoint = drainMicrotask
42
42
  finally { checkpoint(); }
43
43
  }
44
44
  } finally {
45
- running = false;
46
- // Callbacks registered during delivery, including its microtasks, belong
47
- // to the next frame. Long callbacks do not add another fixed 16 ms delay.
48
- arm(Math.max(0, Math.ceil(16 - (now() - timestamp))));
45
+ // Paint belongs after this complete callback batch and its microtasks,
46
+ // before the next animation opportunity can overtake presentation.
47
+ try { if(!disposed)afterFrame?.(); }
48
+ finally {
49
+ running = false;
50
+ // Callbacks registered during delivery, including its microtasks, belong
51
+ // to the next frame. Long callbacks do not add another fixed 16 ms delay.
52
+ arm(Math.max(0, Math.ceil(16 - (now() - timestamp))));
53
+ }
49
54
  }
50
55
  }
51
56
  return {