buninu-browser 0.2.2 → 0.2.4

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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -23,18 +23,30 @@
23
23
  * after the load event.
24
24
  */
25
25
  import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
26
- import { setCanvasKit } from "./canvas-2d.js";
26
+ import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-2d.js";
27
+ import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
27
28
  import { DESKTOP_USER_AGENT } from "../user-agent.js";
28
29
  import { parseHTMLDocument } from "../happy-dom/parser.js";
29
30
  import { LayoutEngine } from "../layout/text-layout.js";
30
31
  import { ScrollViewport } from "../layout/scroll-viewport.js";
31
- import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
32
+ import { scrollAlignmentDelta } from "./scroll-into-view.js";
33
+ import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions, renderNodeAtPoint } from "../input/hit-test.js";
34
+ import { beginImageLoad, finishImageLoad } from "./image-loading.js";
35
+ import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
36
+ import { DocumentFonts } from "./font-loading.js";
37
+ import { dispatchPointerMove } from "./pointer-move.js";
38
+ import { keyboardInput } from "./keyboard-input.js";
39
+ import { playbackEvent } from "./media-playback-events.js";
32
40
  import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
33
- import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
41
+ import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryMatches, StyleEngine, topmostConnected } from "../style/computed-style.js";
34
42
  import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
35
43
  import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
36
44
  import { buildDisplayList, visibleItems } from "../paint/display-list.js";
37
- import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
45
+ import { backgroundBytes, cropViewport, encodeImageBase64 } from "../paint/frame.js";
46
+ import { FrameCaptureCache } from "../paint/frame-cache.js";
47
+ import { RasterReadback } from "../paint/raster-readback.js";
48
+ import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
49
+ import { appendParagraphText } from "../paint/paragraph-text.js";
38
50
  import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
39
51
  import { originOf } from "./messaging.js";
40
52
  import { MOBILE_USER_AGENT } from "./flags.js";
@@ -42,7 +54,9 @@ import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
42
54
  import { containerFormat } from "./media-source.js";
43
55
  import { floatToS16, splitSamples } from "../audio/pcm.js";
44
56
  import { advanceAudio } from "../media/position-audio.js";
45
- import { pianoNote, PianoMixer } from "../audio/piano.js";
57
+ import { seekVideoKeyframe } from "../media/video-keyframe.js";
58
+ import { PianoMixer } from "../audio/piano.js";
59
+ import { handlePianoInput } from "./piano-input.js";
46
60
 
47
61
  // Captured at load: in host mode page scripts share this global object.
48
62
  const hostPerformance = globalThis.performance;
@@ -67,11 +81,6 @@ const AUDIO_HISTORY_SECONDS = 3;
67
81
  const RESUME_BUFFER_SECONDS = 2;
68
82
  const RESUME_BUFFER_MAX_WAIT_MS = 6000;
69
83
  const RESUME_BUFFER_IDLE_MS = 400;
70
- // Finding a seek's keyframe (see seekToKeyframe): how far back it may lie,
71
- // and the size of the pictures compared.
72
- const KEYFRAME_SEARCH_SECONDS = 10;
73
- const KEYFRAME_PROBE_WIDTH = 32;
74
- const KEYFRAME_PROBE_HEIGHT = 18;
75
84
  // element → MediaSource, and the reverse. One renderer owns one page pipeline.
76
85
  const elementSources = new WeakMap();
77
86
  const sourceElements = new WeakMap();
@@ -142,6 +151,11 @@ export function loadGraphics() {
142
151
  const fontCollection = CanvasKit.FontCollection.Make();
143
152
  fontCollection.setDefaultFontManager(fontProvider);
144
153
  fontCollection.enableFontFallback();
154
+ const builtinTypefaces = [...loadedFamilies.values()].map(family => ({
155
+ family, typeface: fontProvider.matchFamilyStyle(family, {
156
+ weight: 400, width: 5, slant: CanvasKit.FontSlant.Upright,
157
+ }),
158
+ })).filter(entry => entry.typeface);
145
159
 
146
160
  const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
147
161
  const sansSerifFamily = fontFamilies[0];
@@ -157,7 +171,7 @@ export function loadGraphics() {
157
171
  ]);
158
172
  return loadedGraphics = {
159
173
  CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
160
- genericFamilies,
174
+ genericFamilies, builtinTypefaces,
161
175
  };
162
176
  })();
163
177
  }
