buninu-browser 0.5.1 → 0.7.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 (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -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;
@@ -761,6 +785,10 @@ function buildPageRenderer({
761
785
  const scale = page.deviceScaleFactor;
762
786
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
763
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);
764
792
  const damage = canvasDamage(region);
765
793
  if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
766
794
  if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
@@ -781,7 +809,7 @@ function buildPageRenderer({
781
809
  // Clear and replay all layers inside the damage, including backgrounds
782
810
  // and foreground occluders. Transparent canvas pixels must not leave trails.
783
811
  canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
784
- paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
812
+ paintDisplayList(canvas, page.displayList, region, scale, blackPlan ? {resource:blackPlan.resource,white:false} : null, damage ? {
785
813
  x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
786
814
  } : region);
787
815
  drawHints(canvas, region);
@@ -791,7 +819,26 @@ function buildPageRenderer({
791
819
  let pixels;
792
820
  try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
793
821
  finally { image.delete(); }
794
- 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
+ }
795
842
  page.needsPaint = false;
796
843
  rasterRegion = {...region};
797
844
  rasterized = {
@@ -845,22 +892,31 @@ function buildPageRenderer({
845
892
  }
846
893
 
847
894
  /** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
848
- function schedulePrepaint() {
895
+ function schedulePrepaint(delay = 30) {
849
896
  if (disposedRenderer || !onFrame || embedded) return;
850
897
  hostClearTimeout(prepaintTimer);
851
898
  prepaintTimer = hostSetTimeout(() => {
852
899
  prepaintTimer = null;
853
- // A pending relayout does not stop this: the current layout, even if
854
- // slightly stale, is better than background where the user scrolled.
855
900
  const width = Math.max(1, Math.floor(page.width));
856
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
+ }
857
913
  // Still covered with a quarter viewport to spare in both directions
858
914
  // (as far as the page goes).
859
915
  const top = Math.max(0, viewport.y - height / 4);
860
916
  const bottom = Math.max(viewport.y + height, Math.min(viewport.contentHeight, viewport.y + height * 1.25));
861
917
  if (tileCovers(lastTile, { x: viewport.x, y: top, width, height: bottom - top }) && sameRasterInput()) return;
862
918
  publish(rasterTile(prepaintRegion()));
863
- }, 30);
919
+ }, delay);
864
920
  }
865
921
 
866
922
  /** A rendering opportunity for DOM changes nobody has asked to see yet. */
@@ -919,7 +975,10 @@ function buildPageRenderer({
919
975
  if (rounded) canvas.drawRRect(rounded, paint);
920
976
  else canvas.drawRect(deviceRect(rect), paint);
921
977
  };
922
- 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) {
923
982
  const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
924
983
  try {
925
984
  if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
@@ -1476,6 +1535,14 @@ function buildPageRenderer({
1476
1535
  const records = styleChanges.records;
1477
1536
  if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
1478
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
+
1479
1546
  // qml handle begin: QML visual attributes invalidate retained rendering
1480
1547
  if (hasQmlDomImages(page.document) && qmlTextureVisibilityChanged(records)) {
1481
1548
  page.resourcePaintDirty = true;
@@ -1649,6 +1716,7 @@ function buildPageRenderer({
1649
1716
 
1650
1717
  function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1651
1718
  if (page.document) loadImageResource.cancelDocument(page.document);
1719
+ resourceElements?.dispose();
1652
1720
  resetInputCaret();
1653
1721
  rasterReadback.dispose();
1654
1722
  clearPaintParagraphs();
@@ -1667,6 +1735,10 @@ function buildPageRenderer({
1667
1735
  page.window = parsed.window;
1668
1736
  pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
1669
1737
  page.document = parsed.document;
1738
+ resourceElements = createResourceElements(parsed.document);
1739
+ computedSheets = null;
1740
+ computedEnvironment = null;
1741
+ styleLayoutPending = false;
1670
1742
  observeDocumentCanvases(parsed.document, (node) => {
1671
1743
  if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
1672
1744
  page.resourcePaintDirty = true;
@@ -1697,7 +1769,7 @@ function buildPageRenderer({
1697
1769
  }
1698
1770
 
1699
1771
  /** Style, render tree, layout and display list of the current document. */
1700
- function styleAndLayout() {
1772
+ function styleAndLayout(styleOnly = false) {
1701
1773
  const started = hostPerformance.now();
1702
1774
  const ms = (from, to) => Math.round(to - from);
1703
1775
  const sheets = currentStyleSheets();
@@ -1713,30 +1785,47 @@ function buildPageRenderer({
1713
1785
  if (key !== fontDefinitionKey) {
1714
1786
  fontDefinitionKey = key;
1715
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;
1716
1792
  page.resourceLayoutDirty = true;
1717
1793
  }
1718
1794
  }
1719
1795
  const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
1720
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
+ }
1721
1803
  // A rendering opportunity can be scheduled by lifecycle work which did
1722
1804
  // not actually change DOM, state, resources, or styles. Keep the retained
1723
1805
  // layout/display list in that case instead of laying the whole page out.
1724
- if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1806
+ if (!styleOnly && !styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1725
1807
  && changes.roots.size === 0 && changes.self.size === 0) {
1726
1808
  lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
1727
- return { style: Math.round(taken - started), layout: 0, skipped: true };
1809
+ return { style: Math.round(taken - started), layout: 0, skipped: true, geometryUnchanged: true };
1728
1810
  }
1729
1811
  styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
1812
+ computedSheets = sheets;
1813
+ computedEnvironment = environmentKey;
1730
1814
  const computed = hostPerformance.now();
1731
1815
  if (!hasState || hasStateStale) {
1732
1816
  recordHasState(selectorTraitsFor(sheets));
1733
1817
  hasStateStale = false;
1734
1818
  }
1735
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
+ }
1736
1825
  // Attribute mutations used by application bookkeeping often match no
1737
1826
  // different rule and do not alter render-tree content. Once style has
1738
1827
  // proved that no element changed, retain layout and paint unchanged.
1739
- if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1828
+ if (!styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1740
1829
  && !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
1741
1830
  lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
1742
1831
  return { style: Math.round(recorded - started), layout: 0, skipped: true };
@@ -1755,12 +1844,27 @@ function buildPageRenderer({
1755
1844
  ignoreResources: true,
1756
1845
  // A zero-element incremental pass retained every computed style.
1757
1846
  // Control placeholders may still wrap that style in a fresh object.
1758
- ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
1847
+ ignoreStyles: !styleLayoutPending && styleEngine.incremental && styleEngine.restyled === 0,
1759
1848
  })) {
1849
+ styleLayoutPending = false;
1760
1850
  lastStyleParts += ", unchanged layout input";
1761
1851
  return { style: Math.round(styled - started), layout: 0, skipped: true };
1762
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;
1763
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
+ }
1764
1868
  page.layout = layoutEngine.layout(page.renderTree, {
1765
1869
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
1766
1870
  });
@@ -1805,7 +1909,6 @@ function buildPageRenderer({
1805
1909
  function rebuildRenderTree(reason = "update") {
1806
1910
  const started = hostPerformance.now();
1807
1911
  page.dirty = false;
1808
- page.layoutVersion++;
1809
1912
  page.title = page.document?.title ?? page.title;
1810
1913
  let staleImages = [];
1811
1914
  if (page.document) {
@@ -1813,6 +1916,11 @@ function buildPageRenderer({
1813
1916
  staleImages = queueImageResources();
1814
1917
  }
1815
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++;
1816
1924
  if (staleImages.length) {
1817
1925
  const stale = new Set(staleImages);
1818
1926
  page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
@@ -1859,7 +1967,7 @@ function buildPageRenderer({
1859
1967
  }
1860
1968
 
1861
1969
  async function runLoad({
1862
- 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 = "",
1863
1971
  }, generation, commit) {
1864
1972
  const current = () => generation === loadGeneration;
1865
1973
  const finish = () => {
@@ -1878,7 +1986,7 @@ function buildPageRenderer({
1878
1986
  // qml handle end
1879
1987
 
1880
1988
  page.url = url;
1881
- page.cookie = typeof cookie === "string" ? cookie : "";
1989
+ if (!documentCookies.accept(cookieState)) documentCookies.seed(cookieUrl(), cookie, !embedded);
1882
1990
  if (error) {
1883
1991
  replaceDocument("", url);
1884
1992
  page.title = "Network error";
@@ -2406,10 +2514,12 @@ function buildPageRenderer({
2406
2514
  return { ...resource, originClean };
2407
2515
  },
2408
2516
  canvasFont(element, value) {
2517
+ if (page.dirty) styleAndLayout(true);
2409
2518
  return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
2410
2519
  styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
2411
2520
  },
2412
2521
  canvasText(element, request) {
2522
+ if (request.settings.direction === "inherit" && page.dirty) styleAndLayout(true);
2413
2523
  const style = { ...request.settings.parsedFont,
2414
2524
  direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
2415
2525
  documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
@@ -2420,6 +2530,7 @@ function buildPageRenderer({
2420
2530
  playMedia,
2421
2531
  pauseMedia,
2422
2532
  seekMedia,
2533
+ reselectMedia,
2423
2534
  attachMediaSrc,
2424
2535
  mediaReadyState,
2425
2536
  mediaNetworkState,
@@ -2476,11 +2587,14 @@ function buildPageRenderer({
2476
2587
  if (page.dirty) rebuildRenderTree("script scrolled");
2477
2588
  revealElement(element, options);
2478
2589
  },
2479
- computedStyle(element) {
2590
+ computedStyle(element, needsLayout = true) {
2480
2591
  if (!element) return null;
2481
- if (page.dirty) rebuildRenderTree("getComputedStyle");
2592
+ if (page.dirty) {
2593
+ if (needsLayout) rebuildRenderTree("getComputedStyle");
2594
+ else styleAndLayout(true);
2595
+ }
2482
2596
  const style = styleEngine.get(element);
2483
- if (style?.padding.some(value => typeof value === "object")) {
2597
+ if (needsLayout && style?.padding.some(value => typeof value === "object")) {
2484
2598
  const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
2485
2599
  if (box) return { ...style, padding: box.style.padding };
2486
2600
  }
@@ -2490,9 +2604,9 @@ function buildPageRenderer({
2490
2604
  if (page.dirty) rebuildRenderTree("intersection observer");
2491
2605
  return page.layoutVersion;
2492
2606
  },
2493
- customProperties(element) {
2607
+ customProperties(element, updateStyle = true) {
2494
2608
  if (!element) return null;
2495
- if (page.dirty) rebuildRenderTree("getComputedStyle");
2609
+ if (updateStyle && page.dirty) styleAndLayout(true);
2496
2610
  return styleEngine.customPropertiesOf(element);
2497
2611
  },
2498
2612
  navigate: (url) => navigateTo(String(url)),
@@ -2527,18 +2641,18 @@ function buildPageRenderer({
2527
2641
  userAgent: () => page.userAgent,
2528
2642
  initScripts: () => page.initScripts ?? [],
2529
2643
  rewriteScript,
2530
- documentCookie: () => page.cookie,
2644
+ documentCookie: () => documentCookies.get(cookieUrl()),
2531
2645
  setDocumentCookie(value) {
2532
- page.cookie = mergeDocumentCookie(page.cookie, String(value));
2533
- setCookie(String(value));
2646
+ documentCookies.set(cookieUrl(), value);
2534
2647
  },
2535
2648
  clipboardRead,
2536
2649
  clipboardWrite,
2537
2650
  webSockets,
2538
2651
  pageFetch(request) {
2652
+ const generation = loadGeneration;
2539
2653
  const networkPending = pageFetch(request);
2540
2654
  const result = networkPending.then((response) => {
2541
- if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
2655
+ if (generation === loadGeneration) acceptCookies(response);
2542
2656
  return response;
2543
2657
  });
2544
2658
  result.abort = (reason) => networkPending.abort?.(reason);
@@ -2598,6 +2712,9 @@ function buildPageRenderer({
2598
2712
 
2599
2713
  /** The border box of an element's layout box in viewport coordinates. */
2600
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;
2601
2718
  let fixed = false;
2602
2719
  for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
2603
2720
  if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
@@ -2606,12 +2723,8 @@ function buildPageRenderer({
2606
2723
  const shift = scrollShift(element);
2607
2724
  const scrollX = (fixed ? 0 : viewport.x) + shift.x;
2608
2725
  const scrollY = (fixed ? 0 : viewport.y) + shift.y;
2609
- const box = elementBox(page.renderTree, page.layout, element)?.box;
2610
2726
  if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
2611
- const content = elementBounds(page.renderTree, page.layout, element);
2612
- return content
2613
- ? { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height }
2614
- : null;
2727
+ return { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height };
2615
2728
  }
2616
2729
 
2617
2730
  /**
@@ -2750,6 +2863,7 @@ function buildPageRenderer({
2750
2863
 
2751
2864
  const api = {
2752
2865
  loadDocument,
2866
+ updateCookies: state => documentCookies.accept(state),
2753
2867
  /** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
2754
2868
  setScripting(mode) {
2755
2869
  page.scripting = ["ses", "host"].includes(mode) ? mode : "off";
@@ -2959,14 +3073,31 @@ function buildPageRenderer({
2959
3073
  void placeVideo(state, mediaTime(state)).then(() => {
2960
3074
  if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
2961
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);
2962
3094
  } else if (Number.isFinite(report.end)) {
2963
- state.video.frameTime = report.end;
2964
- if (needsMore(state.video)) void refillMedia(state, state.video);
2965
- else {
2966
- readPresentedFrame(state);
2967
- stopVideoPresentation(state);
2968
- state.video.done = true;
2969
- }
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);
2970
3101
  } else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
2971
3102
  },
2972
3103
  /** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
@@ -3255,6 +3386,8 @@ function buildPageRenderer({
3255
3386
  if (disposedRenderer) return;
3256
3387
  resetInputCaret();
3257
3388
  disposedRenderer = true;
3389
+ resourceElements?.dispose();
3390
+ resourceElements = null;
3258
3391
  loadGeneration++;
3259
3392
  if (page.document) loadImageResource.cancelDocument(page.document);
3260
3393
  hostClearTimeout(prepaintTimer);
@@ -3299,7 +3432,9 @@ function buildPageRenderer({
3299
3432
  */
3300
3433
  function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
3301
3434
  const sheets = [];
3302
- 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) {
3303
3438
  if (!isCssStyleSheet(element)) continue;
3304
3439
  const applies = styleSheetMatches(element);
3305
3440
  if (element.tagName === "STYLE") {
@@ -3376,7 +3511,7 @@ function buildPageRenderer({
3376
3511
  /** Brings the frames in line with the document's iframes: their documents and sizes. */
3377
3512
  function syncFrames() {
3378
3513
  const document = page.document;
3379
- const elements = document ? [...document.getElementsByTagName("iframe")] : [];
3514
+ const elements = document ? resourceElements.get("frames") : [];
3380
3515
  if (!elements.length && !frames.size) return;
3381
3516
  const boxes = frameBoxes();
3382
3517
  for (const [element, frame] of frames) {
@@ -3433,6 +3568,8 @@ function buildPageRenderer({
3433
3568
  clipboardWriteInput,
3434
3569
  webSockets,
3435
3570
  setCookie,
3571
+ documentCookies,
3572
+ inheritedCookieUrl: cookieUrl,
3436
3573
  consoleMessage,
3437
3574
  onFrame: (update) => showFrame(frame, update),
3438
3575
  onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
@@ -3503,7 +3640,8 @@ function buildPageRenderer({
3503
3640
  contentType: response.contentType || "text/html",
3504
3641
  status: response.status,
3505
3642
  statusText: response.statusText ?? "",
3506
- cookie: response.documentCookie ?? page.cookie,
3643
+ cookie: documentCookies.get(target.href),
3644
+ cookieState: response.cookieState,
3507
3645
  };
3508
3646
  } catch (error) {
3509
3647
  trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
@@ -3615,10 +3753,10 @@ function buildPageRenderer({
3615
3753
  * Null for an element that is not one.
3616
3754
  */
3617
3755
  function scrollerGeometry(element) {
3618
- const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
3619
- if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
3620
3756
  const found = layoutBoxOf(element);
3621
3757
  if (!found) return null;
3758
+ const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
3759
+ if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
3622
3760
  const { node, box } = found;
3623
3761
  const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
3624
3762
  const padding = style.padding ?? [0, 0, 0, 0];
@@ -3787,7 +3925,9 @@ function buildPageRenderer({
3787
3925
 
3788
3926
  /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
3789
3927
  function initializeVideos(document, resources) {
3790
- 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) {
3791
3931
  if (resources.get(element)) continue;
3792
3932
  const audioOnly = element.tagName === "AUDIO";
3793
3933
  resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
@@ -3853,6 +3993,45 @@ function buildPageRenderer({
3853
3993
  return null;
3854
3994
  }
3855
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
+
3856
4035
  /** A resource record for a media element created after the document was parsed. */
3857
4036
  function ensureMediaResource(element) {
3858
4037
  if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
@@ -3871,24 +4050,17 @@ function buildPageRenderer({
3871
4050
 
3872
4051
  /** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
3873
4052
  function attachMediaSrc(element, value) {
3874
- if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
3875
- const source = resolveMediaSource(value);
3876
- if (!source) {
3877
- elementSources.delete(element);
3878
- return;
3879
- }
3880
- elementSources.set(element, source);
3881
- sourceElements.set(source, element);
3882
- if (source.readyState === "closed") {
3883
- try {
3884
- source.attach(typeof value === "string" ? value : source._objectURL);
3885
- } catch {
3886
- return;
3887
- }
3888
- }
3889
- 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));
3890
4061
  if (!state) return;
3891
- state.mediaSource = source;
4062
+ const source = state.mediaSource;
4063
+ if (!source) return;
3892
4064
  if (Number.isFinite(source.duration)) {
3893
4065
  state.durationOverride = true;
3894
4066
  state.duration = source.duration;
@@ -3898,11 +4070,8 @@ function buildPageRenderer({
3898
4070
  }
3899
4071
 
3900
4072
  function mediaAppended(source) {
3901
- const element = sourceElements.get(source);
3902
- if (!element) return;
3903
- const state = page.media.get(element) ?? mediaStateFor(element);
4073
+ const state = attachedMediaState(source);
3904
4074
  if (!state) return;
3905
- state.mediaSource = source;
3906
4075
  bindSourcePipes(state);
3907
4076
  // A later initialization segment can replace already-buffered video.
3908
4077
  // Shorten the active presentation at its epoch boundary as soon as that
@@ -3926,8 +4095,7 @@ function buildPageRenderer({
3926
4095
  }
3927
4096
 
3928
4097
  function mediaDuration(source, seconds) {
3929
- const element = sourceElements.get(source);
3930
- const state = element && page.media.get(element);
4098
+ const state = attachedMediaState(source);
3931
4099
  if (!state || !Number.isFinite(seconds)) return;
3932
4100
  state.durationOverride = true;
3933
4101
  state.duration = seconds;
@@ -3936,28 +4104,27 @@ function buildPageRenderer({
3936
4104
  }
3937
4105
 
3938
4106
  /** The element's playback state, created (not loaded) on first use. */
3939
- function mediaStateFor(element) {
4107
+ function mediaStateFor(element, reselect = false) {
4108
+ if (!element || element.ownerDocument !== page.document) return null;
3940
4109
  const resource = ensureMediaResource(element);
3941
- let source = elementSources.get(element) ?? null;
3942
- const attributeUrl = videoSource(element);
3943
- if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
3944
- const url = attributeUrl ?? source?._objectURL ?? null;
3945
- if (!videoDecoder || !resource || (!url && !source)) return null;
3946
- if (source && source.readyState === "closed") {
3947
- try {
3948
- source.attach(url);
3949
- elementSources.set(element, source);
3950
- sourceElements.set(source, element);
3951
- } catch {}
3952
- }
4110
+ const { input, source, url } = selectedMediaSource(element, reselect);
4111
+ linkMediaSource(element, source);
3953
4112
  let state = page.media.get(element);
3954
- if (state && state.url !== url) {
4113
+ if (state && (state.mediaSource !== source || (!source && (state.input !== input || reselect)))) {
3955
4114
  closeMedia(state);
3956
4115
  page.media.delete(element);
3957
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 {}
3958
4124
  }
4125
+ if (!videoDecoder || !resource || (!url && !source)) return null;
3959
4126
  if (!state) {
3960
- 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,
3961
4128
  loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
3962
4129
  video: null, sound: null, playing: false, ended: false,
3963
4130
  duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
@@ -3969,12 +4136,17 @@ function buildPageRenderer({
3969
4136
  state.durationOverride = true;
3970
4137
  resource.duration = source.duration;
3971
4138
  }
3972
- } else if (source) {
3973
- state.mediaSource = source;
4139
+ } else {
4140
+ state.input = input;
4141
+ state.url = url;
3974
4142
  }
3975
4143
  return state;
3976
4144
  }
3977
4145
 
4146
+ function reselectMedia(element) {
4147
+ return mediaStateFor(element, true);
4148
+ }
4149
+
3978
4150
  /** Current position in seconds. */
3979
4151
  function mediaTime(state) {
3980
4152
  const sound = state.sound;
@@ -4000,10 +4172,21 @@ function buildPageRenderer({
4000
4172
 
4001
4173
  /** The control bar and frame redraw without a relayout. */
4002
4174
  function repaintMedia() {
4003
- // The compositor replaces this video's pixels independently. Page changes
4004
- // still repaint normally; native controls get their periodic time update.
4005
- if ([...page.media.values()].some(state => state.video?.presentation && state.playing)
4006
- && !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
+ }
4007
4190
  rasterized.list = null;
4008
4191
  render();
4009
4192
  }
@@ -4012,11 +4195,81 @@ function buildPageRenderer({
4012
4195
  const presentation = state.video?.presentation;
4013
4196
  if (!presentation) return;
4014
4197
  readPresentedFrame(state);
4198
+ presentation.continuation?.decoder?.close();
4199
+ if (presentation.continuation) presentation.continuation.decoder = null;
4200
+ presentation.continuation = null;
4015
4201
  onVideo?.({ type: "video-stop", id: presentation.id });
4016
4202
  state.video.presentation = null;
4017
4203
  state.video.needsLocalPosition = true;
4018
4204
  }
4019
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
+
4020
4273
  function readPresentedFrame(state) {
4021
4274
  const p = state.video?.presentation;
4022
4275
  if (!p) return;
@@ -4032,10 +4285,28 @@ function buildPageRenderer({
4032
4285
  if (!image) return;
4033
4286
  state.resource.image?.delete();
4034
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};
4035
4293
  p.serial = serial;
4036
4294
  }
4037
4295
 
4038
- 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) {
4039
4310
  if (!onVideo) return null;
4040
4311
  const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
4041
4312
  const item = items.length === 1 ? items[0] : null;
@@ -4050,37 +4321,31 @@ function buildPageRenderer({
4050
4321
  if (!video.presentation) {
4051
4322
  const id = nextVideoPresentation++;
4052
4323
  const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
4053
- video.presentation = { id, shared, serial: 0 };
4324
+ video.presentation = { id, shared, serial: 0, revision: 0 };
4054
4325
  onVideo({ type: "video-open", id, shared, bytes: video.bytes,
4326
+ snapshotId: video.snapshotId, generation: video.openedGeneration,
4055
4327
  options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
4056
4328
  startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
4057
4329
  hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
4058
4330
  audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
4059
4331
  }
4060
- const rect = { ...item.rect };
4061
- if (item.transform) {
4062
- rect.x = rect.x * item.transform.a + item.transform.e;
4063
- rect.y = rect.y * item.transform.d + item.transform.f;
4064
- rect.width *= item.transform.a;
4065
- rect.height *= item.transform.d;
4066
- }
4067
- const raster = white => {
4068
- canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
4069
- paintDisplayList(canvas, page.displayList, region, scale, { resource, white });
4070
- drawHints(canvas, region);
4071
- surface.flush();
4072
- const image = surface.makeImageSnapshot();
4073
- try {
4074
- return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
4075
- alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
4076
- } finally { image.delete(); }
4077
- };
4078
- const base = raster(false), mask = raster(true);
4079
- for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
4080
- return { id: video.presentation.id, base, mask, rect: {
4081
- x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
4082
- width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
4083
- } };
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 };
4084
4349
  }
4085
4350
 
4086
4351
  function isPipe(value) {
@@ -4103,10 +4368,12 @@ function buildPageRenderer({
4103
4368
  state.loading ??= (async () => {
4104
4369
  if (state.mediaSource) {
4105
4370
  await state.mediaSource.waitForPipes();
4371
+ if (!currentMediaState(state)) return;
4106
4372
  bindSourcePipes(state);
4107
4373
  }
4108
4374
  if (!state.pipe && !state.bytes && !state.mediaSource) {
4109
4375
  const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
4376
+ if (!currentMediaState(state)) return;
4110
4377
  if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
4111
4378
  state.contentType = response.contentType ?? "";
4112
4379
  if (isPipe(response.body)) {
@@ -4118,6 +4385,7 @@ function buildPageRenderer({
4118
4385
  }
4119
4386
  }
4120
4387
  await ensureProbed(state);
4388
+ if (!currentMediaState(state)) return;
4121
4389
  if (!state.mediaSource) watchProgressiveMetadata(state);
4122
4390
  if (state.hasVideo && !state.video) await openVideoDecoder(state);
4123
4391
  // A separate audio buffer often arrives as an init segment, or empty.
@@ -4155,27 +4423,85 @@ function buildPageRenderer({
4155
4423
  return state.loading;
4156
4424
  }
4157
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
+
4158
4474
  /** Probe until the growing body exposes a picture or sample rate, or ends. */
4159
- async function ensureProbed(state) {
4475
+ function ensureProbed(state) {
4160
4476
  if (state.probed) return;
4161
- const pipe = state.videoPipe ?? state.pipe;
4162
- let want = 1024;
4163
- let lastError = null;
4164
- while (true) {
4165
- 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;
4166
4486
  if (pipe?.error) throw pipe.error;
4167
- 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
+ }
4168
4491
  if (!bytes?.byteLength) {
4169
4492
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
4493
+ await waitForProbeBytes(state, input, pipe.size + 1);
4170
4494
  continue;
4171
4495
  }
4172
4496
  try {
4173
- 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;
4174
4500
  const videoReady = probe.video?.width > 0;
4175
4501
  const audioReady = probe.audio?.sampleRate > 0;
4176
4502
  if (!videoReady && !audioReady) {
4177
4503
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
4178
- want = Math.max(bytes.byteLength + 1, want * 2);
4504
+ rejected = input;
4179
4505
  continue;
4180
4506
  }
4181
4507
  if (!state.durationOverride) state.duration = probe.duration ?? NaN;
@@ -4189,40 +4515,60 @@ function buildPageRenderer({
4189
4515
  mediaEvent(state, "loadedmetadata");
4190
4516
  return;
4191
4517
  } catch (error) {
4192
- lastError = error;
4193
- if (!pipe || pipe.done) throw lastError;
4194
- 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;
4195
4522
  }
4196
4523
  }
4197
4524
  }
4198
4525
 
4199
4526
  /** A later MSE append can extend the duration and the buffered range. */
4200
- async function refreshProbe(state) {
4201
- if (state.probing) return;
4202
- state.probing = true;
4203
- try {
4204
- if (!state.probed) {
4205
- await ensureProbed(state);
4206
- return;
4207
- }
4208
- const pipe = state.videoPipe ?? state.pipe;
4209
- if (!pipe?.size) return;
4210
- try {
4211
- const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
4212
- if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
4213
- state.duration = probe.duration;
4214
- state.resource.duration = probe.duration;
4215
- 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;
4216
4539
  }
4217
- const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
4218
- state.bufferedEnd = end;
4219
- state.mediaSource?.noteBuffered(end);
4220
- if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
4221
- if (probe.audio?.sampleRate > 0) state.hasAudio = true;
4222
- } catch {}
4223
- } finally {
4224
- state.probing = false;
4225
- }
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;
4226
4572
  }
4227
4573
 
4228
4574
  /** Refresh duration as a progressive response grows, especially MP3. */
@@ -4418,6 +4764,7 @@ function buildPageRenderer({
4418
4764
  playbackEvent(state, "play", mediaEvent);
4419
4765
  try {
4420
4766
  await loadMedia(state);
4767
+ if (!currentMediaState(state)) return;
4421
4768
  if (state.ended) {
4422
4769
  state.ended = false;
4423
4770
  await positionMedia(state, 0);
@@ -4428,7 +4775,7 @@ function buildPageRenderer({
4428
4775
  }
4429
4776
  state.needsPosition = false;
4430
4777
  } catch (error) {
4431
- 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);
4432
4779
  return;
4433
4780
  }
4434
4781
  if (!state.playing || state.document !== page.document) return;
@@ -4464,7 +4811,7 @@ function buildPageRenderer({
4464
4811
  if (state.refilling) await state.refilling.catch(() => {});
4465
4812
  if (state.seekSerial !== serial) return;
4466
4813
  await loadMedia(state);
4467
- if (state.seekSerial !== serial) return;
4814
+ if (state.seekSerial !== serial || !currentMediaState(state)) return;
4468
4815
  const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
4469
4816
  state.ended = false;
4470
4817
  await positionMedia(state, target);
@@ -4500,7 +4847,7 @@ function buildPageRenderer({
4500
4847
  if (!state.video || state.video.done || seconds === 0 || attempt > 0
4501
4848
  || stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
4502
4849
  try {
4503
- 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);
4504
4851
  state.video.hideUntil = seconds;
4505
4852
  state.video.done = false;
4506
4853
  return;
@@ -4644,7 +4991,7 @@ function buildPageRenderer({
4644
4991
  const video = state.video;
4645
4992
  if (video?.needsLocalPosition && !video.presentation && !video.done) {
4646
4993
  try {
4647
- video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
4994
+ video.frameTime = seekToKeyframe(video.decoder, now, video.startTime, video.bytes);
4648
4995
  video.hideUntil = now;
4649
4996
  video.needsLocalPosition = false;
4650
4997
  } catch (error) { failMedia(state, error, 3); return; }
@@ -4756,7 +5103,7 @@ function buildPageRenderer({
4756
5103
  function openAudioStream(state) {
4757
5104
  const sound = state.sound;
4758
5105
  sound.finishing = false;
4759
- const name = new URL(state.url).pathname.split("/").pop() || "audio";
5106
+ const name = new URL(state.url ?? state.document.URL).pathname.split("/").pop() || "audio";
4760
5107
  // Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
4761
5108
  sound.streamStart = sound.position;
4762
5109
  trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
@@ -4910,8 +5257,8 @@ function buildPageRenderer({
4910
5257
  * binary search over seek targets finds where that keyframe starts (to a
4911
5258
  * frame). The frames from there to the clock are then decoded unshown.
4912
5259
  */
4913
- function seekToKeyframe(decoder, seconds, startTime = 0) {
4914
- return seekVideoKeyframe(decoder, seconds, startTime);
5260
+ function seekToKeyframe(decoder, seconds, startTime = 0, bytes = null) {
5261
+ return seekVideoKeyframe(decoder, seconds, startTime, bytes);
4915
5262
  }
4916
5263
 
4917
5264
  /**
@@ -4932,6 +5279,7 @@ function buildPageRenderer({
4932
5279
  return state.refilling;
4933
5280
  }
4934
5281
  const serial = state.seekSerial;
5282
+ const resumePresentation = Boolean(state.video?.presentation);
4935
5283
  state.base = mediaTime(state);
4936
5284
  // The audio output stays open through a stall: it plays the PCM it holds
4937
5285
  // and then runs dry, and playing resumes right after that PCM. Closing
@@ -4958,8 +5306,17 @@ function buildPageRenderer({
4958
5306
  // An append may have arrived after this decoder's snapshot was
4959
5307
  // opened but before it reached EOF. Consume that generation now;
4960
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.
4961
5317
  const hasNewSnapshot = Boolean(stalledTrack && pipe
4962
- && pipe.generation !== stalledTrack.openedGeneration);
5318
+ && (pipe.generation !== stalledTrack.openedGeneration
5319
+ || (nextEpoch?.id != null && nextEpoch.id !== stalledTrack.snapshotId)));
4963
5320
  // Some demuxers report EOF at an fMP4/WebM fragment boundary even
4964
5321
  // though the same snapshot contains later fragments. Reopen once at
4965
5322
  // each position/generation; if it makes no progress, wait for append.
@@ -4990,26 +5347,35 @@ function buildPageRenderer({
4990
5347
  + `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
4991
5348
  if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
4992
5349
  // A video decoder still decoding is left alone: reopened, it restarts
4993
- // at a keyframe. Audio reopens on any newer snapshot (it may share the
4994
- // 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.
4995
5354
  const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
4996
- const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
4997
- || (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
5355
+ const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound);
4998
5356
  if (refillVideo) {
4999
5357
  const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
5000
5358
  // The picture must not go back to the keyframe the decoder restarts at.
5001
5359
  const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
5002
5360
  await openVideoDecoder(state, resumeAt);
5003
5361
  state.video.hideUntil = hideUntil;
5004
- try {
5005
- state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
5006
- } catch {
5007
- 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
+ }
5008
5373
  }
5009
5374
  state.video.done = false;
5010
5375
  }
5011
5376
  if (refillAudio) {
5012
5377
  let opened = false;
5378
+ const replacingAudio = Boolean(state.sound);
5013
5379
  try {
5014
5380
  let audioTime = held ? writtenEnd : time;
5015
5381
  if (state.sound && Number.isFinite(state.sound.endTime)
@@ -5018,7 +5384,10 @@ function buildPageRenderer({
5018
5384
  }
5019
5385
  opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
5020
5386
  } catch (error) {
5021
- 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;
5022
5391
  reportError(error);
5023
5392
  }
5024
5393
  // A held stream continues right after the PCM it was given.
@@ -5028,9 +5397,24 @@ function buildPageRenderer({
5028
5397
  // Drop this job before startMedia so a new stall can refill.
5029
5398
  if (state.refilling === job) state.refilling = null;
5030
5399
  state.buffering = false;
5031
- 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
+ }
5032
5411
  } catch (error) {
5033
- 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
+ }
5034
5418
  } finally {
5035
5419
  if (state.refilling === job) {
5036
5420
  state.refilling = null;
@@ -5069,6 +5453,8 @@ function buildPageRenderer({
5069
5453
  }
5070
5454
 
5071
5455
  function closeMedia(state) {
5456
+ state.wakeProbe?.();
5457
+ state.lastProbe = null;
5072
5458
  state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
5073
5459
  state.audioPositioning = null;
5074
5460
  stopClock(state);
@@ -5083,7 +5469,11 @@ function buildPageRenderer({
5083
5469
  }
5084
5470
 
5085
5471
  function disposeVideos() {
5086
- 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
+ }
5087
5477
  page.media.clear();
5088
5478
  }
5089
5479
 
@@ -5168,7 +5558,7 @@ function buildPageRenderer({
5168
5558
  page.imageElements.delete(element);
5169
5559
  imageRequests.delete(element);
5170
5560
  }
5171
- for (const element of document.querySelectorAll("img")) {
5561
+ for (const element of resourceElements.get("images")) {
5172
5562
  const src = element.getAttribute("src");
5173
5563
  const credentials = imageCrossOrigin(element);
5174
5564
  const key = imageRequestKey(src, credentials);
@@ -5246,26 +5636,6 @@ function canvasColor(CanvasKit, value) {
5246
5636
  : CanvasKit.BLACK;
5247
5637
  }
5248
5638
 
5249
- /**
5250
- * Applies a document.cookie assignment to the local copy until the
5251
- * controller's jar (the authority) reports the next value.
5252
- */
5253
- function mergeDocumentCookie(current, assignment) {
5254
- const [pair, ...attributes] = assignment.split(";");
5255
- const equals = pair.indexOf("=");
5256
- if (equals < 1) return current;
5257
- const name = pair.slice(0, equals).trim();
5258
- const value = pair.slice(equals + 1).trim();
5259
- const expired = attributes.some((attribute) => {
5260
- const [key, argument = ""] = attribute.split("=").map((part) => part.trim().toLowerCase());
5261
- return (key === "max-age" && Number(argument) <= 0)
5262
- || (key === "expires" && Date.parse(argument) <= Date.now());
5263
- });
5264
- const entries = current ? current.split("; ").filter((entry) => !entry.startsWith(`${name}=`)) : [];
5265
- if (!expired) entries.push(`${name}=${value}`);
5266
- return entries.join("; ");
5267
- }
5268
-
5269
5639
  /** Page values leave the renderer only as primitives or JSON data. */
5270
5640
  function cloneableResult(value) {
5271
5641
  if (value === null || ["string", "number", "boolean", "undefined"].includes(typeof value)) return value;