buninu-browser 0.5.0 → 0.6.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 (146) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/inert-link.js +26 -0
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/node-iterator-removal.js +36 -0
  9. package/lib/happy-dom/node-replacement.js +60 -0
  10. package/lib/happy-dom/parser.js +6 -1
  11. package/lib/headless-shell.js +49 -9
  12. package/lib/layout/repaint.js +89 -0
  13. package/lib/layout/text-layout.js +4 -2
  14. package/lib/media/mp4-keyframes.js +144 -0
  15. package/lib/media/video-keyframe.js +29 -1
  16. package/lib/media/webm-snapshots.js +282 -0
  17. package/lib/network/cookie-jar.js +18 -2
  18. package/lib/paint/display-list.js +4 -3
  19. package/lib/paint/frame.js +12 -5
  20. package/lib/render-tree/index.js +3 -0
  21. package/lib/renderer/animation-frames.js +76 -0
  22. package/lib/renderer/bindings.js +107 -3
  23. package/lib/renderer/compositor-channel.js +95 -10
  24. package/lib/renderer/compositor.js +54 -18
  25. package/lib/renderer/document-cookies.js +87 -0
  26. package/lib/renderer/font-loading.js +60 -16
  27. package/lib/renderer/host.js +48 -19
  28. package/lib/renderer/idle-callbacks.js +109 -0
  29. package/lib/renderer/main.js +5 -1
  30. package/lib/renderer/media-source.js +4 -0
  31. package/lib/renderer/page-realm.js +159 -49
  32. package/lib/renderer/page-renderer.js +577 -201
  33. package/lib/renderer/resource-elements.js +88 -0
  34. package/lib/renderer/script-rewrite.js +18 -1
  35. package/lib/renderer/seccomp.js +6 -4
  36. package/lib/renderer/timers.js +71 -0
  37. package/lib/renderer/video-channel.js +94 -0
  38. package/lib/renderer/video-mask.js +115 -0
  39. package/lib/renderer/video-presentation.js +304 -39
  40. package/lib/renderer/video-worker.js +46 -0
  41. package/lib/renderer/workers.js +7 -17
  42. package/lib/style/computed-style.js +257 -61
  43. package/package.json +1 -1
  44. package/test/animation-frames.test.js +67 -0
  45. package/test/canvas-style-snapshot.test.js +18 -0
  46. package/test/cdp-stream.test.js +96 -3
  47. package/test/compare-node-iterator.js +42 -0
  48. package/test/compositor-channel-ownership.test.js +194 -0
  49. package/test/compositor.test.js +27 -1
  50. package/test/computed-style-binding.test.js +49 -0
  51. package/test/computed-style-connectivity.test.js +13 -0
  52. package/test/computed-style.test.js +86 -0
  53. package/test/cookie-sync.test.js +82 -0
  54. package/test/document-base-cache.test.js +103 -0
  55. package/test/document-cookies.test.js +117 -0
  56. package/test/duplicate-style-keys.test.js +11 -0
  57. package/test/fixtures/animation-frames.js +56 -0
  58. package/test/fixtures/canvas-style-snapshot.js +81 -0
  59. package/test/fixtures/computed-style-connectivity.js +101 -0
  60. package/test/fixtures/computed-style-lazy.js +135 -0
  61. package/test/fixtures/duplicate-style-keys.js +97 -0
  62. package/test/fixtures/fake-renderer.js +12 -0
  63. package/test/fixtures/font-definition-invalidation.js +97 -0
  64. package/test/fixtures/font-metrics-invalidation.js +87 -0
  65. package/test/fixtures/fractional-text-scale.js +38 -0
  66. package/test/fixtures/full-style-identity.js +36 -0
  67. package/test/fixtures/hidden-style-engine.js +260 -0
  68. package/test/fixtures/hidden-style-reentry.js +232 -0
  69. package/test/fixtures/hidden-style-renderer.js +203 -0
  70. package/test/fixtures/layout-repaint.js +69 -0
  71. package/test/fixtures/media-presentation-continuation.js +141 -0
  72. package/test/fixtures/media-presentation-refill.js +113 -0
  73. package/test/fixtures/media-probe.js +148 -0
  74. package/test/fixtures/media-refill-cancel.js +79 -0
  75. package/test/fixtures/media-repaint.js +48 -0
  76. package/test/fixtures/media-resource-reselection.js +94 -0
  77. package/test/fixtures/media-source-attachment.js +136 -0
  78. package/test/fixtures/media-source-no-decoder.js +17 -0
  79. package/test/fixtures/media-track-refill.js +132 -0
  80. package/test/fixtures/media-webm-seek.js +51 -0
  81. package/test/fixtures/mutation-observer.js +201 -0
  82. package/test/fixtures/node-iterator-cases.js +59 -0
  83. package/test/fixtures/node-iterator-chromium.json +315 -0
  84. package/test/fixtures/observer-invalidation.js +105 -0
  85. package/test/fixtures/prepaint-scheduling.js +73 -0
  86. package/test/fixtures/resource-discovery.js +32 -0
  87. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  88. package/test/fixtures/style-propagation.js +26 -0
  89. package/test/fixtures/svg-node-reuse.js +83 -0
  90. package/test/fixtures/video-black-replay.js +140 -0
  91. package/test/fixtures/video-controls-invalidation.js +71 -0
  92. package/test/fixtures/viewport-bounds.js +148 -0
  93. package/test/font-loading.test.js +77 -0
  94. package/test/font-metrics-invalidation.test.js +60 -0
  95. package/test/fractional-text-scale.test.js +6 -0
  96. package/test/full-style-identity.test.js +191 -0
  97. package/test/hidden-style-lazy.test.js +31 -0
  98. package/test/hidden-style-reentry.test.js +20 -0
  99. package/test/idle-callback.test.js +89 -0
  100. package/test/idle-scheduler.test.js +148 -0
  101. package/test/inert-link.test.js +37 -0
  102. package/test/inline-style-cascade.test.js +63 -0
  103. package/test/layout-repaint-renderer.test.js +9 -0
  104. package/test/layout-repaint.test.js +222 -0
  105. package/test/media-presentation-continuation.test.js +14 -0
  106. package/test/media-presentation-refill.test.js +8 -0
  107. package/test/media-probe.test.js +16 -0
  108. package/test/media-repaint.test.js +8 -0
  109. package/test/media-source-attachment.test.js +34 -0
  110. package/test/media-track-refill.test.js +26 -0
  111. package/test/media-webm-seek.test.js +11 -0
  112. package/test/mp4-keyframes.test.js +36 -0
  113. package/test/mutation-observer.test.js +91 -0
  114. package/test/node-iterator.test.js +81 -0
  115. package/test/observer-invalidation.test.js +12 -0
  116. package/test/page-scripts.test.js +151 -1
  117. package/test/page-timers.test.js +67 -0
  118. package/test/paint-frame.test.js +18 -1
  119. package/test/pcm.test.js +63 -0
  120. package/test/prepaint-scheduling.test.js +13 -0
  121. package/test/pulse-client.test.js +165 -0
  122. package/test/raw-capture.test.js +34 -5
  123. package/test/renderer-host.test.js +51 -0
  124. package/test/resource-discovery.test.js +12 -0
  125. package/test/resource-elements.test.js +234 -0
  126. package/test/root-self-style.test.js +104 -0
  127. package/test/script-rewrite.test.js +35 -0
  128. package/test/seccomp.test.js +45 -0
  129. package/test/spare-renderer-scheduling.test.js +8 -0
  130. package/test/style-propagation.test.js +225 -0
  131. package/test/style-sheets.test.js +19 -0
  132. package/test/svg.test.js +45 -0
  133. package/test/text-layout.test.js +15 -0
  134. package/test/timers.test.js +58 -0
  135. package/test/video-black-replay.test.js +34 -0
  136. package/test/video-capture.test.js +170 -0
  137. package/test/video-channel.test.js +253 -0
  138. package/test/video-continuation.test.js +57 -0
  139. package/test/video-controls-invalidation.test.js +12 -0
  140. package/test/video-keyframe.test.js +104 -4
  141. package/test/video-mask.test.js +94 -0
  142. package/test/video-presentation.test.js +239 -1
  143. package/test/video-timing-presentation.test.js +115 -0
  144. package/test/viewport-bounds.test.js +13 -0
  145. package/test/webm-snapshots.test.js +372 -0
  146. package/test/window-resize.test.js +29 -0
@@ -28,6 +28,7 @@ import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
28
28
  import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-2d.js";
29
29
  import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
30
30
  import { DESKTOP_USER_AGENT } from "../user-agent.js";
31
+ import { DocumentCookies } from "./document-cookies.js";
31
32
 
32
33
  // qml handle begin: optional software shader canvas bridge
33
34
  import {paintQmlCanvas} from './canvas-2d.js';
@@ -51,10 +52,12 @@ import { scrollAlignmentDelta } from "./scroll-into-view.js";
51
52
  import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions, renderNodeAtPoint } from "../input/hit-test.js";
52
53
  import { beginImageLoad, finishImageLoad } from "./image-loading.js";
53
54
  import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
55
+ import { createResourceElements } from "./resource-elements.js";
54
56
  import { DocumentFonts } from "./font-loading.js";
55
57
  import { dispatchPointerMove } from "./pointer-move.js";
56
58
  import { keyboardInput } from "./keyboard-input.js";
57
59
  import { playbackEvent } from "./media-playback-events.js";
60
+ import { repaintLayout } from "../layout/repaint.js";
58
61
  import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
59
62
  import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryMatches, StyleEngine, topmostConnected } from "../style/computed-style.js";
60
63
  import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
@@ -63,6 +66,8 @@ import { buildDisplayList, visibleItems } from "../paint/display-list.js";
63
66
  import { backgroundBytes, rawFrame, captureViewport, cropViewport, encodeImageBase64 } from "../paint/frame.js";
64
67
  import { FrameCaptureCache } from "../paint/frame-cache.js";
65
68
  import { RasterReadback } from "../paint/raster-readback.js";