@@ -219,6 +233,7 @@ function buildPageRenderer({
219
233
  consoleMessage = null,
220
234
  trace = null,
221
235
  onFrame = null,
236
+ onVideo = null,
222
237
  onLifecycle = null,
223
238
  videoDecoder = null,
224
239
  // Prepares a classic script on another thread: (source) => Promise<{ code, parsed, strict }>.
@@ -229,38 +244,47 @@ function buildPageRenderer({
229
244
  // An iframe's document (see syncFrames): frames go to the embedding page,
230
245
  // which needs only the viewport, so nothing is painted ahead.
231
246
  embedded = false,
247
+ allocateFramePixels = null,
232
248
  // This document's window handle, made by the embedding page (see windowHandle).
233
249
  frameWindow = null,
234
250
  }, {
235
251
  CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
236
- genericFamilies,
252
+ genericFamilies, builtinTypefaces,
237
253
  }) {
254
+ let documentFonts = null;
255
+ let fontSheets = null;
256
+ let fontEnvironment = "";
257
+ let fontDefinitions = [];
258
+ let fontDefinitionKey = "[]";
238
259
  const resolvedFamilies = new Map();
239
260
  /** First available family from a computed font-family list, then the UI fallbacks. */
240
- function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
261
+ function resolveFontFamilies(families = ["sans-serif"], weight = 400, fontStyle = "normal", point = null, stretch = 100) {
241
262
  const bold = weight >= 600;
242
- const key = `${bold}\n${families.join("\n")}`;
243
- let resolved = resolvedFamilies.get(key);
263
+ const key = `${weight}/${fontStyle}/${stretch}\n${families.join("\n")}`;
264
+ let resolved = point === null && resolvedFamilies.get(key);
244
265
  if (!resolved) {
245
- let primary = families
246
- .map((family) => loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase()))
247
- .find(Boolean) ?? sansSerifFamily;
248
- if (bold && primary === monospaceFamily) primary = monospaceBoldFamily;
249
- resolved = [primary, ...fontFamilies.filter((family) => family !== primary)];
250
- resolvedFamilies.set(key, resolved);
266
+ let primary = [];
267
+ for (const family of families) {
268
+ const web = documentFonts?.resolve(family, weight, fontStyle, point, stretch);
269
+ if (web?.length) { primary.push(...web); continue; }
270
+ const builtin = loadedFamilies.get(family.toLowerCase()) ?? genericFamilies.get(family.toLowerCase());
271
+ if (builtin) { primary.push(bold && builtin === monospaceFamily ? monospaceBoldFamily : builtin); break; }
272
+ }
273
+ if (!primary.length) primary = [sansSerifFamily];
274
+ resolved = [...primary, ...fontFamilies.filter((family) => !primary.includes(family))];
275
+ if (point === null) resolvedFamilies.set(key, resolved);
251
276
  }
252
277
  return resolved;
253
278
  }
254
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
255
- const emojiPresentation = /^\p{Emoji_Presentation}/u;
256
279
  const renderTreeBuilder = new RenderTreeBuilder();
257
280
  const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
258
- const styleEngine = new StyleEngine();
281
+ const styleEngine = new StyleEngine({ measureText });
259
282
  // Incremental restyle: what may have changed style since the last
260
283
  // computation. DOM mutations come from a MutationObserver; state that is
261
284
  // not in the DOM tree (checked, value, focus) is marked by the code that
262
285
  // changes it. `full` asks for a complete restyle.
263
- const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
286
+ const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
287
+ activeElement: null, focusAncestors: new Set() };
264
288
  let selectorTraits = { sibling: false, has: null, sheets: null };
265
289
  let lastStyleParts = "";
266
290
  let laidOutSheets = null;
@@ -304,6 +328,7 @@ function buildPageRenderer({
304
328
  deviceScaleFactor: 1,
305
329
  pageScale: 1,
306
330
  needsPaint: true,
331
+ resourcePaintDirty: false,
307
332
  loading: false,
308
333
  hints: [],
309
334
  hintsVisible: false,
@@ -314,6 +339,7 @@ function buildPageRenderer({
314
339
  backgroundColor: "rgba(255, 255, 255, 1)",
315
340
  resources: new WeakMap(),
316
341
  decodedImages: [],
342
+ imageElements: new Set(),
317
343
  imagesLoaded: null,
318
344
  // CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
319
345
  backgrounds: new Map(),
@@ -339,15 +365,58 @@ function buildPageRenderer({
339
365
  if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
340
366
  return response;
341
367
  }
368
+ const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
369
+ const renderCanvasText = createCanvasTextRenderer(CanvasKit, makeParagraph);
370
+
371
+ function resetDocumentFonts() {
372
+ documentFonts?.dispose();
373
+ fontSheets = null;
374
+ fontDefinitions = [];
375
+ if (fontDefinitionKey !== "[]") clearFontCaches();
376
+ fontEnvironment = "";
377
+ fontDefinitionKey = "[]";
378
+ documentFonts = new DocumentFonts({
379
+ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection, fetch: pageFetch,
380
+ availableFamilies: new Set([...loadedFamilies.keys(), ...genericFamilies.keys()]),
381
+ changed() {
382
+ if (disposedRenderer) return;
383
+ clearFontCaches();
384
+ // Font metrics also affect CSS ch units and normal line-height.
385
+ styleChanges.full = true;
386
+ page.dirty = true;
387
+ page.resourceLayoutDirty = true;
388
+ page.needsPaint = true;
389
+ scheduleFrame();
390
+ },
391
+ error: error => trace?.(`font failed: ${error?.message ?? error}`),
392
+ });
393
+ }
394
+
395
+ function clearFontCaches() {
396
+ resolvedFamilies.clear();
397
+ measuredWidths.clear();
398
+ measuredCount = 0;
399
+ fontKeys = new WeakMap();
400
+ fontMetricsCache.clear();
401
+ clearPaintParagraphs();
402
+ rasterized.list = null;
403
+ }
404
+
405
+ function queueFontLoads() {
406
+ if (page.renderTree && fontDefinitions.length) documentFonts.update(fontDefinitions,
407
+ [...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
408
+ }
342
409
 
343
- function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
410
+ function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
344
411
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
345
412
  const weight = computedStyle?.fontWeight ?? 400;
346
- const families = resolveFontFamilies(computedStyle?.fontFamily, weight);
347
- const emojiFontFamilies = [
348
- ...families.filter((name) => name.includes("Emoji")),
349
- ...families.filter((name) => !name.includes("Emoji")),
350
- ];
413
+ const fontStyle = computedStyle?.fontStyle ?? "normal";
414
+ const stretch = computedStyle?.fontStretch ?? 100;
415
+ const widths = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200];
416
+ const width = widths.reduce((best, value, index) => Math.abs(value - stretch) < Math.abs(widths[best] - stretch) ? index : best, 4) + 1;
417
+ const slant = fontStyle === "italic" ? CanvasKit.FontSlant.Italic
418
+ : fontStyle === "oblique" ? CanvasKit.FontSlant.Oblique : CanvasKit.FontSlant.Upright;
419
+ const families = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch);
351
420
  // text-decoration lines, drawn by SkParagraph under/over/through the glyphs.
352
421
  const decoration = computedStyle?.decoration ? {
353
422
  decoration: computedStyle.decoration.lines.reduce((mask, line) => mask | ({
@@ -361,30 +430,60 @@ function buildPageRenderer({
361
430
  color,
362
431
  fontFamilies: families,
363
432
  fontSize: Math.max(1, Math.round(fontSize * scale)),
364
- fontStyle: { weight },
433
+ fontStyle: { weight, slant, width },
365
434
  // Roboto is a variable font; static fonts ignore the axis and match by weight.
366
- fontVariations: [{ axis: "wght", value: weight }],
435
+ fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
367
436
  ...decoration,
368
437
  },
369
438
  maxLines: 1,
439
+ ...(computedStyle?.direction ? { textDirection: computedStyle.direction === "rtl" ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR } : {}),
370
440
  });
371
- const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, fontCollection);
372
- for (const { segment } of graphemes.segment(text || " ")) {
373
- const emoji = emojiPresentation.test(segment) || segment.includes("\uFE0F");
374
- if (emoji) {
375
- builder.pushStyle(CanvasKit.TextStyle({
376
- color,
377
- ...decoration,
378
- fontFamilies: emojiFontFamilies,
379
- fontSize: Math.max(1, Math.round(fontSize * scale)),
380
- fontStyle: { weight: computedStyle?.fontWeight ?? 400 },
381
- }));
382
- }
383
- builder.addText(segment);
384
- if (emoji) builder.pop();
441
+ const builder = CanvasKit.ParagraphBuilder.MakeFromFontCollection(style, documentFonts?.collection ?? fontCollection);
442
+ const backgroundPaint = foregroundPaint ? new CanvasKit.Paint() : null;
443
+ if (backgroundPaint) backgroundPaint.setColor(CanvasKit.TRANSPARENT);
444
+ const pushTextStyle = foregroundPaint
445
+ ? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
446
+ const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
447
+ fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
448
+ fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
449
+ function append(text, fonts) {
450
+ let emojiStyle;
451
+ const writer = foregroundPaint ? { addText: value => builder.addText(value), pushStyle: pushTextStyle, pop: () => builder.pop() } : builder;
452
+ appendParagraphText(writer, text, () => emojiStyle ??= textStyle([
453
+ ...fonts.filter(name => name.includes("Emoji")), ...fonts.filter(name => !name.includes("Emoji")),
454
+ ]));
385
455
  }
456
+ if (foregroundPaint) pushTextStyle(textStyle(families));
457
+ if (documentFonts?.hasUnicodeRanges && text) {
458
+ // A downloaded subset may physically contain more glyphs than its CSS
459
+ // unicode-range permits. Apply the range when choosing native families,
460
+ // preserving whole adjacent runs for shaping instead of per-letter calls.
461
+ const choices = new Map();
462
+ let run = "", previous = null, previousKey = "";
463
+ const flush = () => {
464
+ if (!run) return;
465
+ pushTextStyle(textStyle(previous));
466
+ append(run, previous);
467
+ builder.pop();
468
+ };
469
+ for (const character of text) {
470
+ const point = character.codePointAt(0);
471
+ let choice = choices.get(point);
472
+ if (!choice) {
473
+ const fonts = resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, point, stretch);
474
+ choices.set(point, choice = { fonts, key: fonts.join("\n") });
475
+ }
476
+ if (run && choice.key !== previousKey) { flush(); run = ""; }
477
+ previous = choice.fonts;
478
+ previousKey = choice.key;
479
+ run += character;
480
+ }
481
+ flush();
482
+ } else append(text, families);
483
+ if (foregroundPaint) builder.pop();
386
484
  const paragraph = builder.build();
387
485
  builder.delete();
486
+ backgroundPaint?.delete();
388
487
  return paragraph;
389
488
  }
390
489
 
@@ -418,22 +517,24 @@ function buildPageRenderer({
418
517
  }
419
518
 
420
519
  // Computed styles are immutable and shared, so each one's key is computed once.
421
- const fontKeys = new WeakMap();
520
+ let fontKeys = new WeakMap();
422
521
  function fontKeyOf(computedStyle) {
423
522
  if (computedStyle && typeof computedStyle === "object") {
424
523
  const cached = fontKeys.get(computedStyle);
425
524
  if (cached) return cached;
426
525
  }
427
526
  const weight = computedStyle?.fontWeight ?? 400;
428
- const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${
429
- resolveFontFamilies(computedStyle?.fontFamily, weight).join(",")}`;
527
+ const fontStyle = computedStyle?.fontStyle ?? "normal";
528
+ const stretch = computedStyle?.fontStretch ?? 100;
529
+ const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
530
+ resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
430
531
  if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
431
532
  return key;
432
533
  }
433
534
 
434
535
  const fontMetricsCache = new Map();
435
536
  function fontMetrics(computedStyle = null) {
436
- const key = `${computedStyle?.fontSize ?? 16}/${computedStyle?.fontWeight ?? 400}/${computedStyle?.fontFamily?.join(",")}`;
537
+ const key = fontKeyOf(computedStyle);
437
538
  let metrics = fontMetricsCache.get(key);
438
539
  if (!metrics) {
439
540
  const paragraph = makeParagraph("Hg", computedStyle);
@@ -450,9 +551,10 @@ function buildPageRenderer({
450
551
  // thread scrolls on its own (keys, fragments, script, a new document).
451
552
  let scrollEpoch = 0;
452
553
  let syncedScroll = { x: 0, y: 0 };
554
+ let syncedViewport = { width: 0, height: 0 };
453
555
  let tileSerial = 0;
454
556
  let lastTile = null;
455
- let encoded = { key: "", png: "" };
557
+ const captures = new FrameCaptureCache();
456
558
  let prepaintTimer = null;
457
559
  let frameTimer = null;
458
560
  let lastRebuildMs = 0;
@@ -463,9 +565,71 @@ function buildPageRenderer({
463
565
  // PaintController reusing cached display items
464
566
  // (platform/graphics/paint/paint_controller.cc, BSD).
465
567
  let rasterized = { list: null, overlay: "", background: "", scale: 0 };
568
+ let nextVideoPresentation = 1;
466
569
  let rasterSurface = null;
467
570
  let rasterSurfaceWidth = 0;
468
571
  let rasterSurfaceHeight = 0;
572
+ const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
573
+ // A scroll or unrelated DOM update does not change text shaping. Keep a
574
+ // bounded set of visible paragraphs keyed by their complete shaping input.
575
+ // Both limits matter: a page may contain many unique or very long strings.
576
+ const paintedParagraphs = new Map();
577
+ let paragraphKeys = new WeakMap();
578
+ let paintedCharacters = 0;
579
+ const MAX_PAINT_PARAGRAPHS = 256;
580
+ const MAX_PAINT_CHARACTERS = 64_000;
581
+
582
+ function releasePaintParagraph(key, entry) {
583
+ entry.paragraph.delete();
584
+ paintedParagraphs.delete(key);
585
+ paintedCharacters -= entry.characters;
586
+ }
587
+
588
+ function clearPaintParagraphs() {
589
+ renderCanvasText.clear();
590
+ for (const entry of paintedParagraphs.values()) entry.paragraph.delete();
591
+ paintedParagraphs.clear();
592
+ paragraphKeys = new WeakMap();
593
+ paintedCharacters = 0;
594
+ }
595
+
596
+ function paragraphForPaint(item, scale, width) {
597
+ const retained = item.text.length <= 4096;
598
+ let cachedKey = retained && paragraphKeys.get(item);
599
+ if (retained && (!cachedKey || cachedKey.scale !== scale || cachedKey.width !== width)) {
600
+ cachedKey = { scale, width, key: JSON.stringify([item.text, item.color, item.font,
601
+ item.decoration, scale, width]) };
602
+ paragraphKeys.set(item, cachedKey);
603
+ }
604
+ const key = cachedKey?.key;
605
+ let entry = retained && paintedParagraphs.get(key);
606
+ if (entry) {
607
+ paintedParagraphs.delete(key);
608
+ paintedParagraphs.set(key, entry);
609
+ return entry;
610
+ }
611
+ const paragraph = makeParagraph(item.text, {
612
+ color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
613
+ fontStyle: item.font.style, fontStretch: item.font.stretch,
614
+ decoration: item.decoration,
615
+ }, undefined, scale);
616
+ try {
617
+ paragraph.layout(width);
618
+ } catch (error) {
619
+ paragraph.delete();
620
+ throw error;
621
+ }
622
+ entry = { paragraph, retained, characters: item.text.length };
623
+ if (entry.retained) {
624
+ paintedParagraphs.set(key, entry);
625
+ paintedCharacters += item.text.length;
626
+ while (paintedParagraphs.size > MAX_PAINT_PARAGRAPHS || paintedCharacters > MAX_PAINT_CHARACTERS) {
627
+ const [oldKey, oldEntry] = paintedParagraphs.entries().next().value;
628
+ releasePaintParagraph(oldKey, oldEntry);
629
+ }
630
+ }
631
+ return entry;
632
+ }
469
633
 
470
634
  /** Hint overlay contents, as a comparable key. */
471
635
  function overlayKey() {
@@ -482,6 +646,8 @@ function buildPageRenderer({
482
646
  if (page.displayList?.items.some((item) => item.fixed)
483
647
  && (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
484
648
  if (rasterized.list === page.displayList) return true;
649
+ if (rasterized.list?.images.length !== page.displayList?.images.length
650
+ || page.displayList?.images.some((resource, index) => resource !== rasterized.list.images[index])) return false;
485
651
  if (!rasterized.list || !page.displayList || !Bun.deepEquals(rasterized.list.items, page.displayList.items)) return false;
486
652
  rasterized.list = page.displayList;
487
653
  return true;
@@ -489,26 +655,46 @@ function buildPageRenderer({
489
655
 
490
656
  /** Rasterizes the viewport and hands it to the compositor. */
491
657
  function render() {
658
+ if (disposedRenderer) return;
659
+ if (page.resourcePaintDirty) {
660
+ page.displayList = paintList();
661
+ page.resourcePaintDirty = false;
662
+ }
663
+ page.realm?.layoutChanged(page.layoutVersion);
664
+ if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
492
665
  const width = Math.max(1, Math.floor(page.width));
493
666
  const height = Math.max(1, Math.floor(page.height));
494
667
  const region = { x: viewport.x, y: viewport.y, width, height };
495
668
  if (lastTile && tileCovers(lastTile, region) && sameRasterInput()) {
496
669
  page.needsPaint = false;
497
670
  // Only this thread's scroll position changed: tell the compositor.
498
- if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) publish(lastTile);
671
+ if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y
672
+ || page.width !== syncedViewport.width || page.height !== syncedViewport.height) publish(lastTile);
499
673
  } else {
500
- publish(rasterTile(region));
674
+ // Keep a previously prepainted region during ordinary page updates.
675
+ // Shrinking to the viewport and expanding again 30ms later used to
676
+ // rasterize each animation twice and repeatedly resize native surfaces.
677
+ const retained = !page.loading && lastTile && tileCovers(lastTile, region)
678
+ && lastTile.height <= height * 2.5;
679
+ publish(rasterTile(retained ? { x: lastTile.x, y: lastTile.y, width: lastTile.width, height: lastTile.height } : region));
501
680
  }
502
681
  schedulePrepaint();
503
682
  }
504
683
 
505
684
  /** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
506
685
  function rasterTile(region) {
686
+ // Prepaint can run before a queued canvas/image repaint. Refresh native
687
+ // snapshots here too: the old display list may hold a released image.
688
+ if (page.resourcePaintDirty) {
689
+ page.displayList = paintList();
690
+ page.resourcePaintDirty = false;
691
+ }
507
692
  const scale = page.deviceScaleFactor;
508
693
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
509
694
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
510
695
  if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
511
- rasterSurface?.delete();
696
+ // MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
697
+ rasterSurface?.dispose();
512
698
  rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
513
699
  rasterSurfaceWidth = pixelWidth;
514
700
  rasterSurfaceHeight = pixelHeight;
@@ -521,14 +707,10 @@ function buildPageRenderer({
521
707
  drawHints(canvas, region);
522
708
  surface.flush();
523
709
  const image = surface.makeImageSnapshot();
524
- const pixels = image.readPixels(0, 0, {
525
- width: pixelWidth,
526
- height: pixelHeight,
527
- colorType: CanvasKit.ColorType.RGBA_8888,
528
- alphaType: CanvasKit.AlphaType.Unpremul,
529
- colorSpace: CanvasKit.ColorSpace.SRGB,
530
- });
531
- image.delete();
710
+ let pixels;
711
+ try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
712
+ finally { image.delete(); }
713
+ const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
532
714
  page.needsPaint = false;
533
715
  rasterized = {
534
716
  list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
@@ -544,12 +726,14 @@ function buildPageRenderer({
544
726
  height: region.height,
545
727
  scale,
546
728
  background: backgroundBytes(page.backgroundColor),
729
+ videoLayer,
547
730
  };
548
731
  }
549
732
 
550
733
  function publish(tile) {
551
734
  lastTile = tile;
552
735
  tileSerial++;
736
+ syncedViewport = { width: page.width, height: page.height };
553
737
  if (viewport.x !== syncedScroll.x || viewport.y !== syncedScroll.y) {
554
738
  scrollEpoch++;
555
739
  syncedScroll = { x: viewport.x, y: viewport.y };
@@ -580,7 +764,7 @@ function buildPageRenderer({
580
764
 
581
765
  /** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
582
766
  function schedulePrepaint() {
583
- if (!onFrame || embedded) return;
767
+ if (disposedRenderer || !onFrame || embedded) return;
584
768
  hostClearTimeout(prepaintTimer);
585
769
  prepaintTimer = hostSetTimeout(() => {
586
770
  prepaintTimer = null;
@@ -599,7 +783,7 @@ function buildPageRenderer({
599
783
 
600
784
  /** A rendering opportunity for DOM changes nobody has asked to see yet. */
601
785
  function scheduleFrame() {
602
- if (!onFrame || frameTimer || page.loading) return;
786
+ if (disposedRenderer || !onFrame || frameTimer || page.loading) return;
603
787
  frameTimer = hostSetTimeout(() => {
604
788
  frameTimer = null;
605
789
  refresh();
@@ -609,32 +793,32 @@ function buildPageRenderer({
609
793
  }, Math.max(50, lastRebuildMs * 2));
610
794
  }
611
795
 
612
- /** The viewport as a PNG (base64), cut from the latest tile. */
613
- async function viewportPng() {
796
+ /** The viewport as an encoded image (base64), cut from the latest tile. */
797
+ async function viewportPng({ format = "png", quality = 80, optimizeForSpeed = false } = {}) {
614
798
  if (!lastTile) return "";
615
- const key = `${tileSerial}:${viewport.x}:${viewport.y}`;
616
- if (encoded.key !== key) {
799
+ const key = `${tileSerial}:${viewport.x}:${viewport.y}:${page.width}:${page.height}:${format}:${quality}:${optimizeForSpeed}`;
800
+ return captures.get(key, () => {
617
801
  const { pixels, width, height } = cropViewport(lastTile, viewport.x, viewport.y, page.width, page.height);
618
- encoded = { key, png: Buffer.from(await encodeImage(pixels, width, height)).toString("base64") };
619
- }
620
- return encoded.png;
802
+ return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
803
+ });
621
804
  }
622
805
 
623
806
  /** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
624
807
  const paintFailures = new Set();
625
808
 
626
- function paintDisplayList(canvas, displayList, view, scale) {
809
+ function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
627
810
  if (!displayList) return;
628
811
  const pixel = (value) => Math.round(value * scale);
629
812
  const deviceRect = (rect) => CanvasKit.XYWHRect(
630
813
  pixel(rect.x - view.x), pixel(rect.y - view.y), pixel(rect.width), pixel(rect.height),
631
814
  );
632
815
  const deviceRRect = (rect, radii) => radii && (radii.x > 0 || radii.y > 0)
633
- ? CanvasKit.RRectXY(deviceRect(rect), radii.x * scale, radii.y * scale)
816
+ ? roundedRect(deviceRect(rect), radii, scale)
634
817
  : null;
635
818
  // Returns a paint plus a release function that also frees its shader.
636
819
  const fillPaint = (item) => {
637
820
  const paint = new CanvasKit.Paint();
821
+ if (item.radii && (item.radii.x > 0 || item.radii.y > 0)) paint.setAntiAlias(true);
638
822
  const shader = item.gradient ? gradientShader(item.gradient, view, scale) : null;
639
823
  if (shader) paint.setShader(shader);
640
824
  else paint.setColor(canvasColor(CanvasKit, item.color));
@@ -654,6 +838,9 @@ function buildPageRenderer({
654
838
  try {
655
839
  if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
656
840
  if (item.clipRect) canvas.clipRect(deviceRect(item.clipRect), CanvasKit.ClipOp.Intersect, true);
841
+ for (const clip of item.roundedClips ?? []) {
842
+ canvas.clipRRect(deviceRRect(clip.rect, clip.radii), CanvasKit.ClipOp.Intersect, true);
843
+ }
657
844
  // A scale in document space, as a device-space matrix around the item's device coordinates.
658
845
  if (item.transform) {
659
846
  const { a, d, e, f } = item.transform;
@@ -790,7 +977,10 @@ function buildPageRenderer({
790
977
  canvas.clipRect(rect, CanvasKit.ClipOp.Intersect, true);
791
978
  paint.setColor(CanvasKit.Color(0, 0, 0, 1));
792
979
  canvas.drawRect(rect, paint);
793
- if (resource?.image) {
980
+ if (videoOverride?.resource === resource) {
981
+ paint.setColor(CanvasKit.Color(videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, 1));
982
+ canvas.drawRect(rect, paint);
983
+ } else if (resource?.image) {
794
984
  canvas.drawImageRect(resource.image,
795
985
  CanvasKit.XYWHRect(0, 0, resource.bitmapWidth ?? resource.width, resource.bitmapHeight ?? resource.height),
796
986
  rect, null, false);
@@ -798,7 +988,7 @@ function buildPageRenderer({
798
988
  const centerX = rect[0] + (rect[2] - rect[0]) / 2;
799
989
  const centerY = rect[1] + (rect[3] - rect[1]) / 2;
800
990
  paint.setColor(CanvasKit.Color(0, 0, 0, 0.65));
801
- if (!resource?.playing) {
991
+ if (item.controls && !resource?.playing) {
802
992
  canvas.drawCircle(centerX, centerY, 22 * scale, paint);
803
993
  paint.setColor(CanvasKit.Color(255, 255, 255, 1));
804
994
  const path = CanvasKit.Path.MakeFromSVGString(
@@ -806,7 +996,7 @@ function buildPageRenderer({
806
996
  if (path) { canvas.drawPath(path, paint); path.delete(); }
807
997
  }
808
998
  // The control bar (play/pause, time, seekable progress) once playing or known.
809
- if (resource?.playing || Number.isFinite(resource?.duration)) {
999
+ if (item.controls && (resource?.playing || Number.isFinite(resource?.duration))) {
810
1000
  drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
811
1001
  item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
812
1002
  }
@@ -818,25 +1008,23 @@ function buildPageRenderer({
818
1008
  } else if (item.op === "drawSvg") {
819
1009
  drawSvg(item);
820
1010
  } else if (item.op === "drawText") {
821
- const textStyle = {
822
- color: item.color, fontSize: item.font.size, fontWeight: item.font.weight, fontFamily: item.font.family,
823
- decoration: item.decoration,
824
- };
825
- const paragraph = makeParagraph(item.text, textStyle, undefined, scale);
826
- paragraph.layout(pixel(Math.max(1, view.width + view.x - item.x)));
827
- if (item.gradient) {
828
- // background-clip:text: draw glyphs, then keep the gradient only where they are.
829
- canvas.saveLayer(null, deviceRect(item.bounds));
830
- canvas.drawParagraph(paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
831
- const paint = fillPaint(item);
832
- paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
833
- canvas.drawRect(deviceRect(item.bounds), paint);
834
- paint.release();
835
- canvas.restore();
836
- } else {
837
- canvas.drawParagraph(paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1011
+ const entry = paragraphForPaint(item, scale, pixel(Math.max(1, view.width + view.x - item.x)));
1012
+ try {
1013
+ if (item.gradient) {
1014
+ // background-clip:text: draw glyphs, then keep the gradient only where they are.
1015
+ canvas.saveLayer(null, deviceRect(item.bounds));
1016
+ canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1017
+ const paint = fillPaint(item);
1018
+ paint.setBlendMode(CanvasKit.BlendMode.SrcIn);
1019
+ canvas.drawRect(deviceRect(item.bounds), paint);
1020
+ paint.release();
1021
+ canvas.restore();
1022
+ } else {
1023
+ canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1024
+ }
1025
+ } finally {
1026
+ if (!entry.retained) entry.paragraph.delete();
838
1027
  }
839
- paragraph.delete();
840
1028
  }
841
1029
  }
842
1030
  }
@@ -853,13 +1041,9 @@ function buildPageRenderer({
853
1041
  const Y0 = (item.rect.y - view.y) * scale;
854
1042
  const X1 = X0 + item.rect.width * scale;
855
1043
  const Y1 = Y0 + item.rect.height * scale;
856
- const rx = item.radii.x * scale;
857
- const ry = item.radii.y * scale;
858
1044
  const [x0, y0, x1, y1] = [X0 + left, Y0 + top, Math.max(X0 + left, X1 - right), Math.max(Y0 + top, Y1 - bottom)];
859
- const outer = Float32Array.of(X0, Y0, X1, Y1, rx, ry, rx, ry, rx, ry, rx, ry);
860
- const inner = Float32Array.of(x0, y0, x1, y1,
861
- Math.max(0, rx - left), Math.max(0, ry - top), Math.max(0, rx - right), Math.max(0, ry - top),
862
- Math.max(0, rx - right), Math.max(0, ry - bottom), Math.max(0, rx - left), Math.max(0, ry - bottom));
1045
+ const outer = roundedRect([X0, Y0, X1, Y1], item.radii, scale);
1046
+ const inner = roundedRect([x0, y0, x1, y1], insetRadii(item.radii, item.widths), scale);
863
1047
  const sides = [
864
1048
  [X0, Y0, X1, Y0, x1, y0, x0, y0],
865
1049
  [X1, Y0, X1, Y1, x1, y1, x1, y0],
@@ -1016,6 +1200,29 @@ function buildPageRenderer({
1016
1200
  }
1017
1201
  }
1018
1202
 
1203
+ function nativeControlState(element) {
1204
+ return [element.value, element.checked, element.indeterminate, element.selected, element.selectedIndex];
1205
+ }
1206
+
1207
+ /** HTML activation can change control properties without attribute records
1208
+ * (including a different radio in the same group). Compare these few native
1209
+ * states instead of restyling the entire document for every pointer click.
1210
+ */
1211
+ function controlStatesBeforeActivation() {
1212
+ return new Map(Array.from(page.document.querySelectorAll("input,select,textarea,option"),
1213
+ element => [element,nativeControlState(element)]));
1214
+ }
1215
+
1216
+ function invalidateActivatedControls(previous) {
1217
+ for (const [element,before] of previous) {
1218
+ const after = nativeControlState(element);
1219
+ if (before.some((value,index) => value !== after[index])) {
1220
+ markStyleDirty(element);
1221
+ page.dirty = true;
1222
+ }
1223
+ }
1224
+ }
1225
+
1019
1226
  function observeStyleChanges(document) {
1020
1227
  styleChanges.observer?.disconnect();
1021
1228
  styleChanges.records = [];
@@ -1023,6 +1230,7 @@ function buildPageRenderer({
1023
1230
  styleChanges.rootFeatures = new Map();
1024
1231
  styleChanges.full = "new document";
1025
1232
  styleChanges.activeElement = null;
1233
+ styleChanges.focusAncestors = new Set();
1026
1234
  const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
1027
1235
  observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
1028
1236
  styleChanges.observer = observer;
@@ -1047,6 +1255,7 @@ function buildPageRenderer({
1047
1255
  descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
1048
1256
  descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
1049
1257
  siblingTargetCompounds: [...union("siblingTargetCompounds")],
1258
+ observedAttributes: union("observedAttributes"),
1050
1259
  forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
1051
1260
  defined: any("defined"), target: any("target"),
1052
1261
  };
@@ -1176,11 +1385,12 @@ function buildPageRenderer({
1176
1385
  let treeDirty = false;
1177
1386
  let full = styleChanges.full;
1178
1387
  const active = page.document.activeElement;
1179
- if (active !== styleChanges.activeElement) {
1180
- // :focus, :focus-within and :focus-visible follow the focused element.
1181
- if (styleChanges.activeElement !== null) full ||= "focus moved";
1182
- styleChanges.activeElement = active;
1183
- }
1388
+ const previousActive = styleChanges.activeElement;
1389
+ const previousFocusAncestors = styleChanges.focusAncestors;
1390
+ const focusAncestors = new Set();
1391
+ for (let element = active; element; element = element.parentElement) focusAncestors.add(element);
1392
+ styleChanges.activeElement = active;
1393
+ styleChanges.focusAncestors = focusAncestors;
1184
1394
  // Elements whose subtree restyles (`roots`), and elements whose own
1185
1395
  // matches may change but whose subtree restyles only if their style does
1186
1396
  // (`self`, decided by the style engine).
@@ -1208,7 +1418,8 @@ function buildPageRenderer({
1208
1418
  if (!states.length) return false;
1209
1419
  const available = new Set(elementFeatures(element));
1210
1420
  return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
1211
- && entry.required.every((feature) => available.has(feature)));
1421
+ && entry.required.every((feature) => available.has(feature))
1422
+ && entry.alternatives.every(group => group.some(required => required.every(feature => available.has(feature)))));
1212
1423
  };
1213
1424
  /** An element whose features changed: itself, and wider where non-subject compounds use them. */
1214
1425
  const changed = (element, features) => {
@@ -1225,22 +1436,38 @@ function buildPageRenderer({
1225
1436
  if (matchesChanged(structuralState, element)) roots.add(element);
1226
1437
  };
1227
1438
  if (!full && !hasState) full = "first restyle";
1228
- // Marked elements: state outside the DOM (checked, value) or a DOM change
1229
- // the records also describe; both start as a change of the element's state.
1230
- for (const element of [...roots]) {
1231
- roots.delete(element);
1439
+ // Only the explicitly marked roots need converting to feature changes;
1440
+ // roots added by relational/focus invalidation below must remain roots.
1441
+ const markedRoots = [...roots];
1442
+ roots.clear();
1443
+ for (const element of markedRoots) {
1232
1444
  if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
1233
1445
  }
1446
+ if (!full) {
1447
+ // Focus affects the two subjects, their :focus-within ancestor paths,
1448
+ // and selectors related to those nodes. Keep the previous path because
1449
+ // the old focused element may have been removed or reparented already.
1450
+ if (active !== previousActive) {
1451
+ if (previousActive) changed(previousActive, [":focus", ":focus-visible"]);
1452
+ if (active) changed(active, [":focus", ":focus-visible"]);
1453
+ }
1454
+ for (const element of previousFocusAncestors) if (!focusAncestors.has(element)) changed(element, [":focus-within"]);
1455
+ for (const element of focusAncestors) if (!previousFocusAncestors.has(element)) changed(element, [":focus-within"]);
1456
+ }
1234
1457
  for (const record of records) {
1235
1458
  if (full) break;
1236
1459
  const target = record.target;
1237
1460
  if (record.type === "attributes") {
1238
1461
  if (target.nodeType !== 1) continue;
1239
1462
  const name = record.attributeName;
1463
+ // Happy DOM reports null for an empty attribute's previous value on
1464
+ // removal. Null therefore cannot prove that the attribute is unchanged.
1465
+ if (record.oldValue !== null && record.oldValue === target.getAttribute(name)) continue;
1240
1466
  // data/ARIA/title changes have no direct render-tree representation;
1241
1467
  // selectors that observe them are handled by the style diff below.
1242
1468
  // Other attributes may alter controls, replaced elements or visibility.
1243
1469
  if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
1470
+ else if (!traits.observedAttributes.has(name.toLowerCase())) continue;
1244
1471
  const features = ["[" + name];
1245
1472
  if (name === "class") {
1246
1473
  const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
@@ -1253,6 +1480,12 @@ function buildPageRenderer({
1253
1480
  if (id) features.push("#" + id);
1254
1481
  }
1255
1482
  changed(target, features);
1483
+ // The UA disclosure marker depends on its parent's open state even
1484
+ // when no author selector observes [open].
1485
+ if (name === "open" && target.tagName === "DETAILS") {
1486
+ const summary = [...target.children].find(element => element.tagName === "SUMMARY");
1487
+ if (summary) self.add(summary);
1488
+ }
1256
1489
  } else if (record.type === "childList") {
1257
1490
  treeDirty = true;
1258
1491
  if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
@@ -1267,6 +1500,11 @@ function buildPageRenderer({
1267
1500
  }
1268
1501
  }
1269
1502
  self.add(target);
1503
+ // Adding, removing or moving a summary can change which one receives
1504
+ // the UA list-item display and disclosure marker.
1505
+ if (target.tagName === "DETAILS") {
1506
+ for (const child of target.children) if (child.tagName === "SUMMARY") self.add(child);
1507
+ }
1270
1508
  hasChanges(traits, target, add, hasChecked);
1271
1509
  let next = record.nextSibling;
1272
1510
  while (next && next.nodeType !== 1) next = next.nextSibling;
@@ -1305,16 +1543,26 @@ function buildPageRenderer({
1305
1543
  }
1306
1544
 
1307
1545
  function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1546
+ if (page.document) loadImageResource.cancelDocument(page.document);
1547
+ rasterReadback.dispose();
1548
+ clearPaintParagraphs();
1549
+ resetDocumentFonts();
1308
1550
  disposeFrames();
1309
1551
  closePiano();
1310
1552
  disposeVideos();
1311
1553
  page.realm?.dispose();
1312
1554
  page.realm = null;
1313
- page.window?.happyDOM?.abort?.();
1555
+ page.window?.happyDOM?.close?.();
1314
1556
  for (const image of page.decodedImages) image.delete();
1315
1557
  disposeBackgrounds();
1316
1558
  page.window = parsed.window;
1317
1559
  page.document = parsed.document;
1560
+ observeDocumentCanvases(parsed.document, (node) => {
1561
+ if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
1562
+ page.resourcePaintDirty = true;
1563
+ page.needsPaint = true;
1564
+ scheduleFrame();
1565
+ });
1318
1566
  observeStyleChanges(parsed.document);
1319
1567
  page.title = parsed.document.title;
1320
1568
  page.styleSheets = styleSheets;
@@ -1322,6 +1570,8 @@ function buildPageRenderer({
1322
1570
  page.resources = imageResources.map;
1323
1571
  initializeVideos(parsed.document, page.resources);
1324
1572
  page.decodedImages = imageResources.decoded;
1573
+ page.imageElements = new Set(imageResources.entries.map(entry => entry.element));
1574
+ page.resourcePaintDirty = false;
1325
1575
  page.imagesLoaded = null;
1326
1576
  imageRequests = new WeakMap();
1327
1577
  page.elementScroll = new Map();
@@ -1342,6 +1592,20 @@ function buildPageRenderer({
1342
1592
  const ms = (from, to) => Math.round(to - from);
1343
1593
  const sheets = currentStyleSheets();
1344
1594
  const environment = { width: page.width, height: page.height, mobile };
1595
+ const documentBaseURL = page.document.baseURI || page.url;
1596
+ const environmentKey = `${page.width}/${page.height}/${mobile}/${documentBaseURL}`;
1597
+ if (fontSheets !== sheets || fontEnvironment !== environmentKey) {
1598
+ fontSheets = sheets;
1599
+ fontEnvironment = environmentKey;
1600
+ fontDefinitions = sheets.flatMap(sheet => /@font-face\b/i.test(sheet) ? extractFontFaces(sheet, documentBaseURL, environment) : []);
1601
+ documentFonts.setFaces(fontDefinitions);
1602
+ const key = JSON.stringify(fontDefinitions);
1603
+ if (key !== fontDefinitionKey) {
1604
+ fontDefinitionKey = key;
1605
+ clearFontCaches();
1606
+ page.resourceLayoutDirty = true;
1607
+ }
1608
+ }
1345
1609
  const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
1346
1610
  const taken = hostPerformance.now();
1347
1611
  // A rendering opportunity can be scheduled by lifecycle work which did
@@ -1391,17 +1655,19 @@ function buildPageRenderer({
1391
1655
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
1392
1656
  });
1393
1657
  page.displayList = paintList();
1658
+ page.resourcePaintDirty = false;
1394
1659
  // The content may now reach less far than a container was scrolled.
1395
1660
  if (clampElementScroll()) page.displayList = paintList();
1396
1661
  page.resourceLayoutDirty = false;
1397
1662
  laidOutSheets = sheets;
1398
1663
  setContentSize();
1664
+ queueFontLoads();
1399
1665
  return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
1400
1666
  }
1401
1667
 
1402
1668
  /** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
1403
1669
  function verifyIncrementalStyle(sheets, environment) {
1404
- const reference = new StyleEngine().compute(page.document, sheets, environment);
1670
+ const reference = new StyleEngine({ measureText }).compute(page.document, sheets, environment);
1405
1671
  const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
1406
1672
  let mismatches = 0;
1407
1673
  for (const element of page.document.querySelectorAll("*")) {
@@ -1437,13 +1703,14 @@ function buildPageRenderer({
1437
1703
  staleImages = queueImageResources();
1438
1704
  }
1439
1705
  const timing = styleAndLayout();
1440
- for (const image of staleImages) {
1441
- const index = page.decodedImages.indexOf(image);
1442
- if (index >= 0) page.decodedImages.splice(index, 1);
1443
- image.delete();
1706
+ if (staleImages.length) {
1707
+ const stale = new Set(staleImages);
1708
+ page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
1709
+ for (const image of stale) image.delete();
1444
1710
  }
1445
1711
  if (!timing.skipped) page.needsPaint = true;
1446
1712
  syncFrames();
1713
+ page.realm?.layoutChanged(page.layoutVersion);
1447
1714
  if (!timing.skipped) queueBackgroundImages();
1448
1715
  lastRebuildMs = hostPerformance.now() - started;
1449
1716
  trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
@@ -1462,6 +1729,7 @@ function buildPageRenderer({
1462
1729
  * page); the rest of the load continues, see `whenLoaded`.
1463
1730
  */
1464
1731
  function loadDocument(document) {
1732
+ if (disposedRenderer) return Promise.reject(new Error("Renderer has been disposed"));
1465
1733
  const generation = ++loadGeneration;
1466
1734
  let commit;
1467
1735
  const committed = new Promise((resolve, reject) => {
@@ -1481,7 +1749,7 @@ function buildPageRenderer({
1481
1749
  }
1482
1750
 
1483
1751
  async function runLoad({
1484
- url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "",
1752
+ url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
1485
1753
  }, generation, commit) {
1486
1754
  const current = () => generation === loadGeneration;
1487
1755
  const finish = () => {
@@ -1525,7 +1793,7 @@ function buildPageRenderer({
1525
1793
  phaseStart = now;
1526
1794
  } : () => {};
1527
1795
  const isHtml = contentType.toLowerCase().includes("html");
1528
- const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
1796
+ const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url, referrer);
1529
1797
  phase("parse");
1530
1798
  // Stylesheets block rendering and scripts; images do not (only the
1531
1799
  // window load event waits for them, see whenImagesLoaded): one slow image
@@ -1592,6 +1860,9 @@ function buildPageRenderer({
1592
1860
  refresh();
1593
1861
  phase("style, layout and raster after scripts");
1594
1862
  } else {
1863
+ await documentFonts.ready();
1864
+ if (!current()) return;
1865
+ refresh();
1595
1866
  finish();
1596
1867
  }
1597
1868
  }
@@ -1607,7 +1878,7 @@ function buildPageRenderer({
1607
1878
  const preloaded = new Map();
1608
1879
  const preload = (href) => {
1609
1880
  let target;
1610
- try { target = new URL(href, documentUrl); } catch { return; }
1881
+ try { target = new URL(href, document.baseURI || documentUrl); } catch { return; }
1611
1882
  if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
1612
1883
  const response = fetchResource(target.href, "script");
1613
1884
  response.catch(() => {}); // reported when the script runs
@@ -1623,7 +1894,8 @@ function buildPageRenderer({
1623
1894
 
1624
1895
  /** Frees what a superseded load fetched. */
1625
1896
  async function discard(images, parsed = null) {
1626
- parsed?.window?.happyDOM?.abort?.();
1897
+ if (parsed?.document) loadImageResource.cancelDocument(parsed.document);
1898
+ parsed?.window?.happyDOM?.close?.();
1627
1899
  const loaded = await images.catch(() => emptyImageResources());
1628
1900
  for (const image of loaded.decoded) image.delete();
1629
1901
  }
@@ -1638,7 +1910,7 @@ function buildPageRenderer({
1638
1910
  // queueImageResources must not fetch these again.
1639
1911
  for (const element of document.querySelectorAll("img")) {
1640
1912
  const src = element.getAttribute("src");
1641
- if (src) imageRequests.set(element, src);
1913
+ if (src) imageRequests.set(element, imageRequestKey(src, imageCrossOrigin(element)));
1642
1914
  }
1643
1915
  page.imagesLoaded = images.then((loaded) => {
1644
1916
  if (generation !== loadGeneration || document !== page.document) {
@@ -1646,12 +1918,14 @@ function buildPageRenderer({
1646
1918
  return;
1647
1919
  }
1648
1920
  for (const { element, src, resource } of loaded.entries) {
1649
- if (element.getAttribute("src") !== src || page.resources.get(element)) {
1921
+ if (element.getAttribute("src") !== src || imageCrossOrigin(element) !== resource.crossOrigin || page.resources.get(element)) {
1650
1922
  resource.image.delete();
1651
1923
  continue;
1652
1924
  }
1653
1925
  page.resources.set(element, resource);
1654
1926
  page.decodedImages.push(resource.image);
1927
+ page.imageElements.add(element);
1928
+ finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
1655
1929
  }
1656
1930
  page.dirty = true;
1657
1931
  page.resourceLayoutDirty = true;
@@ -1661,31 +1935,51 @@ function buildPageRenderer({
1661
1935
 
1662
1936
  function inlineStyleSheets(document) {
1663
1937
  return [...document.querySelectorAll("style")]
1664
- .filter((element) => !element.closest("noscript"))
1938
+ .filter((element) => isCssStyleSheet(element) && styleSheetMatches(element))
1665
1939
  .map((element) => element.textContent ?? "");
1666
1940
  }
1667
1941
 
1942
+ function isCssStyleSheet(element) {
1943
+ const type = element.getAttribute("type")?.trim().toLowerCase();
1944
+ return (!type || type === "text/css") && !element.closest("noscript");
1945
+ }
1946
+
1947
+ function styleSheetMatches(element) {
1948
+ return mediaQueryMatches(element.getAttribute("media") ?? "", { width: page.width, height: page.height, mobile })
1949
+ && !(element.tagName === "LINK" && element.hasAttribute("disabled"));
1950
+ }
1951
+
1668
1952
  /** Brings style, layout and raster up to date; returns whether it repainted. */
1669
1953
  function refresh() {
1954
+ if (disposedRenderer) return false;
1670
1955
  if (page.dirty) rebuildRenderTree();
1671
1956
  if (!page.needsPaint) return false;
1672
1957
  render();
1673
1958
  return true;
1674
1959
  }
1675
1960
 
1961
+ function isDisclosureControl(element) {
1962
+ if (element?.tagName === "DETAILS") return ![...element.children].some(child => child.tagName === "SUMMARY");
1963
+ return element?.tagName === "SUMMARY" && element.parentElement?.tagName === "DETAILS"
1964
+ && [...element.parentElement.children].find(child => child.tagName === "SUMMARY") === element;
1965
+ }
1966
+
1967
+ function activateDisclosure(element) {
1968
+ const prevented = page.realm.dispatch(element, new page.window.MouseEvent("click", {
1969
+ bubbles: true, cancelable: true, composed: true, detail: 0,
1970
+ }));
1971
+ // An explicit summary is activated by the DOM's click default action.
1972
+ // The generated label has no DOM summary, so its owner toggles here.
1973
+ if (!prevented && element.tagName === "DETAILS") element.open = !element.open;
1974
+ }
1975
+
1676
1976
  function editFocusedText(text, backspace = false) {
1677
1977
  const element = page.document.activeElement;
1678
1978
  if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
1679
1979
  if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
1680
1980
  // Mobile IMEs often send committed text without a usable keydown (key 229
1681
1981
  // or Unidentified). Handle that before the normal beforeinput insertion.
1682
- if (!backspace && element.getAttribute("id") === "buninu-piano-input" && audio && text.length === 1) {
1683
- const note = pianoNote(text);
1684
- if (note !== null) {
1685
- playPiano(note);
1686
- return;
1687
- }
1688
- }
1982
+ if (!backspace && handlePianoInput(element, { text }, audio ? playPiano : null)) return;
1689
1983
  const value = String(element.value ?? "");
1690
1984
  let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
1691
1985
  const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
@@ -1757,6 +2051,31 @@ function buildPageRenderer({
1757
2051
  page.dirty = true;
1758
2052
  scheduleFrame();
1759
2053
  },
2054
+ activationState: controlStatesBeforeActivation,
2055
+ activationChanged(previous) {
2056
+ invalidateActivatedControls(previous);
2057
+ if (page.dirty) scheduleFrame();
2058
+ },
2059
+ canvasImage(element) {
2060
+ if (!element) return null;
2061
+ if (element.tagName === "CANVAS") return canvasResource(element);
2062
+ const resource = page.resources.get(element);
2063
+ if (!resource?.image) return null;
2064
+ const url = resource.url ?? element.src ?? "";
2065
+ const originClean = resource.originClean ?? (url.startsWith("data:") || originOf(url) === originOf(page.url));
2066
+ return { ...resource, originClean };
2067
+ },
2068
+ canvasFont(element, value) {
2069
+ return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
2070
+ styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
2071
+ },
2072
+ canvasText(element, request) {
2073
+ const style = { ...request.settings.parsedFont,
2074
+ direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
2075
+ documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
2076
+ return renderCanvasText({ ...request, style,
2077
+ settings: { ...request.settings, direction: style.direction } });
2078
+ },
1760
2079
  mediaState,
1761
2080
  playMedia,
1762
2081
  pauseMedia,
@@ -1780,7 +2099,7 @@ function buildPageRenderer({
1780
2099
  scheduleFrame();
1781
2100
  }
1782
2101
  },
1783
- whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
2102
+ whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
1784
2103
  afterTask() {},
1785
2104
  boundsOf(element) {
1786
2105
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -1812,14 +2131,19 @@ function buildPageRenderer({
1812
2131
  if (page.dirty) rebuildRenderTree("script scrolled");
1813
2132
  setElementScroll(element, x, y);
1814
2133
  },
1815
- scrollIntoView(element) {
2134
+ scrollIntoView(element, options) {
1816
2135
  if (page.dirty) rebuildRenderTree("script scrolled");
1817
- revealElement(element);
2136
+ revealElement(element, options);
1818
2137
  },
1819
2138
  computedStyle(element) {
1820
2139
  if (!element) return null;
1821
2140
  if (page.dirty) rebuildRenderTree("getComputedStyle");
1822
- return styleEngine.get(element);
2141
+ const style = styleEngine.get(element);
2142
+ if (style?.padding.some(value => typeof value === "object")) {
2143
+ const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
2144
+ if (box) return { ...style, padding: box.style.padding };
2145
+ }
2146
+ return style;
1823
2147
  },
1824
2148
  layoutVersion() {
1825
2149
  if (page.dirty) rebuildRenderTree("intersection observer");
@@ -1832,7 +2156,7 @@ function buildPageRenderer({
1832
2156
  },
1833
2157
  navigate: (url) => navigateTo(String(url)),
1834
2158
  activateLink(anchor) {
1835
- const href = new URL(anchor.getAttribute("href"), page.url).href;
2159
+ const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
1836
2160
  const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
1837
2161
  if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
1838
2162
  else navigateTo(href, name);
@@ -1874,7 +2198,8 @@ function buildPageRenderer({
1874
2198
  if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
1875
2199
  return response;
1876
2200
  });
1877
- result.abort = () => networkPending.abort?.();
2201
+ result.abort = (reason) => networkPending.abort?.(reason);
2202
+ result.finished = networkPending.finished;
1878
2203
  return result;
1879
2204
  },
1880
2205
  };
@@ -1888,7 +2213,7 @@ function buildPageRenderer({
1888
2213
  function submitForm(form, submitter = null) {
1889
2214
  const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
1890
2215
  const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
1891
- const url = new URL(action || page.url, page.url);
2216
+ const url = new URL(action || page.url, form.baseURI || page.url);
1892
2217
  const entries = [...new page.window.FormData(form, submitter)];
1893
2218
  if (method === "post") {
1894
2219
  const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
@@ -1938,14 +2263,7 @@ function buildPageRenderer({
1938
2263
  const shift = scrollShift(element);
1939
2264
  const scrollX = (fixed ? 0 : viewport.x) + shift.x;
1940
2265
  const scrollY = (fixed ? 0 : viewport.y) + shift.y;
1941
- let nodeId = null;
1942
- for (const node of page.renderTree.nodesById.values()) {
1943
- if (node.domNode === element && !node.pseudo) {
1944
- nodeId = node.id;
1945
- break;
1946
- }
1947
- }
1948
- const box = nodeId == null ? null : page.layout.boxes.find((candidate) => candidate.nodeId === nodeId && !candidate.inline);
2266
+ const box = elementBox(page.renderTree, page.layout, element)?.box;
1949
2267
  if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
1950
2268
  const content = elementBounds(page.renderTree, page.layout, element);
1951
2269
  return content
@@ -2129,9 +2447,9 @@ function buildPageRenderer({
2129
2447
  rebuildRenderTree();
2130
2448
  render();
2131
2449
  },
2132
- screenshot() {
2450
+ screenshot(options) {
2133
2451
  refresh();
2134
- return viewportPng();
2452
+ return viewportPng(options);
2135
2453
  },
2136
2454
  /**
2137
2455
  * Diagnostics (not exposed over IPC): page-space border boxes and chosen
@@ -2199,6 +2517,7 @@ function buildPageRenderer({
2199
2517
  /** The compositor scrolled: follow it, and rasterize around the new position when idle. */
2200
2518
  applyCompositorScroll(x, y) {
2201
2519
  viewport.scrollTo(x, y);
2520
+ page.realm?.layoutChanged(page.layoutVersion);
2202
2521
  syncedScroll = { x: viewport.x, y: viewport.y };
2203
2522
  schedulePrepaint();
2204
2523
  },
@@ -2215,26 +2534,27 @@ function buildPageRenderer({
2215
2534
  if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
2216
2535
  refresh();
2217
2536
  const pianoInput = page.document?.activeElement;
2218
- if (audio && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
2219
- const note = pianoNote(String(key), String(options.code ?? ""));
2220
- if (note !== null && !options.repeat && !options.modifiers) {
2221
- playPiano(note);
2222
- return { x: viewport.x, y: viewport.y };
2223
- }
2537
+ if (handlePianoInput(pianoInput, { ...options, key }, audio ? playPiano : null)) {
2538
+ return { x: viewport.x, y: viewport.y };
2224
2539
  }
2225
2540
  // Pages see keydown/keyup first; preventDefault() cancels the default scroll.
2226
2541
  if (page.realm) {
2227
2542
  const target = page.document.activeElement ?? page.document.body;
2228
2543
  const modifiers = Number(options?.modifiers) || 0;
2229
2544
  const init = {
2230
- key: String(key), code: String(options?.code ?? ""), repeat: Boolean(options?.repeat),
2545
+ ...keyboardInput(key, options), repeat: Boolean(options?.repeat),
2231
2546
  altKey: Boolean(modifiers & 1), ctrlKey: Boolean(modifiers & 2),
2232
2547
  metaKey: Boolean(modifiers & 4), shiftKey: Boolean(modifiers & 8),
2233
2548
  bubbles: true, cancelable: true,
2234
2549
  };
2235
2550
  const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
2551
+ let activated = false;
2236
2552
  if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
2237
- if (key === "Backspace") editFocusedText("", true);
2553
+ if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
2554
+ activated = true;
2555
+ if (key === "Enter") activateDisclosure(target);
2556
+ }
2557
+ else if (key === "Backspace") editFocusedText("", true);
2238
2558
  else if (key === "Enter" && target.tagName === "TEXTAREA") {
2239
2559
  // A search IME's action key submits a search form. Other textareas
2240
2560
  // keep their normal newline behavior.
@@ -2246,8 +2566,9 @@ function buildPageRenderer({
2246
2566
  }
2247
2567
  else if (String(key).length === 1) editFocusedText(String(key));
2248
2568
  }
2249
- page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
2250
- if (refresh() || prevented) return { x: viewport.x, y: viewport.y };
2569
+ const upPrevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
2570
+ if (activated && key === " " && !upPrevented) activateDisclosure(target);
2571
+ if (refresh() || prevented || activated) return { x: viewport.x, y: viewport.y };
2251
2572
  }
2252
2573
  const pageStep = Math.max(31, viewport.height - 31);
2253
2574
  const delta = {
@@ -2272,10 +2593,37 @@ function buildPageRenderer({
2272
2593
  scrollOffset() {
2273
2594
  return { x: viewport.x, y: viewport.y };
2274
2595
  },
2596
+ videoReport(report) {
2597
+ const state = [...page.media.values()].find(state => state.video?.presentation?.id === report.id);
2598
+ if (!state || !state.playing) return;
2599
+ if (report.error) {
2600
+ state.presentationFailed = true;
2601
+ stopVideoPresentation(state);
2602
+ const serial = state.seekSerial;
2603
+ void placeVideo(state, mediaTime(state)).then(() => {
2604
+ if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
2605
+ }).catch(reportError);
2606
+ } else if (Number.isFinite(report.end)) {
2607
+ state.video.frameTime = report.end;
2608
+ if (needsMore(state.video)) void refillMedia(state, state.video);
2609
+ else {
2610
+ readPresentedFrame(state);
2611
+ stopVideoPresentation(state);
2612
+ state.video.done = true;
2613
+ }
2614
+ } else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
2615
+ },
2275
2616
  /** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
2276
2617
  wheel(x, y, deltaX, deltaY, options = undefined) {
2277
2618
  return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
2278
2619
  },
2620
+ /** Move the pointer in viewport coordinates, including boundary events. */
2621
+ move(x, y) {
2622
+ refresh();
2623
+ const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport);
2624
+ dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y));
2625
+ refresh();
2626
+ },
2279
2627
  /**
2280
2628
  * Clicks at a viewport point: the page receives a click event first, and
2281
2629
  * unless it calls preventDefault() a link under the point is returned for
@@ -2283,9 +2631,6 @@ function buildPageRenderer({
2283
2631
  */
2284
2632
  click(x, y) {
2285
2633
  refresh();
2286
- // Activation can toggle checked state (radios in a group, through a
2287
- // label) without a DOM mutation, so the next restyle is a full one.
2288
- markStyleDirty(null); // full: click
2289
2634
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
2290
2635
  let forwarded = false;
2291
2636
  if (!region) {
@@ -2308,12 +2653,17 @@ function buildPageRenderer({
2308
2653
  // it is now (scroll containers move their content); the interactive
2309
2654
  // regions (laid out, unscrolled) stand in only where nothing is painted,
2310
2655
  // and a search form's forwarded field takes the click on the form.
2311
- const precise = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2656
+ const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2657
+ const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
2312
2658
  const target = forwarded ? region.element : precise ?? region?.element ?? null;
2659
+ const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
2313
2660
  if (!target) {
2314
2661
  focusedFrame = null;
2315
2662
  return null;
2316
2663
  }
2664
+ // Event dispatch determines the activation target before handlers run.
2665
+ // A handler may replace the clicked descendants while retaining the link.
2666
+ const activationAnchor = target.closest?.("a[href]");
2317
2667
  // A click inside an iframe belongs to its document alone.
2318
2668
  const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
2319
2669
  focusedFrame = inFrame?.frame ?? null;
@@ -2334,11 +2684,12 @@ function buildPageRenderer({
2334
2684
  }
2335
2685
  // The press focuses the focusable element it lands in, unless mousedown was canceled.
2336
2686
  const focus = () => {
2337
- const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
2687
+ const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
2338
2688
  if (focusable && !focusable.disabled) focusable.focus();
2339
2689
  };
2340
2690
  if (!page.realm) focus();
2341
2691
  else {
2692
+ const controlStates = controlStatesBeforeActivation();
2342
2693
  // A press and release, then the click (UI Events §3.5).
2343
2694
  const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
2344
2695
  const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
@@ -2348,16 +2699,22 @@ function buildPageRenderer({
2348
2699
  page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
2349
2700
  page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
2350
2701
  const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
2702
+ invalidateActivatedControls(controlStates);
2351
2703
  refresh();
2352
2704
  if (prevented) return null;
2353
2705
  }
2354
- if (target.tagName === "AUDIO" || target.tagName === "VIDEO") {
2706
+ if (generatedDisclosure) {
2707
+ target.open = !target.open;
2708
+ refresh();
2709
+ return null;
2710
+ }
2711
+ if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
2355
2712
  clickMedia(target, x, y);
2356
2713
  return null;
2357
2714
  }
2358
- const anchor = target.closest?.("a[href]");
2359
- if (!anchor) return null;
2360
- const href = new URL(anchor.getAttribute("href"), page.url).href;
2715
+ const anchor = activationAnchor;
2716
+ if (!anchor?.hasAttribute("href")) return null;
2717
+ const href = new URL(anchor.getAttribute("href"), anchor.baseURI || page.url).href;
2361
2718
  if (href.startsWith("javascript:")) {
2362
2719
  this.runJavaScriptURL(href);
2363
2720
  return null;
@@ -2430,26 +2787,37 @@ function buildPageRenderer({
2430
2787
  if (!page.realm) return undefined;
2431
2788
  let value = page.realm.evaluate(utilityExpression);
2432
2789
  if (value !== null && typeof value === "object" && typeof value.then === "function") {
2433
- value = await Promise.race([
2434
- Promise.resolve(value),
2435
- new Promise((resolve) => hostSetTimeout(resolve, 5000, undefined)),
2436
- ]).catch((error) => String(error?.message ?? error));
2790
+ let timer;
2791
+ try {
2792
+ value = await Promise.race([
2793
+ Promise.resolve(value),
2794
+ new Promise((resolve) => { timer = hostSetTimeout(resolve, 5000, undefined); }),
2795
+ ]).catch((error) => String(error?.message ?? error));
2796
+ } finally { hostClearTimeout(timer); }
2437
2797
  }
2438
2798
  refresh();
2439
2799
  return cloneableResult(value);
2440
2800
  },
2441
2801
  dispose() {
2802
+ if (disposedRenderer) return;
2442
2803
  disposedRenderer = true;
2804
+ loadGeneration++;
2805
+ if (page.document) loadImageResource.cancelDocument(page.document);
2443
2806
  hostClearTimeout(prepaintTimer);
2444
2807
  hostClearTimeout(frameTimer);
2445
2808
  disposeFrames();
2446
2809
  page.realm?.dispose();
2447
2810
  closePiano();
2448
2811
  disposeVideos();
2449
- rasterSurface?.delete();
2812
+ rasterSurface?.dispose();
2450
2813
  rasterSurface = null;
2451
- page.window?.happyDOM?.abort?.();
2814
+ rasterReadback.dispose();
2815
+ clearPaintParagraphs();
2816
+ documentFonts.dispose();
2817
+ page.window?.happyDOM?.close?.();
2452
2818
  for (const image of page.decodedImages) image.delete();
2819
+ page.decodedImages = [];
2820
+ page.imageElements.clear();
2453
2821
  disposeBackgrounds();
2454
2822
  },
2455
2823
  };
@@ -2475,18 +2843,22 @@ function buildPageRenderer({
2475
2843
  function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
2476
2844
  const sheets = [];
2477
2845
  for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
2478
- if (element.closest("noscript")) continue;
2846
+ if (!isCssStyleSheet(element)) continue;
2847
+ const applies = styleSheetMatches(element);
2479
2848
  if (element.tagName === "STYLE") {
2480
- sheets.push(element.textContent ?? "");
2849
+ if (applies) sheets.push(element.textContent ?? "");
2481
2850
  continue;
2482
2851
  }
2852
+ // A disabled link is fetched when enabled; nonmatching media may be
2853
+ // fetched now, but must never contribute rules to the screen cascade.
2854
+ if (element.hasAttribute("disabled")) continue;
2483
2855
  const href = element.getAttribute("href");
2484
2856
  let url;
2485
- try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
2857
+ try { url = href ? new URL(href, element.baseURI || documentUrl).href : null; } catch { url = null; }
2486
2858
  if (!url) continue;
2487
2859
  const loaded = linkSheets.get(element);
2488
2860
  if (loaded?.url === url) {
2489
- if (loaded.text !== null) sheets.push(loaded.text);
2861
+ if (applies && loaded.text !== null) sheets.push(loaded.text);
2490
2862
  continue;
2491
2863
  }
2492
2864
  const entry = { url, text: null };
@@ -2497,9 +2869,10 @@ function buildPageRenderer({
2497
2869
  }, () => {
2498
2870
  // A failed stylesheet must not prevent the document itself from loading.
2499
2871
  }).then(() => {
2500
- if (entry.text === null || document !== page.document || linkSheets.get(element) !== entry) return;
2872
+ if (document !== page.document || linkSheets.get(element) !== entry) return;
2501
2873
  page.dirty = true;
2502
2874
  scheduleFrame();
2875
+ element.dispatchEvent(new page.window.Event(entry.text === null ? "error" : "load"));
2503
2876
  });
2504
2877
  pending?.push(request);
2505
2878
  }
@@ -2522,20 +2895,16 @@ function buildPageRenderer({
2522
2895
  await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
2523
2896
  const src = element.getAttribute("src");
2524
2897
  if (!src) return;
2898
+ beginImageLoad(element, src);
2525
2899
  try {
2526
- const response = await fetchResource(new URL(src, documentUrl).href, "image");
2527
- if (!response.ok) return;
2528
- // Untrusted image bytes are decoded here, inside the renderer.
2529
- const bytes = response.body;
2530
- const { width, height } = await new Bun.Image(bytes).metadata();
2531
- const image = CanvasKit.MakeImageFromEncoded(bytes);
2532
- if (!image) return;
2533
- const resource = Object.freeze({ width, height, image, url: response.url });
2900
+ const resource = await loadImageResource(document, new URL(src, element.baseURI || documentUrl).href, imageCrossOrigin(element));
2901
+ finishImageLoad(element, src, resource);
2534
2902
  map.set(element, resource);
2535
- decoded.push(image);
2903
+ decoded.push(resource.image);
2536
2904
  entries.push({ element, src, resource });
2537
2905
  } catch {
2538
2906
  // Broken images keep their fallback replaced-element dimensions.
2907
+ finishImageLoad(element, src, null);
2539
2908
  }
2540
2909
  }));
2541
2910
  return { map, decoded, entries };
@@ -2651,7 +3020,7 @@ function buildPageRenderer({
2651
3020
  const src = element.getAttribute("src")?.trim();
2652
3021
  if (!src) return "about:blank";
2653
3022
  try {
2654
- return new URL(src, page.url).href;
3023
+ return new URL(src, element.baseURI || page.url).href;
2655
3024
  } catch {
2656
3025
  return "about:blank";
2657
3026
  }
@@ -2754,6 +3123,12 @@ function buildPageRenderer({
2754
3123
  }
2755
3124
 
2756
3125
  function paintList() {
3126
+ for (const node of page.renderTree?.nodesById.values() ?? []) {
3127
+ if (node.tagName !== "CANVAS") continue;
3128
+ const resource = canvasResource(node.domNode);
3129
+ if (resource) page.resources.set(node.domNode, resource);
3130
+ else page.resources.delete(node.domNode);
3131
+ }
2757
3132
  return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
2758
3133
  }
2759
3134
 
@@ -2763,13 +3138,7 @@ function buildPageRenderer({
2763
3138
 
2764
3139
  /** The render node and layout box of an element, or null. */
2765
3140
  function layoutBoxOf(element) {
2766
- if (!page.renderTree || !page.layout) return null;
2767
- for (const node of page.renderTree.nodesById.values()) {
2768
- if (node.domNode !== element || node.pseudo) continue;
2769
- const box = page.layout.boxes.find((candidate) => candidate.nodeId === node.id && !candidate.inline);
2770
- return box ? { node, box } : null;
2771
- }
2772
- return null;
3141
+ return elementBox(page.renderTree, page.layout, element);
2773
3142
  }
2774
3143
 
2775
3144
  /**
@@ -2823,6 +3192,7 @@ function buildPageRenderer({
2823
3192
  if (next.x || next.y) page.elementScroll.set(element, next);
2824
3193
  else page.elementScroll.delete(element);
2825
3194
  page.layoutVersion++;
3195
+ page.realm?.layoutChanged(page.layoutVersion);
2826
3196
  page.displayList = paintList();
2827
3197
  page.needsPaint = true;
2828
3198
  // A scroll event at the element, as a task (CSSOM View §13.1).
@@ -2920,20 +3290,31 @@ function buildPageRenderer({
2920
3290
  return left;
2921
3291
  }
2922
3292
 
2923
- /** Scrolls the scroll containers around an element, then the page, so that its top shows. */
2924
- function revealElement(element) {
2925
- for (let node = element.parentElement; node && node.nodeType === 1; node = node.parentElement) {
3293
+ /** Aligns the element within each scrollport, from the innermost out. */
3294
+ function revealElement(element, { block = "start", inline = "nearest", container = "all" } = {}) {
3295
+ let moved = false;
3296
+ let fixed = styleEngine.get(element)?.position === "fixed";
3297
+ const direction = styleEngine.get(element)?.direction;
3298
+ if (direction === "rtl" && inline !== "nearest" && inline !== "center") inline = inline === "start" ? "end" : "start";
3299
+ for (let node = element.parentElement; !fixed && node && node.nodeType === 1; node = node.parentElement) {
3300
+ if (node === page.document.documentElement) break;
3301
+ fixed = styleEngine.get(node)?.position === "fixed";
2926
3302
  const geometry = scrollerGeometry(node);
2927
3303
  if (!geometry) continue;
2928
3304
  const target = viewportBounds(element);
2929
- const container = viewportBounds(node);
2930
- if (!target || !container) continue;
3305
+ const bounds = viewportBounds(node);
3306
+ if (!target || !bounds) continue;
2931
3307
  const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
2932
- setElementScroll(node, geometry.x + target.left - (container.left + borderLeft), geometry.y + target.top - (container.top + borderTop));
3308
+ moved = setElementScroll(node,
3309
+ geometry.x + scrollAlignmentDelta(target.left - bounds.left - borderLeft, target.width, geometry.clientWidth, inline),
3310
+ geometry.y + scrollAlignmentDelta(target.top - bounds.top - borderTop, target.height, geometry.clientHeight, block)) || moved;
3311
+ if (container === "nearest") { if (moved) render(); return; }
2933
3312
  }
2934
3313
  const bounds = viewportBounds(element);
2935
- if (bounds) viewport.scrollTo(viewport.x, viewport.y + bounds.top);
2936
- render();
3314
+ if (bounds && !fixed) moved = viewport.scrollTo(
3315
+ viewport.x + scrollAlignmentDelta(bounds.left, bounds.width, viewport.width, inline),
3316
+ viewport.y + scrollAlignmentDelta(bounds.top, bounds.height, viewport.height, block)) || moved;
3317
+ if (moved) render();
2937
3318
  }
2938
3319
 
2939
3320
  /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
@@ -2949,7 +3330,7 @@ function buildPageRenderer({
2949
3330
 
2950
3331
  function videoSource(element) {
2951
3332
  const src = element.getAttribute("src") ?? element.querySelector("source[src]")?.getAttribute("src");
2952
- try { return src ? new URL(src, page.url).href : null; } catch { return null; }
3333
+ try { return src ? new URL(src, element.baseURI || page.url).href : null; } catch { return null; }
2953
3334
  }
2954
3335
 
2955
3336
  // ---- media: <video> and <audio> share one pipeline. An <audio> is media
@@ -3055,6 +3436,22 @@ function buildPageRenderer({
3055
3436
  if (!state) return;
3056
3437
  state.mediaSource = source;
3057
3438
  bindSourcePipes(state);
3439
+ // A later initialization segment can replace already-buffered video.
3440
+ // Shorten the active presentation at its epoch boundary as soon as that
3441
+ // boundary is known, without discarding the frames before it.
3442
+ if (state.video?.pipe?.snapshotAt) {
3443
+ const current = state.video.pipe.snapshotAt(state.video.startTime);
3444
+ const endTime = current.id === state.video.snapshotId ? current.endTime : current.startTime;
3445
+ if (endTime !== state.video.endTime) {
3446
+ state.video.endTime = endTime;
3447
+ if (state.video.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
3448
+ endTime });
3449
+ }
3450
+ }
3451
+ if (state.sound?.pipe?.snapshotAt) {
3452
+ const current = state.sound.pipe.snapshotAt(state.sound.startTime);
3453
+ state.sound.endTime = current.id === state.sound.snapshotId ? current.endTime : current.startTime;
3454
+ }
3058
3455
  if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
3059
3456
  void refreshProbe(state);
3060
3457
  if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
@@ -3135,10 +3532,89 @@ function buildPageRenderer({
3135
3532
 
3136
3533
  /** The control bar and frame redraw without a relayout. */
3137
3534
  function repaintMedia() {
3535
+ // The compositor replaces this video's pixels independently. Page changes
3536
+ // still repaint normally; native controls get their periodic time update.
3537
+ if ([...page.media.values()].some(state => state.video?.presentation && state.playing)
3538
+ && !page.dirty && !page.needsPaint) return;
3138
3539
  rasterized.list = null;
3139
3540
  render();
3140
3541
  }
3141
3542
 
3543
+ function stopVideoPresentation(state) {
3544
+ const presentation = state.video?.presentation;
3545
+ if (!presentation) return;
3546
+ readPresentedFrame(state);
3547
+ onVideo?.({ type: "video-stop", id: presentation.id });
3548
+ state.video.presentation = null;
3549
+ state.video.needsLocalPosition = true;
3550
+ }
3551
+
3552
+ function readPresentedFrame(state) {
3553
+ const p = state.video?.presentation;
3554
+ if (!p) return;
3555
+ const meta = new Int32Array(p.shared, 0, 4);
3556
+ const serial = Atomics.load(meta, 0);
3557
+ if (serial % 2 || !serial || serial === p.serial) return;
3558
+ const width = Atomics.load(meta, 1), height = Atomics.load(meta, 2);
3559
+ if (width < 1 || height < 1 || width * height > 480 * 480) return;
3560
+ const pixels = new Uint8Array(p.shared, 16, width * height * 4).slice();
3561
+ if (serial !== Atomics.load(meta, 0)) return;
3562
+ const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
3563
+ colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
3564
+ if (!image) return;
3565
+ state.resource.image?.delete();
3566
+ Object.assign(state.resource, { image, bitmapWidth: width, bitmapHeight: height });
3567
+ p.serial = serial;
3568
+ }
3569
+
3570
+ function makeVideoLayer(canvas, surface, region, scale, width, height) {
3571
+ if (!onVideo) return null;
3572
+ const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
3573
+ const item = items.length === 1 ? items[0] : null;
3574
+ const resource = item && page.displayList.images[item.image];
3575
+ const state = [...page.media.values()].find(state => state.resource === resource);
3576
+ const eligible = state?.playing && state.video && !state.video.done && !state.buffering
3577
+ && !state.presentationFailed && !item.controls && !item.fixed
3578
+ && (!item.transform || (item.transform.a > 0 && item.transform.d > 0));
3579
+ for (const other of page.media.values()) if (!eligible || other !== state) stopVideoPresentation(other);
3580
+ if (!eligible) return null;
3581
+ const video = state.video;
3582
+ if (!video.presentation) {
3583
+ const id = nextVideoPresentation++;
3584
+ const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
3585
+ video.presentation = { id, shared, serial: 0 };
3586
+ onVideo({ type: "video-open", id, shared, bytes: video.bytes,
3587
+ options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
3588
+ startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
3589
+ hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
3590
+ audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
3591
+ }
3592
+ const rect = { ...item.rect };
3593
+ if (item.transform) {
3594
+ rect.x = rect.x * item.transform.a + item.transform.e;
3595
+ rect.y = rect.y * item.transform.d + item.transform.f;
3596
+ rect.width *= item.transform.a;
3597
+ rect.height *= item.transform.d;
3598
+ }
3599
+ const raster = white => {
3600
+ canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
3601
+ paintDisplayList(canvas, page.displayList, region, scale, { resource, white });
3602
+ drawHints(canvas, region);
3603
+ surface.flush();
3604
+ const image = surface.makeImageSnapshot();
3605
+ try {
3606
+ return image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
3607
+ alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
3608
+ } finally { image.delete(); }
3609
+ };
3610
+ const base = raster(false), mask = raster(true);
3611
+ for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
3612
+ return { id: video.presentation.id, base, mask, rect: {
3613
+ x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
3614
+ width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
3615
+ } };
3616
+ }
3617
+
3142
3618
  function isPipe(value) {
3143
3619
  return Boolean(value && typeof value.waitFor === "function" && typeof value.bytes === "function");
3144
3620
  }
@@ -3220,7 +3696,7 @@ function buildPageRenderer({
3220
3696
  while (true) {
3221
3697
  if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
3222
3698
  if (pipe?.error) throw pipe.error;
3223
- const bytes = pipe ? pipe.bytes() : state.bytes;
3699
+ const bytes = snapshot(pipe, state.bytes);
3224
3700
  if (!bytes?.byteLength) {
3225
3701
  if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
3226
3702
  continue;
@@ -3264,7 +3740,7 @@ function buildPageRenderer({
3264
3740
  const pipe = state.videoPipe ?? state.pipe;
3265
3741
  if (!pipe?.size) return;
3266
3742
  try {
3267
- const probe = await videoDecoder.probe(pipe.bytes());
3743
+ const probe = await videoDecoder.probe(snapshot(pipe, state.bytes, mediaTime(state)));
3268
3744
  if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
3269
3745
  state.duration = probe.duration;
3270
3746
  state.resource.duration = probe.duration;
@@ -3313,18 +3789,21 @@ function buildPageRenderer({
3313
3789
  return format ? { format } : {};
3314
3790
  }
3315
3791
 
3316
- function snapshot(pipe, bytes) {
3317
- return pipe ? pipe.bytes() : bytes;
3792
+ function snapshot(pipe, bytes, time = 0) {
3793
+ return pipe?.snapshotAt ? pipe.snapshotAt(time).bytes : pipe ? pipe.bytes() : bytes;
3318
3794
  }
3319
3795
 
3320
- async function openVideoDecoder(state) {
3796
+ async function openVideoDecoder(state, time = mediaTime(state)) {
3797
+ stopVideoPresentation(state);
3321
3798
  const pipe = state.videoPipe ?? state.pipe;
3322
3799
  const generation = pipe?.generation ?? 0;
3323
- const bytes = snapshot(pipe, state.bytes);
3800
+ const selected = pipe?.snapshotAt?.(time);
3801
+ const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
3324
3802
  if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
3325
3803
  state.video?.decoder.close();
3326
3804
  const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
3327
- state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation };
3805
+ state.video = { decoder, bytes, frameTime: selected?.startTime ?? 0, done: false, pipe, openedGeneration: generation,
3806
+ snapshotId: selected?.id, startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
3328
3807
  trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
3329
3808
  if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
3330
3809
  state.resource.width = decoder.width;
@@ -3340,13 +3819,14 @@ function buildPageRenderer({
3340
3819
  * (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
3341
3820
  * The previous decoder stays until a new one succeeds.
3342
3821
  */
3343
- async function openAudioDecoder(state, { keepStream = false } = {}) {
3822
+ async function openAudioDecoder(state, { keepStream = false, time = mediaTime(state) } = {}) {
3344
3823
  const serial = state.seekSerial;
3345
3824
  const operation = (state.audioOpenSerial ?? 0) + 1;
3346
3825
  state.audioOpenSerial = operation;
3347
3826
  const pipe = state.audioPipe ?? state.pipe;
3348
3827
  const generation = pipe?.generation ?? 0;
3349
- const bytes = snapshot(pipe, state.bytes);
3828
+ const selected = pipe?.snapshotAt?.(time);
3829
+ const bytes = selected?.bytes ?? snapshot(pipe, state.bytes);
3350
3830
  const pending = pipe && !pipe.done && !pipe.error;
3351
3831
  if (!bytes?.byteLength) {
3352
3832
  if (pending) return false;
@@ -3383,8 +3863,10 @@ function buildPageRenderer({
3383
3863
  throw new Error("audio format changed while output stream is active");
3384
3864
  }
3385
3865
  previous?.decoder.close();
3386
- state.sound = { decoder, id: null, position: 0, pending: firstSamples, history: [], finishing: false, done: false,
3387
- pipe, openedGeneration: generation };
3866
+ state.sound = { decoder, id: null, position: Math.round((selected?.startTime ?? 0) * decoder.sampleRate),
3867
+ pending: firstSamples, history: [], finishing: false, done: false,
3868
+ pipe, openedGeneration: generation, snapshotId: selected?.id,
3869
+ startTime: selected?.startTime ?? 0, endTime: selected?.endTime ?? Infinity };
3388
3870
  if (keepStream && previousId != null) {
3389
3871
  // The output stream carries on with the new decoder's samples.
3390
3872
  Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
@@ -3435,7 +3917,10 @@ function buildPageRenderer({
3435
3917
  function needsMore(track) {
3436
3918
  const pipe = track?.pipe;
3437
3919
  if (!pipe || pipe.error) return false;
3438
- return pipe.generation !== track.openedGeneration || !pipe.done;
3920
+ const atBoundary = Number.isFinite(track.endTime) && (track.frameTime !== undefined
3921
+ ? track.frameTime + 1e-6 >= track.endTime
3922
+ : track.position >= Math.round(track.endTime * track.decoder.sampleRate));
3923
+ return pipe.generation !== track.openedGeneration || !pipe.done || atBoundary;
3439
3924
  }
3440
3925
 
3441
3926
  /** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
@@ -3462,6 +3947,7 @@ function buildPageRenderer({
3462
3947
  if (!state || state.playing) return;
3463
3948
  state.error = null;
3464
3949
  setMediaPlaying(state, true);
3950
+ playbackEvent(state, "play", mediaEvent);
3465
3951
  try {
3466
3952
  await loadMedia(state);
3467
3953
  if (state.ended) {
@@ -3484,7 +3970,6 @@ function buildPageRenderer({
3484
3970
  return;
3485
3971
  }
3486
3972
  startMedia(state);
3487
- mediaEvent(state, "play");
3488
3973
  }
3489
3974
 
3490
3975
  function pauseMedia(element) {
@@ -3495,7 +3980,7 @@ function buildPageRenderer({
3495
3980
  stopClock(state);
3496
3981
  state.needsPosition = true;
3497
3982
  setMediaPlaying(state, false);
3498
- mediaEvent(state, "pause");
3983
+ playbackEvent(state, "pause", mediaEvent);
3499
3984
  }
3500
3985
 
3501
3986
  async function seekMedia(element, seconds) {
@@ -3539,9 +4024,15 @@ function buildPageRenderer({
3539
4024
  /** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
3540
4025
  async function placeVideo(state, seconds) {
3541
4026
  for (let attempt = 0; attempt < 8; attempt++) {
3542
- if (!state.video || state.video.done || seconds === 0 || attempt > 0) await openVideoDecoder(state);
4027
+ const pipe = state.videoPipe ?? state.pipe;
4028
+ const selected = pipe?.snapshotAt?.(seconds);
4029
+ // A seek beyond an old snapshot can succeed at its last keyframe.
4030
+ // Reopen on newer bytes first; an exception does not detect that case.
4031
+ const stale = pipe && (pipe !== state.video?.pipe || pipe.generation !== state.video?.openedGeneration);
4032
+ if (!state.video || state.video.done || seconds === 0 || attempt > 0
4033
+ || stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
3543
4034
  try {
3544
- state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
4035
+ state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
3545
4036
  state.video.hideUntil = seconds;
3546
4037
  state.video.done = false;
3547
4038
  return;
@@ -3586,7 +4077,7 @@ function buildPageRenderer({
3586
4077
  const reached = await advanceAudio(() => state.sound, seconds, {
3587
4078
  needsMore,
3588
4079
  active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
3589
- reopen: () => openAudioDecoder(state, { keepStream: true }),
4080
+ reopen: () => openAudioDecoder(state, { keepStream: true, time: seconds }),
3590
4081
  });
3591
4082
  if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
3592
4083
  throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
@@ -3598,6 +4089,8 @@ function buildPageRenderer({
3598
4089
 
3599
4090
  /** Starts the clock, the audio stream and the frame/time ticks. */
3600
4091
  function startMedia(state) {
4092
+ hostClearTimeout(state.timer);
4093
+ state.timer = null;
3601
4094
  // An output stream held through a stall (see refillMedia) is already
3602
4095
  // open: the clock goes on from where its audio is, and what it asked for
3603
4096
  // meanwhile is sent now.
@@ -3642,6 +4135,7 @@ function buildPageRenderer({
3642
4135
  }
3643
4136
 
3644
4137
  function stopClock(state, { keepAudio = false } = {}) {
4138
+ stopVideoPresentation(state);
3645
4139
  hostClearTimeout(state.timer);
3646
4140
  state.timer = null;
3647
4141
  state.clockStart = null;
@@ -3680,7 +4174,18 @@ function buildPageRenderer({
3680
4174
  if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
3681
4175
  const now = mediaTime(state);
3682
4176
  const video = state.video;
3683
- if (video && !video.done && video.frameTime <= now) {
4177
+ if (video?.needsLocalPosition && !video.presentation && !video.done) {
4178
+ try {
4179
+ video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
4180
+ video.hideUntil = now;
4181
+ video.needsLocalPosition = false;
4182
+ } catch (error) { failMedia(state, error, 3); return; }
4183
+ }
4184
+ if (video?.presentation) {
4185
+ // Decode and present on the compositor thread. This tick only updates
4186
+ // DOM media state; it must not decode the same movie a second time.
4187
+ video.frameTime = now + 1 / (video.decoder.fps || 30);
4188
+ } else if (video && !video.done && video.frameTime <= now) {
3684
4189
  // Late frames are decoded but only the newest is shown.
3685
4190
  const decodeDeadline = hostPerformance.now() + 100;
3686
4191
  while (video.frameTime <= mediaTime(state) && !video.done) {
@@ -3713,6 +4218,10 @@ function buildPageRenderer({
3713
4218
  }
3714
4219
  }
3715
4220
  state.resource.time = now;
4221
+ if (!state.buffering && !state.awaitingAudio) {
4222
+ playbackEvent(state, "playing", mediaEvent);
4223
+ if (!state.playing) return;
4224
+ }
3716
4225
  repaintMedia();
3717
4226
  if (Math.floor(now * 4) !== state.lastTimeUpdate) {
3718
4227
  state.lastTimeUpdate = Math.floor(now * 4);
@@ -3751,6 +4260,7 @@ function buildPageRenderer({
3751
4260
  function drawVideoFrame(state, show) {
3752
4261
  const video = state.video;
3753
4262
  const decoder = video.decoder;
4263
+ if (video.frameTime + 1e-6 >= video.endTime) return "stall";
3754
4264
  const width = Math.min(480, decoder.width);
3755
4265
  const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
3756
4266
  let pixels;
@@ -3796,7 +4306,8 @@ function buildPageRenderer({
3796
4306
  if (!state.lastSyncTrace || now - state.lastSyncTrace >= 1000) {
3797
4307
  state.lastSyncTrace = now;
3798
4308
  trace?.(`media sync: audio played ${sound.playedTime.toFixed(3)}s, video shown ${(state.video?.shownTime ?? 0).toFixed(3)}s,`
3799
- + ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`);
4309
+ + ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`
4310
+ + `, PCM reserve ${Number(event.bufferedSeconds ?? 0).toFixed(3)}s`);
3800
4311
  }
3801
4312
  } else if (event.event === "timing-error") {
3802
4313
  // Do not silently switch to a free-running clock on missing feedback.
@@ -3830,6 +4341,8 @@ function buildPageRenderer({
3830
4341
  }
3831
4342
  });
3832
4343
  sound.id = id;
4344
+ if (state.video?.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
4345
+ audioId: id, audioStart: sound.streamStart / sound.decoder.sampleRate, time: mediaTime(state) });
3833
4346
  }
3834
4347
 
3835
4348
  /** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
@@ -3838,14 +4351,20 @@ function buildPageRenderer({
3838
4351
  if (!state.playing || sound.id === null || sound.finishing) return;
3839
4352
  // While buffering the output plays what it holds and then nothing, as
3840
4353
  // the video waits; what it asks for is sent when playing resumes.
3841
- if (state.buffering || state.audioPositioning) {
4354
+ if (state.buffering || state.refilling || state.audioPositioning) {
3842
4355
  sound.owed = (sound.owed ?? 0) + bytes;
3843
4356
  return;
3844
4357
  }
3845
4358
  const { channels, sampleRate } = sound.decoder;
3846
4359
  const frameBytes = channels * 2;
4360
+ const endSample = Math.round(sound.endTime * sampleRate);
3847
4361
  sound.lastRequest = bytes;
3848
4362
  for (let sent = 0; sent + frameBytes <= bytes;) {
4363
+ if (sound.position >= endSample) {
4364
+ sound.owed = (sound.owed ?? 0) + bytes - sent;
4365
+ void refillMedia(state, sound);
4366
+ return;
4367
+ }
3849
4368
  let samples = sound.pending;
3850
4369
  sound.pending = null;
3851
4370
  if (!samples) {
@@ -3871,7 +4390,8 @@ function buildPageRenderer({
3871
4390
  audio.finish(sound.id);
3872
4391
  return;
3873
4392
  }
3874
- const [taken, remaining] = splitSamples(samples, channels, bytes - sent);
4393
+ const boundaryBytes = Math.max(0, endSample - sound.position) * frameBytes;
4394
+ const [taken, remaining] = splitSamples(samples, channels, Math.min(bytes - sent, boundaryBytes));
3875
4395
  if (remaining.length) sound.pending = remaining;
3876
4396
  sound.history.push({ start: sound.position, samples: taken });
3877
4397
  sound.position += taken.length / channels;
@@ -3917,43 +4437,8 @@ function buildPageRenderer({
3917
4437
  * binary search over seek targets finds where that keyframe starts (to a
3918
4438
  * frame). The frames from there to the clock are then decoded unshown.
3919
4439
  */
3920
- function seekToKeyframe(decoder, seconds) {
3921
- // wasmpeg fails a seek to 0 once frames were decoded; 1 ms reaches the
3922
- // first keyframe as well.
3923
- const seek = (time) => decoder.seek(Math.max(1, time * 1000));
3924
- const frame = 1 / (decoder.fps || 30);
3925
- if (!(seconds >= frame)) {
3926
- seek(0);
3927
- return 0;
3928
- }
3929
- const probe = (time) => {
3930
- seek(time);
3931
- return pictureKey(decoder.nextFrame(KEYFRAME_PROBE_WIDTH, KEYFRAME_PROBE_HEIGHT));
3932
- };
3933
- const target = probe(seconds);
3934
- let keyframe = seconds;
3935
- if (target !== null) {
3936
- let earlier = Math.max(0, seconds - KEYFRAME_SEARCH_SECONDS);
3937
- if (earlier > 0 && probe(earlier) !== target) {
3938
- // The keyframe lies in (earlier, keyframe]: narrow it to a frame.
3939
- while (keyframe - earlier > frame) {
3940
- const middle = (earlier + keyframe) / 2;
3941
- if (probe(middle) === target) keyframe = middle;
3942
- else earlier = middle;
3943
- }
3944
- } else {
3945
- keyframe = earlier;
3946
- }
3947
- }
3948
- seek(keyframe);
3949
- return keyframe;
3950
- }
3951
-
3952
- function pictureKey(pixels) {
3953
- if (!pixels) return null;
3954
- let hash = 0x811c9dc5;
3955
- for (let index = 0; index < pixels.length; index += 3) hash = Math.imul(hash ^ pixels[index], 0x01000193);
3956
- return hash >>> 0;
4440
+ function seekToKeyframe(decoder, seconds, startTime = 0) {
4441
+ return seekVideoKeyframe(decoder, seconds, startTime);
3957
4442
  }
3958
4443
 
3959
4444
  /**
@@ -3963,17 +4448,33 @@ function buildPageRenderer({
3963
4448
  * new snapshot. The reported output cursor remains the A/V master clock.
3964
4449
  */
3965
4450
  function refillMedia(state, stalledTrack = null) {
3966
- if (state.refilling) return state.refilling;
4451
+ if (state.refilling) {
4452
+ // An audio prefetch may already be waiting for a later segment. If the
4453
+ // picture catches its own end meanwhile, it really must wait too.
4454
+ if (stalledTrack === state.video && !state.buffering) {
4455
+ state.buffering = true;
4456
+ stopClock(state, { keepAudio: true });
4457
+ if (state.playing) playbackEvent(state, "waiting", mediaEvent);
4458
+ }
4459
+ return state.refilling;
4460
+ }
3967
4461
  const serial = state.seekSerial;
3968
- state.buffering = true;
3969
4462
  state.base = mediaTime(state);
3970
4463
  // The audio output stays open through a stall: it plays the PCM it holds
3971
4464
  // and then runs dry, and playing resumes right after that PCM. Closing
3972
4465
  // and reopening it instead loses track of what was heard (a closed
3973
4466
  // stream may still play out its device buffer, which then plays twice).
3974
4467
  const holdAudio = Boolean(stalledTrack && state.sound && state.sound.id != null && !state.sound.finishing);
4468
+ // PCM prefetch reaches the end of a snapshot ahead of actual playback.
4469
+ // Keep presenting video against measured audio while the controller plays
4470
+ // its reserve; a decoder needing bytes is not itself an output underrun.
4471
+ const prefetch = holdAudio && stalledTrack === state.sound;
4472
+ state.buffering = !prefetch;
3975
4473
  const stall = { at: hostPerformance.now(), base: state.base };
3976
- stopClock(state, { keepAudio: holdAudio });
4474
+ if (!prefetch) {
4475
+ stopClock(state, { keepAudio: holdAudio });
4476
+ if (state.playing) playbackEvent(state, "waiting", mediaEvent);
4477
+ }
3977
4478
  // The first await lets `job` be assigned before the body looks at it,
3978
4479
  // so a stall inside startMedia can begin a new refill.
3979
4480
  const job = (async () => {
@@ -4025,19 +4526,24 @@ function buildPageRenderer({
4025
4526
  const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
4026
4527
  // The picture must not go back to the keyframe the decoder restarts at.
4027
4528
  const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
4028
- await openVideoDecoder(state);
4529
+ await openVideoDecoder(state, resumeAt);
4029
4530
  state.video.hideUntil = hideUntil;
4030
4531
  try {
4031
- state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
4532
+ state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime);
4032
4533
  } catch {
4033
- state.video.frameTime = 0;
4534
+ state.video.frameTime = state.video.startTime;
4034
4535
  }
4035
4536
  state.video.done = false;
4036
4537
  }
4037
4538
  if (refillAudio) {
4038
4539
  let opened = false;
4039
4540
  try {
4040
- opened = await openAudioDecoder(state, { keepStream: Boolean(held) });
4541
+ let audioTime = held ? writtenEnd : time;
4542
+ if (state.sound && Number.isFinite(state.sound.endTime)
4543
+ && state.sound.position >= Math.round(state.sound.endTime * state.sound.decoder.sampleRate)) {
4544
+ audioTime = Math.max(audioTime, state.sound.endTime);
4545
+ }
4546
+ opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
4041
4547
  } catch (error) {
4042
4548
  if (!state.video) throw error;
4043
4549
  reportError(error);
@@ -4078,7 +4584,7 @@ function buildPageRenderer({
4078
4584
  state.ended = true;
4079
4585
  state.base = Number.isFinite(state.duration) ? state.duration : state.base;
4080
4586
  setMediaPlaying(state, false);
4081
- mediaEvent(state, "pause");
4587
+ playbackEvent(state, "pause", mediaEvent);
4082
4588
  mediaEvent(state, "ended");
4083
4589
  }
4084
4590
 
@@ -4121,7 +4627,7 @@ function buildPageRenderer({
4121
4627
  /** The decoded CSS background image a url() names, if it has arrived. */
4122
4628
  function backgroundResource(url) {
4123
4629
  let absolute;
4124
- try { absolute = new URL(url, page.url).href; } catch { return null; }
4630
+ try { absolute = new URL(url, page.document.baseURI || page.url).href; } catch { return null; }
4125
4631
  return page.backgrounds.get(absolute)?.resource ?? null;
4126
4632
  }
4127
4633
 
@@ -4134,28 +4640,25 @@ function buildPageRenderer({
4134
4640
  if (!tree) return;
4135
4641
  const document = page.document;
4136
4642
  const seen = new Set();
4643
+ const used = new Set();
4137
4644
  for (const node of tree.nodesById.values()) {
4138
4645
  const value = node.style?.backgroundImage;
4139
4646
  if (!value || seen.has(value) || !value.includes("url(")) continue;
4140
4647
  seen.add(value);
4141
4648
  for (const match of value.matchAll(CSS_URL)) {
4142
4649
  let url;
4143
- try { url = new URL(match[2], page.url).href; } catch { continue; }
4650
+ try { url = new URL(match[2], document.baseURI || page.url).href; } catch { continue; }
4651
+ used.add(url);
4144
4652
  if (page.backgrounds.has(url)) continue;
4145
4653
  const entry = { resource: null, loaded: null };
4146
4654
  page.backgrounds.set(url, entry);
4147
- entry.loaded = fetchResource(url, "image").then(async (response) => {
4148
- if (!response.ok || page.backgrounds.get(url) !== entry) return;
4149
- const bytes = response.body;
4150
- const { width, height } = await new Bun.Image(bytes).metadata();
4151
- const image = CanvasKit.MakeImageFromEncoded(bytes);
4152
- if (!image) return;
4655
+ entry.loaded = loadImageResource(document, url).then((resource) => {
4153
4656
  if (page.backgrounds.get(url) !== entry || document !== page.document) {
4154
- image.delete();
4657
+ resource.image.delete();
4155
4658
  return;
4156
4659
  }
4157
- entry.resource = Object.freeze({ width, height, image, url });
4158
- if (page.layout) page.displayList = paintList();
4660
+ entry.resource = resource;
4661
+ page.resourcePaintDirty = true;
4159
4662
  page.needsPaint = true;
4160
4663
  scheduleFrame();
4161
4664
  }).catch(() => {
@@ -4163,6 +4666,11 @@ function buildPageRenderer({
4163
4666
  });
4164
4667
  }
4165
4668
  }
4669
+ for (const [url, entry] of page.backgrounds) {
4670
+ if (used.has(url)) continue;
4671
+ entry.resource?.image.delete();
4672
+ page.backgrounds.delete(url);
4673
+ }
4166
4674
  }
4167
4675
 
4168
4676
  function disposeBackgrounds() {
@@ -4174,34 +4682,58 @@ function buildPageRenderer({
4174
4682
  const document = page.document;
4175
4683
  const generation = loadGeneration;
4176
4684
  const staleImages = [];
4685
+ // Infinite feeds remove old DOM images without navigating. The resource
4686
+ // WeakMap alone cannot release their explicitly-owned Skia handles.
4687
+ for (const element of page.imageElements) {
4688
+ if (element.isConnected && element.ownerDocument === document && element.getAttribute("src")) continue;
4689
+ const image = page.resources.get(element)?.image;
4690
+ if (image) {
4691
+ staleImages.push(image);
4692
+ page.resourceLayoutDirty = true;
4693
+ }
4694
+ page.resources.delete(element);
4695
+ page.imageElements.delete(element);
4696
+ imageRequests.delete(element);
4697
+ }
4177
4698
  for (const element of document.querySelectorAll("img")) {
4178
4699
  const src = element.getAttribute("src");
4179
- if (!src || imageRequests.get(element) === src) continue;
4700
+ const credentials = imageCrossOrigin(element);
4701
+ const key = imageRequestKey(src, credentials);
4702
+ if (!src || imageRequests.get(element) === key) continue;
4180
4703
  let url;
4181
- try { url = new URL(src, page.url).href; } catch { continue; }
4182
- if (page.resources.get(element)?.url === url) {
4183
- imageRequests.set(element, src);
4704
+ try { url = new URL(src, element.baseURI || page.url).href; } catch { continue; }
4705
+ const existing = page.resources.get(element);
4706
+ if (existing?.requestedURL === url && existing.crossOrigin === credentials) {
4707
+ imageRequests.set(element, key);
4184
4708
  continue;
4185
4709
  }
4186
- imageRequests.set(element, src);
4710
+ imageRequests.set(element, key);
4187
4711
  const oldImage = page.resources.get(element)?.image;
4188
- if (oldImage) staleImages.push(oldImage);
4712
+ if (oldImage) {
4713
+ staleImages.push(oldImage);
4714
+ page.resourceLayoutDirty = true;
4715
+ }
4189
4716
  page.resources.delete(element);
4190
- fetchResource(url, "image").then(async (response) => {
4191
- if (!response.ok) return;
4192
- const bytes = response.body;
4193
- const { width, height } = await new Bun.Image(bytes).metadata();
4194
- const image = CanvasKit.MakeImageFromEncoded(bytes);
4195
- if (!image) return;
4196
- if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src) {
4717
+ beginImageLoad(element, src);
4718
+ loadImageResource(document, url, credentials).then((resource) => {
4719
+ const { width, height, image } = resource;
4720
+ if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src
4721
+ || imageCrossOrigin(element) !== credentials) {
4197
4722
  image.delete();
4198
4723
  return;
4199
4724
  }
4200
- page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
4725
+ page.resources.set(element, resource);
4201
4726
  page.decodedImages.push(image);
4202
- const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
4203
- const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
4204
- .find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
4727
+ page.imageElements.add(element);
4728
+ let node, run;
4729
+ for (const candidate of page.renderTree.nodesById.values()) {
4730
+ if (candidate.domNode === element) { node = candidate; break; }
4731
+ }
4732
+ if (node) outer: for (const fragment of page.layout.fragments) {
4733
+ for (const candidate of fragment.runs) {
4734
+ if (candidate.type === "image" && candidate.nodeId === node.id) { run = candidate; break outer; }
4735
+ }
4736
+ }
4205
4737
  const cssWidth = node?.style?.width;
4206
4738
  const cssHeight = node?.style?.height;
4207
4739
  const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
@@ -4212,15 +4744,22 @@ function buildPageRenderer({
4212
4744
  const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
4213
4745
  && Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
4214
4746
  if (paintOnly) {
4215
- page.displayList = paintList();
4747
+ page.resourcePaintDirty = true;
4216
4748
  page.needsPaint = true;
4217
4749
  } else {
4218
4750
  page.dirty = true;
4219
4751
  page.resourceLayoutDirty = true;
4220
4752
  }
4221
- refresh();
4222
- }).catch(() => {
4753
+ // All arrivals in this rendering opportunity share layout/raster.
4754
+ // An onload handler which reads geometry still flushes synchronously.
4755
+ finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
4756
+ scheduleFrame();
4757
+ }).catch((error) => {
4223
4758
  // A failed image retains its fallback replaced-element dimensions.
4759
+ trace?.(`image failed ${url}: ${error?.message ?? error}`);
4760
+ if (generation === loadGeneration && document === page.document && imageCrossOrigin(element) === credentials) {
4761
+ finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
4762
+ }
4224
4763
  });
4225
4764
  }
4226
4765
  return staleImages;