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
@@ -1,3 +1,10 @@
1
+ import { timeVideoDecoder } from "../media/mp4-frame-times.js";
2
+ import {createCSSAnimations} from './css-animations.js';
3
+ import { CanvasComposer } from "../paint/direct-canvas.ts";
4
+ import { makeRasterSurface } from "./raster-surface.js";
5
+ import {chooseInputFiles} from './file-input.js';
6
+ import {inverseTransformPoint,transformRect} from '../paint/display-list.js';
7
+ import { HTMLDrag, isDraggable } from './html-drag.js';
1
8
  /**
2
9
  * The page pipeline of one renderer: parse, style, layout, display list and
3
10
  * CanvasKit raster, plus the page-side input helpers (scrolling, hit testing,
@@ -50,7 +57,7 @@ import { LayoutEngine } from "../layout/text-layout.js";
50
57
  import { ScrollViewport } from "../layout/scroll-viewport.js";
51
58
  import { scrollAlignmentDelta } from "./scroll-into-view.js";
52
59
  import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions, renderNodeAtPoint } from "../input/hit-test.js";
53
- import { beginImageLoad, finishImageLoad } from "./image-loading.js";
60
+ import { beginImageLoad, finishImageLoad, imageSourceChanged } from "./image-loading.js";
54
61
  import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
55
62
  import { createResourceElements } from "./resource-elements.js";
56
63
  import { DocumentFonts } from "./font-loading.js";
@@ -252,6 +259,7 @@ function buildPageRenderer({
252
259
  requestDownload = () => {},
253
260
  reportError = () => {},
254
261
  pageFetch = () => Promise.reject(new TypeError("network access is not available")),
262
+ dialog = (type) => type === "confirm" ? false : type === "prompt" ? null : undefined,
255
263
  clipboardReadInput = () => clipboardRead(),
256
264
  clipboardWriteInput = text => clipboardWrite(text),
257
265
  clipboardRead = () => Promise.reject(Error("Clipboard unavailable")),
@@ -314,7 +322,19 @@ function buildPageRenderer({
314
322
  }
315
323
  const renderTreeBuilder = new RenderTreeBuilder();
316
324
  let layoutEngine = new LayoutEngine({ measureText, fontMetrics });
317
- const styleEngine = new StyleEngine({ measureText, deferHidden: true });
325
+ const cssAnimations = createCSSAnimations({
326
+ invalidate(element) { markStyleDirty(element);page.dirty=true;scheduleFrame(true); },
327
+ dispatch(element,type,detail) {
328
+ if(!page.realm||element.ownerDocument!==page.document)return;
329
+ const event=new page.window.AnimationEvent(type,{bubbles:true,...detail});
330
+ page.realm.dispatch(element,event);
331
+ // Legacy event names are aliases for the same CSS animation timeline.
332
+ if(type!=='animationcancel') {
333
+ const alias=new page.window.AnimationEvent({animationstart:'webkitAnimationStart',animationiteration:'webkitAnimationIteration',animationend:'webkitAnimationEnd'}[type],{bubbles:true,...detail});page.realm.dispatch(element,alias);
334
+ }
335
+ },
336
+ });
337
+ const styleEngine = new StyleEngine({ measureText, deferHidden: true, sampleStyle:(element,style)=>cssAnimations.sample(element,style) });
318
338
  // Incremental restyle: what may have changed style since the last
319
339
  // computation. DOM mutations come from a MutationObserver; state that is
320
340
  // not in the DOM tree (checked, value, focus) is marked by the code that
@@ -357,7 +377,9 @@ function buildPageRenderer({
357
377
  // The frame keyboard input goes to, after a click into it.
358
378
  let focusedFrame = null;
359
379
  let pointerButtons = 0, suppressPointerMouse = false;
380
+ let spaceDisclosureActivation = null;
360
381
  const pointerPresses = new Map();
382
+ let htmlDrag = null;
361
383
  let selectionDrag = null;
362
384
  let selectionVersion = 0;
363
385
  const viewport = new ScrollViewport(800, 600);
@@ -387,6 +409,7 @@ function buildPageRenderer({
387
409
  decodedImages: [],
388
410
  imageElements: new Set(),
389
411
  imagesLoaded: null,
412
+ scriptImages: new Set(),
390
413
  // CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
391
414
  backgrounds: new Map(),
392
415
  // Scroll offsets of scroll containers, by element (see setElementScroll).
@@ -618,6 +641,7 @@ function buildPageRenderer({
618
641
  const captures = new FrameCaptureCache();
619
642
  let prepaintTimer = null;
620
643
  let frameTimer = null;
644
+ let lastAnimationPaint = -Infinity;
621
645
  let lastRebuildMs = 0;
622
646
 
623
647
  // What the latest tile was rasterized from. A rebuild that produces the
@@ -632,6 +656,10 @@ function buildPageRenderer({
632
656
  let rasterSurfaceHeight = 0;
633
657
  let rasterRegion = null;
634
658
  const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
659
+ const canvasComposer = new CanvasComposer();
660
+ let canvasLayers = null;
661
+ let canvasPixels = new WeakMap();
662
+ let clippedCanvasPixels = new WeakMap();
635
663
  // A scroll or unrelated DOM update does not change text shaping. Keep a
636
664
  // bounded set of visible paragraphs keyed by their complete shaping input.
637
665
  // Both limits matter: a page may contain many unique or very long strings.
@@ -744,7 +772,108 @@ function buildPageRenderer({
744
772
  schedulePrepaint();
745
773
  }
746
774
 
747
- /** Conservative canvas-only damage, in document CSS pixels. */
775
+ /** Adapt one or more ordinary Canvas display items to Land surfaces. */
776
+ function composeCanvasLayers(region,width,height) {
777
+ const list=page.displayList,background=backgroundBytes(page.backgroundColor);
778
+ const selection=page.window?.getSelection();
779
+ if(background[3]!==255||page.hintsVisible||(selection&&!selection.isCollapsed)
780
+ ||framesVersion||list.items.some(item=>item.op==='drawVideo'))return null;
781
+ const groups=[],canvases=[];let pending=[];
782
+ for(const item of list.items){
783
+ if(item.op!=='drawImage'||page.renderTree.nodesById.get(item.nodeId)?.domNode?.tagName!=='CANVAS'){
784
+ pending.push(item);continue;
785
+ }
786
+ // Complex CSS effects still use the ordinary renderer until their layer
787
+ // rasterization can preserve those effects independently of composition.
788
+ if(item.fixed||item.transform
789
+ ||item.opacity<1||item.radii?.x||item.radii?.y)return null;
790
+ const resource=list.images[item.image];
791
+ if(!resource?.image)return null;
792
+ groups.push(pending);pending=[];canvases.push({item,resource});
793
+ }
794
+ if(!canvases.length)return null;
795
+ groups.push(pending);
796
+ // Ignore animated Canvas resources in the static layers' cache key.
797
+ const indices=new Set(canvases.map(({item})=>item.image));
798
+ const images=list.images.map((resource,index)=>indices.has(index)?null:resource);
799
+ const key={region,scale:page.deviceScaleFactor,background:page.backgroundColor,
800
+ scrollX:viewport.x,scrollY:viewport.y,groups};
801
+ if(!canvasLayers||!Bun.deepEquals(canvasLayers.key,key)
802
+ ||canvasLayers.images.length!==images.length||images.some((image,index)=>image!==canvasLayers.images[index])){
803
+ const layers=groups.map(items=>{
804
+ if(!items.length)return null;
805
+ const surface=makeRasterSurface(CanvasKit,width,height);
806
+ if(!surface)throw Error('Unable to rasterize Canvas DOM layer');
807
+ try{
808
+ const canvas=surface.getCanvas();canvas.clear(CanvasKit.TRANSPARENT);
809
+ paintDisplayList(canvas,{...list,images:list.images,items},region,page.deviceScaleFactor);
810
+ surface.flush();const image=surface.makeImageSnapshot();
811
+ try{
812
+ const pixels=rasterReadback.read(image,width,height,{premultiplied:true,allocate:bytes=>new Uint8Array(bytes)});
813
+ // Cache just the painted bounds, not a viewport-sized transparent
814
+ // surface for a tiny marker. Land then visits only this small layer.
815
+ const words=new Uint32Array(pixels.buffer,pixels.byteOffset,pixels.byteLength/4);
816
+ let left=width,top=height,right=0,bottom=0;
817
+ for(let y=0,at=0;y<height;y++)for(let x=0;x<width;x++,at++)if(words[at]>>>24){
818
+ if(x<left)left=x;if(y<top)top=y;if(x>=right)right=x+1;if(y>=bottom)bottom=y+1;
819
+ }
820
+ if(right<=left||bottom<=top)return null;
821
+ const w=right-left,h=bottom-top;
822
+ const cropped=cropViewport({pixels,pixelWidth:width,pixelHeight:height,x:0,y:0,width,height,scale:1},left,top,w,h);
823
+ return {pixels:cropped.pixels,width:w,height:h,x:left,y:top,drawWidth:w,drawHeight:h};
824
+ }finally{image.delete();}
825
+ }finally{surface.dispose();}
826
+ });
827
+ canvasLayers={key,images,layers};
828
+ trace?.(`${page.url}: direct canvas DOM layers rebuilt`);
829
+ }
830
+ const layers=[];
831
+ for(let i=0;i<groups.length;i++){
832
+ const layer=canvasLayers.layers[i];
833
+ if(layer)layers.push(layer);
834
+ if(i===canvases.length)continue;
835
+ const {item,resource}=canvases[i];
836
+ const r=item.rect,scale=page.deviceScaleFactor;
837
+ const x=Math.round((r.x-region.x)*scale),y=Math.round((r.y-region.y)*scale);
838
+ const drawWidth=Math.round(r.width*scale),drawHeight=Math.round(r.height*scale);
839
+ if(drawWidth<=0||drawHeight<=0)continue;
840
+ if(item.clipRect||item.roundedClips?.length){
841
+ // Preserve CSS clipping with Skia on this Canvas layer alone. The DOM
842
+ // layers remain cached and Land still performs final composition.
843
+ // Align the local raster to the global device grid, including at DPR
844
+ // fractions; rounding the clip in a different origin changes its edge.
845
+ const view={x:region.x+x/scale,y:region.y+y/scale,width:drawWidth/scale,height:drawHeight/scale};
846
+ const key={view,scale,clipRect:item.clipRect,roundedClips:item.roundedClips};
847
+ let cached=clippedCanvasPixels.get(resource);
848
+ if(!cached||!Bun.deepEquals(cached.key,key)){
849
+ const surface=makeRasterSurface(CanvasKit,drawWidth,drawHeight);
850
+ if(!surface) return null;
851
+ try{
852
+ const canvas=surface.getCanvas();canvas.clear(CanvasKit.TRANSPARENT);
853
+ paintDisplayList(canvas,{...list,images:list.images,items:[item]},view,scale);
854
+ surface.flush();
855
+ const image=surface.makeImageSnapshot();
856
+ try{cached={key,pixels:rasterReadback.read(image,drawWidth,drawHeight,
857
+ {premultiplied:true,allocate:bytes=>new Uint8Array(bytes)})};}
858
+ finally{image.delete();}
859
+ }finally{surface.dispose();}
860
+ clippedCanvasPixels.set(resource,cached);
861
+ }
862
+ layers.push({pixels:cached.pixels,width:drawWidth,height:drawHeight,x,y,drawWidth,drawHeight});
863
+ }else{
864
+ let source=canvasPixels.get(resource);
865
+ if(!source){source=rasterReadback.read(resource.image,resource.width,resource.height,
866
+ {premultiplied:true,allocate:bytes=>new Uint8Array(bytes)});canvasPixels.set(resource,source);}
867
+ layers.push({pixels:source,width:resource.width,height:resource.height,x,y,drawWidth,drawHeight});
868
+ }
869
+ }
870
+ const composed=canvasComposer.compose(width,height,background,layers);
871
+ const pixels=allocateFramePixels?.(composed.length)
872
+ ??new Uint8Array(onFrame?new SharedArrayBuffer(composed.length):new ArrayBuffer(composed.length));
873
+ pixels.set(composed);return pixels;
874
+ }
875
+
876
+ /** Conservative canvas and plain-text damage, in document CSS pixels. */
748
877
  function canvasDamage(region) {
749
878
  const old = rasterized.list, next = page.displayList;
750
879
  // Reuse pixels only when geometry and every non-canvas input are stable.
@@ -754,9 +883,31 @@ function buildPageRenderer({
754
883
  || rasterized.overlay !== overlayKey() || rasterized.frames !== framesVersion
755
884
  || rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y
756
885
  || !old || !next || old.images.length !== next.images.length
757
- || !Bun.deepEquals(old.items, next.items)
886
+ || old.items.length !== next.items.length
758
887
  || next.items.some(item => item.op === 'drawVideo')) return null;
759
888
  let damage = null;
889
+ const include = (r, pad = 1) => {
890
+ if (!r || ![r.x,r.y,r.width,r.height,pad].every(Number.isFinite) || r.width < 0 || r.height < 0) return false;
891
+ const left=r.x-pad,top=r.y-pad,right=r.x+r.width+pad,bottom=r.y+r.height+pad;
892
+ damage=damage?{x:Math.min(damage.x,left),y:Math.min(damage.y,top),right:Math.max(damage.right,right),bottom:Math.max(damage.bottom,bottom)}
893
+ :{x:left,y:top,right,bottom};
894
+ return true;
895
+ };
896
+ for(let i=0;i<next.items.length;i++){
897
+ const before=old.items[i],after=next.items[i];
898
+ if(Bun.deepEquals(before,after))continue;
899
+ // Only the text and its measured width may change. Keep line position,
900
+ // font, clips and every effect identical; reflow/other changes repaint fully.
901
+ if(before.op!=='drawText'||after.op!=='drawText'||before.fixed||after.fixed
902
+ ||before.transform||after.transform||before.gradient||after.gradient)return null;
903
+ const {text:bt,textWidth:bw,bounds:bb,nodeId:bi,...a}=before;
904
+ const {text:at,textWidth:aw,bounds:ab,nodeId:ai,...b}=after;
905
+ if(!Bun.deepEquals(a,b))return null;
906
+ // Include overhangs and antialiasing outside advance widths, as well as
907
+ // the previous glyphs when the replacement string becomes shorter.
908
+ const pad=Math.max(2,before.font.size*2);
909
+ if(!include(bb,pad)||!include(ab,pad))return null;
910
+ }
760
911
  for (let i = 0; i < next.images.length; i++) {
761
912
  if (old.images[i] === next.images[i]) continue;
762
913
  const uses = next.items.filter(item => item.image === i);
@@ -785,6 +936,18 @@ function buildPageRenderer({
785
936
  const scale = page.deviceScaleFactor;
786
937
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
787
938
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
939
+ // Canvas snapshots are layers in Land's unmodified TS compositor. Static
940
+ // DOM below/above them is rasterized only when its own inputs change.
941
+ const direct = composeCanvasLayers(region,pixelWidth,pixelHeight);
942
+ if(direct){
943
+ rasterRegion=null;
944
+ page.needsPaint=false;
945
+ rasterized={list:page.displayList,overlay:overlayKey(),background:page.backgroundColor,scale,
946
+ scrollX:viewport.x,scrollY:viewport.y,frames:framesVersion};
947
+ trace?.(`${page.url}: direct canvas composition ${pixelWidth}x${pixelHeight}`);
948
+ return {pixels:direct,pixelWidth,pixelHeight,x:region.x,y:region.y,width:region.width,height:region.height,
949
+ scale,background:backgroundBytes(page.backgroundColor),videoLayer:null};
950
+ }
788
951
  // The independent compositor already reconstructs an opaque video from
789
952
  // black/white masks. Reuse that path for the ordinary tile too, avoiding
790
953
  // a large Skia image upscale and a second black pass on each UI repaint.
@@ -794,7 +957,7 @@ function buildPageRenderer({
794
957
  if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
795
958
  // MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
796
959
  rasterSurface?.dispose();
797
- rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
960
+ rasterSurface = makeRasterSurface(CanvasKit,pixelWidth,pixelHeight);
798
961
  rasterSurfaceWidth = pixelWidth;
799
962
  rasterSurfaceHeight = pixelHeight;
800
963
  }
@@ -817,7 +980,14 @@ function buildPageRenderer({
817
980
  surface.flush();
818
981
  const image = surface.makeImageSnapshot();
819
982
  let pixels;
820
- try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
983
+ try {
984
+ const left=damage?Math.max(0,Math.floor((damage.x-region.x)*scale)):0;
985
+ const top=damage?Math.max(0,Math.floor((damage.y-region.y)*scale)):0;
986
+ const right=damage?Math.min(pixelWidth,Math.ceil((damage.right-region.x)*scale)):pixelWidth;
987
+ const bottom=damage?Math.min(pixelHeight,Math.ceil((damage.bottom-region.y)*scale)):pixelHeight;
988
+ pixels = rasterReadback.read(image, pixelWidth, pixelHeight, damage && lastTile
989
+ ? {previous:lastTile.pixels,region:{x:left,y:top,width:right-left,height:bottom-top}} : undefined);
990
+ }
821
991
  finally { image.delete(); }
822
992
  const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight, pixels, blackPlan?.resource);
823
993
  if (blackPlan && !videoLayer) {
@@ -920,7 +1090,7 @@ function buildPageRenderer({
920
1090
  }
921
1091
 
922
1092
  /** A rendering opportunity for DOM changes nobody has asked to see yet. */
923
- function scheduleFrame() {
1093
+ function scheduleFrame(animation = false) {
924
1094
  if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
925
1095
  frameTimer = hostSetTimeout(() => {
926
1096
  frameTimer = null;
@@ -928,7 +1098,11 @@ function buildPageRenderer({
928
1098
  // Coalesce ordinary mutations for the configured minimum interval.
929
1099
  // Expensive style/layout rebuilds retain a longer idle window so related script mutations can settle
930
1100
  // instead of continuously rebuilding a large document.
931
- }, Math.max(renderMinInterval, lastRebuildMs * 2));
1101
+ // A previous expensive reflow must not permanently throttle Canvas-only
1102
+ // animation, which does not need another layout pass.
1103
+ // CSS animation samples are already paced and must not inherit the
1104
+ // mutation-settling delay, which skips most of a short drop/bounce.
1105
+ }, Math.max(renderMinInterval, !animation && page.dirty ? lastRebuildMs * 2 : 0));
932
1106
  }
933
1107
 
934
1108
  /** The viewport as an encoded image (base64), cut from the latest tile. */
@@ -988,8 +1162,8 @@ function buildPageRenderer({
988
1162
  }
989
1163
  // A scale in document space, as a device-space matrix around the item's device coordinates.
990
1164
  if (item.transform) {
991
- const { a, d, e, f } = item.transform;
992
- canvas.concat([a, 0, ((a - 1) * view.x + e) * scale, 0, d, ((d - 1) * view.y + f) * scale, 0, 0, 1]);
1165
+ const { a, b=0, c=0, d, e, f } = item.transform;
1166
+ canvas.concat([a, c, ((a - 1) * view.x + c*view.y + e) * scale, b, d, (b*view.x + (d - 1) * view.y + f) * scale, 0, 0, 1]);
993
1167
  }
994
1168
  if (item.opacity < 1) {
995
1169
  const layer = new CanvasKit.Paint();
@@ -1718,7 +1892,9 @@ function buildPageRenderer({
1718
1892
  if (page.document) loadImageResource.cancelDocument(page.document);
1719
1893
  resourceElements?.dispose();
1720
1894
  resetInputCaret();
1895
+ lastAnimationPaint=-Infinity;
1721
1896
  rasterReadback.dispose();
1897
+ canvasComposer.close();canvasLayers=null;canvasPixels=new WeakMap();clippedCanvasPixels=new WeakMap();
1722
1898
  clearPaintParagraphs();
1723
1899
  resetDocumentFonts();
1724
1900
  disposeFrames();
@@ -1727,13 +1903,14 @@ function buildPageRenderer({
1727
1903
  // qml handle begin: dispose application resources with their document
1728
1904
  page.document?.__qmlHost?.dispose();
1729
1905
  // qml handle end
1906
+ cssAnimations.dispose();
1730
1907
  page.realm?.dispose();
1731
1908
  page.realm = null;
1732
1909
  page.window?.happyDOM?.close?.();
1733
1910
  for (const image of page.decodedImages) image.delete();
1734
1911
  disposeBackgrounds();
1735
1912
  page.window = parsed.window;
1736
- pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
1913
+ pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null; htmlDrag=null;spaceDisclosureActivation=null;
1737
1914
  page.document = parsed.document;
1738
1915
  resourceElements = createResourceElements(parsed.document);
1739
1916
  computedSheets = null;
@@ -1750,11 +1927,13 @@ function buildPageRenderer({
1750
1927
  page.styleSheets = styleSheets;
1751
1928
  page.linkSheets = linkSheets;
1752
1929
  page.resources = imageResources.map;
1930
+ for(const {element,src,resource} of imageResources.entries)finishImageLoad(element,src,resource);
1753
1931
  initializeVideos(parsed.document, page.resources);
1754
1932
  page.decodedImages = imageResources.decoded;
1755
1933
  page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
1756
1934
  page.resourcePaintDirty = false;
1757
1935
  page.imagesLoaded = null;
1936
+ page.scriptImages.clear();
1758
1937
  imageRequests = new WeakMap();
1759
1938
  page.elementScroll = new Map();
1760
1939
  const timing = styleAndLayout();
@@ -1808,6 +1987,7 @@ function buildPageRenderer({
1808
1987
  lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
1809
1988
  return { style: Math.round(taken - started), layout: 0, skipped: true, geometryUnchanged: true };
1810
1989
  }
1990
+ cssAnimations.setSheets(sheets,environment);
1811
1991
  styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
1812
1992
  computedSheets = sheets;
1813
1993
  computedEnvironment = environmentKey;
@@ -1949,6 +2129,7 @@ function buildPageRenderer({
1949
2129
  function loadDocument(document) {
1950
2130
  if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
1951
2131
  const generation = ++loadGeneration;
2132
+ clipboardActivationUntil = 0;
1952
2133
  let commit;
1953
2134
  const committed = new Promise((resolve, reject) => {
1954
2135
  commit = { resolve: () => resolve(generation), reject };
@@ -1999,9 +2180,9 @@ function buildPageRenderer({
1999
2180
  }
2000
2181
  if (url === "about:blank") {
2001
2182
  replaceDocument("", url);
2002
- // An iframe's blank document is scripted from its page (through
2003
- // contentWindow and contentDocument), so it has a realm.
2004
- if (embedded && page.scripting !== "off") {
2183
+ // Blank documents also need a realm for javascript: URLs, CDP evaluation
2184
+ // and parent access through iframe.contentWindow/contentDocument.
2185
+ if (page.scripting !== "off") {
2005
2186
  page.realm = createPageRealm({ window: page.window, document: page.document, mode: page.scripting, hooks: realmHooks(generation) });
2006
2187
  render();
2007
2188
  commit.resolve();
@@ -2379,6 +2560,30 @@ function buildPageRenderer({
2379
2560
  return to>from?{from,to}:null;
2380
2561
  }
2381
2562
 
2563
+ let clipboardActivationUntil = 0;
2564
+ let executingCopy = false;
2565
+ function execCommand(command) {
2566
+ if (command !== 'copy' || executingCopy || Date.now() > clipboardActivationUntil) return false;
2567
+ executingCopy = true;
2568
+ try {
2569
+ const element = page.document.activeElement;
2570
+ const target = element ?? page.document.body;
2571
+ const event = new page.window.Event('copy', {bubbles:true,cancelable:true});
2572
+ target?.dispatchEvent(event);
2573
+ if (event.defaultPrevented) return true;
2574
+ // Capture synchronously: the caller can remove its temporary textarea
2575
+ // immediately after execCommand returns. Reuse the established frontend
2576
+ // clipboard transport, which retains the host's user-action checks.
2577
+ const text = isTextInput(element) && element.type !== 'password'
2578
+ ? String(element.value).slice(element.selectionStart, element.selectionEnd)
2579
+ : page.window.getSelection().toString();
2580
+ if (!text) return false;
2581
+ clipboardActivationUntil = 0;
2582
+ Promise.resolve(clipboardWriteInput(text)).catch(reportError);
2583
+ return true;
2584
+ } finally { executingCopy = false; }
2585
+ }
2586
+
2382
2587
  function clipboardForInput(element,operation,backend={read:clipboardReadInput,write:clipboardWriteInput}) {
2383
2588
  return inputClipboard(element,operation,{...backend,replace:(text,type)=>{
2384
2589
  if(page.document.activeElement!==element)return;
@@ -2466,6 +2671,7 @@ function buildPageRenderer({
2466
2671
 
2467
2672
  function realmHooks(generation) {
2468
2673
  return {
2674
+ decodeAudio: videoDecoder ? bytes => videoDecoder.decodeAudio(bytes) : null,
2469
2675
  frames: {
2470
2676
  self: ownWindow,
2471
2677
  parent: ownWindow.parent(),
@@ -2481,6 +2687,10 @@ function buildPageRenderer({
2481
2687
  scheduleFrame();
2482
2688
  },
2483
2689
  touch(node = null, stateFeatures = []) {
2690
+ if(node?.tagName==='IMG')imageSourceChanged(node);
2691
+ // HTML images load even when scripts keep them outside the DOM (e.g.
2692
+ // canvas and WebGL textures). They remain document-owned resources.
2693
+ if(node?.tagName==='IMG'&&!node.isConnected&&node.getAttribute('src'))page.scriptImages.add(node);
2484
2694
  if (stateFeatures.length) markStyleDirty(node, stateFeatures);
2485
2695
  page.dirty = true;
2486
2696
  scheduleFrame();
@@ -2526,6 +2736,7 @@ function buildPageRenderer({
2526
2736
  return renderCanvasText({ ...request, style,
2527
2737
  settings: { ...request.settings, direction: style.direction } });
2528
2738
  },
2739
+ audio,
2529
2740
  mediaState,
2530
2741
  playMedia,
2531
2742
  pauseMedia,
@@ -2553,10 +2764,32 @@ function buildPageRenderer({
2553
2764
  whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
2554
2765
  selectionChanged(){selectionVersion++;page.needsPaint=true;scheduleFrame();},
2555
2766
  afterTask() {},
2767
+ afterAnimationFrame() {
2768
+ if(disposedRenderer||page.loading||!onFrame||page.dirty||!page.resourcePaintDirty||!page.needsPaint)return;
2769
+ const now=hostPerformance.now();
2770
+ if(now-lastAnimationPaint<renderMinInterval)return;
2771
+ lastAnimationPaint=now;
2772
+ hostClearTimeout(frameTimer);frameTimer=null;
2773
+ refresh();
2774
+ },
2775
+ pickFile(element){void chooseInputFiles(element,dialog?.pickFiles,page.window).catch(error=>console.error(error));},
2776
+ layoutBounds(element){if(page.dirty)rebuildRenderTree("script read layout geometry");const box=elementBox(page.renderTree,page.layout,element)?.box;return box?{left:box.x,top:box.y,width:box.width,height:box.height}:null;},
2556
2777
  boundsOf(element) {
2557
2778
  if (page.dirty) rebuildRenderTree("script read geometry");
2558
2779
  return viewportBounds(element);
2559
2780
  },
2781
+ mouseOffset(target, x, y) {
2782
+ if (page.dirty) rebuildRenderTree("script read mouse offset");
2783
+ const element = target?.nodeType === 1 ? target : target?.parentElement;
2784
+ const geometry=element && elementBox(page.renderTree,page.layout,element);
2785
+ const hit=geometry && page.displayList?.hits.find(h=>h.nodeId===geometry.node.id && h.localRect);
2786
+ const rect = element && viewportBounds(element);
2787
+ if (!rect) return null;
2788
+ if(hit?.transform){const p=inverseTransformPoint(hit.transform,x+(hit.fixed?0:viewport.x),y+(hit.fixed?0:viewport.y));x=p.x;y=p.y;rect.left=hit.localRect.x;rect.top=hit.localRect.y;}
2789
+ // Offset coordinates are in the target's untransformed padding box.
2790
+ const borders = styleEngine.get(element)?.borderWidths ?? [0, 0, 0, 0];
2791
+ return { x: x - rect.left - borders[3], y: y - rect.top - borders[0] };
2792
+ },
2560
2793
  viewport: () => ({
2561
2794
  width: page.width,
2562
2795
  height: page.height,
@@ -2645,6 +2878,9 @@ function buildPageRenderer({
2645
2878
  setDocumentCookie(value) {
2646
2879
  documentCookies.set(cookieUrl(), value);
2647
2880
  },
2881
+ dialog,
2882
+ execCommand,
2883
+ copyEnabled: () => Date.now() <= clipboardActivationUntil,
2648
2884
  clipboardRead,
2649
2885
  clipboardWrite,
2650
2886
  webSockets,
@@ -2712,7 +2948,10 @@ function buildPageRenderer({
2712
2948
 
2713
2949
  /** The border box of an element's layout box in viewport coordinates. */
2714
2950
  function viewportBounds(element) {
2715
- const box = elementBox(page.renderTree, page.layout, element)?.box;
2951
+ const geometry = elementBox(page.renderTree, page.layout, element);
2952
+ const box = geometry?.box;
2953
+ const hit=geometry && page.displayList?.hits.find(h=>h.nodeId===geometry.node.id && h.localRect);
2954
+ if(hit?.transform){const r=transformRect(hit.transform,hit.localRect);return {left:r.x-(hit.fixed?0:viewport.x),top:r.y-(hit.fixed?0:viewport.y),width:r.width,height:r.height};}
2716
2955
  const content = box ? null : elementBounds(page.renderTree, page.layout, element);
2717
2956
  if (!box && !content) return null;
2718
2957
  let fixed = false;
@@ -2997,8 +3236,30 @@ function buildPageRenderer({
2997
3236
  return { x: viewport.x, y: viewport.y };
2998
3237
  },
2999
3238
  press(key, options = {}) {
3239
+ clipboardActivationUntil = Date.now() + 5000;
3240
+ if(key==='Escape' && htmlDrag?.active){htmlDrag.end(undefined,undefined,options,true);htmlDrag=null;pointerButtons=0;pointerPresses.clear();selectionDrag=null;suppressPointerMouse=false;refresh();return {x:viewport.x,y:viewport.y};}
3000
3241
  if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
3001
- refresh();
3242
+ // A CDP key-up is an independent task, not another synthetic key press.
3243
+ if (options.eventType === "keyup") {
3244
+ const target=page.document?.activeElement??page.document?.body;
3245
+ if(target&&page.realm){
3246
+ const modifiers=Number(options.modifiers)||0;
3247
+ const prevented=page.realm.dispatch(target,new page.window.KeyboardEvent("keyup",{
3248
+ ...keyboardInput(key,options),bubbles:true,cancelable:true,
3249
+ altKey:!!(modifiers&1),ctrlKey:!!(modifiers&2),metaKey:!!(modifiers&4),shiftKey:!!(modifiers&8),
3250
+ }));
3251
+ if(key===' '){
3252
+ if(spaceDisclosureActivation?.deref()===target&&!prevented)activateDisclosure(target);
3253
+ spaceDisclosureActivation=null;
3254
+ }
3255
+ if(page.dirty)refresh();
3256
+ else if(page.needsPaint)scheduleFrame();
3257
+ }
3258
+ return {x:viewport.x,y:viewport.y};
3259
+ }
3260
+ // A held key's arrival must not force a second raster pass over an
3261
+ // otherwise current Canvas-only page before delivering the event.
3262
+ if(options.eventType!=="keydown"||page.dirty)refresh();
3002
3263
  const pianoInput = page.document?.activeElement;
3003
3264
  if (handlePianoInput(pianoInput, { ...options, key }, audio ? playPiano : null)) {
3004
3265
  return { x: viewport.x, y: viewport.y };
@@ -3036,10 +3297,23 @@ function buildPageRenderer({
3036
3297
  }
3037
3298
  else if (String(key).length === 1) editFocusedText(String(key));
3038
3299
  }
3039
- const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
3040
- if (activated && key === " " && !upPrevented) activateDisclosure(target);
3300
+ if(options.eventType==='keydown'){
3301
+ if(key===' ')spaceDisclosureActivation=activated?new WeakRef(target):null;
3302
+ }else{
3303
+ const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
3304
+ if (activated && key === " " && !upPrevented) activateDisclosure(target);
3305
+ }
3306
+ if(options.eventType==='keydown'&&prevented&&!page.dirty){
3307
+ if(page.needsPaint)scheduleFrame();
3308
+ return {x:viewport.x,y:viewport.y};
3309
+ }
3041
3310
  if (refresh() || prevented || activated || inputHandled) return { x: viewport.x, y: viewport.y };
3042
3311
  }
3312
+ // CSS Overflow §3.3: root overflow applies to the viewport, with the
3313
+ // HTML body's overflow propagated when the root has visible overflow.
3314
+ // Hidden/clip must suppress keyboard scrolling, while scrollTo remains
3315
+ // available for hidden overflow. A game's Space key is not special.
3316
+ if (!viewportAllowsUserScroll("y")) return {x:viewport.x,y:viewport.y};
3043
3317
  const pageStep = Math.max(31, viewport.height - 31);
3044
3318
  const delta = {
3045
3319
  ArrowUp: -31,
@@ -3113,6 +3387,11 @@ function buildPageRenderer({
3113
3387
  const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport) ?? (pointerButtons ? page.document.documentElement : null);
3114
3388
  const inside = target?.tagName === 'IFRAME' ? frameAt(target, x, y) : null;
3115
3389
  if (inside) inside.frame.renderer.move(inside.x, inside.y, options);
3390
+ if (htmlDrag?.move(target,Number(x),Number(y),options)) {
3391
+ selectionDrag=null;
3392
+ if(page.dirty || page.needsPaint)scheduleFrame();
3393
+ return;
3394
+ }
3116
3395
  dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y), {...options, buttons: pointerButtons, suppressMouse:suppressPointerMouse});
3117
3396
  if(selectionDrag && (pointerButtons&1)) extendDrag(x,y);
3118
3397
  if (page.dirty || page.needsPaint) scheduleFrame();
@@ -3127,6 +3406,7 @@ function buildPageRenderer({
3127
3406
  return this.click(x, y, {...options, button: button === 273 ? 'right' : button === 274 ? 'middle' : 'left', phase: state ? 'down' : 'up'});
3128
3407
  },
3129
3408
  click(x, y, options = {}) {
3409
+ clipboardActivationUntil = Date.now() + 5000;
3130
3410
  const detail = Number.isInteger(options.clickCount) && options.clickCount > 0 ? options.clickCount : 1;
3131
3411
  refresh();
3132
3412
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
@@ -3211,13 +3491,24 @@ function buildPageRenderer({
3211
3491
  if (before !== 0) page.realm.dispatch(target,new Pointer('pointermove',pointer));
3212
3492
  if (!record.suppressMouse) {
3213
3493
  const prevented = page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...mouseInit,buttons:pointerButtons}));
3214
- if (mouseButton === 0 && !prevented) focus();
3494
+ if (mouseButton === 0 && !prevented) {
3495
+ focus();
3496
+ let source=target;
3497
+ while(source && !isDraggable(source))source=source.parentElement;
3498
+ if(source)htmlDrag=new HTMLDrag(page.window,page.realm,source,Number(x),Number(y));
3499
+ }
3215
3500
  }
3216
3501
  if (mouseButton === 2) page.realm.dispatch(target,new Pointer('contextmenu',pointer));
3217
3502
  } else if (mouseButton === 0) focus();
3218
3503
  refresh();
3219
3504
  return null;
3220
3505
  }
3506
+ if(mouseButton===0 && htmlDrag){
3507
+ const drag=htmlDrag;htmlDrag=null;
3508
+ if(drag.end(Number(x),Number(y),options)){
3509
+ pointerPresses.delete(mouseButton);selectionDrag=null;suppressPointerMouse=false;refresh();return null;
3510
+ }
3511
+ }
3221
3512
  let selectedByDrag=false;
3222
3513
  if(mouseButton===0){
3223
3514
  if(selectionDrag){extendDrag(x,y);selectedByDrag=!selectionDrag?.element&&!page.window.getSelection().isCollapsed;}
@@ -3287,6 +3578,7 @@ function buildPageRenderer({
3287
3578
  if (prevented) return null;
3288
3579
  }
3289
3580
  if (options.selectionDragged) return null;
3581
+ if(target.tagName==='INPUT'&&target.type==='file'){void chooseInputFiles(target,dialog?.pickFiles,page.window).catch(error=>console.error(error));return null;}
3290
3582
  if (generatedDisclosure) {
3291
3583
  target.open = !target.open;
3292
3584
  refresh();
@@ -3386,6 +3678,7 @@ function buildPageRenderer({
3386
3678
  if (disposedRenderer) return;
3387
3679
  resetInputCaret();
3388
3680
  disposedRenderer = true;
3681
+ cssAnimations.dispose();
3389
3682
  resourceElements?.dispose();
3390
3683
  resourceElements = null;
3391
3684
  loadGeneration++;
@@ -3402,6 +3695,7 @@ function buildPageRenderer({
3402
3695
  rasterSurface?.dispose();
3403
3696
  rasterSurface = null;
3404
3697
  rasterReadback.dispose();
3698
+ canvasComposer.close();canvasLayers=null;canvasPixels=new WeakMap();clippedCanvasPixels=new WeakMap();
3405
3699
  clearPaintParagraphs();
3406
3700
  documentFonts.dispose();
3407
3701
  page.window?.happyDOM?.close?.();
@@ -3490,7 +3784,8 @@ function buildPageRenderer({
3490
3784
  beginImageLoad(element, src);
3491
3785
  try {
3492
3786
  const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
3493
- finishImageLoad(element, src, resource);
3787
+ // decode() resolves only once the renderer has installed these pixels
3788
+ // (replaceParsedDocument/awaitImages), so drawImage can use them then.
3494
3789
  map.set(element, resource);
3495
3790
  decoded.push(resource.image);
3496
3791
  entries.push({ element, src, resource });
@@ -3562,6 +3857,7 @@ function buildPageRenderer({
3562
3857
  requestDownload,
3563
3858
  reportError,
3564
3859
  pageFetch,
3860
+ dialog,
3565
3861
  clipboardRead,
3566
3862
  clipboardWrite,
3567
3863
  clipboardReadInput,
@@ -3738,6 +4034,15 @@ function buildPageRenderer({
3738
4034
  return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
3739
4035
  }
3740
4036
 
4037
+ function viewportAllowsUserScroll(axis) {
4038
+ const root=styleEngine.get(page.document?.documentElement);
4039
+ const body=styleEngine.get(page.document?.body);
4040
+ const style=root?.overflowX==="visible"&&root?.overflowY==="visible"&&body?.display!=="none" ? body??root : root;
4041
+ const overflow=axis==="x"?style?.overflowX:style?.overflowY;
4042
+ const scrolling=axis==="x"?style?.overflowScrollX:style?.overflowScrollY;
4043
+ return overflow!=="clip"||scrolling==="auto";
4044
+ }
4045
+
3741
4046
  function scrollOffsetOf(element) {
3742
4047
  return element ? page.elementScroll.get(element) ?? null : null;
3743
4048
  }
@@ -3954,7 +4259,7 @@ function buildPageRenderer({
3954
4259
  function mediaState(element) {
3955
4260
  const state = page.media.get(element);
3956
4261
  const decoder = state?.video?.decoder;
3957
- return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
4262
+ return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? presentedMediaTime(state) : 0,
3958
4263
  duration: state?.duration ?? NaN, videoWidth: decoder?.width || 0, videoHeight: decoder?.height || 0 };
3959
4264
  }
3960
4265
 
@@ -4162,6 +4467,15 @@ function buildPageRenderer({
4162
4467
  return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
4163
4468
  }
4164
4469
 
4470
+ // Keep the decode target clock separate from the observable playback
4471
+ // position. A silent software decoder may lag behind real time; controls
4472
+ // must describe the picture actually presented, not its catch-up target.
4473
+ function presentedMediaTime(state) {
4474
+ const target = mediaTime(state);
4475
+ if (!state.playing || state.hasAudio || !state.video) return target;
4476
+ return Math.min(target, Math.max(state.base, state.video.shownTime ?? state.base));
4477
+ }
4478
+
4165
4479
  /** Fires a media event (play, pause, ended, seeked, timeupdate...) at the element. */
4166
4480
  function mediaEvent(state, type) {
4167
4481
  if (state.document !== page.document || !page.realm) return;
@@ -4179,8 +4493,11 @@ function buildPageRenderer({
4179
4493
  && [...page.media.values()].some(state => state.video?.presentation && state.playing)) {
4180
4494
  // Another media element can still own a native progress/control bar.
4181
4495
  // Its mutable resource time is not a DOM mutation, so invalidate paint.
4182
- if (page.displayList?.items.some(item => item.op === "drawAudio"
4183
- || (item.op === "drawVideo" && item.controls))) {
4496
+ const controlsTime = hostPerformance.now();
4497
+ if (controlsTime - (page.lastMediaControlsPaint ?? -Infinity) >= 250
4498
+ && page.displayList?.items.some(item => item.op === "drawAudio"
4499
+ || (item.op === "drawVideo" && item.controls))) {
4500
+ page.lastMediaControlsPaint = controlsTime;
4184
4501
  rasterized.list = null;
4185
4502
  page.needsPaint = true;
4186
4503
  }
@@ -4254,7 +4571,7 @@ function buildPageRenderer({
4254
4571
  void (async () => {
4255
4572
  let decoder;
4256
4573
  try {
4257
- decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
4574
+ decoder = timeVideoDecoder(await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url)), bytes);
4258
4575
  if (!current()) { decoder.close(); decoder = null; failVideoContinuation(state, p); return; }
4259
4576
  if (decoder.width !== video.decoder.width || decoder.height !== video.decoder.height || decoder.fps !== video.decoder.fps)
4260
4577
  throw new Error("video snapshot configuration changed");
@@ -4301,7 +4618,7 @@ function buildPageRenderer({
4301
4618
  const state = [...page.media.values()].find(state => state.resource === resource);
4302
4619
  const frame = state?.video?.presentation?.frame;
4303
4620
  if (!frame?.opaque || !state.playing || state.video.done || state.buffering || state.presentationFailed
4304
- || state.document !== page.document || !state.element.isConnected || item.controls || item.fixed) return null;
4621
+ || state.document !== page.document || !state.element.isConnected || item.fixed) return null;
4305
4622
  if (!videoLayerGeometry(item,region,page.deviceScaleFactor).replayable) return null;
4306
4623
  return {resource,frame};
4307
4624
  }
@@ -4312,9 +4629,9 @@ function buildPageRenderer({
4312
4629
  const item = items.length === 1 ? items[0] : null;
4313
4630
  const resource = item && page.displayList.images[item.image];
4314
4631
  const state = [...page.media.values()].find(state => state.resource === resource);
4315
- const eligible = state?.playing && state.video && !state.video.done && !state.buffering
4316
- && !state.presentationFailed && !item.controls && !item.fixed
4317
- && (!item.transform || (item.transform.a > 0 && item.transform.d > 0));
4632
+ const eligible = state?.playing && state.video && Number.isFinite(state.video.shownTime) && !state.video.done && !state.buffering
4633
+ && !state.presentationFailed && !item.fixed
4634
+ && (!item.transform || (item.transform.a > 0 && item.transform.d > 0 && !item.transform.b && !item.transform.c));
4318
4635
  for (const other of page.media.values()) if (!eligible || other !== state) stopVideoPresentation(other);
4319
4636
  if (!eligible) return null;
4320
4637
  const video = state.video;
@@ -4615,13 +4932,17 @@ function buildPageRenderer({
4615
4932
  const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
4616
4933
  if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
4617
4934
  state.video?.decoder.close();
4618
- const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
4935
+ const decoder = timeVideoDecoder(await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url)), bytes);
4619
4936
  state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
4620
4937
  snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
4621
4938
  trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
4622
4939
  if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
4623
- state.resource.width = decoder.width;
4624
- state.resource.height = decoder.height;
4940
+ // Intrinsic layout caches key resources by identity. Publish a new
4941
+ // record on a size change; ordinary video-frame updates stay mutable.
4942
+ state.resource = {...state.resource, width:decoder.width, height:decoder.height};
4943
+ page.resources.set(state.element,state.resource);
4944
+ // Metadata changes intrinsic sizing even when no DOM/CSS changed.
4945
+ page.resourceLayoutDirty = true;
4625
4946
  page.dirty = true;
4626
4947
  refresh();
4627
4948
  }
@@ -4790,7 +5111,7 @@ function buildPageRenderer({
4790
5111
  function pauseMedia(element) {
4791
5112
  const state = page.media.get(element);
4792
5113
  if (!state?.playing) return;
4793
- state.base = mediaTime(state);
5114
+ state.base = presentedMediaTime(state);
4794
5115
  trace?.(`media pause at ${state.base.toFixed(3)}s`);
4795
5116
  stopClock(state);
4796
5117
  state.needsPosition = true;
@@ -4823,7 +5144,7 @@ function buildPageRenderer({
4823
5144
  return;
4824
5145
  }
4825
5146
  if (wasPlaying && state.playing && state.document === page.document) startMedia(state);
4826
- state.resource.time = mediaTime(state);
5147
+ state.resource.time = presentedMediaTime(state);
4827
5148
  repaintMedia();
4828
5149
  mediaEvent(state, "seeked");
4829
5150
  mediaEvent(state, "timeupdate");
@@ -5003,7 +5324,11 @@ function buildPageRenderer({
5003
5324
  } else if (video && !video.done && video.frameTime <= now) {
5004
5325
  // Late frames are decoded but only the newest is shown.
5005
5326
  const decodeDeadline = hostPerformance.now() + 100;
5006
- while (video.frameTime <= mediaTime(state) && !video.done) {
5327
+ // A silent video uses a duration-clamped wall clock. Once it reaches
5328
+ // the advertised duration, finish decoding rather than abandoning the
5329
+ // tail (or waiting forever on rounding beyond that clock).
5330
+ const drainSilentTail=!state.hasAudio&&Number.isFinite(state.duration)&&now>=state.duration;
5331
+ while ((video.frameTime <= mediaTime(state)||drainSilentTail) && !video.done) {
5007
5332
  const current = mediaTime(state);
5008
5333
  const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= current;
5009
5334
  // If this tick has spent its decode budget, decode one final frame
@@ -5016,7 +5341,7 @@ function buildPageRenderer({
5016
5341
  const reached = video.frameTime + 1e-6 >= (video.hideUntil ?? 0);
5017
5342
  let step;
5018
5343
  try {
5019
- step = drawVideoFrame(state, reached && (!nextIsLate || yielding));
5344
+ step = drawVideoFrame(state, reached && (!Number.isFinite(video.shownTime) || !nextIsLate || yielding));
5020
5345
  } catch (error) {
5021
5346
  failMedia(state, error, 3);
5022
5347
  return;
@@ -5029,17 +5354,18 @@ function buildPageRenderer({
5029
5354
  // Keep tasks responsive even when software decoding is slower than
5030
5355
  // real time. The zero-delay continuation resumes catching up against
5031
5356
  // a freshly read clock instead of accumulating this task's duration.
5032
- if (yielding && video.frameTime <= mediaTime(state)) break;
5357
+ if (yielding && (video.frameTime <= mediaTime(state)||drainSilentTail)) break;
5033
5358
  }
5034
5359
  }
5035
- state.resource.time = now;
5360
+ const presented = presentedMediaTime(state);
5361
+ state.resource.time = presented;
5036
5362
  if (!state.buffering && !state.awaitingAudio) {
5037
5363
  playbackEvent(state, "playing", mediaEvent);
5038
5364
  if (!state.playing) return;
5039
5365
  }
5040
5366
  repaintMedia();
5041
- if (Math.floor(now * 4) !== state.lastTimeUpdate) {
5042
- state.lastTimeUpdate = Math.floor(now * 4);
5367
+ if (Math.floor(presented * 4) !== state.lastTimeUpdate) {
5368
+ state.lastTimeUpdate = Math.floor(presented * 4);
5043
5369
  mediaEvent(state, "timeupdate");
5044
5370
  }
5045
5371
  const videoFinished = !video || video.done;
@@ -5062,7 +5388,9 @@ function buildPageRenderer({
5062
5388
  void refillMedia(state, stalled);
5063
5389
  return;
5064
5390
  }
5065
- if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
5391
+ // Silent software decoding can lag behind wall time. Do not replace an
5392
+ // unfinished picture with ended solely because its duration elapsed.
5393
+ if ((videoFinished && soundFinished) || (pastEnd && soundFinished && state.hasAudio)) return endMedia(state);
5066
5394
  const delay = video && !video.done ? Math.max(0, (video.frameTime - mediaTime(state)) * 1000) : 250;
5067
5395
  state.timer = hostSetTimeout(() => mediaTick(state), delay);
5068
5396
  }
@@ -5085,11 +5413,22 @@ function buildPageRenderer({
5085
5413
  if (needsMore(video)) return "stall";
5086
5414
  throw error;
5087
5415
  }
5088
- if (!pixels) return needsMore(video) ? "stall" : false;
5089
- const shownTime = state.video.frameTime;
5090
- state.video.frameTime += 1 / (decoder.fps || 30);
5091
- if (!show) return true;
5092
- state.video.shownTime = shownTime;
5416
+ if (!pixels) {
5417
+ if(needsMore(video))return "stall";
5418
+ // Late intermediate frames may be skipped, but EOF must retain the
5419
+ // last decoded picture rather than whichever older frame was painted.
5420
+ if(video.pendingFrame){paintDecodedVideoFrame(state,video.pendingFrame);video.pendingFrame=null;}
5421
+ return false;
5422
+ }
5423
+ const shownTime = decoder.frameTime ?? state.video.frameTime;
5424
+ state.video.frameTime = decoder.nextFrameTime ?? (state.video.frameTime + 1 / (decoder.fps || 30));
5425
+ if (!show) {video.pendingFrame={pixels,width,height,time:shownTime};return true;}
5426
+ video.pendingFrame=null;
5427
+ return paintDecodedVideoFrame(state,{pixels,width,height,time:shownTime});
5428
+ }
5429
+
5430
+ function paintDecodedVideoFrame(state,{pixels,width,height,time}) {
5431
+ state.video.shownTime = time;
5093
5432
  const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
5094
5433
  colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
5095
5434
  if (!image) return true;
@@ -5438,6 +5777,11 @@ function buildPageRenderer({
5438
5777
 
5439
5778
  function endMedia(state) {
5440
5779
  stopClock(state);
5780
+ // Ended elements can remain reachable indefinitely (e.g. game effects).
5781
+ // Keep metadata and the final video image, but release the bounded WASM
5782
+ // audio session; loadMedia recreates it for a later play or seek.
5783
+ state.sound?.decoder.close();
5784
+ state.sound = null;
5441
5785
  state.ended = true;
5442
5786
  state.base = Number.isFinite(state.duration) ? state.duration : state.base;
5443
5787
  setMediaPlaying(state, false);
@@ -5448,7 +5792,7 @@ function buildPageRenderer({
5448
5792
  function setMediaPlaying(state, playing) {
5449
5793
  state.playing = playing;
5450
5794
  state.resource.playing = playing;
5451
- state.resource.time = mediaTime(state);
5795
+ state.resource.time = presentedMediaTime(state);
5452
5796
  if (state.document === page.document) repaintMedia();
5453
5797
  }
5454
5798
 
@@ -5548,7 +5892,7 @@ function buildPageRenderer({
5548
5892
  // Infinite feeds remove old DOM images without navigating. The resource
5549
5893
  // WeakMap alone cannot release their explicitly-owned Skia handles.
5550
5894
  for (const element of page.imageElements) {
5551
- if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
5895
+ if ((element.isConnected || page.scriptImages.has(element)) && element.ownerDocument === document && element.getAttribute("src")) continue;
5552
5896
  const image = page.resources.get(element)?.image;
5553
5897
  if (image) {
5554
5898
  staleImages.push(image);
@@ -5558,13 +5902,17 @@ function buildPageRenderer({
5558
5902
  page.imageElements.delete(element);
5559
5903
  imageRequests.delete(element);
5560
5904
  }
5561
- for (const element of resourceElements.get("images")) {
5905
+ for (const element of new Set([...resourceElements.get("images"),...page.scriptImages])) {
5562
5906
  const src = element.getAttribute("src");
5563
5907
  const credentials = imageCrossOrigin(element);
5564
5908
  const key = imageRequestKey(src, credentials);
5565
5909
  if (!src || imageRequests.get(element) === key) continue;
5566
5910
  let url;
5567
- try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
5911
+ try { url = new URL(src, element.baseURI || page.url).href; } catch {
5912
+ imageRequests.set(element,key);
5913
+ finishImageLoad(element,src,null,type=>element.dispatchEvent(new page.window.Event(type)));
5914
+ continue;
5915
+ }
5568
5916
  const existing = page.resources.get(element);
5569
5917
  if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
5570
5918
  imageRequests.set(element, key);