69
+ import { rasterVideoMask, videoMaskBounds, videoMaskItems, videoLayerGeometry } from "./video-mask.js";
70
+ import { compositeVideo } from "./video-presentation.js";
66
71
  import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
67
72
  import { appendParagraphText } from "../paint/paragraph-text.js";
68
73
  import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
@@ -102,6 +107,8 @@ const RESUME_BUFFER_IDLE_MS = 400;
102
107
  // element → MediaSource, and the reverse. One renderer owns one page pipeline.
103
108
  const elementSources = new WeakMap();
104
109
  const sourceElements = new WeakMap();
110
+ // srcObject is independent of the src attribute and takes precedence over it.
111
+ const elementSourceObjects = new WeakMap();
105
112
 
106
113
  /**
107
114
  * A frame's window as other frames of the page reach it (messaging.js): its
@@ -252,6 +259,8 @@ function buildPageRenderer({
252
259
  webSockets = null,
253
260
  audio = null,
254
261
  setCookie = () => {},
262
+ documentCookies = new DocumentCookies(setCookie),
263
+ inheritedCookieUrl = null,
255
264
  consoleMessage = null,
256
265
  trace = null,
257
266
  onFrame = null,
@@ -304,17 +313,23 @@ function buildPageRenderer({
304
313
  return resolved;
305
314
  }
306
315
  const renderTreeBuilder = new RenderTreeBuilder();
307
- const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
308
- const styleEngine = new StyleEngine({ measureText });
316
+ let layoutEngine = new LayoutEngine({ measureText, fontMetrics });
317
+ const styleEngine = new StyleEngine({ measureText, deferHidden: true });
309
318
  // Incremental restyle: what may have changed style since the last
310
319
  // computation. DOM mutations come from a MutationObserver; state that is
311
320
  // not in the DOM tree (checked, value, focus) is marked by the code that
312
321
  // changes it. `full` asks for a complete restyle.
313
322
  const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
314
323
  activeElement: null, focusAncestors: new Set() };
324
+ let resourceElements = null;
315
325
  let selectorTraits = { sibling: false, has: null, sheets: null };
316
326
  let lastStyleParts = "";
317
327
  let laidOutSheets = null;
328
+ let computedSheets = null;
329
+ let computedEnvironment = null;
330
+ // A style-only CSSOM read may consume mutation records before layout runs.
331
+ // Keep that work pending so a later geometry read cannot reuse stale boxes.
332
+ let styleLayoutPending = false;
318
333
  const sheetTraitsCache = new Map();
319
334
  // Invalidation compounds compiled for matchesCompound() and matchSelectors().
320
335
  const compiledCompounds = new Map();
@@ -387,13 +402,17 @@ function buildPageRenderer({
387
402
  dirty: false,
388
403
  resourceLayoutDirty: false,
389
404
  userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
390
- // document.cookie as last reported by the controller (non-HttpOnly only).
391
- cookie: "",
392
405
  };
393
406
 
407
+ const cookieUrl = () => page.url === "about:blank" && inheritedCookieUrl ? inheritedCookieUrl() : page.url;
408
+ function acceptCookies(response, url = cookieUrl()) {
409
+ if (!documentCookies.accept(response.cookieState)) documentCookies.seed(url, response.documentCookie, true);
410
+ }
411
+
394
412
  async function fetchResource(url, kind) {
413
+ const generation = loadGeneration;
395
414
  const response = await rawFetchResource(url, kind);
396
- if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
415
+ if (generation === loadGeneration) acceptCookies(response);
397
416
  return response;
398
417
  }
399
418
  const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
@@ -412,8 +431,13 @@ function buildPageRenderer({
412
431
  changed() {
413
432
  if (disposedRenderer) return;
414
433
  clearFontCaches();
415
- // Font metrics also affect CSS ch units and normal line-height.
416
- styleChanges.full = true;
434
+ // Layout caches retain shaping, normal line-height and intrinsic
435
+ // sizes by style/node identity. A loaded typeface changes those even
436
+ // when CSS values stay the same; reset them only on font completion.
437
+ layoutEngine = new LayoutEngine({ measureText, fontMetrics });
438
+ // ch (including a root font-size) also changes computed CSS. Other
439
+ // documents need fresh layout, while retaining their existing styles.
440
+ if (styleEngine.usesFontMetrics) styleChanges.full = true;
417
441
  page.dirty = true;
418
442
  page.resourceLayoutDirty = true;
419
443
  page.needsPaint = true;
@@ -438,6 +462,8 @@ function buildPageRenderer({
438
462
  [...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
439
463
  }
440
464
 
465
+ // Keep fractional CSS sizes through measurement and painting. Rounding each
466
+ // scale independently can widen painted glyphs beyond the measured line.
441
467
  function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
442
468
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
443
469
  const weight = computedStyle?.fontWeight ?? 400;
@@ -460,7 +486,7 @@ function buildPageRenderer({
460
486
  textStyle: {
461
487
  color,
462
488
  fontFamilies: families,
463
- fontSize: Math.max(1, Math.round(fontSize * scale)),
489
+ fontSize: Math.max(1, fontSize) * scale,
464
490
  fontStyle: { weight, slant, width },
465
491
  // Roboto is a variable font; static fonts ignore the axis and match by weight.
466
492
  fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
@@ -475,7 +501,7 @@ function buildPageRenderer({
475
501
  const pushTextStyle = foregroundPaint
476
502
  ? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
477
503
  const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
478
- fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
504
+ fontSize: Math.max(1, fontSize) * scale, fontStyle: { weight, slant, width },
479
505
  fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
480
506
  function append(text, fonts) {
481
507
  let emojiStyle;
@@ -518,7 +544,7 @@ function buildPageRenderer({
518
544
  return paragraph;
519
545
  }
520
546
 
521
- // Text widths depend only on the font (families, rounded size, weight),
547
+ // Text widths depend only on the font (families, fractional size, weight),
522
548
  // not on the element, so they are cached per font across elements,
523
549
  // relayouts and documents. The cache is dropped when it grows too large.
524
550
  const MAX_MEASURED_TEXTS = 200_000;
@@ -557,7 +583,7 @@ function buildPageRenderer({
557
583
  const weight = computedStyle?.fontWeight ?? 400;
558
584
  const fontStyle = computedStyle?.fontStyle ?? "normal";
559
585
  const stretch = computedStyle?.fontStretch ?? 100;
560
- const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
586
+ const key = `${Math.max(1, computedStyle?.fontSize ?? 16)}/${weight}/${fontStyle}/${stretch}/${
561
587
  resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
562
588
  if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
563
589
  return key;
@@ -759,6 +785,10 @@ function buildPageRenderer({
759
785
  const scale = page.deviceScaleFactor;
760
786
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
761
787
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
788
+ // The independent compositor already reconstructs an opaque video from
789
+ // black/white masks. Reuse that path for the ordinary tile too, avoiding
790
+ // a large Skia image upscale and a second black pass on each UI repaint.
791
+ const blackPlan = blackVideoPlan(region);
762
792
  const damage = canvasDamage(region);
763
793
  if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
764
794
  if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
@@ -779,7 +809,7 @@ function buildPageRenderer({
779
809
  // Clear and replay all layers inside the damage, including backgrounds
780
810
  // and foreground occluders. Transparent canvas pixels must not leave trails.
781
811
  canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
782
- paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
812
+ paintDisplayList(canvas, page.displayList, region, scale, blackPlan ? {resource:blackPlan.resource,white:false} : null, damage ? {
783
813
  x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
784
814
  } : region);
785
815
  drawHints(canvas, region);
@@ -789,7 +819,26 @@ function buildPageRenderer({
789
819
  let pixels;
790
820
  try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
791
821
  finally { image.delete(); }
792
- const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
822
+ const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight, pixels, blackPlan?.resource);
823
+ if (blackPlan && !videoLayer) {
824
+ // An injected callback may invalidate presentation during the paint.
825
+ // Recover the ordinary pixels before publishing an unlayered tile.
826
+ canvas.save();
827
+ try {
828
+ canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
829
+ paintDisplayList(canvas, page.displayList, region, scale);
830
+ drawHints(canvas, region);
831
+ } finally { canvas.restore(); }
832
+ surface.flush();
833
+ const retryImage = surface.makeImageSnapshot();
834
+ try { pixels = rasterReadback.read(retryImage, pixelWidth, pixelHeight); }
835
+ finally { retryImage.delete(); }
836
+ }
837
+ if (blackPlan && videoLayer) {
838
+ const composed = compositeVideo({pixels,pixelWidth,pixelHeight,x:region.x,y:region.y,width:region.width,height:region.height,scale,videoLayer},blackPlan.frame);
839
+ // Preserve the caller's shared readback allocation and tile contract.
840
+ pixels.set(composed.pixels);
841
+ }
793
842
  page.needsPaint = false;
794
843
  rasterRegion = {...region};
795
844
  rasterized = {
@@ -843,22 +892,31 @@ function buildPageRenderer({
843
892
  }
844
893
 
845
894
  /** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
846
- function schedulePrepaint() {
895
+ function schedulePrepaint(delay = 30) {
847
896
  if (disposedRenderer || !onFrame || embedded) return;
848
897
  hostClearTimeout(prepaintTimer);
849
898
  prepaintTimer = hostSetTimeout(() => {
850
899
  prepaintTimer = null;
851
- // A pending relayout does not stop this: the current layout, even if
852
- // slightly stale, is better than background where the user scrolled.
853
900
  const width = Math.max(1, Math.floor(page.width));
854
901
  const height = Math.max(1, Math.floor(page.height));
902
+ // The visible page already has pixels. Let loading and queued layout/
903
+ // paint settle before spending time expanding a speculative tile.
904
+ // Retry even if that work proves to be a no-op and never calls render.
905
+ // A stalled load backs off to one cheap check per second; a new paint
906
+ // or scroll resets the usual 30ms delay. Scrolling outside the tile
907
+ // still paints at that delay, using the current layout if necessary.
908
+ if ((page.loading || page.dirty || page.needsPaint || frameTimer)
909
+ && tileCovers(lastTile, { x: viewport.x, y: viewport.y, width, height })) {
910
+ schedulePrepaint(Math.min(1000, Math.max(100, delay * 2)));
911
+ return;
912
+ }
855
913
  // Still covered with a quarter viewport to spare in both directions
856
914
  // (as far as the page goes).
857
915
  const top = Math.max(0, viewport.y - height / 4);
858
916
  const bottom = Math.max(viewport.y + height, Math.min(viewport.contentHeight, viewport.y + height * 1.25));
859
917
  if (tileCovers(lastTile, { x: viewport.x, y: top, width, height: bottom - top }) && sameRasterInput()) return;
860
918
  publish(rasterTile(prepaintRegion()));
861
- }, 30);
919
+ }, delay);
862
920
  }
863
921
 
864
922
  /** A rendering opportunity for DOM changes nobody has asked to see yet. */
