buninu-browser 0.2.1 → 0.2.2

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 (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. package/vendor/ses/dist/ses.mjs +52 -6
@@ -23,21 +23,25 @@
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
27
  import { DESKTOP_USER_AGENT } from "../user-agent.js";
27
28
  import { parseHTMLDocument } from "../happy-dom/parser.js";
28
29
  import { LayoutEngine } from "../layout/text-layout.js";
29
30
  import { ScrollViewport } from "../layout/scroll-viewport.js";
30
- import { elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
31
+ import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
31
32
  import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
32
- import { matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
33
+ import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
33
34
  import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
34
35
  import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
35
36
  import { buildDisplayList, visibleItems } from "../paint/display-list.js";
36
37
  import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
37
38
  import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
39
+ import { originOf } from "./messaging.js";
38
40
  import { MOBILE_USER_AGENT } from "./flags.js";
39
41
  import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
42
+ import { containerFormat } from "./media-source.js";
40
43
  import { floatToS16, splitSamples } from "../audio/pcm.js";
44
+ import { advanceAudio } from "../media/position-audio.js";
41
45
  import { pianoNote, PianoMixer } from "../audio/piano.js";
42
46
 
43
47
  // Captured at load: in host mode page scripts share this global object.
@@ -53,8 +57,110 @@ const MAX_COMPILED_COMPOUNDS = 10_000;
53
57
 
54
58
  // Media controls: the bar's height over video, and where its parts sit.
55
59
  const MEDIA_BAR_HEIGHT = 24;
60
+ // url(...) in a computed background-image.
61
+ const CSS_URL = /url\(\s*(["']?)(.*?)\1\s*\)/gis;
56
62
  // Decoded audio already sent, kept so pause/resume continues exactly without decoding again.
57
63
  const AUDIO_HISTORY_SECONDS = 3;
64
+ // After a stall, playing resumes once this much more media has arrived (or
65
+ // the wait gives up): resuming on the first bytes stalls again at once on a
66
+ // slow network.
67
+ const RESUME_BUFFER_SECONDS = 2;
68
+ const RESUME_BUFFER_MAX_WAIT_MS = 6000;
69
+ 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
+ // element → MediaSource, and the reverse. One renderer owns one page pipeline.
76
+ const elementSources = new WeakMap();
77
+ const sourceElements = new WeakMap();
78
+
79
+ /**
80
+ * A frame's window as other frames of the page reach it (messaging.js): its
81
+ * place among the frames, its document's origin, and delivery of messages to
82
+ * the realm of whatever document it shows. The page renderer showing the
83
+ * frame attaches itself; until then the frame shows nothing to post to.
84
+ */
85
+ export function windowHandle(parent = null, name = "") {
86
+ let target = null;
87
+ // The browsing context name (window.name); it outlives navigations.
88
+ let contextName = String(name);
89
+ const handle = {
90
+ name: () => contextName,
91
+ setName(value) {
92
+ contextName = String(value);
93
+ },
94
+ parent: () => parent,
95
+ top: () => parent ? parent.top() : handle,
96
+ origin: () => target?.origin() ?? "null",
97
+ post: (message) => target?.post(message),
98
+ closed: () => target?.closed() ?? false,
99
+ children: () => target?.children() ?? [],
100
+ global: () => target?.global() ?? null,
101
+ document: () => target?.document() ?? null,
102
+ attach(renderer) {
103
+ target = renderer;
104
+ },
105
+ };
106
+ return handle;
107
+ }
108
+
109
+ /**
110
+ * CanvasKit and the bundled fonts, loaded once per process: a page and the
111
+ * documents of its iframes draw with the same CanvasKit instance (images
112
+ * cannot move between instances), and the files are read before the
113
+ * renderer's sandbox closes.
114
+ */
115
+ let graphics = null;
116
+ // The same, once loaded: an embedding page makes its iframes' renderers with it synchronously.
117
+ let loadedGraphics = null;
118
+ export function loadGraphics() {
119
+ return graphics ??= (async () => {
120
+ const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
121
+ const CanvasKit = await CanvasKitInit({ wasmBinary });
122
+ // Page <canvas> uses this same instance. Two CanvasKit instances cannot share images.
123
+ setCanvasKit(CanvasKit);
124
+ const fontCatalogue = await Bun.file(new URL("fonts.json", fontDirectory)).json();
125
+ const uiFontNames = [fontCatalogue.sets.ui.regular, ...fontCatalogue.sets.ui.fallback];
126
+ const monospaceFontNames = [fontCatalogue.sets.terminal.regular, fontCatalogue.sets.terminal.bold];
127
+ const fontBuffers = await Promise.all([...uiFontNames, ...monospaceFontNames].map((name) =>
128
+ Bun.file(new URL(fontCatalogue.fonts[name].file, fontDirectory)).arrayBuffer()));
129
+ // A TypefaceFontProvider registers each file under an explicit family name.
130
+ // CanvasKit does not pick a static bold face by weight within one family, so
131
+ // the monospace bold file gets its own catalogue name and is chosen by weight.
132
+ const fontProvider = CanvasKit.TypefaceFontProvider.Make();
133
+ const loadedFamilies = new Map();
134
+ [...uiFontNames, ...monospaceFontNames].forEach((name, index) => {
135
+ const family = (fontCatalogue.fonts[name].families ?? [name])[0];
136
+ fontProvider.registerFont(fontBuffers[index], family);
137
+ loadedFamilies.set(family.toLowerCase(), family);
138
+ });
139
+ // One font collection for every paragraph: building each paragraph from
140
+ // the provider made a new collection, so Skia's font matching and fallback
141
+ // caches started cold every time (about 3x slower per paragraph).
142
+ const fontCollection = CanvasKit.FontCollection.Make();
143
+ fontCollection.setDefaultFontManager(fontProvider);
144
+ fontCollection.enableFontFallback();
145
+
146
+ const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
147
+ const sansSerifFamily = fontFamilies[0];
148
+ const monospaceFamily = fontCatalogue.sets.terminal.regular;
149
+ const monospaceBoldFamily = fontCatalogue.sets.terminal.bold;
150
+ // Maps CSS generic and common platform family names onto the bundled fonts.
151
+ const genericFamilies = new Map([
152
+ ["monospace", monospaceFamily], ["ui-monospace", monospaceFamily], ["menlo", monospaceFamily],
153
+ ["consolas", monospaceFamily], ["monaco", monospaceFamily], ["courier", monospaceFamily],
154
+ ["courier new", monospaceFamily], ["sans-serif", sansSerifFamily], ["serif", sansSerifFamily],
155
+ ["system-ui", sansSerifFamily], ["-apple-system", sansSerifFamily], ["blinkmacsystemfont", sansSerifFamily],
156
+ ["ui-sans-serif", sansSerifFamily], ["arial", sansSerifFamily], ["helvetica", sansSerifFamily],
157
+ ]);
158
+ return loadedGraphics = {
159
+ CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
160
+ genericFamilies,
161
+ };
162
+ })();
163
+ }
58
164
 
59
165
  /**
60
166
  * Horizontal layout of a media control bar `width` CSS pixels wide: the
@@ -96,7 +202,12 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
96
202
  * videoDecoder?: object | null,
97
203
  * }} options
98
204
  */
99
- export async function createPageRenderer({
205
+ export async function createPageRenderer(options) {
206
+ return buildPageRenderer(options, await loadGraphics());
207
+ }
208
+
209
+ /** createPageRenderer with the graphics already loaded. */
210
+ function buildPageRenderer({
100
211
  fetchResource: rawFetchResource,
101
212
  requestNavigation = () => {},
102
213
  requestDownload = () => {},
@@ -110,46 +221,20 @@ export async function createPageRenderer({
110
221
  onFrame = null,
111
222
  onLifecycle = null,
112
223
  videoDecoder = null,
224
+ // Prepares a classic script on another thread: (source) => Promise<{ code, parsed, strict }>.
225
+ rewriteScript = null,
113
226
  mobile = false,
114
227
  verifyStyle = false,
115
228
  fullStyle = false,
229
+ // An iframe's document (see syncFrames): frames go to the embedding page,
230
+ // which needs only the viewport, so nothing is painted ahead.
231
+ embedded = false,
232
+ // This document's window handle, made by the embedding page (see windowHandle).
233
+ frameWindow = null,
234
+ }, {
235
+ CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
236
+ genericFamilies,
116
237
  }) {
117
- const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
118
- const CanvasKit = await CanvasKitInit({ wasmBinary });
119
- const fontCatalogue = await Bun.file(new URL("fonts.json", fontDirectory)).json();
120
- const uiFontNames = [fontCatalogue.sets.ui.regular, ...fontCatalogue.sets.ui.fallback];
121
- const monospaceFontNames = [fontCatalogue.sets.terminal.regular, fontCatalogue.sets.terminal.bold];
122
- const fontBuffers = await Promise.all([...uiFontNames, ...monospaceFontNames].map((name) =>
123
- Bun.file(new URL(fontCatalogue.fonts[name].file, fontDirectory)).arrayBuffer()));
124
- // A TypefaceFontProvider registers each file under an explicit family name.
125
- // CanvasKit does not pick a static bold face by weight within one family, so
126
- // the monospace bold file gets its own catalogue name and is chosen by weight.
127
- const fontProvider = CanvasKit.TypefaceFontProvider.Make();
128
- const loadedFamilies = new Map();
129
- [...uiFontNames, ...monospaceFontNames].forEach((name, index) => {
130
- const family = (fontCatalogue.fonts[name].families ?? [name])[0];
131
- fontProvider.registerFont(fontBuffers[index], family);
132
- loadedFamilies.set(family.toLowerCase(), family);
133
- });
134
- // One font collection for every paragraph: building each paragraph from
135
- // the provider made a new collection, so Skia's font matching and fallback
136
- // caches started cold every time (about 3x slower per paragraph).
137
- const fontCollection = CanvasKit.FontCollection.Make();
138
- fontCollection.setDefaultFontManager(fontProvider);
139
- fontCollection.enableFontFallback();
140
-
141
- const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
142
- const sansSerifFamily = fontFamilies[0];
143
- const monospaceFamily = fontCatalogue.sets.terminal.regular;
144
- const monospaceBoldFamily = fontCatalogue.sets.terminal.bold;
145
- // Maps CSS generic and common platform family names onto the bundled fonts.
146
- const genericFamilies = new Map([
147
- ["monospace", monospaceFamily], ["ui-monospace", monospaceFamily], ["menlo", monospaceFamily],
148
- ["consolas", monospaceFamily], ["monaco", monospaceFamily], ["courier", monospaceFamily],
149
- ["courier new", monospaceFamily], ["sans-serif", sansSerifFamily], ["serif", sansSerifFamily],
150
- ["system-ui", sansSerifFamily], ["-apple-system", sansSerifFamily], ["blinkmacsystemfont", sansSerifFamily],
151
- ["ui-sans-serif", sansSerifFamily], ["arial", sansSerifFamily], ["helvetica", sansSerifFamily],
152
- ]);
153
238
  const resolvedFamilies = new Map();
154
239
  /** First available family from a computed font-family list, then the UI fallbacks. */
155
240
  function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
@@ -187,6 +272,24 @@ export async function createPageRenderer({
187
272
  let hasStateStale = false;
188
273
  let structuralState = null;
189
274
  let siblingState = null;
275
+ // iframes (see syncFrames), by element.
276
+ const frames = new Map();
277
+ let disposedRenderer = false;
278
+ const ownWindow = frameWindow ?? windowHandle(null);
279
+ ownWindow.attach({
280
+ // An iframe's about:blank document has the origin of the page that made it.
281
+ origin: () => page.url === "about:blank" && ownWindow.parent() ? ownWindow.parent().origin() : originOf(page.url),
282
+ post: (message) => page.realm?.receiveMessage(message),
283
+ closed: () => disposedRenderer,
284
+ children: () => [...frames.values()].map((frame) => frame.window),
285
+ global: () => page.realm?.pageGlobal ?? null,
286
+ document: () => page.realm?.documentBinding ?? null,
287
+ });
288
+ // Changes whenever a frame's image does, so the page is rasterized again.
289
+ let framesVersion = 0;
290
+ let framePaintTimer = null;
291
+ // The frame keyboard input goes to, after a click into it.
292
+ let focusedFrame = null;
190
293
  const viewport = new ScrollViewport(800, 600);
191
294
  const page = {
192
295
  url: "about:blank",
@@ -212,6 +315,13 @@ export async function createPageRenderer({
212
315
  resources: new WeakMap(),
213
316
  decodedImages: [],
214
317
  imagesLoaded: null,
318
+ // CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
319
+ backgrounds: new Map(),
320
+ // Scroll offsets of scroll containers, by element (see setElementScroll).
321
+ elementScroll: new Map(),
322
+ // Bumped whenever boxes may have moved (layout, element scrolling), so
323
+ // IntersectionObserver checks can skip unchanged frames.
324
+ layoutVersion: 0,
215
325
  // <video> and <audio> playback states, by element (see playMedia).
216
326
  media: new Map(),
217
327
  realm: null,
@@ -353,6 +463,9 @@ export async function createPageRenderer({
353
463
  // PaintController reusing cached display items
354
464
  // (platform/graphics/paint/paint_controller.cc, BSD).
355
465
  let rasterized = { list: null, overlay: "", background: "", scale: 0 };
466
+ let rasterSurface = null;
467
+ let rasterSurfaceWidth = 0;
468
+ let rasterSurfaceHeight = 0;
356
469
 
357
470
  /** Hint overlay contents, as a comparable key. */
358
471
  function overlayKey() {
@@ -365,6 +478,7 @@ export async function createPageRenderer({
365
478
  function sameRasterInput() {
366
479
  if (rasterized.scale !== page.deviceScaleFactor || rasterized.background !== page.backgroundColor) return false;
367
480
  if (rasterized.overlay !== overlayKey()) return false;
481
+ if (rasterized.frames !== framesVersion) return false;
368
482
  if (page.displayList?.items.some((item) => item.fixed)
369
483
  && (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
370
484
  if (rasterized.list === page.displayList) return true;
@@ -393,7 +507,13 @@ export async function createPageRenderer({
393
507
  const scale = page.deviceScaleFactor;
394
508
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
395
509
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
396
- const surface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
510
+ if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
511
+ rasterSurface?.delete();
512
+ rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
513
+ rasterSurfaceWidth = pixelWidth;
514
+ rasterSurfaceHeight = pixelHeight;
515
+ }
516
+ const surface = rasterSurface;
397
517
  if (!surface) throw new Error(`Unable to create a ${pixelWidth}x${pixelHeight} CanvasKit surface`);
398
518
  const canvas = surface.getCanvas();
399
519
  canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
@@ -409,11 +529,10 @@ export async function createPageRenderer({
409
529
  colorSpace: CanvasKit.ColorSpace.SRGB,
410
530
  });
411
531
  image.delete();
412
- surface.delete();
413
532
  page.needsPaint = false;
414
533
  rasterized = {
415
534
  list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
416
- scrollX: viewport.x, scrollY: viewport.y,
535
+ scrollX: viewport.x, scrollY: viewport.y, frames: framesVersion,
417
536
  };
418
537
  return {
419
538
  pixels,
@@ -461,7 +580,7 @@ export async function createPageRenderer({
461
580
 
462
581
  /** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
463
582
  function schedulePrepaint() {
464
- if (!onFrame) return;
583
+ if (!onFrame || embedded) return;
465
584
  hostClearTimeout(prepaintTimer);
466
585
  prepaintTimer = hostSetTimeout(() => {
467
586
  prepaintTimer = null;
@@ -531,10 +650,15 @@ export async function createPageRenderer({
531
650
  else canvas.drawRect(deviceRect(rect), paint);
532
651
  };
533
652
  for (const item of visibleItems(displayList, view, viewport)) {
534
- const saved = item.fixed || item.clipRect || item.opacity < 1 ? canvas.save() : null;
653
+ const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
535
654
  try {
536
655
  if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
537
656
  if (item.clipRect) canvas.clipRect(deviceRect(item.clipRect), CanvasKit.ClipOp.Intersect, true);
657
+ // A scale in document space, as a device-space matrix around the item's device coordinates.
658
+ if (item.transform) {
659
+ const { a, d, e, f } = item.transform;
660
+ canvas.concat([a, 0, ((a - 1) * view.x + e) * scale, 0, d, ((d - 1) * view.y + f) * scale, 0, 0, 1]);
661
+ }
538
662
  if (item.opacity < 1) {
539
663
  const layer = new CanvasKit.Paint();
540
664
  layer.setAlphaf(item.opacity);
@@ -634,6 +758,30 @@ export async function createPageRenderer({
634
758
  false,
635
759
  );
636
760
  canvas.restore();
761
+ } else if (item.op === "drawBackgroundImage") {
762
+ // Tiles from an image shader, repeating (or not) per axis.
763
+ const resource = displayList.images[item.image];
764
+ const { tile } = item;
765
+ canvas.save();
766
+ const clip = deviceRRect(item.rect, item.radii);
767
+ if (clip) canvas.clipRRect(clip, CanvasKit.ClipOp.Intersect, true);
768
+ const matrix = CanvasKit.Matrix.multiply(
769
+ CanvasKit.Matrix.translated((tile.x - view.x) * scale, (tile.y - view.y) * scale),
770
+ CanvasKit.Matrix.scaled(tile.width * scale / resource.width, tile.height * scale / resource.height),
771
+ );
772
+ const shader = resource.image.makeShaderOptions(
773
+ item.repeatX ? CanvasKit.TileMode.Repeat : CanvasKit.TileMode.Decal,
774
+ item.repeatY ? CanvasKit.TileMode.Repeat : CanvasKit.TileMode.Decal,
775
+ CanvasKit.FilterMode.Linear,
776
+ CanvasKit.MipmapMode.None,
777
+ matrix,
778
+ );
779
+ const paint = new CanvasKit.Paint();
780
+ paint.setShader(shader);
781
+ canvas.drawRect(deviceRect(item.bounds), paint);
782
+ paint.delete();
783
+ shader.delete();
784
+ canvas.restore();
637
785
  } else if (item.op === "drawVideo") {
638
786
  const resource = displayList.images[item.image];
639
787
  const rect = deviceRect(item.rect);
@@ -1157,12 +1305,14 @@ export async function createPageRenderer({
1157
1305
  }
1158
1306
 
1159
1307
  function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1308
+ disposeFrames();
1160
1309
  closePiano();
1161
1310
  disposeVideos();
1162
1311
  page.realm?.dispose();
1163
1312
  page.realm = null;
1164
1313
  page.window?.happyDOM?.abort?.();
1165
1314
  for (const image of page.decodedImages) image.delete();
1315
+ disposeBackgrounds();
1166
1316
  page.window = parsed.window;
1167
1317
  page.document = parsed.document;
1168
1318
  observeStyleChanges(parsed.document);
@@ -1174,10 +1324,13 @@ export async function createPageRenderer({
1174
1324
  page.decodedImages = imageResources.decoded;
1175
1325
  page.imagesLoaded = null;
1176
1326
  imageRequests = new WeakMap();
1327
+ page.elementScroll = new Map();
1177
1328
  const timing = styleAndLayout();
1329
+ syncFrames();
1330
+ queueBackgroundImages();
1178
1331
  trace?.(`${page.url}: first style ${timing.style}ms, layout ${timing.layout}ms`);
1179
1332
  viewport.scrollTo(0, 0);
1180
- viewport.setContentSize(page.layout.width, page.layout.height);
1333
+ setContentSize();
1181
1334
  page.hints = [];
1182
1335
  page.hintsVisible = false;
1183
1336
  page.needsPaint = true;
@@ -1237,10 +1390,12 @@ export async function createPageRenderer({
1237
1390
  page.layout = layoutEngine.layout(page.renderTree, {
1238
1391
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
1239
1392
  });
1240
- page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
1393
+ page.displayList = paintList();
1394
+ // The content may now reach less far than a container was scrolled.
1395
+ if (clampElementScroll()) page.displayList = paintList();
1241
1396
  page.resourceLayoutDirty = false;
1242
1397
  laidOutSheets = sheets;
1243
- viewport.setContentSize(page.layout.width, page.layout.height);
1398
+ setContentSize();
1244
1399
  return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
1245
1400
  }
1246
1401
 
@@ -1265,12 +1420,31 @@ export async function createPageRenderer({
1265
1420
  if (mismatches) reportError(`buninu-browser: incremental style: ${mismatches} element(s) differ from a full restyle`);
1266
1421
  }
1267
1422
 
1423
+ /**
1424
+ * Brings style and layout up to date with the DOM. Whichever path asks (a
1425
+ * frame, or a script reading geometry or styles), the media and images the
1426
+ * DOM now names are requested first: a script that inserts images and
1427
+ * measures them at once must not leave them unloaded.
1428
+ */
1268
1429
  function rebuildRenderTree(reason = "update") {
1269
1430
  const started = hostPerformance.now();
1270
1431
  page.dirty = false;
1432
+ page.layoutVersion++;
1271
1433
  page.title = page.document?.title ?? page.title;
1434
+ let staleImages = [];
1435
+ if (page.document) {
1436
+ initializeVideos(page.document, page.resources);
1437
+ staleImages = queueImageResources();
1438
+ }
1272
1439
  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();
1444
+ }
1273
1445
  if (!timing.skipped) page.needsPaint = true;
1446
+ syncFrames();
1447
+ if (!timing.skipped) queueBackgroundImages();
1274
1448
  lastRebuildMs = hostPerformance.now() - started;
1275
1449
  trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
1276
1450
  + ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
@@ -1329,6 +1503,16 @@ export async function createPageRenderer({
1329
1503
  }
1330
1504
  if (url === "about:blank") {
1331
1505
  replaceDocument("", url);
1506
+ // An iframe's blank document is scripted from its page (through
1507
+ // contentWindow and contentDocument), so it has a realm.
1508
+ if (embedded && page.scripting !== "off") {
1509
+ page.realm = createPageRealm({ window: page.window, document: page.document, mode: page.scripting, hooks: realmHooks(generation) });
1510
+ render();
1511
+ commit.resolve();
1512
+ // No scripts of its own: it is complete at once, and its realm reports so.
1513
+ await page.realm.runDocumentScripts();
1514
+ return;
1515
+ }
1332
1516
  render();
1333
1517
  commit.resolve();
1334
1518
  finish();
@@ -1384,8 +1568,11 @@ export async function createPageRenderer({
1384
1568
  page.document.body.textContent = source;
1385
1569
  rebuildRenderTree();
1386
1570
  }
1387
- if (status < 200 || status > 299) {
1388
- page.document.body.textContent = `HTTP ${status} ${statusText}\n${page.document.body.textContent}`;
1571
+ // An error status with a body shows that body, scripts included, as in
1572
+ // Chrome: sites serve real pages that way (Google's "unusual traffic"
1573
+ // check comes as 429). Only an empty response gets a note instead.
1574
+ if ((status < 200 || status > 299) && !source.trim()) {
1575
+ page.document.body.textContent = `HTTP ${status} ${statusText}`.trim();
1389
1576
  rebuildRenderTree();
1390
1577
  }
1391
1578
  render();
@@ -1480,16 +1667,7 @@ export async function createPageRenderer({
1480
1667
 
1481
1668
  /** Brings style, layout and raster up to date; returns whether it repainted. */
1482
1669
  function refresh() {
1483
- if (page.dirty) {
1484
- initializeVideos(page.document, page.resources);
1485
- const staleImages = queueImageResources();
1486
- rebuildRenderTree();
1487
- for (const image of staleImages) {
1488
- const index = page.decodedImages.indexOf(image);
1489
- if (index >= 0) page.decodedImages.splice(index, 1);
1490
- image.delete();
1491
- }
1492
- }
1670
+ if (page.dirty) rebuildRenderTree();
1493
1671
  if (!page.needsPaint) return false;
1494
1672
  render();
1495
1673
  return true;
@@ -1560,6 +1738,12 @@ export async function createPageRenderer({
1560
1738
 
1561
1739
  function realmHooks(generation) {
1562
1740
  return {
1741
+ frames: {
1742
+ self: ownWindow,
1743
+ parent: ownWindow.parent(),
1744
+ top: ownWindow.top(),
1745
+ childOf: (element) => ensureFrame(element)?.window ?? null,
1746
+ },
1563
1747
  definedChanged(name) {
1564
1748
  // An upgrade changes :defined for that tag's elements only.
1565
1749
  if (selectorTraits.defined) {
@@ -1577,8 +1761,17 @@ export async function createPageRenderer({
1577
1761
  playMedia,
1578
1762
  pauseMedia,
1579
1763
  seekMedia,
1764
+ attachMediaSrc,
1765
+ mediaReadyState,
1766
+ mediaNetworkState,
1767
+ mediaError,
1768
+ mediaBuffered,
1769
+ mediaAppended,
1770
+ mediaDuration,
1580
1771
  lifecycle(name) {
1581
1772
  if (generation !== loadGeneration) return;
1773
+ // The images load waited for are in the frame shown at load.
1774
+ if (name === "load") refresh();
1582
1775
  onLifecycle?.(name, generation);
1583
1776
  // Parsing and the deferred scripts are done: changes from async
1584
1777
  // scripts and late images get frames, without waiting for load.
@@ -1587,7 +1780,7 @@ export async function createPageRenderer({
1587
1780
  scheduleFrame();
1588
1781
  }
1589
1782
  },
1590
- whenImagesLoaded: () => page.imagesLoaded,
1783
+ whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
1591
1784
  afterTask() {},
1592
1785
  boundsOf(element) {
1593
1786
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -1606,11 +1799,37 @@ export async function createPageRenderer({
1606
1799
  if (page.dirty) rebuildRenderTree("script scrolled");
1607
1800
  if (viewport.scrollTo(x, y)) render();
1608
1801
  },
1802
+ elementScroll(element) {
1803
+ if (page.dirty) rebuildRenderTree("script read geometry");
1804
+ return scrollerGeometry(element);
1805
+ },
1806
+ elementAt(x, y) {
1807
+ if (page.dirty) rebuildRenderTree("script read geometry");
1808
+ if (!(x >= 0 && y >= 0 && x < page.width && y < page.height) || !page.displayList) return null;
1809
+ return elementAtPoint(page.renderTree, page.displayList, x, y, viewport);
1810
+ },
1811
+ setElementScroll(element, x, y) {
1812
+ if (page.dirty) rebuildRenderTree("script scrolled");
1813
+ setElementScroll(element, x, y);
1814
+ },
1815
+ scrollIntoView(element) {
1816
+ if (page.dirty) rebuildRenderTree("script scrolled");
1817
+ revealElement(element);
1818
+ },
1609
1819
  computedStyle(element) {
1610
1820
  if (!element) return null;
1611
1821
  if (page.dirty) rebuildRenderTree("getComputedStyle");
1612
1822
  return styleEngine.get(element);
1613
1823
  },
1824
+ layoutVersion() {
1825
+ if (page.dirty) rebuildRenderTree("intersection observer");
1826
+ return page.layoutVersion;
1827
+ },
1828
+ customProperties(element) {
1829
+ if (!element) return null;
1830
+ if (page.dirty) rebuildRenderTree("getComputedStyle");
1831
+ return styleEngine.customPropertiesOf(element);
1832
+ },
1614
1833
  navigate: (url) => navigateTo(String(url)),
1615
1834
  activateLink(anchor) {
1616
1835
  const href = new URL(anchor.getAttribute("href"), page.url).href;
@@ -1620,7 +1839,14 @@ export async function createPageRenderer({
1620
1839
  },
1621
1840
  submitForm,
1622
1841
  async fetchScript(url) {
1623
- if (/^(?:blob|data):/i.test(url)) {
1842
+ // Blob URLs live in this document's registry. Host fetch only accepts
1843
+ // http(s) and s3, and a MediaSource URL has no script bytes.
1844
+ if (/^blob:/i.test(url)) {
1845
+ const stored = page.realm?.blobEntry?.(url);
1846
+ if (stored?.kind !== "blob") throw new TypeError("Failed to fetch");
1847
+ return new TextDecoder().decode(await stored.blob.arrayBuffer());
1848
+ }
1849
+ if (/^data:/i.test(url)) {
1624
1850
  const response = await fetch(url);
1625
1851
  if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
1626
1852
  return response.text();
@@ -1634,40 +1860,84 @@ export async function createPageRenderer({
1634
1860
  reportError,
1635
1861
  consoleMessage,
1636
1862
  userAgent: () => page.userAgent,
1863
+ initScripts: () => page.initScripts ?? [],
1864
+ rewriteScript,
1637
1865
  documentCookie: () => page.cookie,
1638
1866
  setDocumentCookie(value) {
1639
1867
  page.cookie = mergeDocumentCookie(page.cookie, String(value));
1640
1868
  setCookie(String(value));
1641
1869
  },
1642
1870
  webSockets,
1643
- async pageFetch(request) {
1644
- const response = await pageFetch(request);
1645
- if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
1646
- return response;
1871
+ pageFetch(request) {
1872
+ const networkPending = pageFetch(request);
1873
+ const result = networkPending.then((response) => {
1874
+ if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
1875
+ return response;
1876
+ });
1877
+ result.abort = () => networkPending.abort?.();
1878
+ return result;
1647
1879
  },
1648
1880
  };
1649
1881
  }
1650
1882
 
1883
+ /**
1884
+ * Form submission (HTML §4.10.21.3): GET puts the entries in the action's
1885
+ * query; POST sends them as the body, encoded per the form's enctype, and
1886
+ * the controller navigates with that request.
1887
+ */
1651
1888
  function submitForm(form, submitter = null) {
1652
1889
  const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
1653
- if (method !== "get") return;
1654
1890
  const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
1655
1891
  const url = new URL(action || page.url, page.url);
1892
+ const entries = [...new page.window.FormData(form, submitter)];
1893
+ if (method === "post") {
1894
+ const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
1895
+ requestNavigation(url.href, null, null, encodeFormBody(entries, enctype));
1896
+ return;
1897
+ }
1898
+ if (method !== "get") return;
1656
1899
  url.search = "";
1657
- for (const [name, value] of new page.window.FormData(form, submitter)) {
1900
+ for (const [name, value] of entries) {
1658
1901
  if (typeof value === "string") url.searchParams.append(name, value);
1659
1902
  }
1660
1903
  navigateTo(url.href);
1661
1904
  }
1662
1905
 
1906
+ /** A POST form's entries as a request body and its content type. Files go as empty parts. */
1907
+ function encodeFormBody(entries, enctype) {
1908
+ const encoder = new TextEncoder();
1909
+ if (enctype === "multipart/form-data") {
1910
+ const boundary = `----buninuFormBoundary${crypto.randomUUID().replaceAll("-", "")}`;
1911
+ const quote = (text) => String(text).replace(/\r\n|\r|\n/g, "\r\n").replaceAll('"', "%22");
1912
+ let body = "";
1913
+ for (const [name, value] of entries) {
1914
+ const file = typeof value === "string" ? null : value;
1915
+ body += `--${boundary}\r\nContent-Disposition: form-data; name="${quote(name)}"`
1916
+ + (file ? `; filename="${quote(file.name ?? "")}"\r\nContent-Type: application/octet-stream` : "")
1917
+ + `\r\n\r\n${file ? "" : String(value).replace(/\r\n|\r|\n/g, "\r\n")}\r\n`;
1918
+ }
1919
+ body += `--${boundary}--\r\n`;
1920
+ return { method: "POST", body: encoder.encode(body), contentType: `multipart/form-data; boundary=${boundary}` };
1921
+ }
1922
+ if (enctype === "text/plain") {
1923
+ const body = entries.map(([name, value]) => `${name}=${typeof value === "string" ? value : value.name ?? ""}\r\n`).join("");
1924
+ return { method: "POST", body: encoder.encode(body), contentType: "text/plain" };
1925
+ }
1926
+ const params = new URLSearchParams();
1927
+ for (const [name, value] of entries) params.append(name, typeof value === "string" ? value : value.name ?? "");
1928
+ return { method: "POST", body: encoder.encode(params.toString()), contentType: "application/x-www-form-urlencoded" };
1929
+ }
1930
+
1663
1931
  /** The border box of an element's layout box in viewport coordinates. */
1664
1932
  function viewportBounds(element) {
1665
1933
  let fixed = false;
1666
1934
  for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
1667
1935
  if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
1668
1936
  }
1669
- const scrollX = fixed ? 0 : viewport.x;
1670
- const scrollY = fixed ? 0 : viewport.y;
1937
+ // Scroll containers around it move it too.
1938
+ const shift = scrollShift(element);
1939
+ const scrollX = (fixed ? 0 : viewport.x) + shift.x;
1940
+ const scrollY = (fixed ? 0 : viewport.y) + shift.y;
1671
1941
  let nodeId = null;
1672
1942
  for (const node of page.renderTree.nodesById.values()) {
1673
1943
  if (node.domNode === element && !node.pseudo) {
@@ -1748,7 +2018,26 @@ export async function createPageRenderer({
1748
2018
 
1749
2019
  function collectHints() {
1750
2020
  const chars = ["a", "s", "d", "f", "j", "k", "l"];
1751
- const regions = interactiveRegions(page.renderTree, page.layout, viewport, viewport).slice(0, 49);
2021
+ // Laid-out regions moved by the scroll containers around them, kept
2022
+ // where the element itself shows (not scrolled out of a container or covered).
2023
+ const regions = interactiveRegions(page.renderTree, page.layout, viewport, {})
2024
+ .map((region) => {
2025
+ const shift = scrollShift(region.element);
2026
+ if (!shift.x && !shift.y) return region;
2027
+ const rects = region.rects.map((rect) => ({
2028
+ ...rect, left: rect.left - shift.x, right: rect.right - shift.x, top: rect.top - shift.y, bottom: rect.bottom - shift.y,
2029
+ }));
2030
+ return { element: region.element, rects };
2031
+ })
2032
+ .filter((region) => {
2033
+ const rect = region.rects[0];
2034
+ const cx = (rect.left + rect.right) / 2;
2035
+ const cy = (rect.top + rect.bottom) / 2;
2036
+ if (cx < 0 || cy < 0 || cx >= page.width || cy >= page.height) return false;
2037
+ const shown = page.displayList ? elementAtPoint(page.renderTree, page.displayList, cx, cy, viewport) : null;
2038
+ return !shown || shown === region.element || region.element.contains(shown) || shown.contains(region.element);
2039
+ })
2040
+ .slice(0, 49);
1752
2041
  const labels = regions.length <= chars.length
1753
2042
  ? chars
1754
2043
  : chars.flatMap((first) => chars.map((second) => first + second));
@@ -1783,9 +2072,14 @@ export async function createPageRenderer({
1783
2072
 
1784
2073
  async function downloadBlob(url, name) {
1785
2074
  try {
1786
- const response = await fetch(url);
1787
- if (!response.ok) throw new Error(`blob download failed: HTTP ${response.status}`);
1788
- const data = new Uint8Array(await response.arrayBuffer());
2075
+ const stored = page.realm?.blobEntry?.(url);
2076
+ let data;
2077
+ if (stored?.kind === "blob") data = new Uint8Array(await stored.blob.arrayBuffer());
2078
+ else {
2079
+ const response = await fetch(url);
2080
+ if (!response.ok) throw new Error(`blob download failed: HTTP ${response.status}`);
2081
+ data = new Uint8Array(await response.arrayBuffer());
2082
+ }
1789
2083
  if (data.byteLength > 64 * 1024 * 1024) throw new Error("blob download exceeds 64 MB");
1790
2084
  requestDownload({ url, name, data });
1791
2085
  } catch (error) {
@@ -1802,6 +2096,10 @@ export async function createPageRenderer({
1802
2096
  setUserAgent(userAgent) {
1803
2097
  if (typeof userAgent === "string" && userAgent) page.userAgent = userAgent;
1804
2098
  },
2099
+ /** Scripts every new document runs before its own (CDP Page.addScriptToEvaluateOnNewDocument). */
2100
+ setInitScripts(sources) {
2101
+ page.initScripts = Array.isArray(sources) ? sources.map(String) : [];
2102
+ },
1805
2103
  /** Messages the page logged, for diagnostics. */
1806
2104
  consoleMessages() {
1807
2105
  return page.realm?.console ?? [];
@@ -1914,6 +2212,7 @@ export async function createPageRenderer({
1914
2212
  return { x: viewport.x, y: viewport.y };
1915
2213
  },
1916
2214
  press(key, options = {}) {
2215
+ if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
1917
2216
  refresh();
1918
2217
  const pianoInput = page.document?.activeElement;
1919
2218
  if (audio && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
@@ -1966,12 +2265,17 @@ export async function createPageRenderer({
1966
2265
  return { x: viewport.x, y: viewport.y };
1967
2266
  },
1968
2267
  type(text) {
2268
+ if (focusedFrame?.renderer) return focusedFrame.renderer.type(text);
1969
2269
  editFocusedText(String(text ?? ""));
1970
2270
  refresh();
1971
2271
  },
1972
2272
  scrollOffset() {
1973
2273
  return { x: viewport.x, y: viewport.y };
1974
2274
  },
2275
+ /** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
2276
+ wheel(x, y, deltaX, deltaY, options = undefined) {
2277
+ return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
2278
+ },
1975
2279
  /**
1976
2280
  * Clicks at a viewport point: the page receives a click event first, and
1977
2281
  * unless it calls preventDefault() a link under the point is returned for
@@ -1983,6 +2287,7 @@ export async function createPageRenderer({
1983
2287
  // label) without a DOM mutation, so the next restyle is a full one.
1984
2288
  markStyleDirty(null); // full: click
1985
2289
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
2290
+ let forwarded = false;
1986
2291
  if (!region) {
1987
2292
  // Some search forms draw a wide control around a narrow textarea and
1988
2293
  // rely on script to forward clicks. Preserve that affordance when the
@@ -1992,13 +2297,34 @@ export async function createPageRenderer({
1992
2297
  if (!bounds || x < bounds.left || x >= bounds.left + bounds.width
1993
2298
  || y < bounds.top || y >= bounds.top + bounds.height) continue;
1994
2299
  const fields = form.querySelectorAll('textarea[inputmode="search"], input[type="search"]');
1995
- if (fields.length === 1) region = { element: fields[0] };
2300
+ if (fields.length === 1) {
2301
+ region = { element: fields[0] };
2302
+ forwarded = true;
2303
+ }
1996
2304
  break;
1997
2305
  }
1998
2306
  }
1999
- if (!region) return null;
2307
+ // Pointer events go to the topmost element painted at the point, where
2308
+ // it is now (scroll containers move their content); the interactive
2309
+ // regions (laid out, unscrolled) stand in only where nothing is painted,
2310
+ // 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;
2312
+ const target = forwarded ? region.element : precise ?? region?.element ?? null;
2313
+ if (!target) {
2314
+ focusedFrame = null;
2315
+ return null;
2316
+ }
2317
+ // A click inside an iframe belongs to its document alone.
2318
+ const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
2319
+ focusedFrame = inFrame?.frame ?? null;
2320
+ if (inFrame) {
2321
+ const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y);
2322
+ const href = typeof followed === "string" ? followed : followed?.fragmentFrom ? null : followed?.url;
2323
+ if (href && !followed?.download) void loadFrame(inFrame.frame, href);
2324
+ return null;
2325
+ }
2000
2326
  if (page.url === "about:audio") {
2001
- const key = region.element.closest?.("[data-piano-note]");
2327
+ const key = target.closest?.("[data-piano-note]");
2002
2328
  const note = Number(key?.getAttribute("data-piano-note"));
2003
2329
  if (key && Number.isInteger(note) && note >= 0 && note <= 127 && audio) {
2004
2330
  playPiano(note);
@@ -2006,22 +2332,30 @@ export async function createPageRenderer({
2006
2332
  return null;
2007
2333
  }
2008
2334
  }
2009
- if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(region.element.tagName)) {
2010
- region.element.focus();
2011
- }
2012
- if (page.realm) {
2013
- const event = new page.window.MouseEvent("click", {
2014
- bubbles: true, cancelable: true, clientX: Number(x), clientY: Number(y), button: 0,
2015
- });
2016
- const prevented = page.realm.dispatch(region.element, event);
2335
+ // The press focuses the focusable element it lands in, unless mousedown was canceled.
2336
+ const focus = () => {
2337
+ const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
2338
+ if (focusable && !focusable.disabled) focusable.focus();
2339
+ };
2340
+ if (!page.realm) focus();
2341
+ else {
2342
+ // A press and release, then the click (UI Events §3.5).
2343
+ const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
2344
+ const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
2345
+ const pointer = { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true };
2346
+ page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
2347
+ if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
2348
+ page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
2349
+ page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
2350
+ const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
2017
2351
  refresh();
2018
2352
  if (prevented) return null;
2019
2353
  }
2020
- if (region.element.tagName === "AUDIO" || region.element.tagName === "VIDEO") {
2021
- clickMedia(region.element, x, y);
2354
+ if (target.tagName === "AUDIO" || target.tagName === "VIDEO") {
2355
+ clickMedia(target, x, y);
2022
2356
  return null;
2023
2357
  }
2024
- const anchor = region.element.closest?.("a[href]");
2358
+ const anchor = target.closest?.("a[href]");
2025
2359
  if (!anchor) return null;
2026
2360
  const href = new URL(anchor.getAttribute("href"), page.url).href;
2027
2361
  if (href.startsWith("javascript:")) {
@@ -2105,15 +2439,18 @@ export async function createPageRenderer({
2105
2439
  return cloneableResult(value);
2106
2440
  },
2107
2441
  dispose() {
2442
+ disposedRenderer = true;
2108
2443
  hostClearTimeout(prepaintTimer);
2109
2444
  hostClearTimeout(frameTimer);
2445
+ disposeFrames();
2110
2446
  page.realm?.dispose();
2111
2447
  closePiano();
2112
2448
  disposeVideos();
2449
+ rasterSurface?.delete();
2450
+ rasterSurface = null;
2113
2451
  page.window?.happyDOM?.abort?.();
2114
2452
  for (const image of page.decodedImages) image.delete();
2115
- fontCollection.delete();
2116
- fontProvider.delete();
2453
+ disposeBackgrounds();
2117
2454
  },
2118
2455
  };
2119
2456
 
@@ -2155,7 +2492,8 @@ export async function createPageRenderer({
2155
2492
  const entry = { url, text: null };
2156
2493
  linkSheets.set(element, entry);
2157
2494
  const request = fetchResource(url, "stylesheet").then((response) => {
2158
- if (response.ok) entry.text = new TextDecoder().decode(response.body);
2495
+ // Its url()s are relative to the sheet, not to the document.
2496
+ if (response.ok) entry.text = absolutizeStyleSheetUrls(new TextDecoder().decode(response.body), response.url || url);
2159
2497
  }, () => {
2160
2498
  // A failed stylesheet must not prevent the document itself from loading.
2161
2499
  }).then(() => {
@@ -2203,6 +2541,401 @@ export async function createPageRenderer({
2203
2541
  return { map, decoded, entries };
2204
2542
  }
2205
2543
 
2544
+ // ---- iframes. Each same-origin iframe's document lives in a page renderer
2545
+ // of its own (embedded), sharing this process's CanvasKit: its frames are
2546
+ // cut to its viewport and drawn into the iframe's box through a resource
2547
+ // whose image is swapped as frames arrive. Other documents are not given to
2548
+ // this process (the controller checks), so their iframes stay empty.
2549
+
2550
+ /** Brings the frames in line with the document's iframes: their documents and sizes. */
2551
+ function syncFrames() {
2552
+ const document = page.document;
2553
+ const elements = document ? [...document.getElementsByTagName("iframe")] : [];
2554
+ if (!elements.length && !frames.size) return;
2555
+ const boxes = frameBoxes();
2556
+ for (const [element, frame] of frames) {
2557
+ if (!element.isConnected || element.ownerDocument !== document) disposeFrame(frame);
2558
+ }
2559
+ for (const element of elements) {
2560
+ const frame = ensureFrame(element);
2561
+ page.resources.set(element, frame.holder);
2562
+ // Setting the name attribute names the frame (HTML §4.8.5).
2563
+ const nameAttribute = element.getAttribute("name");
2564
+ if (nameAttribute !== frame.nameAttribute) {
2565
+ frame.nameAttribute = nameAttribute;
2566
+ frame.window.setName(nameAttribute ?? "");
2567
+ }
2568
+ frame.box = boxes.get(element) ?? null;
2569
+ const width = Math.max(1, Math.round(frame.box?.width ?? 300));
2570
+ const height = Math.max(1, Math.round(frame.box?.height ?? 150));
2571
+ if (!frame.size || frame.size.width !== width || frame.size.height !== height || frame.size.scale !== page.deviceScaleFactor) {
2572
+ frame.size = { width, height, scale: page.deviceScaleFactor };
2573
+ void frame.ready.then((renderer) => renderer?.resize(width, height, page.deviceScaleFactor));
2574
+ }
2575
+ const source = frameSource(element);
2576
+ if (source !== frame.source) {
2577
+ frame.source = source;
2578
+ void loadFrame(frame, source);
2579
+ }
2580
+ }
2581
+ }
2582
+
2583
+ /** The frame of an iframe of this document, made on first need (a script may post to it at once). */
2584
+ function ensureFrame(element) {
2585
+ let frame = frames.get(element);
2586
+ if (frame) return frame;
2587
+ if (element?.tagName !== "IFRAME" || !element.isConnected || element.ownerDocument !== page.document) return null;
2588
+ frame = { element, renderer: null, ready: null, source: undefined, token: 0, size: null, box: null,
2589
+ window: windowHandle(ownWindow, element.getAttribute("name") ?? ""), holder: { width: 0, height: 0, image: null } };
2590
+ frame.nameAttribute = element.getAttribute("name");
2591
+ frames.set(element, frame);
2592
+ page.resources.set(element, frame.holder);
2593
+ // Made at once, as is its initial about:blank document (HTML §4.8.5): a
2594
+ // script can use its contentWindow as soon as the iframe is inserted.
2595
+ const renderer = buildPageRenderer({
2596
+ fetchResource: rawFetchResource,
2597
+ requestNavigation: (url, downloadName) => {
2598
+ if (downloadName === null) void loadFrame(frame, url);
2599
+ },
2600
+ requestDownload,
2601
+ reportError,
2602
+ pageFetch,
2603
+ webSockets,
2604
+ setCookie,
2605
+ consoleMessage,
2606
+ onFrame: (update) => showFrame(frame, update),
2607
+ onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
2608
+ videoDecoder,
2609
+ rewriteScript,
2610
+ mobile,
2611
+ embedded: true,
2612
+ frameWindow: frame.window,
2613
+ }, loadedGraphics);
2614
+ // Page scripts in "host" mode share one global object; a frame's scripts need their own.
2615
+ renderer.setScripting(page.scripting === "ses" ? "ses" : "off");
2616
+ renderer.setUserAgent(page.userAgent);
2617
+ renderer.setInitScripts(page.initScripts ?? []);
2618
+ frame.renderer = renderer;
2619
+ frame.ready = Promise.resolve(renderer);
2620
+ frame.loading = renderer.loadDocument({ url: "about:blank" }).then((generation) => {
2621
+ frame.generation = generation;
2622
+ frame.initialGeneration = generation;
2623
+ });
2624
+ // Then its src, at once: syncFrames only adjusts the size later. A frame
2625
+ // without one keeps its initial document, whose load the iframe fires;
2626
+ // otherwise only its src's load is fired.
2627
+ frame.source = frameSource(element);
2628
+ frame.silentInitialLoad = frame.source !== "about:blank";
2629
+ if (frame.source !== "about:blank") void loadFrame(frame, frame.source);
2630
+ return frame;
2631
+ }
2632
+
2633
+ /** Each rendered iframe's content box, in document coordinates. */
2634
+ function frameBoxes() {
2635
+ const boxes = new Map();
2636
+ if (!page.layout || !page.renderTree) return boxes;
2637
+ for (const fragment of page.layout.fragments) {
2638
+ for (const run of fragment.runs) {
2639
+ if (run.type !== "image") continue;
2640
+ const element = page.renderTree.nodesById.get(run.nodeId)?.domNode;
2641
+ if (element?.tagName === "IFRAME") {
2642
+ boxes.set(element, { x: run.x, y: run.y ?? fragment.y, width: run.width, height: run.height });
2643
+ }
2644
+ }
2645
+ }
2646
+ return boxes;
2647
+ }
2648
+
2649
+ /** The URL an iframe shows: its src resolved against the page, or about:blank. */
2650
+ function frameSource(element) {
2651
+ const src = element.getAttribute("src")?.trim();
2652
+ if (!src) return "about:blank";
2653
+ try {
2654
+ return new URL(src, page.url).href;
2655
+ } catch {
2656
+ return "about:blank";
2657
+ }
2658
+ }
2659
+
2660
+ /** Loads a frame's document; a cross-origin one (or a failure) leaves it blank. */
2661
+ async function loadFrame(frame, url) {
2662
+ const token = ++frame.token;
2663
+ let document = { url: "about:blank" };
2664
+ let target = null;
2665
+ try { target = new URL(url); } catch { /* blank */ }
2666
+ if (target && ["http:", "https:"].includes(target.protocol) && target.origin === new URL(page.url).origin) {
2667
+ try {
2668
+ const response = await fetchResource(target.href, "iframe");
2669
+ document = {
2670
+ url: response.url || target.href,
2671
+ source: new TextDecoder().decode(response.body),
2672
+ contentType: response.contentType || "text/html",
2673
+ status: response.status,
2674
+ statusText: response.statusText ?? "",
2675
+ cookie: response.documentCookie ?? page.cookie,
2676
+ };
2677
+ } catch (error) {
2678
+ trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
2679
+ }
2680
+ }
2681
+ const renderer = await frame.ready;
2682
+ if (!renderer || token !== frame.token || !frames.has(frame.element)) return;
2683
+ frame.loading = renderer.loadDocument(document).then((generation) => { frame.generation = generation; });
2684
+ }
2685
+
2686
+ /** A frame's document painted: its viewport becomes the iframe's image. */
2687
+ function showFrame(frame, { tile, scroll, viewport: size }) {
2688
+ if (!frames.has(frame.element)) return;
2689
+ const { pixels, width, height } = cropViewport(tile, scroll.x, scroll.y, size.width, size.height);
2690
+ const image = CanvasKit.MakeImage({
2691
+ width, height, alphaType: CanvasKit.AlphaType.Unpremul, colorType: CanvasKit.ColorType.RGBA_8888,
2692
+ colorSpace: CanvasKit.ColorSpace.SRGB,
2693
+ }, pixels, width * 4);
2694
+ if (!image) return;
2695
+ const first = !frame.holder.image;
2696
+ frame.holder.image?.delete();
2697
+ Object.assign(frame.holder, { width, height, image });
2698
+ framesVersion++;
2699
+ // The display list leaves out images that have nothing to show yet.
2700
+ if (first && page.layout) page.displayList = paintList();
2701
+ page.needsPaint = true;
2702
+ if (!framePaintTimer) {
2703
+ framePaintTimer = hostSetTimeout(() => {
2704
+ framePaintTimer = null;
2705
+ if (page.needsPaint && !page.loading) render();
2706
+ }, 16);
2707
+ }
2708
+ }
2709
+
2710
+ /** A frame's document reached DOMContentLoaded or load: the iframe fires load. */
2711
+ function frameLifecycle(frame, name, generation) {
2712
+ if (name !== "load" || !frames.has(frame.element)) return;
2713
+ // A blank document loads within loadDocument, before its generation is known here.
2714
+ hostSetTimeout(() => void frame.loading?.then(() => {
2715
+ if (frame.generation !== generation || !frames.has(frame.element) || !page.realm) return;
2716
+ if (frame.silentInitialLoad && generation === frame.initialGeneration) return;
2717
+ page.realm.dispatch(frame.element, new page.window.Event("load"));
2718
+ refresh();
2719
+ }), 0);
2720
+ }
2721
+
2722
+ function disposeFrame(frame) {
2723
+ frames.delete(frame.element);
2724
+ if (focusedFrame === frame) focusedFrame = null;
2725
+ frame.token++;
2726
+ void frame.ready?.then((renderer) => renderer?.dispose());
2727
+ frame.holder.image?.delete();
2728
+ frame.holder.image = null;
2729
+ }
2730
+
2731
+ function disposeFrames() {
2732
+ for (const frame of [...frames.values()]) disposeFrame(frame);
2733
+ hostClearTimeout(framePaintTimer);
2734
+ framePaintTimer = null;
2735
+ }
2736
+
2737
+ /** The frame under a viewport point, with the point in the frame's own viewport. */
2738
+ function frameAt(element, x, y) {
2739
+ const frame = frames.get(element);
2740
+ if (!frame?.renderer || !frame.box) return null;
2741
+ const shift = scrollShift(element);
2742
+ return { frame, x: x + viewport.x + shift.x - frame.box.x, y: y + viewport.y + shift.y - frame.box.y };
2743
+ }
2744
+
2745
+ // ---- Scroll containers (CSS Overflow 3 §3). Elements with overflow other
2746
+ // than visible or clip scroll their content: the offsets live here, paint
2747
+ // moves the content by them (see paintEffects), and the wheel, scripts and
2748
+ // scrollIntoView change them.
2749
+
2750
+ /** The page scrolls over its laid-out size and whatever overflows it (see displayList.extent). */
2751
+ function setContentSize() {
2752
+ const extent = page.displayList?.extent;
2753
+ viewport.setContentSize(Math.max(page.layout.width, extent?.width ?? 0), Math.max(page.layout.height, extent?.height ?? 0));
2754
+ }
2755
+
2756
+ function paintList() {
2757
+ return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
2758
+ }
2759
+
2760
+ function scrollOffsetOf(element) {
2761
+ return element ? page.elementScroll.get(element) ?? null : null;
2762
+ }
2763
+
2764
+ /** The render node and layout box of an element, or null. */
2765
+ 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;
2773
+ }
2774
+
2775
+ /**
2776
+ * A scroll container's scroll position and sizes (CSSOM View §6): its
2777
+ * padding box is the client area, its content's reach the scroll area.
2778
+ * Null for an element that is not one.
2779
+ */
2780
+ function scrollerGeometry(element) {
2781
+ const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
2782
+ if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
2783
+ const found = layoutBoxOf(element);
2784
+ if (!found) return null;
2785
+ const { node, box } = found;
2786
+ const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
2787
+ const padding = style.padding ?? [0, 0, 0, 0];
2788
+ const client = {
2789
+ x: box.x + borderLeft,
2790
+ y: box.y + borderTop,
2791
+ width: Math.max(0, box.width - borderLeft - borderRight),
2792
+ height: Math.max(0, box.height - borderTop - borderBottom),
2793
+ };
2794
+ const extent = page.displayList?.scrollers?.get(node.id);
2795
+ // In-flow content gets the end padding; absolutely positioned boxes reach only as far as they do.
2796
+ const scrollWidth = Math.max(client.width, extent ? Math.max(extent.flowRight - client.x + (Number(padding[1]) || 0),
2797
+ extent.right - client.x) : 0);
2798
+ const scrollHeight = Math.max(client.height, extent ? Math.max(extent.flowBottom - client.y + (Number(padding[2]) || 0),
2799
+ extent.bottom - client.y) : 0);
2800
+ const offset = page.elementScroll.get(element) ?? { x: 0, y: 0 };
2801
+ return {
2802
+ x: offset.x,
2803
+ y: offset.y,
2804
+ clientWidth: client.width,
2805
+ clientHeight: client.height,
2806
+ scrollWidth,
2807
+ scrollHeight,
2808
+ maxX: style.overflowScrollX ? Math.max(0, scrollWidth - client.width) : 0,
2809
+ maxY: style.overflowScrollY ? Math.max(0, scrollHeight - client.height) : 0,
2810
+ user: { x: style.overflowScrollX === "auto", y: style.overflowScrollY === "auto" },
2811
+ };
2812
+ }
2813
+
2814
+ /** Scrolls a scroll container to (x, y), clamped; returns whether it moved. */
2815
+ function setElementScroll(element, x, y) {
2816
+ const geometry = scrollerGeometry(element);
2817
+ if (!geometry) return false;
2818
+ const next = {
2819
+ x: Math.round(Math.min(geometry.maxX, Math.max(0, Number(x) || 0))),
2820
+ y: Math.round(Math.min(geometry.maxY, Math.max(0, Number(y) || 0))),
2821
+ };
2822
+ if (next.x === geometry.x && next.y === geometry.y) return false;
2823
+ if (next.x || next.y) page.elementScroll.set(element, next);
2824
+ else page.elementScroll.delete(element);
2825
+ page.layoutVersion++;
2826
+ page.displayList = paintList();
2827
+ page.needsPaint = true;
2828
+ // A scroll event at the element, as a task (CSSOM View §13.1).
2829
+ hostSetTimeout(() => {
2830
+ if (page.realm && element.isConnected) page.realm.dispatch(element, new page.window.Event("scroll"));
2831
+ }, 0);
2832
+ return true;
2833
+ }
2834
+
2835
+ /**
2836
+ * Keeps offsets within what each scroll container can scroll after a
2837
+ * relayout (measured on the display list just built); returns whether any changed.
2838
+ */
2839
+ function clampElementScroll() {
2840
+ let changed = false;
2841
+ for (const [element, offset] of page.elementScroll) {
2842
+ const geometry = element.isConnected ? scrollerGeometry(element) : null;
2843
+ if (!geometry) {
2844
+ page.elementScroll.delete(element);
2845
+ changed = true;
2846
+ } else if (offset.x > geometry.maxX || offset.y > geometry.maxY) {
2847
+ page.elementScroll.set(element, { x: Math.min(offset.x, geometry.maxX), y: Math.min(offset.y, geometry.maxY) });
2848
+ changed = true;
2849
+ }
2850
+ }
2851
+ return changed;
2852
+ }
2853
+
2854
+ /** How far the scroll containers around an element have moved it (to subtract from its layout position). */
2855
+ function scrollShift(element) {
2856
+ let x = 0;
2857
+ let y = 0;
2858
+ for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
2859
+ if (node !== element) {
2860
+ const offset = page.elementScroll.get(node);
2861
+ if (offset) {
2862
+ x += offset.x;
2863
+ y += offset.y;
2864
+ }
2865
+ }
2866
+ // Fixed content does not move with what is around it.
2867
+ if (styleEngine.get(node)?.position === "fixed") break;
2868
+ }
2869
+ return { x, y };
2870
+ }
2871
+
2872
+ /**
2873
+ * The wheel at a viewport point (UI Events §5.4): a wheel event at the
2874
+ * element there; unless it is canceled, the scroll containers from there
2875
+ * outwards each take what they can still scroll of the delta and pass the
2876
+ * rest on (scroll chaining), an iframe's document included unless the
2877
+ * iframe says scrolling="no". Returns the delta left over for the page,
2878
+ * which the compositor scrolls (or, for an iframe's document, the frame here).
2879
+ */
2880
+ function wheel(x, y, deltaX, deltaY, { scrollViewport = true } = {}) {
2881
+ refresh();
2882
+ let left = { x: deltaX, y: deltaY };
2883
+ const target = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2884
+ if (target?.tagName === "IFRAME") {
2885
+ const inFrame = frameAt(target, x, y);
2886
+ if (inFrame) {
2887
+ left = inFrame.frame.renderer.wheel(inFrame.x, inFrame.y, left.x, left.y, {
2888
+ scrollViewport: target.getAttribute("scrolling")?.toLowerCase() !== "no",
2889
+ });
2890
+ if (!left.x && !left.y) return left;
2891
+ }
2892
+ }
2893
+ if (target && page.realm) {
2894
+ const WheelEvent = page.window.WheelEvent ?? page.window.MouseEvent;
2895
+ const event = new WheelEvent("wheel", {
2896
+ bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, deltaX: left.x, deltaY: left.y, deltaMode: 0,
2897
+ });
2898
+ if (page.realm.dispatch(target, event)) return { x: 0, y: 0 };
2899
+ }
2900
+ let moved = false;
2901
+ for (let node = target; node && node.nodeType === 1 && (left.x || left.y); node = node.parentElement) {
2902
+ const geometry = scrollerGeometry(node);
2903
+ if (!geometry) continue;
2904
+ const nextX = geometry.user.x ? Math.min(geometry.maxX, Math.max(0, geometry.x + left.x)) : geometry.x;
2905
+ const nextY = geometry.user.y ? Math.min(geometry.maxY, Math.max(0, geometry.y + left.y)) : geometry.y;
2906
+ if (nextX === geometry.x && nextY === geometry.y) continue;
2907
+ setElementScroll(node, nextX, nextY);
2908
+ left = { x: left.x - (nextX - geometry.x), y: left.y - (nextY - geometry.y) };
2909
+ moved = true;
2910
+ }
2911
+ // An iframe's document scrolls its own viewport; the top-level page leaves that to the compositor.
2912
+ if (embedded && scrollViewport && (left.x || left.y)) {
2913
+ const before = { x: viewport.x, y: viewport.y };
2914
+ if (viewport.scrollBy(left.x, left.y)) {
2915
+ left = { x: left.x - (viewport.x - before.x), y: left.y - (viewport.y - before.y) };
2916
+ moved = true;
2917
+ }
2918
+ }
2919
+ if (moved) render();
2920
+ return left;
2921
+ }
2922
+
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) {
2926
+ const geometry = scrollerGeometry(node);
2927
+ if (!geometry) continue;
2928
+ const target = viewportBounds(element);
2929
+ const container = viewportBounds(node);
2930
+ if (!target || !container) continue;
2931
+ 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));
2933
+ }
2934
+ const bounds = viewportBounds(element);
2935
+ if (bounds) viewport.scrollTo(viewport.x, viewport.y + bounds.top);
2936
+ render();
2937
+ }
2938
+
2206
2939
  /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
2207
2940
  function initializeVideos(document, resources) {
2208
2941
  for (const element of document.querySelectorAll("video, audio")) {
@@ -2231,31 +2964,162 @@ export async function createPageRenderer({
2231
2964
  /** HTMLMediaElement state for page script. */
2232
2965
  function mediaState(element) {
2233
2966
  const state = page.media.get(element);
2967
+ const decoder = state?.video?.decoder;
2234
2968
  return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
2235
- duration: state?.duration ?? NaN };
2969
+ duration: state?.duration ?? NaN, videoWidth: decoder?.width || 0, videoHeight: decoder?.height || 0 };
2970
+ }
2971
+
2972
+ function mediaReadyState(element) {
2973
+ const state = page.media.get(element);
2974
+ if (!state?.probed) return 0;
2975
+ if (state.buffering) return 2;
2976
+ if (state.video || state.sound) return state.pipe && !state.pipe.done ? 3 : 4;
2977
+ return 1;
2978
+ }
2979
+
2980
+ function mediaNetworkState(element) {
2981
+ const state = page.media.get(element);
2982
+ const hasSrc = Boolean(videoSource(element) || elementSources.get(element));
2983
+ if (!state && !hasSrc) return 0;
2984
+ if (state?.error) return 3;
2985
+ if ((state?.pipe && !state.pipe.done) || state?.loading) return 2;
2986
+ return 1;
2987
+ }
2988
+
2989
+ function mediaError(element) {
2990
+ return page.media.get(element)?.error ?? null;
2991
+ }
2992
+
2993
+ function mediaBuffered(element) {
2994
+ const state = page.media.get(element);
2995
+ const end = state?.mediaSource?.bufferedEnd?.() || state?.bufferedEnd || 0;
2996
+ return end > 0 ? { length: 1, start: 0, end } : { length: 0, start: 0, end: 0 };
2997
+ }
2998
+
2999
+ function resolveMediaSource(value) {
3000
+ const realm = page.realm;
3001
+ if (!realm?.isMediaSource) return null;
3002
+ if (realm.isMediaSource(value)) return value;
3003
+ if (typeof value === "string") return realm.mediaSourceAt(value);
3004
+ return null;
3005
+ }
3006
+
3007
+ /** A resource record for a media element created after the document was parsed. */
3008
+ function ensureMediaResource(element) {
3009
+ if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
3010
+ let resource = page.resources.get(element);
3011
+ if (resource) return resource;
3012
+ const audioOnly = element.tagName === "AUDIO";
3013
+ resource = {
3014
+ width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
3015
+ height: audioOnly ? 32 : Number(element.getAttribute("height")) || 150,
3016
+ bitmapWidth: 0, bitmapHeight: 0, image: null, playing: false, time: 0, duration: NaN,
3017
+ };
3018
+ page.resources.set(element, resource);
3019
+ page.dirty = true;
3020
+ return resource;
3021
+ }
3022
+
3023
+ /** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
3024
+ function attachMediaSrc(element, value) {
3025
+ if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
3026
+ const source = resolveMediaSource(value);
3027
+ if (!source) {
3028
+ elementSources.delete(element);
3029
+ return;
3030
+ }
3031
+ elementSources.set(element, source);
3032
+ sourceElements.set(source, element);
3033
+ if (source.readyState === "closed") {
3034
+ try {
3035
+ source.attach(typeof value === "string" ? value : source._objectURL);
3036
+ } catch {
3037
+ return;
3038
+ }
3039
+ }
3040
+ const state = mediaStateFor(element);
3041
+ if (!state) return;
3042
+ state.mediaSource = source;
3043
+ if (Number.isFinite(source.duration)) {
3044
+ state.durationOverride = true;
3045
+ state.duration = source.duration;
3046
+ if (state.resource) state.resource.duration = source.duration;
3047
+ }
3048
+ bindSourcePipes(state);
3049
+ }
3050
+
3051
+ function mediaAppended(source) {
3052
+ const element = sourceElements.get(source);
3053
+ if (!element) return;
3054
+ const state = page.media.get(element) ?? mediaStateFor(element);
3055
+ if (!state) return;
3056
+ state.mediaSource = source;
3057
+ bindSourcePipes(state);
3058
+ if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
3059
+ void refreshProbe(state);
3060
+ if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
3061
+ }
3062
+
3063
+ function mediaDuration(source, seconds) {
3064
+ const element = sourceElements.get(source);
3065
+ const state = element && page.media.get(element);
3066
+ if (!state || !Number.isFinite(seconds)) return;
3067
+ state.durationOverride = true;
3068
+ state.duration = seconds;
3069
+ if (state.resource) state.resource.duration = seconds;
3070
+ mediaEvent(state, "durationchange");
2236
3071
  }
2237
3072
 
2238
3073
  /** The element's playback state, created (not loaded) on first use. */
2239
3074
  function mediaStateFor(element) {
2240
- const resource = page.resources.get(element);
2241
- const url = videoSource(element);
2242
- if (!videoDecoder || !resource || !url) return null;
3075
+ const resource = ensureMediaResource(element);
3076
+ let source = elementSources.get(element) ?? null;
3077
+ const attributeUrl = videoSource(element);
3078
+ if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
3079
+ const url = attributeUrl ?? source?._objectURL ?? null;
3080
+ if (!videoDecoder || !resource || (!url && !source)) return null;
3081
+ if (source && source.readyState === "closed") {
3082
+ try {
3083
+ source.attach(url);
3084
+ elementSources.set(element, source);
3085
+ sourceElements.set(source, element);
3086
+ } catch {}
3087
+ }
2243
3088
  let state = page.media.get(element);
2244
3089
  if (state && state.url !== url) {
2245
3090
  closeMedia(state);
3091
+ page.media.delete(element);
2246
3092
  state = null;
2247
3093
  }
2248
3094
  if (!state) {
2249
- state = { element, url, resource, bytes: null, loading: null, showsPicture: element.tagName === "VIDEO",
2250
- video: null, sound: null, playing: false, ended: false, duration: NaN, base: 0, clockStart: null,
2251
- timer: null, lastTimeUpdate: -1, generation: loadGeneration, document: page.document };
3095
+ state = { element, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
3096
+ loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
3097
+ video: null, sound: null, playing: false, ended: false,
3098
+ duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
3099
+ timer: null, lastTimeUpdate: -1, generation: loadGeneration, document: page.document,
3100
+ probed: false, hasVideo: false, hasAudio: false, buffering: false, refilling: null,
3101
+ seekSerial: 0, error: null, bufferedEnd: 0, contentType: "", videoMime: "", audioMime: "" };
2252
3102
  page.media.set(element, state);
3103
+ if (Number.isFinite(source?.duration)) {
3104
+ state.durationOverride = true;
3105
+ resource.duration = source.duration;
3106
+ }
3107
+ } else if (source) {
3108
+ state.mediaSource = source;
2253
3109
  }
2254
3110
  return state;
2255
3111
  }
2256
3112
 
2257
3113
  /** Current position in seconds. */
2258
3114
  function mediaTime(state) {
3115
+ const sound = state.sound;
3116
+ if (state.playing && sound?.id != null && !sound.done) {
3117
+ const time = sound.playedTime ?? sound.streamStart / sound.decoder.sampleRate;
3118
+ return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
3119
+ }
3120
+ // A known audio track which hasn't arrived must not leave its picture
3121
+ // running independently during a network outage.
3122
+ if (state.playing && state.hasAudio && audio && !sound) return state.base;
2259
3123
  const running = state.playing && state.clockStart !== null ? (hostPerformance.now() - state.clockStart) / 1000 : 0;
2260
3124
  const time = state.base + running;
2261
3125
  return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
@@ -2275,39 +3139,192 @@ export async function createPageRenderer({
2275
3139
  render();
2276
3140
  }
2277
3141
 
2278
- /** Fetches (once), probes, and opens the decoders for what the file holds. */
3142
+ function isPipe(value) {
3143
+ return Boolean(value && typeof value.waitFor === "function" && typeof value.bytes === "function");
3144
+ }
3145
+
3146
+ function bindSourcePipes(state) {
3147
+ const pipes = state.mediaSource?.streamPipes?.();
3148
+ if (!pipes) return;
3149
+ if (pipes.video) state.videoPipe = pipes.video;
3150
+ if (pipes.audio) state.audioPipe = pipes.audio;
3151
+ state.pipe = state.videoPipe ?? state.audioPipe ?? state.pipe;
3152
+ state.videoMime = pipes.videoMime || state.videoMime;
3153
+ state.audioMime = pipes.audioMime || state.audioMime;
3154
+ state.audioHasMedia = pipes.audioReady;
3155
+ }
3156
+
3157
+ /** Fetches (or attaches MSE pipes), probes a prefix, and opens decoders. */
2279
3158
  function loadMedia(state) {
2280
3159
  state.loading ??= (async () => {
2281
- if (!state.bytes) {
3160
+ if (state.mediaSource) {
3161
+ await state.mediaSource.waitForPipes();
3162
+ bindSourcePipes(state);
3163
+ }
3164
+ if (!state.pipe && !state.bytes && !state.mediaSource) {
2282
3165
  const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
2283
3166
  if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
2284
- state.bytes = response.body;
3167
+ state.contentType = response.contentType ?? "";
3168
+ if (isPipe(response.body)) {
3169
+ state.pipe = response.body;
3170
+ state.videoPipe = state.pipe;
3171
+ state.audioPipe = state.pipe;
3172
+ } else {
3173
+ state.bytes = response.body;
3174
+ }
2285
3175
  }
2286
- if (!Number.isFinite(state.duration) && !state.probed) {
2287
- const probe = await videoDecoder.probe(state.bytes);
3176
+ await ensureProbed(state);
3177
+ if (!state.mediaSource) watchProgressiveMetadata(state);
3178
+ if (state.hasVideo && !state.video) await openVideoDecoder(state);
3179
+ // A separate audio buffer often arrives as an init segment, or empty.
3180
+ // That is not a broken video: picture plays, and sound attaches later.
3181
+ if (state.hasAudio && audio && !state.sound) {
3182
+ try {
3183
+ let opened = await openAudioDecoder(state);
3184
+ // With separate MSE buffers the audio init segment often arrives
3185
+ // before its first media segment. Starting the video clock here
3186
+ // makes ensureAudio seek past the opening words once sound becomes
3187
+ // decodable. Briefly wait for that first segment so both start at 0.
3188
+ const audioPipe = state.audioPipe ?? state.pipe;
3189
+ const separateAudio = state.audioPipe && state.audioPipe !== state.videoPipe;
3190
+ if (state.video && audioPipe && !audioPipe.done && !audioPipe.error
3191
+ && (!opened || (separateAudio && !state.audioHasMedia))) {
3192
+ const deadline = hostPerformance.now() + 750;
3193
+ do {
3194
+ const mark = audioPipe.size + 1;
3195
+ const remaining = Math.max(0, deadline - hostPerformance.now());
3196
+ await Promise.race([
3197
+ audioPipe.waitFor(mark),
3198
+ new Promise((resolve) => hostSetTimeout(resolve, remaining)),
3199
+ ]);
3200
+ bindSourcePipes(state);
3201
+ } while (separateAudio && !state.audioHasMedia && !audioPipe.done && !audioPipe.error
3202
+ && hostPerformance.now() < deadline);
3203
+ if (!audioPipe.error) opened = await openAudioDecoder(state);
3204
+ }
3205
+ } catch (error) {
3206
+ if (!state.video) throw error;
3207
+ reportError(error);
3208
+ }
3209
+ }
3210
+ })().finally(() => { state.loading = null; });
3211
+ return state.loading;
3212
+ }
3213
+
3214
+ /** Probe until the growing body exposes a picture or sample rate, or ends. */
3215
+ async function ensureProbed(state) {
3216
+ if (state.probed) return;
3217
+ const pipe = state.videoPipe ?? state.pipe;
3218
+ let want = 1024;
3219
+ let lastError = null;
3220
+ while (true) {
3221
+ if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
3222
+ if (pipe?.error) throw pipe.error;
3223
+ const bytes = pipe ? pipe.bytes() : state.bytes;
3224
+ if (!bytes?.byteLength) {
3225
+ if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
3226
+ continue;
3227
+ }
3228
+ try {
3229
+ const probe = await videoDecoder.probe(bytes);
3230
+ const videoReady = probe.video?.width > 0;
3231
+ const audioReady = probe.audio?.sampleRate > 0;
3232
+ if (!videoReady && !audioReady) {
3233
+ if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
3234
+ want = Math.max(bytes.byteLength + 1, want * 2);
3235
+ continue;
3236
+ }
3237
+ if (!state.durationOverride) state.duration = probe.duration ?? NaN;
3238
+ state.hasVideo = Boolean(state.showsPicture && videoReady);
3239
+ state.hasAudio = Boolean(audioReady || (state.audioPipe && state.audioPipe !== pipe));
2288
3240
  state.probed = true;
2289
- state.duration = probe.duration ?? NaN;
2290
- state.hasVideo = state.showsPicture && probe.video?.width > 0;
2291
- state.hasAudio = probe.audio?.sampleRate > 0;
3241
+ const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : 0.001;
3242
+ state.bufferedEnd = end;
3243
+ state.mediaSource?.noteBuffered(end);
2292
3244
  state.resource.duration = state.duration;
2293
3245
  mediaEvent(state, "loadedmetadata");
3246
+ return;
3247
+ } catch (error) {
3248
+ lastError = error;
3249
+ if (!pipe || pipe.done) throw lastError;
3250
+ want = Math.max(bytes.byteLength + 1, want * 2);
2294
3251
  }
2295
- if (state.hasVideo && !state.video) await openVideoDecoder(state);
2296
- if (state.hasAudio && audio && !state.sound) await openAudioDecoder(state);
2297
- })().finally(() => { state.loading = null; });
2298
- return state.loading;
3252
+ }
2299
3253
  }
2300
3254
 
2301
- // FFmpeg probes the container; formats without magic bytes take a hint from the extension.
2302
- function decodeOptions(state) {
2303
- const hint = formatHint(new URL(state.url).pathname);
2304
- return hint ? { format: hint } : {};
3255
+ /** A later MSE append can extend the duration and the buffered range. */
3256
+ async function refreshProbe(state) {
3257
+ if (state.probing) return;
3258
+ state.probing = true;
3259
+ try {
3260
+ if (!state.probed) {
3261
+ await ensureProbed(state);
3262
+ return;
3263
+ }
3264
+ const pipe = state.videoPipe ?? state.pipe;
3265
+ if (!pipe?.size) return;
3266
+ try {
3267
+ const probe = await videoDecoder.probe(pipe.bytes());
3268
+ if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
3269
+ state.duration = probe.duration;
3270
+ state.resource.duration = probe.duration;
3271
+ mediaEvent(state, "durationchange");
3272
+ }
3273
+ const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
3274
+ state.bufferedEnd = end;
3275
+ state.mediaSource?.noteBuffered(end);
3276
+ if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
3277
+ if (probe.audio?.sampleRate > 0) state.hasAudio = true;
3278
+ } catch {}
3279
+ } finally {
3280
+ state.probing = false;
3281
+ }
3282
+ }
3283
+
3284
+ /** Refresh duration as a progressive response grows, especially MP3. */
3285
+ function watchProgressiveMetadata(state) {
3286
+ const pipe = state.pipe;
3287
+ if (!pipe || pipe.done || state.metadataWatch) return;
3288
+ state.metadataWatch = (async () => {
3289
+ let mark = Math.max(pipe.size + 1, pipe.size * 2);
3290
+ while (state.document === page.document && !pipe.error) {
3291
+ await pipe.waitFor(mark);
3292
+ if (state.document !== page.document || pipe.error) return;
3293
+ await refreshProbe(state);
3294
+ if (pipe.done) return;
3295
+ mark = Math.max(pipe.size + 64 * 1024, pipe.size * 2);
3296
+ }
3297
+ })().catch((error) => reportError(error)).finally(() => {
3298
+ state.metadataWatch = null;
3299
+ });
3300
+ }
3301
+
3302
+ // Content-probed when the URL has a container extension. Blob MSE URLs do
3303
+ // not, so the SourceBuffer MIME selects the demuxer.
3304
+ function decodeOptions(mime, url) {
3305
+ let path = "";
3306
+ try {
3307
+ path = url ? new URL(url).pathname : "";
3308
+ } catch {}
3309
+ const hint = formatHint(path);
3310
+ if (hint) return { format: hint };
3311
+ if (/\.(mp4|m4v|m4a|webm|mkv|ogg|ogv|mp3|wav|aac|flac)$/i.test(path)) return {};
3312
+ const format = containerFormat(mime);
3313
+ return format ? { format } : {};
3314
+ }
3315
+
3316
+ function snapshot(pipe, bytes) {
3317
+ return pipe ? pipe.bytes() : bytes;
2305
3318
  }
2306
3319
 
2307
3320
  async function openVideoDecoder(state) {
3321
+ const pipe = state.videoPipe ?? state.pipe;
3322
+ const generation = pipe?.generation ?? 0;
3323
+ const bytes = snapshot(pipe, state.bytes);
3324
+ if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
2308
3325
  state.video?.decoder.close();
2309
- const decoder = await videoDecoder.decode(state.bytes, decodeOptions(state));
2310
- state.video = { decoder, frameTime: 0, done: false };
3326
+ const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
3327
+ state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation };
2311
3328
  trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
2312
3329
  if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
2313
3330
  state.resource.width = decoder.width;
@@ -2317,11 +3334,108 @@ export async function createPageRenderer({
2317
3334
  }
2318
3335
  }
2319
3336
 
2320
- async function openAudioDecoder(state) {
2321
- state.sound?.decoder.close();
2322
- const decoder = await videoDecoder.decodeAudio(state.bytes, decodeOptions(state));
2323
- state.sound = { decoder, id: null, position: 0, pending: null, history: [], finishing: false, done: false };
3337
+ /**
3338
+ * Opens the audio decoder on the current snapshot.
3339
+ * Returns false when the pipe can still grow into something decodable
3340
+ * (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
3341
+ * The previous decoder stays until a new one succeeds.
3342
+ */
3343
+ async function openAudioDecoder(state, { keepStream = false } = {}) {
3344
+ const serial = state.seekSerial;
3345
+ const operation = (state.audioOpenSerial ?? 0) + 1;
3346
+ state.audioOpenSerial = operation;
3347
+ const pipe = state.audioPipe ?? state.pipe;
3348
+ const generation = pipe?.generation ?? 0;
3349
+ const bytes = snapshot(pipe, state.bytes);
3350
+ const pending = pipe && !pipe.done && !pipe.error;
3351
+ if (!bytes?.byteLength) {
3352
+ if (pending) return false;
3353
+ throw new Error(`media ${state.url}: no audio bytes`);
3354
+ }
3355
+ let decoder;
3356
+ try {
3357
+ decoder = await videoDecoder.decodeAudio(bytes, decodeOptions(state.audioMime || state.contentType, state.url));
3358
+ } catch (error) {
3359
+ if (pending || (pipe && pipe.generation !== generation)) return false;
3360
+ throw error;
3361
+ }
3362
+ let firstSamples = null;
3363
+ try {
3364
+ firstSamples = decoder.nextSamples();
3365
+ } catch (error) {
3366
+ decoder.close();
3367
+ if (pending || (pipe && pipe.generation !== generation)) return false;
3368
+ throw error;
3369
+ }
3370
+ if (!firstSamples && pending) {
3371
+ decoder.close();
3372
+ return false;
3373
+ }
3374
+ if (state.document !== page.document || state.seekSerial !== serial || state.audioOpenSerial !== operation) {
3375
+ decoder.close();
3376
+ return false;
3377
+ }
3378
+ const previous = state.sound;
3379
+ const previousId = previous?.id ?? null;
3380
+ if (keepStream && previousId != null
3381
+ && (previous.decoder.sampleRate !== decoder.sampleRate || previous.decoder.channels !== decoder.channels)) {
3382
+ decoder.close();
3383
+ throw new Error("audio format changed while output stream is active");
3384
+ }
3385
+ previous?.decoder.close();
3386
+ state.sound = { decoder, id: null, position: 0, pending: firstSamples, history: [], finishing: false, done: false,
3387
+ pipe, openedGeneration: generation };
3388
+ if (keepStream && previousId != null) {
3389
+ // The output stream carries on with the new decoder's samples.
3390
+ Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
3391
+ playedTime: previous.playedTime, owed: previous.owed ?? 0 });
3392
+ } else if (previousId != null) audio?.close(previousId);
2324
3393
  trace?.(`audio decoded ${state.url}: ${decoder.sampleRate} Hz, ${decoder.channels} channels`);
3394
+ return true;
3395
+ }
3396
+
3397
+ /** Attaches sound once a separate audio buffer becomes decodable. */
3398
+ function ensureAudio(state) {
3399
+ // loadMedia/refillMedia own decoder replacement. An append arriving while
3400
+ // either awaits bytes must not start a second opener and reset live audio
3401
+ // back to the first packet.
3402
+ if (state.loading || state.refilling || state.sound || state.audioWatch || !state.hasAudio || !audio) return;
3403
+ const pipe = state.audioPipe ?? state.pipe;
3404
+ if (!pipe) return;
3405
+ state.audioWatch = (async () => {
3406
+ try {
3407
+ while (state.playing && !state.sound && state.document === page.document) {
3408
+ let opened = false;
3409
+ try {
3410
+ opened = await openAudioDecoder(state);
3411
+ } catch (error) {
3412
+ if (state.video) reportError(error);
3413
+ else if (state.document === page.document) failMedia(state, error, 4);
3414
+ return;
3415
+ }
3416
+ if (opened && state.sound && state.playing && state.document === page.document) {
3417
+ await positionAudio(state, mediaTime(state));
3418
+ if (state.sound && !state.sound.id) openAudioStream(state);
3419
+ return;
3420
+ }
3421
+ if (state.sound || pipe.done || pipe.error) return;
3422
+ const size = pipe.size;
3423
+ await pipe.waitFor(size + 1);
3424
+ }
3425
+ } finally {
3426
+ state.audioWatch = null;
3427
+ }
3428
+ })();
3429
+ }
3430
+
3431
+ /**
3432
+ * The decoder was opened on a snapshot that is not the finished body.
3433
+ * null and a truncated-file error both mean "wait", not "ended".
3434
+ */
3435
+ function needsMore(track) {
3436
+ const pipe = track?.pipe;
3437
+ if (!pipe || pipe.error) return false;
3438
+ return pipe.generation !== track.openedGeneration || !pipe.done;
2325
3439
  }
2326
3440
 
2327
3441
  /** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
@@ -2346,6 +3460,7 @@ export async function createPageRenderer({
2346
3460
  async function playMedia(element) {
2347
3461
  const state = mediaStateFor(element);
2348
3462
  if (!state || state.playing) return;
3463
+ state.error = null;
2349
3464
  setMediaPlaying(state, true);
2350
3465
  try {
2351
3466
  await loadMedia(state);
@@ -2354,21 +3469,18 @@ export async function createPageRenderer({
2354
3469
  await positionMedia(state, 0);
2355
3470
  } else if (state.needsPosition) {
2356
3471
  // After a pause the audio decoder is ahead of where playing resumes.
2357
- await positionMedia(state, state.base, { videoToo: false });
3472
+ const moved = await skipPlayedAudio(state);
3473
+ await positionMedia(state, state.base, { videoToo: moved });
2358
3474
  }
2359
3475
  state.needsPosition = false;
2360
3476
  } catch (error) {
2361
- if (state.document === page.document) reportError(error);
2362
- closeMedia(state);
2363
- page.media.delete(element);
2364
- repaintMedia();
3477
+ if (state.document === page.document) failMedia(state, error, state.pipe?.error ? 2 : 4);
2365
3478
  return;
2366
3479
  }
2367
3480
  if (!state.playing || state.document !== page.document) return;
2368
3481
  if (!state.video && !state.sound) {
2369
3482
  // Nothing this engine can play (audio with no audio output).
2370
- setMediaPlaying(state, false);
2371
- reportError(new Error(`media ${state.url}: nothing to play`));
3483
+ failMedia(state, new Error(`media ${state.url}: nothing to play`), 4);
2372
3484
  return;
2373
3485
  }
2374
3486
  startMedia(state);
@@ -2379,6 +3491,7 @@ export async function createPageRenderer({
2379
3491
  const state = page.media.get(element);
2380
3492
  if (!state?.playing) return;
2381
3493
  state.base = mediaTime(state);
3494
+ trace?.(`media pause at ${state.base.toFixed(3)}s`);
2382
3495
  stopClock(state);
2383
3496
  state.needsPosition = true;
2384
3497
  setMediaPlaying(state, false);
@@ -2388,10 +3501,17 @@ export async function createPageRenderer({
2388
3501
  async function seekMedia(element, seconds) {
2389
3502
  const state = mediaStateFor(element);
2390
3503
  if (!state) return;
3504
+ trace?.(`media seek to ${Number(seconds).toFixed(3)}s from ${mediaTime(state).toFixed(3)}s`);
3505
+ const serial = ++state.seekSerial;
2391
3506
  const wasPlaying = state.playing;
2392
3507
  if (wasPlaying) stopClock(state);
3508
+ // A seek goes where it is told, whatever the output still plays.
3509
+ state.outputEnd = null;
2393
3510
  try {
3511
+ if (state.refilling) await state.refilling.catch(() => {});
3512
+ if (state.seekSerial !== serial) return;
2394
3513
  await loadMedia(state);
3514
+ if (state.seekSerial !== serial) return;
2395
3515
  const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
2396
3516
  state.ended = false;
2397
3517
  await positionMedia(state, target);
@@ -2412,80 +3532,149 @@ export async function createPageRenderer({
2412
3532
  /** Moves the decoders (and the clock's base) to `seconds`. */
2413
3533
  async function positionMedia(state, seconds, { videoToo = true } = {}) {
2414
3534
  state.base = seconds;
2415
- if (state.video && videoToo) {
2416
- if (state.video.done || seconds === 0) await openVideoDecoder(state);
2417
- if (seconds > 0) state.video.decoder.seek(seconds * 1000);
2418
- state.video.frameTime = seconds;
2419
- state.video.done = false;
2420
- }
3535
+ if (state.video && videoToo) await placeVideo(state, seconds);
2421
3536
  if (state.sound) await positionAudio(state, seconds);
2422
3537
  }
2423
3538
 
2424
- /** Puts the audio decoder at `seconds`: from the kept history, by decoding ahead, or from the start. */
2425
- async function positionAudio(state, seconds) {
2426
- const sound = state.sound;
2427
- const { sampleRate, channels } = sound.decoder;
2428
- const target = Math.max(0, Math.round(seconds * sampleRate));
2429
- sound.done = false;
2430
- const historyStart = sound.history[0]?.start ?? Infinity;
2431
- if (target >= historyStart && target < sound.position) {
2432
- // Resume inside audio already decoded: take it from the history.
2433
- const parts = [];
2434
- for (const chunk of sound.history) {
2435
- const frames = chunk.samples.length / channels;
2436
- if (chunk.start + frames <= target) continue;
2437
- parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
2438
- }
2439
- if (sound.pending) parts.push(sound.pending);
2440
- sound.pending = concatSamples(parts);
2441
- sound.position = target;
2442
- sound.history = [];
2443
- return;
2444
- }
2445
- if (target < sound.position) await openAudioDecoder(state);
2446
- const current = state.sound;
2447
- current.history = [];
2448
- if (current.pending) {
2449
- const frames = current.pending.length / channels;
2450
- if (current.position + frames > target) {
2451
- current.pending = current.pending.subarray((target - current.position) * channels);
2452
- current.position = target;
3539
+ /** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
3540
+ async function placeVideo(state, seconds) {
3541
+ for (let attempt = 0; attempt < 8; attempt++) {
3542
+ if (!state.video || state.video.done || seconds === 0 || attempt > 0) await openVideoDecoder(state);
3543
+ try {
3544
+ state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
3545
+ state.video.hideUntil = seconds;
3546
+ state.video.done = false;
2453
3547
  return;
3548
+ } catch (error) {
3549
+ if (!needsMore(state.video)) throw error;
3550
+ const pipe = state.video.pipe;
3551
+ const size = pipe.size;
3552
+ await pipe.waitFor(size + 1);
3553
+ if (pipe.error) throw pipe.error;
3554
+ if (pipe.size === size) throw error;
2454
3555
  }
2455
- current.position += frames;
2456
- current.pending = null;
2457
3556
  }
2458
- while (current.position < target) {
2459
- const samples = current.decoder.nextSamples();
2460
- if (!samples) return;
2461
- const frames = samples.length / channels;
2462
- if (current.position + frames > target) {
2463
- current.pending = samples.subarray((target - current.position) * channels);
2464
- current.position = target;
3557
+ }
3558
+
3559
+ /** Puts the audio decoder at `seconds`: from the kept history, by decoding ahead, or from the start. */
3560
+ async function positionAudio(state, seconds) {
3561
+ const transaction = {};
3562
+ state.audioPositioning = transaction;
3563
+ const serial = state.seekSerial;
3564
+ try {
3565
+ if (state.sound) trace?.(`audio positioned at ${seconds.toFixed(3)}s (was at ${(state.sound.position / state.sound.decoder.sampleRate).toFixed(3)}s)`);
3566
+ const sound = state.sound;
3567
+ const { sampleRate, channels } = sound.decoder;
3568
+ const target = Math.max(0, Math.round(seconds * sampleRate));
3569
+ sound.done = false;
3570
+ const historyStart = sound.history[0]?.start ?? Infinity;
3571
+ if (target >= historyStart && target < sound.position) {
3572
+ // Resume inside audio already decoded: take it from the history.
3573
+ const parts = [];
3574
+ for (const chunk of sound.history) {
3575
+ const frames = chunk.samples.length / channels;
3576
+ if (chunk.start + frames <= target) continue;
3577
+ parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
3578
+ }
3579
+ if (sound.pending) parts.push(sound.pending);
3580
+ sound.pending = concatSamples(parts);
3581
+ sound.position = target;
3582
+ sound.history = [];
2465
3583
  return;
2466
3584
  }
2467
- current.position += frames;
3585
+ sound.history = [];
3586
+ const reached = await advanceAudio(() => state.sound, seconds, {
3587
+ needsMore,
3588
+ active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
3589
+ reopen: () => openAudioDecoder(state, { keepStream: true }),
3590
+ });
3591
+ if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
3592
+ throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
3593
+ }
3594
+ } finally {
3595
+ if (state.audioPositioning === transaction) state.audioPositioning = null;
2468
3596
  }
2469
3597
  }
2470
3598
 
2471
3599
  /** Starts the clock, the audio stream and the frame/time ticks. */
2472
3600
  function startMedia(state) {
3601
+ // An output stream held through a stall (see refillMedia) is already
3602
+ // open: the clock goes on from where its audio is, and what it asked for
3603
+ // meanwhile is sent now.
3604
+ if (state.sound && !state.sound.done && state.sound.id != null) {
3605
+ const sound = state.sound;
3606
+ state.base = mediaTime(state);
3607
+ state.clockStart = hostPerformance.now();
3608
+ if (state.resyncAudio) audio.resync?.(sound.id);
3609
+ state.resyncAudio = false;
3610
+ const owed = sound.owed ?? 0;
3611
+ sound.owed = 0;
3612
+ if (owed > 0) feedAudio(state, owed);
3613
+ mediaTick(state);
3614
+ return;
3615
+ }
3616
+ // Opening a PulseAudio stream is asynchronous. Advancing the video clock
3617
+ // before the output is ready used to make a later decoder reopen seek
3618
+ // audio past PCM which had not actually reached the output yet. Let the
3619
+ // output stream establish the common A/V epoch instead.
3620
+ if (state.sound && !state.sound.done) {
3621
+ state.clockStart = null;
3622
+ state.awaitingAudio = true;
3623
+ openAudioStream(state);
3624
+ // A misbehaving output server must not freeze video forever. Normally
3625
+ // ready/request arrives almost immediately; this is only a safety net.
3626
+ state.audioClockTimer = hostSetTimeout(() => startAudioClock(state, state.sound), 500);
3627
+ return;
3628
+ }
3629
+ state.clockStart = hostPerformance.now();
3630
+ if (state.hasAudio) ensureAudio(state);
3631
+ mediaTick(state);
3632
+ }
3633
+
3634
+ /** Releases a clock which was waiting for audio output to become writable. */
3635
+ function startAudioClock(state, sound) {
3636
+ if (!state.awaitingAudio || state.sound !== sound || !state.playing) return;
3637
+ state.awaitingAudio = false;
3638
+ hostClearTimeout(state.audioClockTimer);
3639
+ state.audioClockTimer = null;
2473
3640
  state.clockStart = hostPerformance.now();
2474
- if (state.sound && !state.sound.done) openAudioStream(state);
2475
3641
  mediaTick(state);
2476
3642
  }
2477
3643
 
2478
- function stopClock(state) {
3644
+ function stopClock(state, { keepAudio = false } = {}) {
2479
3645
  hostClearTimeout(state.timer);
2480
3646
  state.timer = null;
2481
3647
  state.clockStart = null;
2482
- if (state.sound?.id != null) {
2483
- audio.close(state.sound.id);
2484
- state.sound.id = null;
2485
- state.sound.finishing = false;
3648
+ state.awaitingAudio = false;
3649
+ hostClearTimeout(state.audioClockTimer);
3650
+ state.audioClockTimer = null;
3651
+ state.outputEnd = null;
3652
+ if (state.sound?.id != null && !keepAudio) {
3653
+ const sound = state.sound;
3654
+ trace?.(`audio stream closed at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s written, clock ${mediaTime(state).toFixed(3)}s`);
3655
+ const { sampleRate, channels } = sound.decoder;
3656
+ const start = sound.streamStart ?? sound.position;
3657
+ // The output may go on playing the PCM it already holds (some servers
3658
+ // drain a closed stream): playing resumes after it, not twice.
3659
+ state.outputEnd = Promise.resolve(audio.close(sound.id))
3660
+ .then((sentBytes) => (start + (Number(sentBytes) || 0) / (2 * channels)) / sampleRate, () => null);
3661
+ sound.id = null;
3662
+ sound.finishing = false;
2486
3663
  }
2487
3664
  }
2488
3665
 
3666
+ /**
3667
+ * Moves the media clock past the audio the closed output stream still
3668
+ * plays (see stopClock). True when it moved, so video must follow.
3669
+ */
3670
+ async function skipPlayedAudio(state) {
3671
+ const end = await (state.outputEnd ?? null);
3672
+ state.outputEnd = null;
3673
+ if (!Number.isFinite(end) || end <= state.base) return false;
3674
+ state.base = Number.isFinite(state.duration) ? Math.min(state.duration, end) : end;
3675
+ return true;
3676
+ }
3677
+
2489
3678
  /** Draws the frames the clock has reached, updates time, ends when everything has played. */
2490
3679
  function mediaTick(state) {
2491
3680
  if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
@@ -2493,9 +3682,34 @@ export async function createPageRenderer({
2493
3682
  const video = state.video;
2494
3683
  if (video && !video.done && video.frameTime <= now) {
2495
3684
  // Late frames are decoded but only the newest is shown.
2496
- while (video.frameTime <= now && !video.done) {
2497
- const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= now;
2498
- if (!drawVideoFrame(state, !nextIsLate)) video.done = true;
3685
+ const decodeDeadline = hostPerformance.now() + 100;
3686
+ while (video.frameTime <= mediaTime(state) && !video.done) {
3687
+ const current = mediaTime(state);
3688
+ const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= current;
3689
+ // If this tick has spent its decode budget, decode one final frame
3690
+ // for display before yielding. Otherwise a video that can never quite
3691
+ // catch the wall clock discards every frame forever while its controls
3692
+ // continue to advance.
3693
+ const yielding = hostPerformance.now() >= decodeDeadline;
3694
+ // Frames before where the picture already was (or a seek's target)
3695
+ // are decoded unshown: the decoder restarted at an earlier keyframe.
3696
+ const reached = video.frameTime + 1e-6 >= (video.hideUntil ?? 0);
3697
+ let step;
3698
+ try {
3699
+ step = drawVideoFrame(state, reached && (!nextIsLate || yielding));
3700
+ } catch (error) {
3701
+ failMedia(state, error, 3);
3702
+ return;
3703
+ }
3704
+ if (step === "stall") {
3705
+ void refillMedia(state, video);
3706
+ return;
3707
+ }
3708
+ if (!step) video.done = true;
3709
+ // Keep tasks responsive even when software decoding is slower than
3710
+ // real time. The zero-delay continuation resumes catching up against
3711
+ // a freshly read clock instead of accumulating this task's duration.
3712
+ if (yielding && video.frameTime <= mediaTime(state)) break;
2499
3713
  }
2500
3714
  }
2501
3715
  state.resource.time = now;
@@ -2508,22 +3722,49 @@ export async function createPageRenderer({
2508
3722
  const soundFinished = !state.sound || state.sound.done;
2509
3723
  // The clock at the duration ends it too: after a seek, frames restart at a
2510
3724
  // keyframe before the target, and wasmpeg does not tell their timestamps.
2511
- const pastEnd = Number.isFinite(state.duration) && state.clockStart !== null
2512
- && state.base + (hostPerformance.now() - state.clockStart) / 1000 >= state.duration;
3725
+ // A prefix can already report the full duration, so the clock must not
3726
+ // run the element out while bytes are still arriving.
3727
+ const pastEnd = !state.buffering && Number.isFinite(state.duration) && now >= state.duration;
3728
+ const pipeOpen = state.pipe && !state.pipe.done && !state.pipe.error;
3729
+ if (pastEnd && pipeOpen) {
3730
+ const stale = state.video && needsMore(state.video) ? state.video
3731
+ : state.sound && needsMore(state.sound) ? state.sound : null;
3732
+ void refillMedia(state, stale);
3733
+ return;
3734
+ }
3735
+ if (videoFinished && soundFinished && pipeOpen) {
3736
+ const stalled = video?.done && needsMore(video) ? video
3737
+ : state.sound?.done && needsMore(state.sound) ? state.sound : null;
3738
+ void refillMedia(state, stalled);
3739
+ return;
3740
+ }
2513
3741
  if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
2514
- const delay = video && !video.done ? Math.max(5, (video.frameTime - mediaTime(state)) * 1000) : 250;
3742
+ const delay = video && !video.done ? Math.max(0, (video.frameTime - mediaTime(state)) * 1000) : 250;
2515
3743
  state.timer = hostSetTimeout(() => mediaTick(state), delay);
2516
3744
  }
2517
3745
 
2518
- /** Decodes the next frame; shows it when `show`. False at the end of the stream. */
3746
+ /**
3747
+ * Decodes the next frame; shows it when `show`.
3748
+ * True when a frame was produced, false at a real end, "stall" when the
3749
+ * pipe still owes bytes (a short mp4 throws AVERROR_INVALIDDATA there).
3750
+ */
2519
3751
  function drawVideoFrame(state, show) {
2520
- const decoder = state.video.decoder;
3752
+ const video = state.video;
3753
+ const decoder = video.decoder;
2521
3754
  const width = Math.min(480, decoder.width);
2522
- const height = Math.max(1, Math.round(width * decoder.height / decoder.width));
2523
- const pixels = decoder.nextFrame(width, height);
2524
- if (!pixels) return false;
3755
+ const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
3756
+ let pixels;
3757
+ try {
3758
+ pixels = decoder.nextFrame(width, height);
3759
+ } catch (error) {
3760
+ if (needsMore(video)) return "stall";
3761
+ throw error;
3762
+ }
3763
+ if (!pixels) return needsMore(video) ? "stall" : false;
3764
+ const shownTime = state.video.frameTime;
2525
3765
  state.video.frameTime += 1 / (decoder.fps || 30);
2526
3766
  if (!show) return true;
3767
+ state.video.shownTime = shownTime;
2527
3768
  const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
2528
3769
  colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
2529
3770
  if (!image) return true;
@@ -2538,12 +3779,46 @@ export async function createPageRenderer({
2538
3779
  const sound = state.sound;
2539
3780
  sound.finishing = false;
2540
3781
  const name = new URL(state.url).pathname.split("/").pop() || "audio";
3782
+ // Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
3783
+ sound.streamStart = sound.position;
3784
+ trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
2541
3785
  const id = audio.open({ rate: sound.decoder.sampleRate, channels: sound.decoder.channels, name }, (event) => {
2542
- if (sound.id !== id) return;
2543
- if (event.event === "request") feedAudio(state, event.bytes);
2544
- else if (event.event === "ended") {
3786
+ // The stream outlives a decoder reopened during a stall (see
3787
+ // refillMedia), so events go to whichever sound holds it now.
3788
+ const sound = state.sound;
3789
+ if (!sound || sound.id !== id) return;
3790
+ if (event.event === "timing") {
3791
+ if (!Number.isFinite(event.seconds) || event.seconds < 0) return;
3792
+ sound.playedTime = (sound.streamStart / sound.decoder.sampleRate) + event.seconds;
3793
+ state.base = sound.playedTime;
3794
+ sound.timingError = false;
3795
+ const now = hostPerformance.now();
3796
+ if (!state.lastSyncTrace || now - state.lastSyncTrace >= 1000) {
3797
+ state.lastSyncTrace = now;
3798
+ 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`);
3800
+ }
3801
+ } else if (event.event === "timing-error") {
3802
+ // Do not silently switch to a free-running clock on missing feedback.
3803
+ if (!sound.timingError) reportError(new Error(`audio timing unavailable; playback clock held: ${event.message}`));
3804
+ sound.timingError = true;
3805
+ } else if (event.event === "request") {
3806
+ // A request is the first point at which PulseAudio has created the
3807
+ // playback stream. Queue its PCM before releasing the clock: feeding
3808
+ // a decoder opened on a growing snapshot can itself start a refill,
3809
+ // in which case that replacement stream must establish the epoch.
3810
+ feedAudio(state, event.bytes);
3811
+ startAudioClock(state, sound);
3812
+ } else if (event.event === "ready") {
3813
+ // Some servers create a writable stream with an initial grant of zero,
3814
+ // so no request is sent until playback advances.
3815
+ startAudioClock(state, sound);
3816
+ } else if (event.event === "ended") {
3817
+ state.base = sound.playedTime ?? state.base;
3818
+ state.clockStart = hostPerformance.now();
2545
3819
  sound.id = null;
2546
3820
  sound.done = true;
3821
+ startAudioClock(state, sound);
2547
3822
  mediaTick(state);
2548
3823
  } else if (event.event === "error") {
2549
3824
  sound.id = null;
@@ -2551,6 +3826,7 @@ export async function createPageRenderer({
2551
3826
  reportError(new Error(`audio: ${event.message}`));
2552
3827
  // Video plays on without sound; audio alone stops.
2553
3828
  if (!state.video) pauseMedia(state.element);
3829
+ else startAudioClock(state, sound);
2554
3830
  }
2555
3831
  });
2556
3832
  sound.id = id;
@@ -2560,13 +3836,37 @@ export async function createPageRenderer({
2560
3836
  function feedAudio(state, bytes) {
2561
3837
  const sound = state.sound;
2562
3838
  if (!state.playing || sound.id === null || sound.finishing) return;
3839
+ // While buffering the output plays what it holds and then nothing, as
3840
+ // the video waits; what it asks for is sent when playing resumes.
3841
+ if (state.buffering || state.audioPositioning) {
3842
+ sound.owed = (sound.owed ?? 0) + bytes;
3843
+ return;
3844
+ }
2563
3845
  const { channels, sampleRate } = sound.decoder;
2564
3846
  const frameBytes = channels * 2;
3847
+ sound.lastRequest = bytes;
2565
3848
  for (let sent = 0; sent + frameBytes <= bytes;) {
2566
3849
  let samples = sound.pending;
2567
3850
  sound.pending = null;
2568
- samples ??= sound.decoder.nextSamples();
2569
3851
  if (!samples) {
3852
+ try {
3853
+ samples = sound.decoder.nextSamples();
3854
+ } catch (error) {
3855
+ if (needsMore(sound)) {
3856
+ sound.owed = (sound.owed ?? 0) + bytes - sent;
3857
+ void refillMedia(state, sound);
3858
+ return;
3859
+ }
3860
+ failMedia(state, error, 3);
3861
+ return;
3862
+ }
3863
+ }
3864
+ if (!samples) {
3865
+ if (needsMore(sound)) {
3866
+ sound.owed = (sound.owed ?? 0) + bytes - sent;
3867
+ void refillMedia(state, sound);
3868
+ return;
3869
+ }
2570
3870
  sound.finishing = true;
2571
3871
  audio.finish(sound.id);
2572
3872
  return;
@@ -2584,6 +3884,195 @@ export async function createPageRenderer({
2584
3884
  }
2585
3885
  }
2586
3886
 
3887
+ /**
3888
+ * While bytes keep arriving, waits until `pipe` holds about
3889
+ * RESUME_BUFFER_SECONDS more media than `position` needs (estimated from
3890
+ * the bytes per second so far). Stops waiting when it is finished, when
3891
+ * nothing arrived for RESUME_BUFFER_IDLE_MS (that burst is over: waiting
3892
+ * for the next could take long), or after RESUME_BUFFER_MAX_WAIT_MS.
3893
+ */
3894
+ async function bufferAhead(pipe, position) {
3895
+ const deadline = hostPerformance.now() + RESUME_BUFFER_MAX_WAIT_MS;
3896
+ // The pipe ends near `position` when starved; early on that says little,
3897
+ // so the rate is taken over at least a few seconds (an underestimate).
3898
+ const bytesPerSecond = pipe.size / Math.max(4, position);
3899
+ const goal = pipe.size + bytesPerSecond * RESUME_BUFFER_SECONDS;
3900
+ while (!pipe.done && !pipe.error && pipe.size < goal) {
3901
+ const wait = Math.min(RESUME_BUFFER_IDLE_MS, deadline - hostPerformance.now());
3902
+ if (wait <= 0) return;
3903
+ const size = pipe.size;
3904
+ let timer = null;
3905
+ await Promise.race([pipe.waitFor(size + 1), new Promise((resolve) => { timer = hostSetTimeout(resolve, wait); })]);
3906
+ hostClearTimeout(timer);
3907
+ if (pipe.size === size) return;
3908
+ }
3909
+ }
3910
+
3911
+ /**
3912
+ * Seeks `decoder` to the keyframe at or before `seconds` and returns that
3913
+ * keyframe's time. wasmpeg seeks to the keyframe without telling its
3914
+ * timestamp; taking the target as the position would show every later
3915
+ * frame early by the distance to the keyframe, and the picture would trail
3916
+ * the sound. Seeks to the same keyframe yield the same first picture, so a
3917
+ * binary search over seek targets finds where that keyframe starts (to a
3918
+ * frame). The frames from there to the clock are then decoded unshown.
3919
+ */
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;
3957
+ }
3958
+
3959
+ /**
3960
+ * The decoder ran off the end of the snapshot it was opened on. Freeze the
3961
+ * clock, wait for more bytes, reopen on the new snapshot and seek back.
3962
+ * Retain the output stream and outstanding credits while positioning the
3963
+ * new snapshot. The reported output cursor remains the A/V master clock.
3964
+ */
3965
+ function refillMedia(state, stalledTrack = null) {
3966
+ if (state.refilling) return state.refilling;
3967
+ const serial = state.seekSerial;
3968
+ state.buffering = true;
3969
+ state.base = mediaTime(state);
3970
+ // The audio output stays open through a stall: it plays the PCM it holds
3971
+ // and then runs dry, and playing resumes right after that PCM. Closing
3972
+ // and reopening it instead loses track of what was heard (a closed
3973
+ // stream may still play out its device buffer, which then plays twice).
3974
+ const holdAudio = Boolean(stalledTrack && state.sound && state.sound.id != null && !state.sound.finishing);
3975
+ const stall = { at: hostPerformance.now(), base: state.base };
3976
+ stopClock(state, { keepAudio: holdAudio });
3977
+ // The first await lets `job` be assigned before the body looks at it,
3978
+ // so a stall inside startMedia can begin a new refill.
3979
+ const job = (async () => {
3980
+ await Promise.resolve();
3981
+ try {
3982
+ const pipe = stalledTrack?.pipe ?? state.video?.pipe ?? state.sound?.pipe ?? state.pipe;
3983
+ const mark = (pipe?.size ?? 0) + 1;
3984
+ // An append may have arrived after this decoder's snapshot was
3985
+ // opened but before it reached EOF. Consume that generation now;
3986
+ // waiting for one more append creates a segment-sized pause.
3987
+ const hasNewSnapshot = Boolean(stalledTrack && pipe
3988
+ && pipe.generation !== stalledTrack.openedGeneration);
3989
+ // Some demuxers report EOF at an fMP4/WebM fragment boundary even
3990
+ // though the same snapshot contains later fragments. Reopen once at
3991
+ // each position/generation; if it makes no progress, wait for append.
3992
+ const retry = state.lastRefill;
3993
+ const canRetrySnapshot = Boolean(stalledTrack && pipe && !hasNewSnapshot
3994
+ && (!retry || retry.pipe !== pipe || retry.generation !== pipe.generation
3995
+ || state.base > retry.time + 0.25));
3996
+ if (pipe && !pipe.done && !hasNewSnapshot && !canRetrySnapshot) {
3997
+ await pipe.waitFor(mark);
3998
+ // Starved by a slow network, the smallest refill stalls again at
3999
+ // once: buffer a few seconds ahead first, as browsers do before
4000
+ // leaving "waiting".
4001
+ await bufferAhead(pipe, state.base);
4002
+ }
4003
+ if (state.seekSerial !== serial || state.document !== page.document || !state.playing) return;
4004
+ if (pipe?.error) throw pipe.error;
4005
+ // Where the audio is now: it kept playing what it held after the stall.
4006
+ const held = holdAudio && state.sound?.id != null ? state.sound : null;
4007
+ const writtenEnd = held ? held.position / held.decoder.sampleRate : null;
4008
+ if (held) {
4009
+ state.base = mediaTime(state);
4010
+ } else {
4011
+ await skipPlayedAudio(state);
4012
+ }
4013
+ if (state.seekSerial !== serial || state.document !== page.document || !state.playing) return;
4014
+ const time = state.base;
4015
+ trace?.(`media stall (${stalledTrack === state.video ? "video" : stalledTrack === state.sound ? "audio" : "end"}): resume at ${time.toFixed(3)}s`
4016
+ + `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
4017
+ if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
4018
+ // A video decoder still decoding is left alone: reopened, it restarts
4019
+ // at a keyframe. Audio reopens on any newer snapshot (it may share the
4020
+ // stalled pipe), which positionAudio continues sample-exactly.
4021
+ const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
4022
+ const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
4023
+ || (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
4024
+ if (refillVideo) {
4025
+ const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
4026
+ // The picture must not go back to the keyframe the decoder restarts at.
4027
+ const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
4028
+ await openVideoDecoder(state);
4029
+ state.video.hideUntil = hideUntil;
4030
+ try {
4031
+ state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
4032
+ } catch {
4033
+ state.video.frameTime = 0;
4034
+ }
4035
+ state.video.done = false;
4036
+ }
4037
+ if (refillAudio) {
4038
+ let opened = false;
4039
+ try {
4040
+ opened = await openAudioDecoder(state, { keepStream: Boolean(held) });
4041
+ } catch (error) {
4042
+ if (!state.video) throw error;
4043
+ reportError(error);
4044
+ }
4045
+ // A held stream continues right after the PCM it was given.
4046
+ if (opened) await positionAudio(state, held ? writtenEnd : time);
4047
+ else if (state.playing && !state.sound) ensureAudio(state);
4048
+ }
4049
+ // Drop this job before startMedia so a new stall can refill.
4050
+ if (state.refilling === job) state.refilling = null;
4051
+ state.buffering = false;
4052
+ if (state.playing && state.seekSerial === serial && state.document === page.document) startMedia(state);
4053
+ } catch (error) {
4054
+ if (state.document === page.document) failMedia(state, error, 3);
4055
+ } finally {
4056
+ if (state.refilling === job) {
4057
+ state.refilling = null;
4058
+ state.buffering = false;
4059
+ }
4060
+ }
4061
+ })();
4062
+ state.refilling = job;
4063
+ return job;
4064
+ }
4065
+
4066
+ function failMedia(state, error, code) {
4067
+ state.error = { code, message: String(error?.message ?? error) };
4068
+ closeMedia(state);
4069
+ state.playing = false;
4070
+ if (state.resource) state.resource.playing = false;
4071
+ mediaEvent(state, "error");
4072
+ reportError(error);
4073
+ repaintMedia();
4074
+ }
4075
+
2587
4076
  function endMedia(state) {
2588
4077
  stopClock(state);
2589
4078
  state.ended = true;
@@ -2601,6 +4090,8 @@ export async function createPageRenderer({
2601
4090
  }
2602
4091
 
2603
4092
  function closeMedia(state) {
4093
+ state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
4094
+ state.audioPositioning = null;
2604
4095
  stopClock(state);
2605
4096
  state.playing = false;
2606
4097
  state.video?.decoder.close();
@@ -2627,6 +4118,58 @@ export async function createPageRenderer({
2627
4118
  return out;
2628
4119
  }
2629
4120
 
4121
+ /** The decoded CSS background image a url() names, if it has arrived. */
4122
+ function backgroundResource(url) {
4123
+ let absolute;
4124
+ try { absolute = new URL(url, page.url).href; } catch { return null; }
4125
+ return page.backgrounds.get(absolute)?.resource ?? null;
4126
+ }
4127
+
4128
+ /**
4129
+ * Requests the background images the laid-out document uses and has not
4130
+ * asked for yet. They change only paint: each repaints the page on arrival.
4131
+ */
4132
+ function queueBackgroundImages() {
4133
+ const tree = page.renderTree;
4134
+ if (!tree) return;
4135
+ const document = page.document;
4136
+ const seen = new Set();
4137
+ for (const node of tree.nodesById.values()) {
4138
+ const value = node.style?.backgroundImage;
4139
+ if (!value || seen.has(value) || !value.includes("url(")) continue;
4140
+ seen.add(value);
4141
+ for (const match of value.matchAll(CSS_URL)) {
4142
+ let url;
4143
+ try { url = new URL(match[2], page.url).href; } catch { continue; }
4144
+ if (page.backgrounds.has(url)) continue;
4145
+ const entry = { resource: null, loaded: null };
4146
+ 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;
4153
+ if (page.backgrounds.get(url) !== entry || document !== page.document) {
4154
+ image.delete();
4155
+ return;
4156
+ }
4157
+ entry.resource = Object.freeze({ width, height, image, url });
4158
+ if (page.layout) page.displayList = paintList();
4159
+ page.needsPaint = true;
4160
+ scheduleFrame();
4161
+ }).catch(() => {
4162
+ // A broken background image paints nothing.
4163
+ });
4164
+ }
4165
+ }
4166
+ }
4167
+
4168
+ function disposeBackgrounds() {
4169
+ for (const entry of page.backgrounds.values()) entry.resource?.image.delete();
4170
+ page.backgrounds = new Map();
4171
+ }
4172
+
2630
4173
  function queueImageResources() {
2631
4174
  const document = page.document;
2632
4175
  const generation = loadGeneration;
@@ -2669,7 +4212,7 @@ export async function createPageRenderer({
2669
4212
  const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
2670
4213
  && Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
2671
4214
  if (paintOnly) {
2672
- page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
4215
+ page.displayList = paintList();
2673
4216
  page.needsPaint = true;
2674
4217
  } else {
2675
4218
  page.dirty = true;