@@ -917,7 +975,10 @@ function buildPageRenderer({
917
975
  if (rounded) canvas.drawRRect(rounded, paint);
918
976
  else canvas.drawRect(deviceRect(rect), paint);
919
977
  };
920
- for (const item of visibleItems(displayList, dirtyView, viewport)) {
978
+ const paintItems = videoOverride && dirtyView !== view
979
+ ? videoMaskItems(displayList, view, dirtyView, viewport)
980
+ : visibleItems(displayList, dirtyView, viewport);
981
+ for (const item of paintItems) {
921
982
  const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
922
983
  try {
923
984
  if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
@@ -1474,6 +1535,14 @@ function buildPageRenderer({
1474
1535
  const records = styleChanges.records;
1475
1536
  if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
1476
1537
 
1538
+ // Video controls are read from the live DOM when building the display
1539
+ // list. Their presence changes paint even when layout input is identical.
1540
+ if (records.some(record => record.type === "attributes" && record.attributeName === "controls"
1541
+ && record.target?.tagName === "VIDEO")) {
1542
+ page.resourcePaintDirty = true;
1543
+ page.needsPaint = true;
1544
+ }
1545
+
1477
1546
  // qml handle begin: QML visual attributes invalidate retained rendering
1478
1547
  if (hasQmlDomImages(page.document) && qmlTextureVisibilityChanged(records)) {
1479
1548
  page.resourcePaintDirty = true;
@@ -1647,6 +1716,7 @@ function buildPageRenderer({
1647
1716
 
1648
1717
  function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1649
1718
  if (page.document) loadImageResource.cancelDocument(page.document);
1719
+ resourceElements?.dispose();
1650
1720
  resetInputCaret();
1651
1721
  rasterReadback.dispose();
1652
1722
  clearPaintParagraphs();
@@ -1665,6 +1735,10 @@ function buildPageRenderer({
1665
1735
  page.window = parsed.window;
1666
1736
  pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
1667
1737
  page.document = parsed.document;
1738
+ resourceElements = createResourceElements(parsed.document);
1739
+ computedSheets = null;
1740
+ computedEnvironment = null;
1741
+ styleLayoutPending = false;
1668
1742
  observeDocumentCanvases(parsed.document, (node) => {
1669
1743
  if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
1670
1744
  page.resourcePaintDirty = true;
@@ -1695,7 +1769,7 @@ function buildPageRenderer({
1695
1769
  }
1696
1770
 
1697
1771
  /** Style, render tree, layout and display list of the current document. */
1698
- function styleAndLayout() {
1772
+ function styleAndLayout(styleOnly = false) {
1699
1773
  const started = hostPerformance.now();
1700
1774
  const ms = (from, to) => Math.round(to - from);
1701
1775
  const sheets = currentStyleSheets();
@@ -1711,30 +1785,47 @@ function buildPageRenderer({
1711
1785
  if (key !== fontDefinitionKey) {
1712
1786
  fontDefinitionKey = key;
1713
1787
  clearFontCaches();
1788
+ // Editing/removing a face can select another loaded face or fallback
1789
+ // without a font-load callback. Its metrics invalidate the same caches.
1790
+ layoutEngine = new LayoutEngine({ measureText, fontMetrics });
1791
+ if (styleEngine.usesFontMetrics) styleChanges.full = true;
1714
1792
  page.resourceLayoutDirty = true;
1715
1793
  }
1716
1794
  }
1717
1795
  const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
1718
1796
  const taken = hostPerformance.now();
1797
+ if (styleOnly && computedSheets === sheets && computedEnvironment === environmentKey
1798
+ && changes && changes.roots.size === 0 && changes.self.size === 0) {
1799
+ // Resource/geometry invalidation does not itself invalidate computed CSS.
1800
+ styleLayoutPending ||= changes.treeDirty;
1801
+ return { style: Math.round(taken - started), layout: 0, skipped: true };
1802
+ }
1719
1803
  // A rendering opportunity can be scheduled by lifecycle work which did
1720
1804
  // not actually change DOM, state, resources, or styles. Keep the retained
1721
1805
  // layout/display list in that case instead of laying the whole page out.
1722
- if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1806
+ if (!styleOnly && !styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1723
1807
  && changes.roots.size === 0 && changes.self.size === 0) {
1724
1808
  lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
1725
- return { style: Math.round(taken - started), layout: 0, skipped: true };
1809
+ return { style: Math.round(taken - started), layout: 0, skipped: true, geometryUnchanged: true };
1726
1810
  }
1727
1811
  styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
1812
+ computedSheets = sheets;
1813
+ computedEnvironment = environmentKey;
1728
1814
  const computed = hostPerformance.now();
1729
1815
  if (!hasState || hasStateStale) {
1730
1816
  recordHasState(selectorTraitsFor(sheets));
1731
1817
  hasStateStale = false;
1732
1818
  }
1733
1819
  const recorded = hostPerformance.now();
1820
+ if (styleOnly) {
1821
+ styleLayoutPending ||= !changes || changes.treeDirty || !styleEngine.incremental || styleEngine.restyled !== 0;
1822
+ if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
1823
+ return { style: Math.round(recorded - started), layout: 0 };
1824
+ }
1734
1825
  // Attribute mutations used by application bookkeeping often match no
1735
1826
  // different rule and do not alter render-tree content. Once style has
1736
1827
  // proved that no element changed, retain layout and paint unchanged.
1737
- if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1828
+ if (!styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1738
1829
  && !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
1739
1830
  lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
1740
1831
  return { style: Math.round(recorded - started), layout: 0, skipped: true };
@@ -1753,12 +1844,27 @@ function buildPageRenderer({
1753
1844
  ignoreResources: true,
1754
1845
  // A zero-element incremental pass retained every computed style.
1755
1846
  // Control placeholders may still wrap that style in a fresh object.
1756
- ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
1847
+ ignoreStyles: !styleLayoutPending && styleEngine.incremental && styleEngine.restyled === 0,
1757
1848
  })) {
1849
+ styleLayoutPending = false;
1758
1850
  lastStyleParts += ", unchanged layout input";
1759
1851
  return { style: Math.round(styled - started), layout: 0, skipped: true };
1760
1852
  }
1853
+ // Paint-only updates such as progress-bar scaling must not reflow the
1854
+ // entire document whenever a script reads geometry.
1855
+ const repainted = page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets
1856
+ ? repaintLayout(page.layout, page.renderTree, nextRenderTree) : null;
1761
1857
  page.renderTree = nextRenderTree;
1858
+ styleLayoutPending = false;
1859
+ if (repainted) {
1860
+ page.layout = repainted;
1861
+ page.displayList = paintList();
1862
+ page.resourcePaintDirty = false;
1863
+ if (clampElementScroll()) page.displayList = paintList();
1864
+ setContentSize();
1865
+ lastStyleParts += ", retained geometry";
1866
+ return { style: Math.round(styled - started), layout: 0 };
1867
+ }
1762
1868
  page.layout = layoutEngine.layout(page.renderTree, {
1763
1869
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
1764
1870
  });
@@ -1803,7 +1909,6 @@ function buildPageRenderer({
1803
1909
  function rebuildRenderTree(reason = "update") {
1804
1910
  const started = hostPerformance.now();
1805
1911
  page.dirty = false;
1806
- page.layoutVersion++;
1807
1912
  page.title = page.document?.title ?? page.title;
1808
1913
  let staleImages = [];
1809
1914
  if (page.document) {
@@ -1811,6 +1916,11 @@ function buildPageRenderer({
1811
1916
  staleImages = queueImageResources();
1812
1917
  }
1813
1918
  const timing = styleAndLayout();
1919
+ // Metadata-only flushes retain the same boxes and styles. Advancing the
1920
+ // version would make every Resize/IntersectionObserver measure again.
1921
+ // Other skipped paths remain conservative; viewport/DPR changes are also
1922
+ // passed separately to layoutChanged, even when this version is unchanged.
1923
+ if (!timing.geometryUnchanged) page.layoutVersion++;
1814
1924
  if (staleImages.length) {
1815
1925
  const stale = new Set(staleImages);
1816
1926
  page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
@@ -1857,7 +1967,7 @@ function buildPageRenderer({
1857
1967
  }
1858
1968
 
1859
1969
  async function runLoad({
1860
- url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
1970
+ url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", cookieState = null, referrer = "",
1861
1971
  }, generation, commit) {
1862
1972
  const current = () => generation === loadGeneration;
1863
1973
  const finish = () => {
@@ -1876,7 +1986,7 @@ function buildPageRenderer({
1876
1986
  // qml handle end
1877
1987
 
1878
1988
  page.url = url;
1879
- page.cookie = typeof cookie === "string" ? cookie : "";
1989
+ if (!documentCookies.accept(cookieState)) documentCookies.seed(cookieUrl(), cookie, !embedded);
1880
1990
  if (error) {
1881
1991
  replaceDocument("", url);
1882
1992
  page.title = "Network error";
@@ -2404,10 +2514,12 @@ function buildPageRenderer({
2404
2514
  return { ...resource, originClean };
2405
2515
  },
2406
2516
  canvasFont(element, value) {
2517
+ if (page.dirty) styleAndLayout(true);
2407
2518
  return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
2408
2519
  styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
2409
2520
  },
2410
2521
  canvasText(element, request) {
2522
+ if (request.settings.direction === "inherit" && page.dirty) styleAndLayout(true);
2411
2523
  const style = { ...request.settings.parsedFont,
2412
2524
  direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
2413
2525
  documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
@@ -2418,6 +2530,7 @@ function buildPageRenderer({
2418
2530
  playMedia,
2419
2531
  pauseMedia,
2420
2532
  seekMedia,
2533
+ reselectMedia,
2421
2534
  attachMediaSrc,
2422
2535
  mediaReadyState,
2423
2536
  mediaNetworkState,
@@ -2474,11 +2587,14 @@ function buildPageRenderer({
2474
2587
  if (page.dirty) rebuildRenderTree("script scrolled");
2475
2588
  revealElement(element, options);
2476
2589
  },
2477
- computedStyle(element) {
2590
+ computedStyle(element, needsLayout = true) {
2478
2591
  if (!element) return null;
2479
- if (page.dirty) rebuildRenderTree("getComputedStyle");
2592
+ if (page.dirty) {
2593
+ if (needsLayout) rebuildRenderTree("getComputedStyle");
2594
+ else styleAndLayout(true);
2595
+ }
2480
2596
  const style = styleEngine.get(element);
2481
- if (style?.padding.some(value => typeof value === "object")) {
2597
+ if (needsLayout && style?.padding.some(value => typeof value === "object")) {
2482
2598
  const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
2483
2599
  if (box) return { ...style, padding: box.style.padding };
2484
2600
  }
@@ -2488,9 +2604,9 @@ function buildPageRenderer({
2488
2604
  if (page.dirty) rebuildRenderTree("intersection observer");
2489
2605
  return page.layoutVersion;
2490
2606
  },
2491
- customProperties(element) {
2607
+ customProperties(element, updateStyle = true) {
2492
2608
  if (!element) return null;
2493
- if (page.dirty) rebuildRenderTree("getComputedStyle");
2609
+ if (updateStyle && page.dirty) styleAndLayout(true);
2494
2610
  return styleEngine.customPropertiesOf(element);
2495
2611
  },
2496
2612
  navigate: (url) => navigateTo(String(url)),
@@ -2525,18 +2641,18 @@ function buildPageRenderer({
2525
2641
  userAgent: () => page.userAgent,
2526
2642
  initScripts: () => page.initScripts ?? [],
2527
2643
  rewriteScript,
2528
- documentCookie: () => page.cookie,
2644
+ documentCookie: () => documentCookies.get(cookieUrl()),
2529
2645
  setDocumentCookie(value) {
2530
- page.cookie = mergeDocumentCookie(page.cookie, String(value));
2531
- setCookie(String(value));
2646
+ documentCookies.set(cookieUrl(), value);
2532
2647
  },
2533
2648
  clipboardRead,
2534
2649
  clipboardWrite,
2535
2650
  webSockets,
2536
2651
  pageFetch(request) {
2652
+ const generation = loadGeneration;
2537
2653
  const networkPending = pageFetch(request);
2538
2654
  const result = networkPending.then((response) => {
2539
- if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
2655
+ if (generation === loadGeneration) acceptCookies(response);
2540
2656
  return response;
2541
2657
  });
2542
2658
  result.abort = (reason) => networkPending.abort?.(reason);
@@ -2596,6 +2712,9 @@ function buildPageRenderer({
2596
2712
 
2597
2713
  /** The border box of an element's layout box in viewport coordinates. */
2598
2714
  function viewportBounds(element) {
2715
+ const box = elementBox(page.renderTree, page.layout, element)?.box;
2716
+ const content = box ? null : elementBounds(page.renderTree, page.layout, element);
2717
+ if (!box && !content) return null;
2599
2718
  let fixed = false;
2600
2719
  for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
2601
2720
  if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
@@ -2604,12 +2723,8 @@ function buildPageRenderer({
2604
2723
  const shift = scrollShift(element);
2605
2724
  const scrollX = (fixed ? 0 : viewport.x) + shift.x;
2606
2725
  const scrollY = (fixed ? 0 : viewport.y) + shift.y;
2607
- const box = elementBox(page.renderTree, page.layout, element)?.box;
2608
2726
  if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
2609
- const content = elementBounds(page.renderTree, page.layout, element);
2610
- return content
2611
- ? { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height }
2612
- : null;
2727
+ return { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height };
2613
2728
  }
2614
2729
 
2615
2730
  /**
@@ -2748,6 +2863,7 @@ function buildPageRenderer({
2748
2863
 
2749
2864
  const api = {
2750
2865
  loadDocument,
2866
+ updateCookies: state => documentCookies.accept(state),
2751
2867
  /** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
2752
2868
  setScripting(mode) {
2753
2869
  page.scripting = ["ses", "host"].includes(mode) ? mode : "off";
@@ -2786,6 +2902,10 @@ function buildPageRenderer({
2786
2902
  ? deviceScaleFactor
2787
2903
  : 1;
2788
2904
  viewport.resize(width, height);
2905
+ // Viewport dimensions are layout input even when DOM/stylesheets did
2906
+ // not mutate (including CSS resizes that keep the same pixel size by
2907
+ // changing DPR). Bypass retained-layout shortcuts in that case.
2908
+ if (resized) page.resourceLayoutDirty = true;
2789
2909
  rebuildRenderTree();
2790
2910
  // Listeners must observe the new viewport and layout. A repeated metrics
2791
2911
  // request (or DPR-only change) does not resize the CSS viewport.
@@ -2953,14 +3073,31 @@ function buildPageRenderer({
2953
3073
  void placeVideo(state, mediaTime(state)).then(() => {
2954
3074
  if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
2955
3075
  }).catch(reportError);
3076
+ } else if (Number.isSafeInteger(report.continued)) {
3077
+ const video = state.video, p = video.presentation, continuation = p.continuation;
3078
+ if (!continuation?.decoder || report.revision !== p.revision + 1 || report.continued !== continuation.generation) return;
3079
+ video.decoder.close();
3080
+ video.decoder = continuation.decoder;
3081
+ continuation.decoder = null;
3082
+ Object.assign(video, { bytes: continuation.bytes, openedGeneration: continuation.generation,
3083
+ needsLocalPosition: true, done: false });
3084
+ p.revision = report.revision;
3085
+ p.continuation = null;
3086
+ p.pendingEnd = null;
3087
+ p.exhaustedAt = null;
3088
+ } else if ((report.revision ?? 0) !== state.video.presentation.revision) return;
3089
+ else if (report.continuationError) {
3090
+ failVideoContinuation(state, state.video.presentation);
3091
+ } else if (Number.isFinite(report.exhaustedAt)) {
3092
+ state.video.presentation.exhaustedAt = report.exhaustedAt;
3093
+ continueVideoSnapshot(state, report.exhaustedAt);
2956
3094
  } else if (Number.isFinite(report.end)) {
2957
- state.video.frameTime = report.end;
2958
- if (needsMore(state.video)) void refillMedia(state, state.video);
2959
- else {
2960
- readPresentedFrame(state);
2961
- stopVideoPresentation(state);
2962
- state.video.done = true;
2963
- }
3095
+ const p = state.video.presentation;
3096
+ // EOF can reach the page before its extension reaches the worker.
3097
+ // Defer that old revision's end until continuation succeeds or fails.
3098
+ if (p.continuation) p.pendingEnd = report.end;
3099
+ else if (p.exhaustedAt !== report.end || !continueVideoSnapshot(state, report.end))
3100
+ finishVideoPresentation(state, report.end);
2964
3101
  } else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
2965
3102
  },
2966
3103
  /** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
@@ -3249,6 +3386,8 @@ function buildPageRenderer({
3249
3386
  if (disposedRenderer) return;
3250
3387
  resetInputCaret();
3251
3388
  disposedRenderer = true;
3389
+ resourceElements?.dispose();
3390
+ resourceElements = null;
3252
3391
  loadGeneration++;
3253
3392
  if (page.document) loadImageResource.cancelDocument(page.document);
3254
3393
  hostClearTimeout(prepaintTimer);
@@ -3293,7 +3432,9 @@ function buildPageRenderer({
3293
3432
  */
3294
3433
  function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
3295
3434
  const sheets = [];
3296
- for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
3435
+ const elements = document === page.document && resourceElements
3436
+ ? resourceElements.get("sheets") : document.querySelectorAll("style, link[rel~='stylesheet']");
3437
+ for (const element of elements) {
3297
3438
  if (!isCssStyleSheet(element)) continue;
3298
3439
  const applies = styleSheetMatches(element);
3299
3440
  if (element.tagName === "STYLE") {
@@ -3370,7 +3511,7 @@ function buildPageRenderer({
3370
3511
  /** Brings the frames in line with the document's iframes: their documents and sizes. */
3371
3512
  function syncFrames() {
3372
3513
  const document = page.document;
3373
- const elements = document ? [...document.getElementsByTagName("iframe")] : [];
3514
+ const elements = document ? resourceElements.get("frames") : [];
3374
3515
  if (!elements.length && !frames.size) return;
3375
3516
  const boxes = frameBoxes();
3376
3517
  for (const [element, frame] of frames) {
@@ -3427,6 +3568,8 @@ function buildPageRenderer({
3427
3568
  clipboardWriteInput,
3428
3569
  webSockets,
3429
3570
  setCookie,
3571
+ documentCookies,
3572
+ inheritedCookieUrl: cookieUrl,
3430
3573
  consoleMessage,
3431
3574
  onFrame: (update) => showFrame(frame, update),
3432
3575
  onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
@@ -3497,7 +3640,8 @@ function buildPageRenderer({
3497
3640
  contentType: response.contentType || "text/html",
3498
3641
  status: response.status,
3499
3642
  statusText: response.statusText ?? "",
3500
- cookie: response.documentCookie ?? page.cookie,
3643
+ cookie: documentCookies.get(target.href),
3644
+ cookieState: response.cookieState,
3501
3645
  };
3502
3646
  } catch (error) {
3503
3647
  trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
@@ -3609,10 +3753,10 @@ function buildPageRenderer({
3609
3753
  * Null for an element that is not one.
3610
3754
  */
3611
3755
  function scrollerGeometry(element) {
3612
- const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
3613
- if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
3614
3756
  const found = layoutBoxOf(element);
3615
3757
  if (!found) return null;
3758
+ const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
3759
+ if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
3616
3760
  const { node, box } = found;
3617
3761
  const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
3618
3762
  const padding = style.padding ?? [0, 0, 0, 0];
@@ -3781,7 +3925,9 @@ function buildPageRenderer({
3781
3925
 
3782
3926
  /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
3783
3927
  function initializeVideos(document, resources) {
3784
- for (const element of document.querySelectorAll("video, audio")) {
3928
+ const elements = document === page.document && resourceElements
3929
+ ? resourceElements.get("media") : document.querySelectorAll("video, audio");
3930
+ for (const element of elements) {
3785
3931
  if (resources.get(element)) continue;
3786
3932
  const audioOnly = element.tagName === "AUDIO";
3787
3933
  resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
@@ -3847,6 +3993,45 @@ function buildPageRenderer({
3847
3993
  return null;
3848
3994
  }
3849
3995
 
3996
+ function selectedMediaInput(element) {
3997
+ return elementSourceObjects.get(element) ?? element.getAttribute("src")
3998
+ ?? element.querySelector("source[src]")?.getAttribute("src") ?? null;
3999
+ }
4000
+
4001
+ function selectedMediaSource(element, reselect = false) {
4002
+ const input = selectedMediaInput(element);
4003
+ const state = page.media.get(element);
4004
+ // A selected resource outlives object-URL revocation or a <base> change.
4005
+ // Explicit src assignment or load() selects again, even when raw src is
4006
+ // unchanged; passive metadata/playback must keep its original selection.
4007
+ if (!reselect && state?.input === input) return { input, source: state.mediaSource, url: state.url };
4008
+ const source = resolveMediaSource(typeof input === "string" ? videoSource(element) : input);
4009
+ return { input, source, url: typeof input === "string" ? videoSource(element) : source?._objectURL ?? null };
4010
+ }
4011
+
4012
+ function linkMediaSource(element, source) {
4013
+ const previous = elementSources.get(element);
4014
+ if (previous && previous !== source && sourceElements.get(previous) === element) sourceElements.delete(previous);
4015
+ if (source) {
4016
+ elementSources.set(element, source);
4017
+ sourceElements.set(source, element);
4018
+ } else elementSources.delete(element);
4019
+ }
4020
+
4021
+ function currentMediaState(state) {
4022
+ if (state.document !== page.document || page.media.get(state.element) !== state) return false;
4023
+ return selectedMediaInput(state.element) === state.input;
4024
+ }
4025
+
4026
+ // Detached sources can still finish queued appends or change their duration.
4027
+ // Only the selected source in this document may notify its current state.
4028
+ function attachedMediaState(source) {
4029
+ const element = sourceElements.get(source);
4030
+ const state = element && page.media.get(element);
4031
+ return state && element.ownerDocument === page.document && elementSources.get(element) === source
4032
+ && state.mediaSource === source && currentMediaState(state) ? state : null;
4033
+ }
4034
+
3850
4035
  /** A resource record for a media element created after the document was parsed. */
3851
4036
  function ensureMediaResource(element) {
3852
4037
  if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
@@ -3865,24 +4050,17 @@ function buildPageRenderer({
3865
4050
 
3866
4051
  /** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
3867
4052
  function attachMediaSrc(element, value) {
3868
- if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
3869
- const source = resolveMediaSource(value);
3870
- if (!source) {
3871
- elementSources.delete(element);
3872
- return;
3873
- }
3874
- elementSources.set(element, source);
3875
- sourceElements.set(source, element);
3876
- if (source.readyState === "closed") {
3877
- try {
3878
- source.attach(typeof value === "string" ? value : source._objectURL);
3879
- } catch {
3880
- return;
3881
- }
3882
- }
3883
- const state = mediaStateFor(element);
4053
+ if (!element || element.ownerDocument !== page.document
4054
+ || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
4055
+ if (typeof value !== "string") {
4056
+ if (value == null) elementSourceObjects.delete(element);
4057
+ else elementSourceObjects.set(element, value);
4058
+ }
4059
+ // Updating an inactive src attribute must not replace srcObject playback.
4060
+ const state = mediaStateFor(element, typeof value !== "string" || !elementSourceObjects.has(element));
3884
4061
  if (!state) return;
3885
- state.mediaSource = source;
4062
+ const source = state.mediaSource;
4063
+ if (!source) return;
3886
4064
  if (Number.isFinite(source.duration)) {
3887
4065
  state.durationOverride = true;
3888
4066
  state.duration = source.duration;
@@ -3892,11 +4070,8 @@ function buildPageRenderer({
3892
4070
  }
3893
4071
 
3894
4072
  function mediaAppended(source) {
3895
- const element = sourceElements.get(source);
3896
- if (!element) return;
3897
- const state = page.media.get(element) ?? mediaStateFor(element);
4073
+ const state = attachedMediaState(source);
3898
4074
  if (!state) return;
3899
- state.mediaSource = source;
3900
4075
  bindSourcePipes(state);
3901
4076
  // A later initialization segment can replace already-buffered video.
3902
4077
  // Shorten the active presentation at its epoch boundary as soon as that
@@ -3920,8 +4095,7 @@ function buildPageRenderer({
3920
4095
  }
3921
4096
 
3922
4097
  function mediaDuration(source, seconds) {
3923
- const element = sourceElements.get(source);
3924
- const state = element && page.media.get(element);
4098
+ const state = attachedMediaState(source);
3925
4099
  if (!state || !Number.isFinite(seconds)) return;
3926
4100
  state.durationOverride = true;
3927
4101
  state.duration = seconds;
@@ -3930,28 +4104,27 @@ function buildPageRenderer({
3930
4104
  }
3931
4105
 
3932
4106
  /** The element's playback state, created (not loaded) on first use. */
3933
- function mediaStateFor(element) {
4107
+ function mediaStateFor(element, reselect = false) {
4108
+ if (!element || element.ownerDocument !== page.document) return null;
3934
4109
  const resource = ensureMediaResource(element);
3935
- let source = elementSources.get(element) ?? null;
3936
- const attributeUrl = videoSource(element);
3937
- if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
3938
- const url = attributeUrl ?? source?._objectURL ?? null;
3939
- if (!videoDecoder || !resource || (!url && !source)) return null;
3940
- if (source && source.readyState === "closed") {
3941
- try {
3942
- source.attach(url);
3943
- elementSources.set(element, source);
3944
- sourceElements.set(source, element);
3945
- } catch {}
3946
- }
4110
+ const { input, source, url } = selectedMediaSource(element, reselect);
4111
+ linkMediaSource(element, source);
3947
4112
  let state = page.media.get(element);
3948
- if (state && state.url !== url) {
4113
+ if (state && (state.mediaSource !== source || (!source && (state.input !== input || reselect)))) {
3949
4114
  closeMedia(state);
3950
4115
  page.media.delete(element);
3951
4116
  state = null;
4117
+ resource.duration = NaN;
4118
+ resource.time = 0;
4119
+ }
4120
+ if (source && source.readyState === "closed") {
4121
+ try {
4122
+ source.attach(url);
4123
+ } catch {}
3952
4124
  }
4125
+ if (!videoDecoder || !resource || (!url && !source)) return null;
3953
4126
  if (!state) {
3954
- state = { element, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
4127
+ state = { element, input, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
3955
4128
  loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
3956
4129
  video: null, sound: null, playing: false, ended: false,
3957
4130
  duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
@@ -3963,12 +4136,17 @@ function buildPageRenderer({
3963
4136
  state.durationOverride = true;
3964
4137
  resource.duration = source.duration;
3965
4138
  }
3966
- } else if (source) {
3967
- state.mediaSource = source;
4139
+ } else {
4140
+ state.input = input;
4141
+ state.url = url;
3968
4142
  }
3969
4143
  return state;
3970
4144
  }
3971
4145
 
4146
+ function reselectMedia(element) {
4147
+ return mediaStateFor(element, true);
4148
+ }
4149
+
3972
4150
  /** Current position in seconds. */
3973
4151
  function mediaTime(state) {
3974
4152
  const sound = state.sound;
@@ -3994,10 +4172,21 @@ function buildPageRenderer({
3994
4172
 
3995
4173
  /** The control bar and frame redraw without a relayout. */
3996
4174
  function repaintMedia() {
3997
- // The compositor replaces this video's pixels independently. Page changes
3998
- // still repaint normally; native controls get their periodic time update.
3999
- if ([...page.media.values()].some(state => state.video?.presentation && state.playing)
4000
- && !page.dirty && !page.needsPaint) return;
4175
+ // Independent presentation already replaces video pixels. Dirty page UI
4176
+ // must take the ordinary scheduled refresh, which rebuilds layout before
4177
+ // painting and coalesces changes; a media tick must not raster stale items.
4178
+ if (onFrame && !embedded
4179
+ && [...page.media.values()].some(state => state.video?.presentation && state.playing)) {
4180
+ // Another media element can still own a native progress/control bar.
4181
+ // 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))) {
4184
+ rasterized.list = null;
4185
+ page.needsPaint = true;
4186
+ }
4187
+ if (page.dirty || page.needsPaint) scheduleFrame();
4188
+ return;
4189
+ }
4001
4190
  rasterized.list = null;
4002
4191
  render();
4003
4192
  }
@@ -4006,11 +4195,81 @@ function buildPageRenderer({
4006
4195
  const presentation = state.video?.presentation;
4007
4196
  if (!presentation) return;
4008
4197
  readPresentedFrame(state);
4198
+ presentation.continuation?.decoder?.close();
4199
+ if (presentation.continuation) presentation.continuation.decoder = null;
4200
+ presentation.continuation = null;
4009
4201
  onVideo?.({ type: "video-stop", id: presentation.id });
4010
4202
  state.video.presentation = null;
4011
4203
  state.video.needsLocalPosition = true;
4012
4204
  }
4013
4205
 
4206
+ function finishVideoPresentation(state, end) {
4207
+ state.video.frameTime = end;
4208
+ if (needsMore(state.video)) void refillMedia(state, state.video);
4209
+ else {
4210
+ readPresentedFrame(state);
4211
+ stopVideoPresentation(state);
4212
+ state.video.done = true;
4213
+ }
4214
+ }
4215
+
4216
+ function failVideoContinuation(state, presentation) {
4217
+ if (state.video?.presentation !== presentation) return;
4218
+ presentation.continuation?.decoder?.close();
4219
+ if (presentation.continuation) presentation.continuation.decoder = null;
4220
+ presentation.continuation = null;
4221
+ const end = presentation.pendingEnd;
4222
+ presentation.pendingEnd = null;
4223
+ if (Number.isFinite(end)) finishVideoPresentation(state, end);
4224
+ }
4225
+
4226
+ // A decoded snapshot can end before its queued pixels are displayed. If
4227
+ // bytes for the same source/configuration already exist, extend that worker
4228
+ // without dispatching waiting, stopping audio or replacing the raster mask.
4229
+ // The ordinary refill path still handles actual starvation and new epochs.
4230
+ function continueVideoSnapshot(state, boundary) {
4231
+ const video = state.video, p = video?.presentation, pipe = video?.pipe;
4232
+ if (!p || p.continuation || !state.playing || state.buffering || !pipe || pipe.error
4233
+ || pipe !== (state.videoPipe ?? state.pipe) || boundary + 1e-6 >= video.endTime
4234
+ || !Number.isSafeInteger(pipe.generation) || pipe.generation <= video.openedGeneration
4235
+ || pipe.generation <= (p.attemptedGeneration ?? -1)) return false;
4236
+ const generation = pipe.generation;
4237
+ let selected, bytes;
4238
+ try {
4239
+ selected = pipe.snapshotAt?.(boundary);
4240
+ if (selected && (selected.id !== video.snapshotId || selected.startTime !== video.startTime
4241
+ || boundary + 1e-6 >= selected.endTime)) return false;
4242
+ bytes = selected?.bytes ?? pipe.bytes();
4243
+ } catch { return false; }
4244
+ if (!(bytes instanceof Uint8Array) || bytes.length <= video.bytes.length) return false;
4245
+ const serial = state.seekSerial;
4246
+ const continuation = p.continuation = { bytes, generation, decoder: null };
4247
+ p.attemptedGeneration = generation;
4248
+ const current = () => state.document === page.document && state.playing && !state.buffering
4249
+ && state.seekSerial === serial && state.video === video && video.presentation === p
4250
+ && p.continuation === continuation && pipe === (state.videoPipe ?? state.pipe)
4251
+ && boundary + 1e-6 < video.endTime;
4252
+ // Keep a correctly opened, lazy page decoder for controls/layout fallback.
4253
+ // Only one replacement is staged; cancellation closes it before reuse.
4254
+ void (async () => {
4255
+ let decoder;
4256
+ try {
4257
+ decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
4258
+ if (!current()) { decoder.close(); decoder = null; failVideoContinuation(state, p); return; }
4259
+ if (decoder.width !== video.decoder.width || decoder.height !== video.decoder.height || decoder.fps !== video.decoder.fps)
4260
+ throw new Error("video snapshot configuration changed");
4261
+ continuation.decoder = decoder;
4262
+ decoder = null;
4263
+ onVideo({ type: "video-extend", id: p.id, revision: p.revision, snapshotId: video.snapshotId,
4264
+ generation, bytes, boundary });
4265
+ } catch {
4266
+ decoder?.close();
4267
+ failVideoContinuation(state, p);
4268
+ }
4269
+ })();
4270
+ return true;
4271
+ }
4272
+
4014
4273
  function readPresentedFrame(state) {
4015
4274
  const p = state.video?.presentation;
4016
4275
  if (!p) return;
@@ -4026,10 +4285,28 @@ function buildPageRenderer({
4026
4285
  if (!image) return;
4027
4286
  state.resource.image?.delete();
4028
4287
  Object.assign(state.resource, { image, bitmapWidth: width, bitmapHeight: height });
4288
+ // Decoder formats may carry alpha. Retain only this coherent frame's
4289
+ // owned copy; stopping/replacing the presentation releases it.
4290
+ let opaque = true;
4291
+ for (let i = 3; i < pixels.length; i += 4) if (pixels[i] !== 255) { opaque = false; break; }
4292
+ p.frame = {pixels,width,height,opaque};
4029
4293
  p.serial = serial;
4030
4294
  }
4031
4295
 
4032
- function makeVideoLayer(canvas, surface, region, scale, width, height) {
4296
+ function blackVideoPlan(region) {
4297
+ if (!onVideo || embedded) return null;
4298
+ const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
4299
+ if (items.length !== 1) return null;
4300
+ const item = items[0], resource = page.displayList.images[item.image];
4301
+ const state = [...page.media.values()].find(state => state.resource === resource);
4302
+ const frame = state?.video?.presentation?.frame;
4303
+ 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;
4305
+ if (!videoLayerGeometry(item,region,page.deviceScaleFactor).replayable) return null;
4306
+ return {resource,frame};
4307
+ }
4308
+
4309
+ function makeVideoLayer(canvas, surface, region, scale, width, height, pixels, blackResource = null) {
4033
4310
  if (!onVideo) return null;
4034
4311
  const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
4035
4312
  const item = items.length === 1 ? items[0] : null;
@@ -4044,37 +4321,31 @@ function buildPageRenderer({
4044
4321
  if (!video.presentation) {
4045
4322
  const id = nextVideoPresentation++;
4046
4323
  const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
4047
- video.presentation = { id, shared, serial: 0 };
4324
+ video.presentation = { id, shared, serial: 0, revision: 0 };
4048
4325
  onVideo({ type: "video-open", id, shared, bytes: video.bytes,
4326
+ snapshotId: video.snapshotId, generation: video.openedGeneration,
4049
4327
  options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
4050
4328
  startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
4051
4329
  hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
4052
4330
  audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
4053
4331
  }
4054
- const rect = { ...item.rect };
4055
- if (item.transform) {
4056
- rect.x = rect.x * item.transform.a + item.transform.e;
4057
- rect.y = rect.y * item.transform.d + item.transform.f;
4058
- rect.width *= item.transform.a;
4059
- rect.height *= item.transform.d;
4060
- }
4061
- const raster = white => {
4062
- canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
4063
- paintDisplayList(canvas, page.displayList, region, scale, { resource, white });
4064
- drawHints(canvas, region);
4065
- surface.flush();
4066
- const image = surface.makeImageSnapshot();
4067
- try {
4068
- return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
4069
- alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
4070
- } finally { image.delete(); }
4071
- };
4072
- const base = raster(false), mask = raster(true);
4073
- for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
4074
- return { id: video.presentation.id, base, mask, rect: {
4075
- x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
4076
- width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
4077
- } };
4332
+ const {rect} = videoLayerGeometry(item,region,scale);
4333
+ // Reuse the ordinary raster outside the video. Only this region can
4334
+ // differ between the black/white passes, including subtitles and overlays.
4335
+ const bounds = videoMaskBounds(item, region, scale, width, height);
4336
+ let cullPadding = 2;
4337
+ for (const entry of page.displayList.items) if (entry.transform) {
4338
+ // Display-list bounds are in CSS space; transformed device rounding may
4339
+ // reach beyond them. Unknown transforms keep conservative full culling.
4340
+ const {a, d} = entry.transform;
4341
+ cullPadding = Math.max(cullPadding, Math.abs(a) + 1, Math.abs(d) + 1);
4342
+ }
4343
+ const {base, mask} = rasterVideoMask({kit:CanvasKit, canvas, surface, width, height, pixels,
4344
+ bounds, region, scale, cullPadding, blackAlreadyRasterized:blackResource === resource, background:canvasColor(CanvasKit, page.backgroundColor),
4345
+ paint:(white, dirtyView) => paintDisplayList(canvas, page.displayList, region, scale, {resource, white}, dirtyView),
4346
+ hints:() => drawHints(canvas, region),
4347
+ });
4348
+ return { id: video.presentation.id, base, mask, rect };
4078
4349
  }
4079
4350
 
4080
4351
  function isPipe(value) {
@@ -4097,10 +4368,12 @@ function buildPageRenderer({
4097
4368
  state.loading ??= (async () => {
4098
4369
  if (state.mediaSource) {
4099
4370
  await state.mediaSource.waitForPipes();
4371
+ if (!currentMediaState(state)) return;
4100
4372
  bindSourcePipes(state);
4101
4373
  }
4102
4374
  if (!state.pipe && !state.bytes && !state.mediaSource) {
4103
4375
  const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
4376
+ if (!currentMediaState(state)) return;
4104
4377
  if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
4105
4378
  state.contentType = response.contentType ?? "";
4106
4379
  if (isPipe(response.body)) {
@@ -4112,6 +4385,7 @@ function buildPageRenderer({
4112
4385
  }
4113
4386
  }
4114
4387
  await ensureProbed(state);
4388
+ if (!currentMediaState(state)) return;
4115
4389
  if (!state.mediaSource) watchProgressiveMetadata(state);
4116
4390
  if (state.hasVideo && !state.video) await openVideoDecoder(state);
4117
4391
  // A separate audio buffer often arrives as an init segment, or empty.
@@ -4149,27 +4423,85 @@ function buildPageRenderer({
4149
4423
  return state.loading;
4150
4424
  }
4151
4425
 
4426
+ // A probe belongs to one immutable snapshot, not merely one media element.
4427
+ // Read MSE's current pipes directly: removeSourceBuffer/changeType can happen
4428
+ // while the demuxer is awaiting its result, before another append notifies us.
4429
+ function mediaProbeInput(state, time = 0) {
4430
+ const pipes = state.mediaSource?.streamPipes?.();
4431
+ const pipe = pipes ? pipes.video : state.videoPipe ?? state.pipe;
4432
+ const selected = pipe?.snapshotAt?.(time);
4433
+ return { pipe, generation: pipe?.generation, id: selected?.id,
4434
+ start: selected?.startTime, end: selected?.endTime,
4435
+ mime: pipes?.videoMime ?? state.videoMime ?? state.contentType,
4436
+ bytes: selected?.bytes ?? (pipe ? pipe.bytes() : state.bytes) };
4437
+ }
4438
+
4439
+ function sameMediaProbeInput(a, b) {
4440
+ return a && a.pipe === b.pipe && a.generation === b.generation
4441
+ && a.id === b.id && a.start === b.start && a.end === b.end && a.mime === b.mime
4442
+ // Versioned pipe snapshots are immutable. An incomplete MP4 header can
4443
+ // be reassembled into a fresh array on each read without new input.
4444
+ && (a.bytes === b.bytes || (a.pipe && Number.isSafeInteger(a.generation)
4445
+ && a.bytes?.byteLength === b.bytes?.byteLength));
4446
+ }
4447
+
4448
+ async function probeMediaInput(state, input) {
4449
+ if (sameMediaProbeInput(state.lastProbe?.input, input)) return state.lastProbe.result;
4450
+ const result = await videoDecoder.probe(input.bytes);
4451
+ // A failed probe remains retryable, including retries of identical bytes.
4452
+ state.lastProbe = { input, result };
4453
+ return result;
4454
+ }
4455
+
4456
+ async function waitForProbeBytes(state, input, size) {
4457
+ let waiting = state.probeByteWait;
4458
+ if (!waiting || waiting.pipe !== input.pipe || waiting.size !== size) {
4459
+ waiting = { pipe: input.pipe, size, promise: null };
4460
+ waiting.promise = input.pipe.waitFor(size).finally(() => {
4461
+ if (state.probeByteWait === waiting) state.probeByteWait = null;
4462
+ });
4463
+ state.probeByteWait = waiting;
4464
+ }
4465
+ let wake;
4466
+ const changed = new Promise(resolve => { wake = resolve; state.wakeProbe = resolve; });
4467
+ state.probeWaitInput = input;
4468
+ try { await Promise.race([waiting.promise, changed]); }
4469
+ finally {
4470
+ if (state.wakeProbe === wake) { state.wakeProbe = null; state.probeWaitInput = null; }
4471
+ }
4472
+ }
4473
+
4152
4474
  /** Probe until the growing body exposes a picture or sample rate, or ends. */
4153
- async function ensureProbed(state) {
4475
+ function ensureProbed(state) {
4154
4476
  if (state.probed) return;
4155
- const pipe = state.videoPipe ?? state.pipe;
4156
- let want = 1024;
4157
- let lastError = null;
4158
- while (true) {
4159
- if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
4477
+ // loadMedia and the first append share this transaction. A second waiter
4478
+ // must not start another probe or wait forever for an unnecessary byte.
4479
+ return state.initialProbe ??= probeInitialMedia(state).finally(() => { state.initialProbe = null; });
4480
+ }
4481
+
4482
+ async function probeInitialMedia(state) {
4483
+ let rejected = null;
4484
+ while (currentMediaState(state) && !state.error) {
4485
+ const input = mediaProbeInput(state), {pipe, bytes} = input;
4160
4486
  if (pipe?.error) throw pipe.error;
4161
- const bytes = snapshot(pipe, state.bytes);
4487
+ if (pipe && !pipe.done && sameMediaProbeInput(rejected, input)) {
4488
+ await waitForProbeBytes(state, input, pipe.size + 1);
4489
+ continue;
4490
+ }
4162
4491
  if (!bytes?.byteLength) {
4163
4492
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
4493
+ await waitForProbeBytes(state, input, pipe.size + 1);
4164
4494
  continue;
4165
4495
  }
4166
4496
  try {
4167
- const probe = await videoDecoder.probe(bytes);
4497
+ const probe = await probeMediaInput(state, input);
4498
+ if (!currentMediaState(state)) return;
4499
+ if (!sameMediaProbeInput(input, mediaProbeInput(state))) continue;
4168
4500
  const videoReady = probe.video?.width > 0;
4169
4501
  const audioReady = probe.audio?.sampleRate > 0;
4170
4502
  if (!videoReady && !audioReady) {
4171
4503
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
4172
- want = Math.max(bytes.byteLength + 1, want * 2);
4504
+ rejected = input;
4173
4505
  continue;
4174
4506
  }
4175
4507
  if (!state.durationOverride) state.duration = probe.duration ?? NaN;
@@ -4183,40 +4515,60 @@ function buildPageRenderer({
4183
4515
  mediaEvent(state, "loadedmetadata");
4184
4516
  return;
4185
4517
  } catch (error) {
4186
- lastError = error;
4187
- if (!pipe || pipe.done) throw lastError;
4188
- want = Math.max(bytes.byteLength + 1, want * 2);
4518
+ if (!currentMediaState(state)) return;
4519
+ if (!sameMediaProbeInput(input, mediaProbeInput(state))) continue;
4520
+ if (!pipe || pipe.done) throw error;
4521
+ rejected = input;
4189
4522
  }
4190
4523
  }
4191
4524
  }
4192
4525
 
4193
4526
  /** A later MSE append can extend the duration and the buffered range. */
4194
- async function refreshProbe(state) {
4195
- if (state.probing) return;
4196
- state.probing = true;
4197
- try {
4198
- if (!state.probed) {
4199
- await ensureProbed(state);
4200
- return;
4201
- }
4202
- const pipe = state.videoPipe ?? state.pipe;
4203
- if (!pipe?.size) return;
4204
- try {
4205
- const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
4206
- if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
4207
- state.duration = probe.duration;
4208
- state.resource.duration = probe.duration;
4209
- mediaEvent(state, "durationchange");
4527
+ function refreshProbe(state) {
4528
+ // Audio-only appends do not wake a waiter on unchanged video bytes.
4529
+ if (state.wakeProbe && !sameMediaProbeInput(state.probeWaitInput, mediaProbeInput(state))) state.wakeProbe();
4530
+ state.probePending = true;
4531
+ if (state.probing) return state.probing;
4532
+ state.probing = (async () => {
4533
+ do {
4534
+ state.probePending = false;
4535
+ if (!currentMediaState(state)) return;
4536
+ if (!state.probed) {
4537
+ try { await ensureProbed(state); } catch {}
4538
+ continue;
4210
4539
  }
4211
- const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
4212
- state.bufferedEnd = end;
4213
- state.mediaSource?.noteBuffered(end);
4214
- if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
4215
- if (probe.audio?.sampleRate > 0) state.hasAudio = true;
4216
- } catch {}
4217
- } finally {
4218
- state.probing = false;
4219
- }
4540
+ const time = mediaTime(state), input = mediaProbeInput(state, time);
4541
+ if (!input.pipe?.size) continue;
4542
+ try {
4543
+ const probe = await probeMediaInput(state, input);
4544
+ if (!currentMediaState(state)) return;
4545
+ // Compare the same selection point. Continuous WebM snapshots trim
4546
+ // their prefix as playback advances, even without any new input.
4547
+ // A seek into another initialization epoch must still reject the
4548
+ // old result; reading only its id leaves lazy snapshot bytes alone.
4549
+ if (!sameMediaProbeInput(input, mediaProbeInput(state, time))
4550
+ || (input.id !== undefined && input.pipe.snapshotAt(mediaTime(state)).id !== input.id)) {
4551
+ state.probePending = true;
4552
+ continue;
4553
+ }
4554
+ if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
4555
+ state.duration = probe.duration;
4556
+ state.resource.duration = probe.duration;
4557
+ mediaEvent(state, "durationchange");
4558
+ }
4559
+ const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
4560
+ state.bufferedEnd = end;
4561
+ state.mediaSource?.noteBuffered(end);
4562
+ if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
4563
+ if (probe.audio?.sampleRate > 0) state.hasAudio = true;
4564
+ } catch {}
4565
+ } while (state.probePending);
4566
+ })().finally(() => {
4567
+ state.probing = null;
4568
+ // A notification queued as this promise settles still needs one pass.
4569
+ if (state.probePending && currentMediaState(state)) void refreshProbe(state);
4570
+ });
4571
+ return state.probing;
4220
4572
  }
4221
4573
 
4222
4574
  /** Refresh duration as a progressive response grows, especially MP3. */
@@ -4412,6 +4764,7 @@ function buildPageRenderer({
4412
4764
  playbackEvent(state, "play", mediaEvent);
4413
4765
  try {
4414
4766
  await loadMedia(state);
4767
+ if (!currentMediaState(state)) return;
4415
4768
  if (state.ended) {
4416
4769
  state.ended = false;
4417
4770
  await positionMedia(state, 0);
@@ -4422,7 +4775,7 @@ function buildPageRenderer({
4422
4775
  }
4423
4776
  state.needsPosition = false;
4424
4777
  } catch (error) {
4425
- if (state.document === page.document) failMedia(state, error, state.pipe?.error ? 2 : 4);
4778
+ if (currentMediaState(state)) failMedia(state, error, state.pipe?.error ? 2 : 4);
4426
4779
  return;
4427
4780
  }
4428
4781
  if (!state.playing || state.document !== page.document) return;
@@ -4458,7 +4811,7 @@ function buildPageRenderer({
4458
4811
  if (state.refilling) await state.refilling.catch(() => {});
4459
4812
  if (state.seekSerial !== serial) return;
4460
4813
  await loadMedia(state);
4461
- if (state.seekSerial !== serial) return;
4814
+ if (state.seekSerial !== serial || !currentMediaState(state)) return;
4462
4815
  const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
4463
4816
  state.ended = false;
4464
4817
  await positionMedia(state, target);
@@ -4494,7 +4847,7 @@ function buildPageRenderer({
4494
4847
  if (!state.video || state.video.done || seconds === 0 || attempt > 0
4495
4848
  || stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
4496
4849
  try {
4497
- state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
4850
+ state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime, state.video.bytes);
4498
4851
  state.video.hideUntil = seconds;
4499
4852
  state.video.done = false;
4500
4853
  return;
@@ -4638,7 +4991,7 @@ function buildPageRenderer({
4638
4991
  const video = state.video;
4639
4992
  if (video?.needsLocalPosition && !video.presentation && !video.done) {
4640
4993
  try {
4641
- video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
4994
+ video.frameTime = seekToKeyframe(video.decoder, now, video.startTime, video.bytes);
4642
4995
  video.hideUntil = now;
4643
4996
  video.needsLocalPosition = false;
4644
4997
  } catch (error) { failMedia(state, error, 3); return; }
@@ -4750,7 +5103,7 @@ function buildPageRenderer({
4750
5103
  function openAudioStream(state) {
4751
5104
  const sound = state.sound;
4752
5105
  sound.finishing = false;
4753
- const name = new URL(state.url).pathname.split("/").pop() || "audio";
5106
+ const name = new URL(state.url ?? state.document.URL).pathname.split("/").pop() || "audio";
4754
5107
  // Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
4755
5108
  sound.streamStart = sound.position;
4756
5109
  trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
@@ -4904,8 +5257,8 @@ function buildPageRenderer({
4904
5257
  * binary search over seek targets finds where that keyframe starts (to a
4905
5258
  * frame). The frames from there to the clock are then decoded unshown.
4906
5259
  */
4907
- function seekToKeyframe(decoder, seconds, startTime = 0) {
4908
- return seekVideoKeyframe(decoder, seconds, startTime);
5260
+ function seekToKeyframe(decoder, seconds, startTime = 0, bytes = null) {
5261
+ return seekVideoKeyframe(decoder, seconds, startTime, bytes);
4909
5262
  }
4910
5263
 
4911
5264
  /**
@@ -4926,6 +5279,7 @@ function buildPageRenderer({
4926
5279
  return state.refilling;
4927
5280
  }
4928
5281
  const serial = state.seekSerial;
5282
+ const resumePresentation = Boolean(state.video?.presentation);
4929
5283
  state.base = mediaTime(state);
4930
5284
  // The audio output stays open through a stall: it plays the PCM it holds
4931
5285
  // and then runs dry, and playing resumes right after that PCM. Closing
@@ -4952,8 +5306,17 @@ function buildPageRenderer({
4952
5306
  // An append may have arrived after this decoder's snapshot was
4953
5307
  // opened but before it reached EOF. Consume that generation now;
4954
5308
  // waiting for one more append creates a segment-sized pause.
5309
+ const atEpochEnd = stalledTrack && Number.isFinite(stalledTrack.endTime)
5310
+ && (stalledTrack.frameTime !== undefined
5311
+ ? stalledTrack.frameTime + 1e-6 >= stalledTrack.endTime
5312
+ : stalledTrack.position >= Math.round(stalledTrack.endTime * stalledTrack.decoder.sampleRate));
5313
+ const nextEpoch = atEpochEnd ? pipe?.snapshotAt?.(stalledTrack.endTime) : null;
5314
+ // Multiple epochs may already be present in one generation. Only a
5315
+ // known different snapshot id proves there is another configuration;
5316
+ // reaching a timestamp alone must not cause an EOF reopen loop.
4955
5317
  const hasNewSnapshot = Boolean(stalledTrack && pipe
4956
- && pipe.generation !== stalledTrack.openedGeneration);
5318
+ && (pipe.generation !== stalledTrack.openedGeneration
5319
+ || (nextEpoch?.id != null && nextEpoch.id !== stalledTrack.snapshotId)));
4957
5320
  // Some demuxers report EOF at an fMP4/WebM fragment boundary even
4958
5321
  // though the same snapshot contains later fragments. Reopen once at
4959
5322
  // each position/generation; if it makes no progress, wait for append.
@@ -4984,26 +5347,35 @@ function buildPageRenderer({
4984
5347
  + `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
4985
5348
  if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
4986
5349
  // A video decoder still decoding is left alone: reopened, it restarts
4987
- // at a keyframe. Audio reopens on any newer snapshot (it may share the
4988
- // stalled pipe), which positionAudio continues sample-exactly.
5350
+ // at a keyframe. A newer shared/download snapshot does not invalidate
5351
+ // an audio decoder which can still produce its queued samples. Leave
5352
+ // it alone until its own EOF or epoch boundary requests a refill;
5353
+ // reopening it here needlessly decodes its entire played prefix.
4989
5354
  const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
4990
- const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
4991
- || (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
5355
+ const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound);
4992
5356
  if (refillVideo) {
4993
5357
  const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
4994
5358
  // The picture must not go back to the keyframe the decoder restarts at.
4995
5359
  const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
4996
5360
  await openVideoDecoder(state, resumeAt);
4997
5361
  state.video.hideUntil = hideUntil;
4998
- try {
4999
- state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
5000
- } catch {
5001
- state.video.frameTime = state.video.startTime;
5362
+ if (resumePresentation) {
5363
+ // The independent decoder will locate its own current keyframe.
5364
+ // Keep the page decoder lazy; if layout/controls now prevent
5365
+ // presentation, mediaTick positions it before local decoding.
5366
+ state.video.needsLocalPosition = true;
5367
+ } else {
5368
+ try {
5369
+ state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime, state.video.bytes);
5370
+ } catch {
5371
+ state.video.frameTime = state.video.startTime;
5372
+ }
5002
5373
  }
5003
5374
  state.video.done = false;
5004
5375
  }
5005
5376
  if (refillAudio) {
5006
5377
  let opened = false;
5378
+ const replacingAudio = Boolean(state.sound);
5007
5379
  try {
5008
5380
  let audioTime = held ? writtenEnd : time;
5009
5381
  if (state.sound && Number.isFinite(state.sound.endTime)
@@ -5012,7 +5384,10 @@ function buildPageRenderer({
5012
5384
  }
5013
5385
  opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
5014
5386
  } catch (error) {
5015
- if (!state.video) throw error;
5387
+ // A still-growing undecodable prefix returns false above. A
5388
+ // genuine failure replacing active audio must stop this media;
5389
+ // keeping its exhausted decoder would retry EOF in a busy loop.
5390
+ if (replacingAudio || !state.video) throw error;
5016
5391
  reportError(error);
5017
5392
  }
5018
5393
  // A held stream continues right after the PCM it was given.
@@ -5022,9 +5397,24 @@ function buildPageRenderer({
5022
5397
  // Drop this job before startMedia so a new stall can refill.
5023
5398
  if (state.refilling === job) state.refilling = null;
5024
5399
  state.buffering = false;
5025
- if (state.playing && state.seekSerial === serial && state.document === page.document) startMedia(state);
5400
+ if (state.playing && state.seekSerial === serial && state.document === page.document) {
5401
+ if (resumePresentation && refillVideo) {
5402
+ // Rebind a fresh layer before the first media tick so a resumed
5403
+ // presentation does not also decode its prefix on the page thread.
5404
+ rasterized.list = null;
5405
+ page.resourcePaintDirty = true;
5406
+ page.needsPaint = true;
5407
+ refresh();
5408
+ }
5409
+ startMedia(state);
5410
+ }
5026
5411
  } catch (error) {
5027
- if (state.document === page.document) failMedia(state, error, 3);
5412
+ // A seek or pause can cancel this asynchronous refill while its
5413
+ // decoder is still opening. Its later failure belongs to that old
5414
+ // operation, not to the playback which replaced it.
5415
+ if (state.document === page.document && state.seekSerial === serial && state.playing) {
5416
+ failMedia(state, error, 3);
5417
+ }
5028
5418
  } finally {
5029
5419
  if (state.refilling === job) {
5030
5420
  state.refilling = null;
@@ -5063,6 +5453,8 @@ function buildPageRenderer({
5063
5453
  }
5064
5454
 
5065
5455
  function closeMedia(state) {
5456
+ state.wakeProbe?.();
5457
+ state.lastProbe = null;
5066
5458
  state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
5067
5459
  state.audioPositioning = null;
5068
5460
  stopClock(state);
@@ -5077,7 +5469,11 @@ function buildPageRenderer({
5077
5469
  }
5078
5470
 
5079
5471
  function disposeVideos() {
5080
- for (const state of page.media.values()) closeMedia(state);
5472
+ for (const state of page.media.values()) {
5473
+ closeMedia(state);
5474
+ linkMediaSource(state.element, null);
5475
+ elementSourceObjects.delete(state.element);
5476
+ }
5081
5477
  page.media.clear();
5082
5478
  }
5083
5479
 
@@ -5162,7 +5558,7 @@ function buildPageRenderer({
5162
5558
  page.imageElements.delete(element);
5163
5559
  imageRequests.delete(element);
5164
5560
  }
5165
- for (const element of document.querySelectorAll("img")) {
5561
+ for (const element of resourceElements.get("images")) {
5166
5562
  const src = element.getAttribute("src");
5167
5563
  const credentials = imageCrossOrigin(element);
5168
5564
  const key = imageRequestKey(src, credentials);
@@ -5240,26 +5636,6 @@ function canvasColor(CanvasKit, value) {
5240
5636
  : CanvasKit.BLACK;
5241
5637
  }
5242
5638
 
5243
- /**
5244
- * Applies a document.cookie assignment to the local copy until the
5245
- * controller's jar (the authority) reports the next value.
5246
- */
5247
- function mergeDocumentCookie(current, assignment) {
5248
- const [pair, ...attributes] = assignment.split(";");
5249
- const equals = pair.indexOf("=");
5250
- if (equals < 1) return current;
5251
- const name = pair.slice(0, equals).trim();
5252
- const value = pair.slice(equals + 1).trim();
5253
- const expired = attributes.some((attribute) => {
5254
- const [key, argument = ""] = attribute.split("=").map((part) => part.trim().toLowerCase());
5255
- return (key === "max-age" && Number(argument) <= 0)
5256
- || (key === "expires" && Date.parse(argument) <= Date.now());
5257
- });
5258
- const entries = current ? current.split("; ").filter((entry) => !entry.startsWith(`${name}=`)) : [];
5259
- if (!expired) entries.push(`${name}=${value}`);
5260
- return entries.join("; ");
5261
- }
5262
-
5263
5639
  /** Page values leave the renderer only as primitives or JSON data. */
5264
5640
  function cloneableResult(value) {
5265
5641
  if (value === null || ["string", "number", "boolean", "undefined"].includes(typeof value)) return value;