buninu-browser 0.1.0 → 0.2.1

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 (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. package/vendor/ses/dist/ses.mjs +2 -0
@@ -23,24 +23,57 @@
23
23
  * after the load event.
24
24
  */
25
25
  import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
26
+ import { DESKTOP_USER_AGENT } from "../user-agent.js";
26
27
  import { parseHTMLDocument } from "../happy-dom/parser.js";
27
- import { layoutText } from "../layout/text-layout.js";
28
+ import { LayoutEngine } from "../layout/text-layout.js";
28
29
  import { ScrollViewport } from "../layout/scroll-viewport.js";
29
30
  import { elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
30
- import { RenderTreeBuilder, renderTreeText } from "../render-tree/index.js";
31
- import { StyleEngine } from "../style/computed-style.js";
31
+ import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
32
+ import { matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
33
+ import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
34
+ import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
32
35
  import { buildDisplayList, visibleItems } from "../paint/display-list.js";
33
36
  import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
34
- import { createPageRealm } from "./page-realm.js";
37
+ import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
35
38
  import { MOBILE_USER_AGENT } from "./flags.js";
39
+ import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
40
+ import { floatToS16, splitSamples } from "../audio/pcm.js";
41
+ import { pianoNote, PianoMixer } from "../audio/piano.js";
36
42
 
37
43
  // Captured at load: in host mode page scripts share this global object.
38
44
  const hostPerformance = globalThis.performance;
39
45
  const hostSetTimeout = globalThis.setTimeout;
40
46
  const hostClearTimeout = globalThis.clearTimeout;
47
+ const hostSetInterval = globalThis.setInterval;
48
+ const hostClearInterval = globalThis.clearInterval;
41
49
 
42
50
  /** How long subresources may take before a preview of the unstyled document is shown. */
43
51
  export const PREVIEW_DELAY_MS = 300;
52
+ const MAX_COMPILED_COMPOUNDS = 10_000;
53
+
54
+ // Media controls: the bar's height over video, and where its parts sit.
55
+ const MEDIA_BAR_HEIGHT = 24;
56
+ // Decoded audio already sent, kept so pause/resume continues exactly without decoding again.
57
+ const AUDIO_HISTORY_SECONDS = 3;
58
+
59
+ /**
60
+ * Horizontal layout of a media control bar `width` CSS pixels wide: the
61
+ * play/pause mark takes the first 30, then "m:ss / m:ss" when there is room,
62
+ * then the progress track that clicks seek.
63
+ */
64
+ export function mediaBarLayout(width) {
65
+ const timeWidth = width >= 200 ? 86 : 0;
66
+ const trackStart = 30 + timeWidth;
67
+ return { timeStart: 30, timeWidth, trackStart, trackEnd: Math.max(trackStart + 10, width - 12) };
68
+ }
69
+
70
+ export function formatMediaTime(seconds) {
71
+ const whole = Math.floor(seconds);
72
+ const hours = Math.floor(whole / 3600);
73
+ const minutes = Math.floor(whole / 60) % 60;
74
+ const rest = String(whole % 60).padStart(2, "0");
75
+ return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`;
76
+ }
44
77
 
45
78
  const canvasKitDirectory = new URL("../../usr/lib/canvaskit/", import.meta.url);
46
79
  const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
@@ -49,10 +82,12 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
49
82
  * @param {{
50
83
  * fetchResource(url: string, kind: "stylesheet" | "image" | "script" | "metadata" | "video"):
51
84
  * Promise<{ ok: boolean, status: number, url: string, contentType: string, body: Uint8Array }>,
52
- * requestNavigation?(url: string): void,
85
+ * requestNavigation?(url: string, downloadName?: string | null, fragmentFrom?: { x: number, y: number } | null): void,
86
+ * requestDownload?(payload: { url: string, name: string, data: Uint8Array }): void,
53
87
  * reportError?(message: string): void,
54
88
  * pageFetch?(request: object): Promise<object>,
55
89
  * webSockets?: { open(url, protocols, onEvent): number, send(id, data): void, close(id, code, reason): void } | null,
90
+ * audio?: { open(options, onEvent): number, write(id, data): void, finish(id): void, close(id): void } | null,
56
91
  * setCookie?(value: string): void,
57
92
  * consoleMessage?: ((level: string, text: string) => void) | null,
58
93
  * trace?: ((text: string) => void) | null,
@@ -62,11 +97,13 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
62
97
  * }} options
63
98
  */
64
99
  export async function createPageRenderer({
65
- fetchResource,
100
+ fetchResource: rawFetchResource,
66
101
  requestNavigation = () => {},
102
+ requestDownload = () => {},
67
103
  reportError = () => {},
68
104
  pageFetch = () => Promise.reject(new TypeError("network access is not available")),
69
105
  webSockets = null,
106
+ audio = null,
70
107
  setCookie = () => {},
71
108
  consoleMessage = null,
72
109
  trace = null,
@@ -74,6 +111,8 @@ export async function createPageRenderer({
74
111
  onLifecycle = null,
75
112
  videoDecoder = null,
76
113
  mobile = false,
114
+ verifyStyle = false,
115
+ fullStyle = false,
77
116
  }) {
78
117
  const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
79
118
  const CanvasKit = await CanvasKitInit({ wasmBinary });
@@ -130,7 +169,24 @@ export async function createPageRenderer({
130
169
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
131
170
  const emojiPresentation = /^\p{Emoji_Presentation}/u;
132
171
  const renderTreeBuilder = new RenderTreeBuilder();
172
+ const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
133
173
  const styleEngine = new StyleEngine();
174
+ // Incremental restyle: what may have changed style since the last
175
+ // computation. DOM mutations come from a MutationObserver; state that is
176
+ // not in the DOM tree (checked, value, focus) is marked by the code that
177
+ // changes it. `full` asks for a complete restyle.
178
+ const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
179
+ let selectorTraits = { sibling: false, has: null, sheets: null };
180
+ let lastStyleParts = "";
181
+ let laidOutSheets = null;
182
+ const sheetTraitsCache = new Map();
183
+ // Invalidation compounds compiled for matchesCompound() and matchSelectors().
184
+ const compiledCompounds = new Map();
185
+ let lastStyleReason = "";
186
+ let hasState = null;
187
+ let hasStateStale = false;
188
+ let structuralState = null;
189
+ let siblingState = null;
134
190
  const viewport = new ScrollViewport(800, 600);
135
191
  const page = {
136
192
  url: "about:blank",
@@ -149,19 +205,31 @@ export async function createPageRenderer({
149
205
  hints: [],
150
206
  hintsVisible: false,
151
207
  styleSheets: [],
208
+ linkSheets: null,
209
+ // The document's scripts fetched ahead of their turn (see preloadScripts), by URL.
210
+ preloadedScripts: new Map(),
152
211
  backgroundColor: "rgba(255, 255, 255, 1)",
153
212
  resources: new WeakMap(),
154
213
  decodedImages: [],
155
- videos: new Map(),
214
+ imagesLoaded: null,
215
+ // <video> and <audio> playback states, by element (see playMedia).
216
+ media: new Map(),
156
217
  realm: null,
157
218
  // "off" until the renderer process has set up its sandbox.
158
219
  scripting: "off",
159
220
  dirty: false,
160
- userAgent: mobile ? MOBILE_USER_AGENT : "Mozilla/5.0 (X11; Linux) BuninuBrowser/0.0.1",
221
+ resourceLayoutDirty: false,
222
+ userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
161
223
  // document.cookie as last reported by the controller (non-HttpOnly only).
162
224
  cookie: "",
163
225
  };
164
226
 
227
+ async function fetchResource(url, kind) {
228
+ const response = await rawFetchResource(url, kind);
229
+ if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
230
+ return response;
231
+ }
232
+
165
233
  function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1) {
166
234
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
167
235
  const weight = computedStyle?.fontWeight ?? 400;
@@ -416,7 +484,10 @@ export async function createPageRenderer({
416
484
  frameTimer = hostSetTimeout(() => {
417
485
  frameTimer = null;
418
486
  refresh();
419
- }, Math.max(50, lastRebuildMs));
487
+ // Leave one rebuild-sized idle window after an expensive frame. Without
488
+ // it, a large document can spend all its time producing back-to-back
489
+ // frames while scripts are still making related mutations.
490
+ }, Math.max(50, lastRebuildMs * 2));
420
491
  }
421
492
 
422
493
  /** The viewport as a PNG (base64), cut from the latest tile. */
@@ -585,15 +656,17 @@ export async function createPageRenderer({
585
656
  const path = CanvasKit.Path.MakeFromSVGString(
586
657
  `M ${centerX - 6 * scale} ${centerY - 10 * scale} L ${centerX + 11 * scale} ${centerY} L ${centerX - 6 * scale} ${centerY + 10 * scale} Z`);
587
658
  if (path) { canvas.drawPath(path, paint); path.delete(); }
588
- } else {
589
- const bar = CanvasKit.XYWHRect(rect[0], rect[3] - 24 * scale, rect[2] - rect[0], 24 * scale);
590
- canvas.drawRect(bar, paint);
591
- paint.setColor(CanvasKit.Color(255, 255, 255, 1));
592
- canvas.drawRect(CanvasKit.XYWHRect(rect[0] + 10 * scale, rect[3] - 18 * scale, 4 * scale, 12 * scale), paint);
593
- canvas.drawRect(CanvasKit.XYWHRect(rect[0] + 17 * scale, rect[3] - 18 * scale, 4 * scale, 12 * scale), paint);
594
659
  }
595
- canvas.restore();
660
+ // The control bar (play/pause, time, seekable progress) once playing or known.
661
+ if (resource?.playing || Number.isFinite(resource?.duration)) {
662
+ drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
663
+ item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
664
+ }
596
665
  paint.delete();
666
+ canvas.restore();
667
+ } else if (item.op === "drawAudio") {
668
+ const resource = displayList.images[item.image];
669
+ drawMediaBar(canvas, item.rect.x - view.x, item.rect.y - view.y, item.rect.width, item.rect.height, resource, scale, false);
597
670
  } else if (item.op === "drawSvg") {
598
671
  drawSvg(item);
599
672
  } else if (item.op === "drawText") {
@@ -694,6 +767,53 @@ export async function createPageRenderer({
694
767
 
695
768
  // Overlays are placed in page coordinates (viewport position plus the
696
769
  // scroll offset when they were created), so they stay on their element.
770
+ /**
771
+ * A media control bar in CSS pixels at (x, y), relative to the tile: a
772
+ * play/pause mark, "m:ss / m:ss" and a progress track that clicks seek
773
+ * (see mediaBarLayout). Video draws it over the picture, audio as its box.
774
+ */
775
+ function drawMediaBar(canvas, x, y, width, height, resource, scale, overlay) {
776
+ const paint = new CanvasKit.Paint();
777
+ paint.setAntiAlias(true);
778
+ const box = CanvasKit.XYWHRect(x * scale, y * scale, width * scale, height * scale);
779
+ paint.setColor(overlay ? CanvasKit.Color(0, 0, 0, 0.65) : CanvasKit.Color(241, 243, 244, 1));
780
+ if (overlay) canvas.drawRect(box, paint);
781
+ else canvas.drawRRect(CanvasKit.RRectXY(box, height / 2 * scale, height / 2 * scale), paint);
782
+ paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 1) : CanvasKit.Color(32, 33, 36, 1));
783
+ const middle = (y + height / 2) * scale;
784
+ const iconX = (x + 12) * scale;
785
+ if (resource?.playing) {
786
+ canvas.drawRect(CanvasKit.XYWHRect(iconX, middle - 6 * scale, 3.5 * scale, 12 * scale), paint);
787
+ canvas.drawRect(CanvasKit.XYWHRect(iconX + 6.5 * scale, middle - 6 * scale, 3.5 * scale, 12 * scale), paint);
788
+ } else {
789
+ const path = CanvasKit.Path.MakeFromSVGString(
790
+ `M ${iconX} ${middle - 7 * scale} L ${iconX + 11 * scale} ${middle} L ${iconX} ${middle + 7 * scale} Z`);
791
+ if (path) { canvas.drawPath(path, paint); path.delete(); }
792
+ }
793
+ const layout = mediaBarLayout(width);
794
+ const duration = resource?.duration;
795
+ const time = Math.max(0, Math.min(Number.isFinite(duration) ? duration : Infinity, resource?.time ?? 0));
796
+ if (layout.timeWidth) {
797
+ const text = `${formatMediaTime(time)} / ${Number.isFinite(duration) ? formatMediaTime(duration) : "--:--"}`;
798
+ const paragraph = makeParagraph(text, { color: overlay ? "rgba(255, 255, 255, 1)" : "rgba(32, 33, 36, 1)",
799
+ fontFamily: ["sans-serif"], fontWeight: 400 }, 11, scale);
800
+ paragraph.layout(layout.timeWidth * scale);
801
+ canvas.drawParagraph(paragraph, (x + layout.timeStart) * scale, middle - paragraph.getHeight() / 2);
802
+ paragraph.delete();
803
+ }
804
+ const trackStart = (x + layout.trackStart) * scale;
805
+ const trackWidth = (layout.trackEnd - layout.trackStart) * scale;
806
+ paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 0.35) : CanvasKit.Color(0, 0, 0, 0.18));
807
+ canvas.drawRRect(CanvasKit.RRectXY(CanvasKit.XYWHRect(trackStart, middle - 2 * scale, trackWidth, 4 * scale), 2 * scale, 2 * scale), paint);
808
+ if (Number.isFinite(duration) && duration > 0) {
809
+ const played = trackWidth * time / duration;
810
+ paint.setColor(overlay ? CanvasKit.Color(255, 255, 255, 1) : CanvasKit.Color(26, 115, 232, 1));
811
+ canvas.drawRRect(CanvasKit.RRectXY(CanvasKit.XYWHRect(trackStart, middle - 2 * scale, played, 4 * scale), 2 * scale, 2 * scale), paint);
812
+ canvas.drawCircle(trackStart + played, middle, 5 * scale, paint);
813
+ }
814
+ paint.delete();
815
+ }
816
+
697
817
  function drawHints(canvas, region) {
698
818
  if (!page.hintsVisible) return;
699
819
  const paint = new CanvasKit.Paint();
@@ -733,7 +853,311 @@ export async function createPageRenderer({
733
853
  replaceParsedDocument(parseHTMLDocument(html, url), styleSheets, imageResources);
734
854
  }
735
855
 
736
- function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources()) {
856
+ /** `linkSheets` (link element -> { url, text }) makes the sheet list follow the DOM; without it the given sheets are fixed. */
857
+ /** Marks a node whose own state changed (its subtree may restyle); null asks for a full restyle. */
858
+ function markStyleDirty(node, features = [":state"]) {
859
+ // Documents and fragments carry no state selectors read; their children's
860
+ // changes arrive as mutation records.
861
+ if (node?.nodeType === 9 || node?.nodeType === 11) return;
862
+ const element = node?.nodeType === 1 ? node : node?.parentElement;
863
+ if (!element) styleChanges.full ||= node ? `touched ${node.nodeName}` : "unknown change";
864
+ else {
865
+ styleChanges.roots.add(element);
866
+ const previous = styleChanges.rootFeatures.get(element);
867
+ styleChanges.rootFeatures.set(element, previous ? [...new Set([...previous, ...features])] : features);
868
+ }
869
+ }
870
+
871
+ function observeStyleChanges(document) {
872
+ styleChanges.observer?.disconnect();
873
+ styleChanges.records = [];
874
+ styleChanges.roots = new Set();
875
+ styleChanges.rootFeatures = new Map();
876
+ styleChanges.full = "new document";
877
+ styleChanges.activeElement = null;
878
+ const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
879
+ observer.observe(document, { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true });
880
+ styleChanges.observer = observer;
881
+ }
882
+
883
+ /**
884
+ * What the stylesheets' selectors make sensitive: sibling combinators let
885
+ * a change restyle the following siblings; :has() lets one change the
886
+ * match of an ancestor (or, with + and ~, of an earlier sibling), so the
887
+ * compounds that contain it are re-checked there (`hasCompounds`).
888
+ */
889
+ function selectorTraitsFor(sheets) {
890
+ if (selectorTraits.sheets === sheets) return selectorTraits;
891
+ const parts = sheets.map(sheetTraitsFor);
892
+ // Only the sheets in use stay cached.
893
+ if (sheetTraitsCache.size > sheets.length) for (const sheet of new Set(sheetTraitsCache.keys()).difference(new Set(sheets))) sheetTraitsCache.delete(sheet);
894
+ const union = (key) => new Set(parts.flatMap((part) => [...part[key]]));
895
+ const any = (key) => parts.some((part) => part[key]);
896
+ selectorTraits = {
897
+ sheets, sibling: any("sibling"), hasSiblings: any("hasSiblings"),
898
+ hasCompounds: [...union("hasCompounds")], structuralCompounds: [...union("structuralCompounds")],
899
+ descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
900
+ descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
901
+ siblingTargetCompounds: [...union("siblingTargetCompounds")],
902
+ forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
903
+ defined: any("defined"), target: any("target"),
904
+ };
905
+ // Checked against the old compounds this time, re-recorded after the restyle.
906
+ hasStateStale = true;
907
+ return selectorTraits;
908
+ }
909
+
910
+ /**
911
+ * One sheet's share of `selectorTraitsFor`, cached by its text (sheets come
912
+ * and go one at a time, and parsing all of them again is slow).
913
+ */
914
+ function sheetTraitsFor(sheet) {
915
+ let traits = sheetTraitsCache.get(sheet);
916
+ if (traits) return traits;
917
+ traits = sheetTraits(sheet);
918
+ sheetTraitsCache.set(sheet, traits);
919
+ return traits;
920
+ }
921
+
922
+ /**
923
+ * Which elements match each :has() and non-subject structural compound, as
924
+ * of the last restyle. After an incremental round the kept maps are
925
+ * current, so only compounds new to the sheets are looked up.
926
+ */
927
+ function recordHasState(traits) {
928
+ const reuse = hasState !== null;
929
+ if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
930
+ const record = (compounds, previous) => {
931
+ const missing = compounds.filter((compound) => !(reuse && previous?.has(compound)));
932
+ const matched = missing.length ? matchSelectors(page.document, missing, compiledCompounds) : null;
933
+ const state = new Map();
934
+ for (const compound of compounds) {
935
+ if (reuse && previous?.has(compound)) state.set(compound, previous.get(compound));
936
+ else if (matched.has(compound)) state.set(compound, matched.get(compound)); // invalid ones never match
937
+ }
938
+ return state;
939
+ };
940
+ hasState = record(traits.hasCompounds, hasState);
941
+ structuralState = record(traits.structuralCompounds, structuralState);
942
+ siblingState = record(traits.siblingTargetCompounds, siblingState);
943
+ }
944
+
945
+ /** Brings the recorded matches of an inserted subtree up to date (it may have been in the document before). */
946
+ function recordInserted(root) {
947
+ const states = [hasState, structuralState, siblingState].filter(Boolean);
948
+ const now = matchSelectors(root, states.flatMap((state) => [...state.keys()]), compiledCompounds);
949
+ for (const state of states) {
950
+ for (const [compound, matched] of state) {
951
+ const inside = now.get(compound);
952
+ if (!inside) continue;
953
+ for (const element of [root, ...root.getElementsByTagName("*")]) {
954
+ if (inside.has(element)) matched.add(element); else matched.delete(element);
955
+ }
956
+ }
957
+ }
958
+ }
959
+
960
+
961
+ /** element.matches(compound), with the compound compiled once (see matchSelectors). */
962
+ function matchesCompound(element, compound) {
963
+ let compiled = compiledCompounds.get(compound);
964
+ if (compiled === undefined) {
965
+ compiled = compileSelector(element, compound);
966
+ if (compiled === undefined) return element.matches(compound);
967
+ if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
968
+ compiledCompounds.set(compound, compiled);
969
+ }
970
+ return compiled !== null && matchesCompiled(element, compiled);
971
+ }
972
+
973
+ /** Re-checks `element` against recorded compounds; whether any match changed (the state is updated). */
974
+ function matchesChanged(state, element) {
975
+ let changed = false;
976
+ for (const [compound, matched] of state ?? []) {
977
+ let now = false;
978
+ try { now = matchesCompound(element, compound); } catch {}
979
+ if (now === matched.has(element)) continue;
980
+ if (now) matched.add(element); else matched.delete(element);
981
+ changed = true;
982
+ }
983
+ return changed;
984
+ }
985
+
986
+ /**
987
+ * Elements whose :has() matches changed around a mutated node: its
988
+ * ancestors (and their earlier siblings for :has(+ / ~)), re-checked
989
+ * against the recorded state, which is updated as it goes.
990
+ */
991
+ function hasChanges(traits, node, add, checked) {
992
+ if (!hasState || hasState.size === 0) return;
993
+ for (let element = node?.nodeType === 1 ? node : node?.parentElement; element; element = element.parentElement) {
994
+ // The records describe one final DOM, so each element is checked once a
995
+ // round; its ancestors were too when it was.
996
+ if (checked.has(element)) return;
997
+ const candidates = [element];
998
+ if (traits.hasSiblings) for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling) candidates.push(sibling);
999
+ for (const candidate of candidates) {
1000
+ if (candidate !== element && checked.has(candidate)) continue;
1001
+ checked.add(candidate);
1002
+ for (const [compound, matched] of hasState) {
1003
+ let now = false;
1004
+ try { now = matchesCompound(candidate, compound); } catch {}
1005
+ if (now === matched.has(candidate)) continue;
1006
+ if (now) matched.add(candidate); else matched.delete(candidate);
1007
+ add(candidate);
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * The style computation's dirty set, or null for a full restyle. Pending
1015
+ * mutation records become subtree roots: an attribute change restyles the
1016
+ * element's subtree (descendant selectors), a child list change its
1017
+ * parent's (structural pseudo-classes), with siblings included when the
1018
+ * sheets use sibling combinators; a change :has() may observe restyles all.
1019
+ */
1020
+ function takeStyleChanges(sheets) {
1021
+ const traits = selectorTraitsFor(sheets);
1022
+ const records = styleChanges.records;
1023
+ if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
1024
+ const roots = styleChanges.roots;
1025
+ // Explicit roots mean that state-dependent selectors may need restyling;
1026
+ // they do not by themselves change render-tree content. DOM mutation
1027
+ // records below identify structural/content changes separately.
1028
+ let treeDirty = false;
1029
+ let full = styleChanges.full;
1030
+ const active = page.document.activeElement;
1031
+ if (active !== styleChanges.activeElement) {
1032
+ // :focus, :focus-within and :focus-visible follow the focused element.
1033
+ if (styleChanges.activeElement !== null) full ||= "focus moved";
1034
+ styleChanges.activeElement = active;
1035
+ }
1036
+ // Elements whose subtree restyles (`roots`), and elements whose own
1037
+ // matches may change but whose subtree restyles only if their style does
1038
+ // (`self`, decided by the style engine).
1039
+ const self = new Set();
1040
+ const add = (element) => roots.add(element);
1041
+ // Elements already re-checked this round against the :has(), sibling and structural state.
1042
+ const hasChecked = new Set();
1043
+ const siblingChecked = new Set();
1044
+ const structuralChecked = new Set();
1045
+ const inserted = new Set();
1046
+ // Siblings after a change: their own matches may differ; their subtrees
1047
+ // only when a non-subject compound right of + / ~ now matches differently.
1048
+ const laterSibling = (element) => {
1049
+ self.add(element);
1050
+ if (siblingChecked.has(element)) return;
1051
+ siblingChecked.add(element);
1052
+ if (matchesChanged(siblingState, element)) roots.add(element);
1053
+ };
1054
+ const later = (element) => { for (let next = element?.nextElementSibling; next; next = next.nextElementSibling) laterSibling(next); };
1055
+ const featureMatches = (used, changedFeatures) => changedFeatures.some((feature) => feature === ":state"
1056
+ ? [...used].some((candidate) => candidate.startsWith(":"))
1057
+ : used.has(feature));
1058
+ const stateAffectsDescendants = (element, changedFeatures) => {
1059
+ const states = changedFeatures.filter((feature) => feature.startsWith(":"));
1060
+ if (!states.length) return false;
1061
+ const available = new Set(elementFeatures(element));
1062
+ return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
1063
+ && entry.required.every((feature) => available.has(feature)));
1064
+ };
1065
+ /** An element whose features changed: itself, and wider where non-subject compounds use them. */
1066
+ const changed = (element, features) => {
1067
+ self.add(element);
1068
+ const ordinary = features.filter((feature) => !feature.startsWith(":"));
1069
+ if (featureMatches(traits.descendantFeatures, ordinary) || stateAffectsDescendants(element, features)) roots.add(element);
1070
+ if (traits.siblingFeatures.has("*") || featureMatches(traits.siblingFeatures, features)) later(element);
1071
+ hasChanges(traits, element, add, hasChecked);
1072
+ };
1073
+ const touchSibling = (element) => {
1074
+ self.add(element);
1075
+ if (structuralChecked.has(element)) return;
1076
+ structuralChecked.add(element);
1077
+ if (matchesChanged(structuralState, element)) roots.add(element);
1078
+ };
1079
+ if (!full && !hasState) full = "first restyle";
1080
+ // Marked elements: state outside the DOM (checked, value) or a DOM change
1081
+ // the records also describe; both start as a change of the element's state.
1082
+ for (const element of [...roots]) {
1083
+ roots.delete(element);
1084
+ if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
1085
+ }
1086
+ for (const record of records) {
1087
+ if (full) break;
1088
+ const target = record.target;
1089
+ if (record.type === "attributes") {
1090
+ if (target.nodeType !== 1) continue;
1091
+ const name = record.attributeName;
1092
+ // data/ARIA/title changes have no direct render-tree representation;
1093
+ // selectors that observe them are handled by the style diff below.
1094
+ // Other attributes may alter controls, replaced elements or visibility.
1095
+ if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
1096
+ const features = ["[" + name];
1097
+ if (name === "class") {
1098
+ const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
1099
+ const after = new Set(target.classList);
1100
+ for (const value of before) if (!after.has(value)) features.push("." + value);
1101
+ for (const value of after) if (!before.has(value)) features.push("." + value);
1102
+ } else if (name === "id") {
1103
+ if (record.oldValue) features.push("#" + record.oldValue);
1104
+ const id = target.getAttribute("id");
1105
+ if (id) features.push("#" + id);
1106
+ }
1107
+ changed(target, features);
1108
+ } else if (record.type === "childList") {
1109
+ treeDirty = true;
1110
+ if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
1111
+ // Inserted elements restyle; the parent itself may change (:empty);
1112
+ // siblings around the change point may match structural
1113
+ // pseudo-classes differently, and the ones after it sibling
1114
+ // combinators whose left side the inserted or removed nodes match.
1115
+ for (const node of record.addedNodes) {
1116
+ if (node.nodeType === 1 && node.parentElement === target) {
1117
+ add(node);
1118
+ inserted.add(node);
1119
+ }
1120
+ }
1121
+ self.add(target);
1122
+ hasChanges(traits, target, add, hasChecked);
1123
+ let next = record.nextSibling;
1124
+ while (next && next.nodeType !== 1) next = next.nextSibling;
1125
+ let previous = record.previousSibling;
1126
+ while (previous && previous.nodeType !== 1) previous = previous.previousSibling;
1127
+ if (next?.parentElement !== target) next = null;
1128
+ if (previous?.parentElement !== target) previous = null;
1129
+ if (traits.forward) for (let element = next; element; element = element.nextElementSibling) touchSibling(element);
1130
+ else if (traits.firstChild && next) touchSibling(next);
1131
+ if (traits.backward) for (let element = previous; element; element = element.previousElementSibling) touchSibling(element);
1132
+ else if (traits.lastChild && previous) touchSibling(previous);
1133
+ if (next && traits.siblingFeatures.size) {
1134
+ const moved = [...record.addedNodes, ...record.removedNodes, previous].filter((node) => node?.nodeType === 1);
1135
+ if (traits.siblingFeatures.has("*") || moved.some((node) => elementFeatures(node).some((feature) => traits.siblingFeatures.has(feature)))) {
1136
+ for (let element = next; element; element = element.nextElementSibling) laterSibling(element);
1137
+ }
1138
+ }
1139
+ } else if (record.type === "characterData") {
1140
+ treeDirty = true;
1141
+ // Only :empty and :has() can see text: the parent itself.
1142
+ const parent = target.parentElement;
1143
+ if (parent) {
1144
+ self.add(parent);
1145
+ hasChanges(traits, parent, add, hasChecked);
1146
+ }
1147
+ }
1148
+ }
1149
+ if (!full) for (const node of topmostConnected(inserted, page.document)) recordInserted(node);
1150
+ styleChanges.records = [];
1151
+ styleChanges.roots = new Set();
1152
+ styleChanges.rootFeatures = new Map();
1153
+ styleChanges.full = false;
1154
+ lastStyleReason = full || `${roots.size} root(s), ${self.size} self`;
1155
+ if (full) hasState = null; // recorded again after the full restyle
1156
+ return full ? null : { roots, self, treeDirty };
1157
+ }
1158
+
1159
+ function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
1160
+ closePiano();
737
1161
  disposeVideos();
738
1162
  page.realm?.dispose();
739
1163
  page.realm = null;
@@ -741,11 +1165,14 @@ export async function createPageRenderer({
741
1165
  for (const image of page.decodedImages) image.delete();
742
1166
  page.window = parsed.window;
743
1167
  page.document = parsed.document;
1168
+ observeStyleChanges(parsed.document);
744
1169
  page.title = parsed.document.title;
745
1170
  page.styleSheets = styleSheets;
1171
+ page.linkSheets = linkSheets;
746
1172
  page.resources = imageResources.map;
747
1173
  initializeVideos(parsed.document, page.resources);
748
1174
  page.decodedImages = imageResources.decoded;
1175
+ page.imagesLoaded = null;
749
1176
  imageRequests = new WeakMap();
750
1177
  const timing = styleAndLayout();
751
1178
  trace?.(`${page.url}: first style ${timing.style}ms, layout ${timing.layout}ms`);
@@ -759,27 +1186,94 @@ export async function createPageRenderer({
759
1186
  /** Style, render tree, layout and display list of the current document. */
760
1187
  function styleAndLayout() {
761
1188
  const started = hostPerformance.now();
762
- styleEngine.compute(page.document, page.styleSheets, { width: page.width, height: page.height, mobile });
1189
+ const ms = (from, to) => Math.round(to - from);
1190
+ const sheets = currentStyleSheets();
1191
+ const environment = { width: page.width, height: page.height, mobile };
1192
+ const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
1193
+ const taken = hostPerformance.now();
1194
+ // A rendering opportunity can be scheduled by lifecycle work which did
1195
+ // not actually change DOM, state, resources, or styles. Keep the retained
1196
+ // layout/display list in that case instead of laying the whole page out.
1197
+ if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1198
+ && changes.roots.size === 0 && changes.self.size === 0) {
1199
+ lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
1200
+ return { style: Math.round(taken - started), layout: 0, skipped: true };
1201
+ }
1202
+ styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
1203
+ const computed = hostPerformance.now();
1204
+ if (!hasState || hasStateStale) {
1205
+ recordHasState(selectorTraitsFor(sheets));
1206
+ hasStateStale = false;
1207
+ }
1208
+ const recorded = hostPerformance.now();
1209
+ // Attribute mutations used by application bookkeeping often match no
1210
+ // different rule and do not alter render-tree content. Once style has
1211
+ // proved that no element changed, retain layout and paint unchanged.
1212
+ if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
1213
+ && !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
1214
+ lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
1215
+ return { style: Math.round(recorded - started), layout: 0, skipped: true };
1216
+ }
1217
+ if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
1218
+ const verified = hostPerformance.now();
763
1219
  page.backgroundColor = documentBackground(page.document, styleEngine);
764
- page.renderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
1220
+ const nextRenderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
765
1221
  const styled = hostPerformance.now();
766
- page.layout = layoutText(page.renderTree, {
1222
+ lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, tree ${ms(verified, styled)}`;
1223
+ // MutationObserver also reports changes under non-rendered nodes such as
1224
+ // script and metadata elements. Compare the actual immutable layout input
1225
+ // after rebuilding it, so those changes do not trigger a full reflow.
1226
+ if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets
1227
+ && sameLayoutInput(page.renderTree.root, nextRenderTree.root, {
1228
+ ignoreResources: true,
1229
+ // A zero-element incremental pass retained every computed style.
1230
+ // Control placeholders may still wrap that style in a fresh object.
1231
+ ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
1232
+ })) {
1233
+ lastStyleParts += ", unchanged layout input";
1234
+ return { style: Math.round(styled - started), layout: 0, skipped: true };
1235
+ }
1236
+ page.renderTree = nextRenderTree;
1237
+ page.layout = layoutEngine.layout(page.renderTree, {
767
1238
  measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
768
1239
  });
769
- page.displayList = buildDisplayList(page.layout, page.renderTree);
1240
+ page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
1241
+ page.resourceLayoutDirty = false;
1242
+ laidOutSheets = sheets;
770
1243
  viewport.setContentSize(page.layout.width, page.layout.height);
771
1244
  return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
772
1245
  }
773
1246
 
1247
+ /** BUNINU_VERIFY_STYLE: compare an incremental restyle with a full one, element by element. */
1248
+ function verifyIncrementalStyle(sheets, environment) {
1249
+ const reference = new StyleEngine().compute(page.document, sheets, environment);
1250
+ const serialize = (style) => JSON.stringify(style, (key, value) => typeof value === "bigint" ? String(value) : value);
1251
+ let mismatches = 0;
1252
+ for (const element of page.document.querySelectorAll("*")) {
1253
+ const ours = serialize(styleEngine.get(element));
1254
+ const expected = serialize(reference.get(element));
1255
+ const pseudo = ["before", "after"].some((name) =>
1256
+ serialize(styleEngine.getPseudo(element, name)) !== serialize(reference.getPseudo(element, name)));
1257
+ if (ours === expected && !pseudo) continue;
1258
+ if (mismatches++ < 3) {
1259
+ const a = styleEngine.get(element);
1260
+ const b = reference.get(element);
1261
+ const fields = Object.keys(b).filter((key) => serialize(a[key]) !== serialize(b[key]));
1262
+ reportError(`buninu-browser: incremental style mismatch at <${element.localName} class="${element.getAttribute("class") ?? ""}">: ${pseudo ? "pseudo " : ""}${fields.join(", ")}`);
1263
+ }
1264
+ }
1265
+ if (mismatches) reportError(`buninu-browser: incremental style: ${mismatches} element(s) differ from a full restyle`);
1266
+ }
1267
+
774
1268
  function rebuildRenderTree(reason = "update") {
775
1269
  const started = hostPerformance.now();
776
1270
  page.dirty = false;
777
- page.needsPaint = true;
778
1271
  page.title = page.document?.title ?? page.title;
779
1272
  const timing = styleAndLayout();
1273
+ if (!timing.skipped) page.needsPaint = true;
780
1274
  lastRebuildMs = hostPerformance.now() - started;
781
1275
  trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
782
- + ` (style ${timing.style}ms, layout ${timing.layout}ms)`);
1276
+ + ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
783
1277
  }
784
1278
 
785
1279
  // Each loadDocument starts a generation; stages of a superseded load stop.
@@ -849,37 +1343,41 @@ export async function createPageRenderer({
849
1343
  const isHtml = contentType.toLowerCase().includes("html");
850
1344
  const parsed = parseHTMLDocument(isHtml ? source : "<body></body>", url);
851
1345
  phase("parse");
852
- const resources = isHtml
853
- ? Promise.all([loadStyleSheets(parsed.document, url), loadImageResources(parsed.document, url)])
854
- : Promise.resolve([[], emptyImageResources()]);
1346
+ // Stylesheets block rendering and scripts; images do not (only the
1347
+ // window load event waits for them, see whenImagesLoaded): one slow image
1348
+ // must not hold back the page's scripts. Images that are already here
1349
+ // when the styles are join the first paint; the rest are drawn as they come.
1350
+ const linkSheets = isHtml ? new WeakMap() : null;
1351
+ const styleSheets = isHtml ? loadStyleSheets(parsed.document, url, linkSheets) : Promise.resolve([]);
1352
+ const images = isHtml ? loadImageResources(parsed.document, url) : Promise.resolve(emptyImageResources());
1353
+ page.preloadedScripts = isHtml && page.scripting !== "off" ? preloadScripts(parsed.document, url) : new Map();
1354
+ let imagesReady = null;
1355
+ images.then((loaded) => { imagesReady = loaded; });
855
1356
  let timer;
856
- let ready = await Promise.race([
857
- resources,
1357
+ let sheets = await Promise.race([
1358
+ styleSheets,
858
1359
  new Promise((resolve) => (timer = hostSetTimeout(resolve, PREVIEW_DELAY_MS, null))),
859
1360
  ]);
860
1361
  hostClearTimeout(timer);
861
- if (!current()) return discard(resources, parsed);
862
- if (!ready) {
863
- // Subresources are slow: show the document with the user-agent
1362
+ if (!current()) return discard(images, parsed);
1363
+ if (!sheets) {
1364
+ // Stylesheets are slow: show the document with the user-agent
864
1365
  // defaults and its inline <style> sheets meanwhile.
865
- replaceParsedDocument(parsed, inlineStyleSheets(parsed.document), emptyImageResources());
1366
+ replaceParsedDocument(parsed, inlineStyleSheets(parsed.document), emptyImageResources(), linkSheets);
1367
+ awaitImages(images, generation);
866
1368
  render();
867
1369
  phase("preview");
868
1370
  commit.resolve();
869
- ready = await resources;
870
- if (!current()) return discard(resources);
871
- phase(`${ready[0].length} stylesheets and ${ready[1].decoded.length} images`);
1371
+ sheets = await styleSheets;
1372
+ if (!current()) return;
1373
+ phase(`${sheets.length} stylesheets`);
872
1374
  // Same document, now styled; the scroll position is kept.
873
- for (const image of page.decodedImages) image.delete();
874
- page.styleSheets = ready[0];
875
- page.resources = ready[1].map;
876
- initializeVideos(page.document, page.resources);
877
- page.decodedImages = ready[1].decoded;
878
- imageRequests = new WeakMap();
1375
+ page.styleSheets = sheets;
879
1376
  rebuildRenderTree("stylesheets loaded");
880
1377
  } else {
881
- phase(`${ready[0].length} stylesheets and ${ready[1].decoded.length} images`);
882
- replaceParsedDocument(parsed, ready[0], ready[1]);
1378
+ phase(`${sheets.length} stylesheets${imagesReady ? ` and ${imagesReady.decoded.length} images` : ""}`);
1379
+ replaceParsedDocument(parsed, sheets, imagesReady ?? emptyImageResources(), linkSheets);
1380
+ if (!imagesReady) awaitImages(images, generation);
883
1381
  }
884
1382
  phase("style and layout");
885
1383
  if (!isHtml) {
@@ -905,22 +1403,79 @@ export async function createPageRenderer({
905
1403
  phase("scripts");
906
1404
  page.loading = false;
907
1405
  refresh();
908
- void hydrateGitHubBranchInfo(generation);
909
1406
  phase("style, layout and raster after scripts");
910
1407
  } else {
911
1408
  finish();
912
1409
  }
913
1410
  }
914
1411
 
1412
+ /**
1413
+ * Like a browser's preload scanner: starts fetching the document's external
1414
+ * scripts and declared module preloads (<link rel=modulepreload>) as soon
1415
+ * as it is parsed, while its stylesheets load and it is styled, instead of
1416
+ * when the scripts' turn comes and a module graph is discovered level by
1417
+ * level. Only the bytes are fetched early; fetchScript() takes them.
1418
+ */
1419
+ function preloadScripts(document, documentUrl) {
1420
+ const preloaded = new Map();
1421
+ const preload = (href) => {
1422
+ let target;
1423
+ try { target = new URL(href, documentUrl); } catch { return; }
1424
+ if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
1425
+ const response = fetchResource(target.href, "script");
1426
+ response.catch(() => {}); // reported when the script runs
1427
+ preloaded.set(target.href, response);
1428
+ };
1429
+ for (const script of document.querySelectorAll("script[src]")) {
1430
+ const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
1431
+ if (type === "module" || (CLASSIC_SCRIPT_TYPES.has(type) && !script.hasAttribute("nomodule"))) preload(script.getAttribute("src"));
1432
+ }
1433
+ for (const link of document.querySelectorAll("link[rel~='modulepreload'][href]")) preload(link.getAttribute("href"));
1434
+ return preloaded;
1435
+ }
1436
+
915
1437
  /** Frees what a superseded load fetched. */
916
- async function discard(resources, parsed = null) {
1438
+ async function discard(images, parsed = null) {
917
1439
  parsed?.window?.happyDOM?.abort?.();
918
- const [, images] = await resources.catch(() => [[], emptyImageResources()]);
919
- for (const image of images.decoded) image.delete();
1440
+ const loaded = await images.catch(() => emptyImageResources());
1441
+ for (const image of loaded.decoded) image.delete();
1442
+ }
1443
+
1444
+ /**
1445
+ * The document's initial images, still on their way: they are drawn when
1446
+ * they arrive (unless a script changed the src meanwhile), and the load
1447
+ * event waits for them.
1448
+ */
1449
+ function awaitImages(images, generation) {
1450
+ const document = page.document;
1451
+ // queueImageResources must not fetch these again.
1452
+ for (const element of document.querySelectorAll("img")) {
1453
+ const src = element.getAttribute("src");
1454
+ if (src) imageRequests.set(element, src);
1455
+ }
1456
+ page.imagesLoaded = images.then((loaded) => {
1457
+ if (generation !== loadGeneration || document !== page.document) {
1458
+ for (const image of loaded.decoded) image.delete();
1459
+ return;
1460
+ }
1461
+ for (const { element, src, resource } of loaded.entries) {
1462
+ if (element.getAttribute("src") !== src || page.resources.get(element)) {
1463
+ resource.image.delete();
1464
+ continue;
1465
+ }
1466
+ page.resources.set(element, resource);
1467
+ page.decodedImages.push(resource.image);
1468
+ }
1469
+ page.dirty = true;
1470
+ page.resourceLayoutDirty = true;
1471
+ scheduleFrame();
1472
+ }, () => {});
920
1473
  }
921
1474
 
922
1475
  function inlineStyleSheets(document) {
923
- return [...document.querySelectorAll("style")].map((element) => element.textContent ?? "");
1476
+ return [...document.querySelectorAll("style")]
1477
+ .filter((element) => !element.closest("noscript"))
1478
+ .map((element) => element.textContent ?? "");
924
1479
  }
925
1480
 
926
1481
  /** Brings style, layout and raster up to date; returns whether it repainted. */
@@ -944,6 +1499,15 @@ export async function createPageRenderer({
944
1499
  const element = page.document.activeElement;
945
1500
  if (!element || !["INPUT", "TEXTAREA"].includes(element.tagName) || element.disabled || element.readOnly) return;
946
1501
  if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
1502
+ // Mobile IMEs often send committed text without a usable keydown (key 229
1503
+ // or Unidentified). Handle that before the normal beforeinput insertion.
1504
+ if (!backspace && element.getAttribute("id") === "buninu-piano-input" && audio && text.length === 1) {
1505
+ const note = pianoNote(text);
1506
+ if (note !== null) {
1507
+ playPiano(note);
1508
+ return;
1509
+ }
1510
+ }
947
1511
  const value = String(element.value ?? "");
948
1512
  let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
949
1513
  const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
@@ -957,23 +1521,73 @@ export async function createPageRenderer({
957
1521
  if (prevented) return;
958
1522
  }
959
1523
  element.value = value.slice(0, start) + text + value.slice(end);
1524
+ markStyleDirty(element); // :placeholder-shown, :valid / :invalid
960
1525
  element.setSelectionRange?.(start + text.length, start + text.length);
961
1526
  if (page.realm) page.realm.dispatch(element, new page.window.Event("input", { bubbles: true }));
962
1527
  page.dirty = true;
963
1528
  }
964
1529
 
1530
+ let pianoStreamId = null;
1531
+ let pianoMixer = null;
1532
+ let pianoIdleTimer = null;
1533
+ const PIANO_IDLE_MS = 2000;
1534
+
1535
+ function playPiano(note) {
1536
+ pianoMixer ??= new PianoMixer();
1537
+ pianoMixer.trigger(note);
1538
+ hostClearTimeout(pianoIdleTimer);
1539
+ pianoIdleTimer = hostSetTimeout(closePiano, PIANO_IDLE_MS);
1540
+ if (pianoStreamId !== null) return;
1541
+ const id = audio.open({ rate: 48000, channels: 1, name: "piano" }, (event) => {
1542
+ if (pianoStreamId !== id) return;
1543
+ if (event.event === "request") audio.write(id, pianoMixer.render(event.bytes));
1544
+ else if (event.event === "error" || event.event === "ended") {
1545
+ pianoStreamId = null;
1546
+ pianoMixer = null;
1547
+ if (event.event === "error") reportError(new Error(`piano audio: ${event.message}`));
1548
+ }
1549
+ });
1550
+ pianoStreamId = id;
1551
+ }
1552
+
1553
+ function closePiano() {
1554
+ hostClearTimeout(pianoIdleTimer);
1555
+ pianoIdleTimer = null;
1556
+ if (pianoStreamId !== null) audio?.close(pianoStreamId);
1557
+ pianoStreamId = null;
1558
+ pianoMixer = null;
1559
+ }
1560
+
965
1561
  function realmHooks(generation) {
966
1562
  return {
967
- touch() {
1563
+ definedChanged(name) {
1564
+ // An upgrade changes :defined for that tag's elements only.
1565
+ if (selectorTraits.defined) {
1566
+ for (const element of page.document.getElementsByTagName(String(name))) markStyleDirty(element, [":defined"]);
1567
+ }
1568
+ page.dirty = true;
1569
+ scheduleFrame();
1570
+ },
1571
+ touch(node = null, stateFeatures = []) {
1572
+ if (stateFeatures.length) markStyleDirty(node, stateFeatures);
968
1573
  page.dirty = true;
969
1574
  scheduleFrame();
970
1575
  },
971
- playVideo,
972
- pauseVideo,
973
- videoState,
1576
+ mediaState,
1577
+ playMedia,
1578
+ pauseMedia,
1579
+ seekMedia,
974
1580
  lifecycle(name) {
975
- if (generation === loadGeneration) onLifecycle?.(name, generation);
1581
+ if (generation !== loadGeneration) return;
1582
+ onLifecycle?.(name, generation);
1583
+ // Parsing and the deferred scripts are done: changes from async
1584
+ // scripts and late images get frames, without waiting for load.
1585
+ if (name === "DOMContentLoaded") {
1586
+ page.loading = false;
1587
+ scheduleFrame();
1588
+ }
976
1589
  },
1590
+ whenImagesLoaded: () => page.imagesLoaded,
977
1591
  afterTask() {},
978
1592
  boundsOf(element) {
979
1593
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -997,9 +1611,23 @@ export async function createPageRenderer({
997
1611
  if (page.dirty) rebuildRenderTree("getComputedStyle");
998
1612
  return styleEngine.get(element);
999
1613
  },
1000
- navigate: (url) => requestNavigation(String(url)),
1614
+ navigate: (url) => navigateTo(String(url)),
1615
+ activateLink(anchor) {
1616
+ const href = new URL(anchor.getAttribute("href"), page.url).href;
1617
+ const name = anchor.hasAttribute("download") ? anchor.getAttribute("download") ?? "" : null;
1618
+ if (href.startsWith("blob:") && name !== null) void downloadBlob(href, name);
1619
+ else navigateTo(href, name);
1620
+ },
1621
+ submitForm,
1001
1622
  async fetchScript(url) {
1002
- const response = await fetchResource(url, "script");
1623
+ if (/^(?:blob|data):/i.test(url)) {
1624
+ const response = await fetch(url);
1625
+ if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
1626
+ return response.text();
1627
+ }
1628
+ const preloaded = page.preloadedScripts.get(url);
1629
+ if (preloaded) page.preloadedScripts.delete(url);
1630
+ const response = await (preloaded ?? fetchResource(url, "script"));
1003
1631
  if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
1004
1632
  return new TextDecoder().decode(response.body);
1005
1633
  },
@@ -1020,6 +1648,18 @@ export async function createPageRenderer({
1020
1648
  };
1021
1649
  }
1022
1650
 
1651
+ function submitForm(form, submitter = null) {
1652
+ const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
1653
+ if (method !== "get") return;
1654
+ const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
1655
+ const url = new URL(action || page.url, page.url);
1656
+ url.search = "";
1657
+ for (const [name, value] of new page.window.FormData(form, submitter)) {
1658
+ if (typeof value === "string") url.searchParams.append(name, value);
1659
+ }
1660
+ navigateTo(url.href);
1661
+ }
1662
+
1023
1663
  /** The border box of an element's layout box in viewport coordinates. */
1024
1664
  function viewportBounds(element) {
1025
1665
  let fixed = false;
@@ -1044,27 +1684,66 @@ export async function createPageRenderer({
1044
1684
  }
1045
1685
 
1046
1686
  /**
1047
- * Scrolls to the fragment's target (HTML §7.4.6.4); returns whether one
1048
- * was found. `prefix` looks for "<prefix><fragment>" instead.
1687
+ * Navigation a page starts (a link, location.href = ...). A fragment of
1688
+ * the current document is navigated to at once, as HTML specifies, so the
1689
+ * page's own click and popstate handlers already see the new location;
1690
+ * the controller then only records the history entry (with the scroll
1691
+ * position being left). Anything else goes to the controller.
1692
+ */
1693
+ function navigateTo(url, downloadName = null) {
1694
+ const from = downloadName === null ? fragmentNavigation(url) : null;
1695
+ requestNavigation(url, downloadName, from);
1696
+ }
1697
+
1698
+ /**
1699
+ * Navigates to `url` at once if it is a fragment of the current document;
1700
+ * returns the scroll offset it left, or null when it is not such a URL.
1049
1701
  */
1050
- function scrollToFragment(href, prefix = "") {
1702
+ function fragmentNavigation(url) {
1703
+ const target = new URL(url, page.url);
1704
+ const current = new URL(page.url);
1705
+ target.hash = current.hash = "";
1706
+ if (!page.realm || !String(url).includes("#") || target.href !== current.href) return null;
1707
+ const from = { x: viewport.x, y: viewport.y };
1708
+ void showFragment(new URL(url, page.url).href);
1709
+ return from;
1710
+ }
1711
+
1712
+ async function showFragment(url) {
1713
+ // :target moves from the old fragment's element to the new one's.
1714
+ if (selectorTraits.target) {
1715
+ for (const href of [page.url, url]) {
1716
+ let id = "";
1717
+ try { id = decodeURIComponent(new URL(href).hash.slice(1)); } catch {}
1718
+ const target = id && page.document.getElementById(id);
1719
+ if (target) markStyleDirty(target);
1720
+ }
1721
+ }
1722
+ page.url = url;
1723
+ scrollToFragment(url);
1724
+ // Pages that map fragments to their own targets scroll from popstate or hashchange listeners.
1725
+ await page.realm?.navigateToFragment(url);
1726
+ render();
1727
+ }
1728
+
1729
+ /** Scrolls to the fragment's target (HTML §7.4.6.4). */
1730
+ function scrollToFragment(href) {
1051
1731
  const url = new URL(href);
1052
1732
  if (!url.hash || url.hash === "#") {
1053
1733
  viewport.scrollTo(0, 0);
1054
- return true;
1734
+ return;
1055
1735
  }
1056
1736
  let identifier;
1057
1737
  try {
1058
- identifier = prefix + decodeURIComponent(url.hash.slice(1));
1738
+ identifier = decodeURIComponent(url.hash.slice(1));
1059
1739
  } catch {
1060
- identifier = prefix + url.hash.slice(1);
1740
+ identifier = url.hash.slice(1);
1061
1741
  }
1062
1742
  const target = page.document.getElementById(identifier)
1063
1743
  ?? [...page.document.getElementsByTagName("a")]
1064
1744
  .find((element) => element.getAttribute("name") === identifier);
1065
1745
  const bounds = target && elementBounds(page.renderTree, page.layout, target);
1066
1746
  if (bounds) viewport.scrollTo(viewport.x, bounds.top);
1067
- return Boolean(target);
1068
1747
  }
1069
1748
 
1070
1749
  function collectHints() {
@@ -1102,6 +1781,18 @@ export async function createPageRenderer({
1102
1781
  render();
1103
1782
  }
1104
1783
 
1784
+ async function downloadBlob(url, name) {
1785
+ 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());
1789
+ if (data.byteLength > 64 * 1024 * 1024) throw new Error("blob download exceeds 64 MB");
1790
+ requestDownload({ url, name, data });
1791
+ } catch (error) {
1792
+ reportError(error);
1793
+ }
1794
+ }
1795
+
1105
1796
  const api = {
1106
1797
  loadDocument,
1107
1798
  /** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
@@ -1116,18 +1807,7 @@ export async function createPageRenderer({
1116
1807
  return page.realm?.console ?? [];
1117
1808
  },
1118
1809
  /** Same-document navigation: keep the document, move to the fragment. */
1119
- async showFragment(url) {
1120
- page.url = url;
1121
- const found = scrollToFragment(url);
1122
- // Pages that map fragments to their own targets scroll from a hashchange
1123
- // listener. Rendered Markdown (GitHub, Gitea) prefixes heading ids with
1124
- // "user-content-" and relies on such a script; if the page did not
1125
- // scroll, go to that target instead.
1126
- const before = viewport.y;
1127
- await page.realm?.navigateToFragment(url);
1128
- if (!found && viewport.y === before) scrollToFragment(url, "user-content-");
1129
- render();
1130
- },
1810
+ showFragment,
1131
1811
  url() {
1132
1812
  return page.url;
1133
1813
  },
@@ -1235,6 +1915,14 @@ export async function createPageRenderer({
1235
1915
  },
1236
1916
  press(key, options = {}) {
1237
1917
  refresh();
1918
+ const pianoInput = page.document?.activeElement;
1919
+ if (audio && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
1920
+ const note = pianoNote(String(key), String(options.code ?? ""));
1921
+ if (note !== null && !options.repeat && !options.modifiers) {
1922
+ playPiano(note);
1923
+ return { x: viewport.x, y: viewport.y };
1924
+ }
1925
+ }
1238
1926
  // Pages see keydown/keyup first; preventDefault() cancels the default scroll.
1239
1927
  if (page.realm) {
1240
1928
  const target = page.document.activeElement ?? page.document.body;
@@ -1248,7 +1936,15 @@ export async function createPageRenderer({
1248
1936
  const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
1249
1937
  if (!prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
1250
1938
  if (key === "Backspace") editFocusedText("", true);
1251
- else if (key === "Enter" && target.tagName === "TEXTAREA") editFocusedText("\n");
1939
+ else if (key === "Enter" && target.tagName === "TEXTAREA") {
1940
+ // A search IME's action key submits a search form. Other textareas
1941
+ // keep their normal newline behavior.
1942
+ const form = target.form;
1943
+ if (target.getAttribute("inputmode")?.toLowerCase() === "search" && form && form.checkValidity()) {
1944
+ const event = new page.window.SubmitEvent("submit", { bubbles: true, cancelable: true, submitter: form });
1945
+ if (!page.realm.dispatch(form, event)) submitForm(form);
1946
+ } else editFocusedText("\n");
1947
+ }
1252
1948
  else if (String(key).length === 1) editFocusedText(String(key));
1253
1949
  }
1254
1950
  page.realm.dispatch(target, new page.window.KeyboardEvent("keyup", init));
@@ -1283,8 +1979,33 @@ export async function createPageRenderer({
1283
1979
  */
1284
1980
  click(x, y) {
1285
1981
  refresh();
1286
- const region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
1982
+ // Activation can toggle checked state (radios in a group, through a
1983
+ // label) without a DOM mutation, so the next restyle is a full one.
1984
+ markStyleDirty(null); // full: click
1985
+ let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
1986
+ if (!region) {
1987
+ // Some search forms draw a wide control around a narrow textarea and
1988
+ // rely on script to forward clicks. Preserve that affordance when the
1989
+ // form has exactly one search field, even if its script did not run.
1990
+ for (const form of page.document.querySelectorAll('form[role="search"]')) {
1991
+ const bounds = viewportBounds(form);
1992
+ if (!bounds || x < bounds.left || x >= bounds.left + bounds.width
1993
+ || y < bounds.top || y >= bounds.top + bounds.height) continue;
1994
+ const fields = form.querySelectorAll('textarea[inputmode="search"], input[type="search"]');
1995
+ if (fields.length === 1) region = { element: fields[0] };
1996
+ break;
1997
+ }
1998
+ }
1287
1999
  if (!region) return null;
2000
+ if (page.url === "about:audio") {
2001
+ const key = region.element.closest?.("[data-piano-note]");
2002
+ const note = Number(key?.getAttribute("data-piano-note"));
2003
+ if (key && Number.isInteger(note) && note >= 0 && note <= 127 && audio) {
2004
+ playPiano(note);
2005
+ page.document.getElementById("buninu-piano-input")?.focus();
2006
+ return null;
2007
+ }
2008
+ }
1288
2009
  if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(region.element.tagName)) {
1289
2010
  region.element.focus();
1290
2011
  }
@@ -1296,10 +2017,8 @@ export async function createPageRenderer({
1296
2017
  refresh();
1297
2018
  if (prevented) return null;
1298
2019
  }
1299
- if (region.element.tagName === "VIDEO") {
1300
- const state = page.videos.get(region.element);
1301
- if (state?.playing) pauseVideo(region.element);
1302
- else void playVideo(region.element);
2020
+ if (region.element.tagName === "AUDIO" || region.element.tagName === "VIDEO") {
2021
+ clickMedia(region.element, x, y);
1303
2022
  return null;
1304
2023
  }
1305
2024
  const anchor = region.element.closest?.("a[href]");
@@ -1309,7 +2028,13 @@ export async function createPageRenderer({
1309
2028
  this.runJavaScriptURL(href);
1310
2029
  return null;
1311
2030
  }
1312
- return href;
2031
+ if (href.startsWith("blob:") && anchor.hasAttribute("download")) {
2032
+ void downloadBlob(href, anchor.getAttribute("download") ?? "");
2033
+ return null;
2034
+ }
2035
+ if (anchor.hasAttribute("download")) return { url: href, download: anchor.getAttribute("download") ?? "" };
2036
+ const fragmentFrom = fragmentNavigation(href);
2037
+ return fragmentFrom ? { url: href, fragmentFrom } : href;
1313
2038
  },
1314
2039
  /**
1315
2040
  * Navigating to a javascript: URL runs its percent-decoded source as a
@@ -1383,6 +2108,7 @@ export async function createPageRenderer({
1383
2108
  hostClearTimeout(prepaintTimer);
1384
2109
  hostClearTimeout(frameTimer);
1385
2110
  page.realm?.dispose();
2111
+ closePiano();
1386
2112
  disposeVideos();
1387
2113
  page.window?.happyDOM?.abort?.();
1388
2114
  for (const image of page.decodedImages) image.delete();
@@ -1395,26 +2121,66 @@ export async function createPageRenderer({
1395
2121
  render();
1396
2122
  return api;
1397
2123
 
1398
- async function loadStyleSheets(document, documentUrl) {
1399
- // Fetched in parallel; the cascade keeps document order.
1400
- const sheets = [...document.querySelectorAll("style, link[rel~='stylesheet']")].map(async (element) => {
1401
- if (element.tagName === "STYLE") return element.textContent ?? "";
2124
+ /** Fetches the document's linked stylesheets (in parallel), then lists all its sheets. */
2125
+ async function loadStyleSheets(document, documentUrl, linkSheets) {
2126
+ const pending = [];
2127
+ collectStyleSheets(document, documentUrl, linkSheets, pending);
2128
+ await Promise.all(pending);
2129
+ return collectStyleSheets(document, documentUrl, linkSheets);
2130
+ }
2131
+
2132
+ /**
2133
+ * The document's style sheets as they are now, in document order: each
2134
+ * <style>'s current text and each loaded <link rel=stylesheet>. Sheets that
2135
+ * scripts add later count too; a link not fetched yet is requested (its
2136
+ * promise goes to `pending`) and redraws the page when it arrives.
2137
+ */
2138
+ function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
2139
+ const sheets = [];
2140
+ for (const element of document.querySelectorAll("style, link[rel~='stylesheet']")) {
2141
+ if (element.closest("noscript")) continue;
2142
+ if (element.tagName === "STYLE") {
2143
+ sheets.push(element.textContent ?? "");
2144
+ continue;
2145
+ }
1402
2146
  const href = element.getAttribute("href");
1403
- if (!href) return null;
1404
- try {
1405
- const response = await fetchResource(new URL(href, documentUrl).href, "stylesheet");
1406
- return response.ok ? new TextDecoder().decode(response.body) : null;
1407
- } catch {
1408
- // A failed stylesheet must not prevent the document itself from loading.
1409
- return null;
2147
+ let url;
2148
+ try { url = href ? new URL(href, documentUrl).href : null; } catch { url = null; }
2149
+ if (!url) continue;
2150
+ const loaded = linkSheets.get(element);
2151
+ if (loaded?.url === url) {
2152
+ if (loaded.text !== null) sheets.push(loaded.text);
2153
+ continue;
1410
2154
  }
1411
- });
1412
- return (await Promise.all(sheets)).filter((sheet) => sheet !== null);
2155
+ const entry = { url, text: null };
2156
+ linkSheets.set(element, entry);
2157
+ const request = fetchResource(url, "stylesheet").then((response) => {
2158
+ if (response.ok) entry.text = new TextDecoder().decode(response.body);
2159
+ }, () => {
2160
+ // A failed stylesheet must not prevent the document itself from loading.
2161
+ }).then(() => {
2162
+ if (entry.text === null || document !== page.document || linkSheets.get(element) !== entry) return;
2163
+ page.dirty = true;
2164
+ scheduleFrame();
2165
+ });
2166
+ pending?.push(request);
2167
+ }
2168
+ return sheets;
2169
+ }
2170
+
2171
+ /** The current sheet list, the same array while nothing changed (the style engine caches parsed rules by it). */
2172
+ function currentStyleSheets() {
2173
+ if (!page.linkSheets) return page.styleSheets;
2174
+ const sheets = collectStyleSheets(page.document, page.url, page.linkSheets);
2175
+ const previous = page.styleSheets;
2176
+ if (previous.length === sheets.length && sheets.every((sheet, index) => sheet === previous[index])) return previous;
2177
+ return page.styleSheets = sheets;
1413
2178
  }
1414
2179
 
1415
2180
  async function loadImageResources(document, documentUrl) {
1416
2181
  const map = new WeakMap();
1417
2182
  const decoded = [];
2183
+ const entries = [];
1418
2184
  await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
1419
2185
  const src = element.getAttribute("src");
1420
2186
  if (!src) return;
@@ -1429,20 +2195,22 @@ export async function createPageRenderer({
1429
2195
  const resource = Object.freeze({ width, height, image, url: response.url });
1430
2196
  map.set(element, resource);
1431
2197
  decoded.push(image);
2198
+ entries.push({ element, src, resource });
1432
2199
  } catch {
1433
2200
  // Broken images keep their fallback replaced-element dimensions.
1434
2201
  }
1435
2202
  }));
1436
- return { map, decoded };
2203
+ return { map, decoded, entries };
1437
2204
  }
1438
2205
 
2206
+ /** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
1439
2207
  function initializeVideos(document, resources) {
1440
- for (const element of document.querySelectorAll("video")) {
2208
+ for (const element of document.querySelectorAll("video, audio")) {
1441
2209
  if (resources.get(element)) continue;
1442
- const width = Number(element.getAttribute("width")) || 300;
1443
- const height = Number(element.getAttribute("height")) || 150;
1444
- resources.set(element, { width, height, bitmapWidth: 0, bitmapHeight: 0,
1445
- image: null, playing: false, url: "" });
2210
+ const audioOnly = element.tagName === "AUDIO";
2211
+ resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
2212
+ height: audioOnly ? 32 : Number(element.getAttribute("height")) || 150,
2213
+ bitmapWidth: 0, bitmapHeight: 0, image: null, playing: false, time: 0, duration: NaN });
1446
2214
  }
1447
2215
  }
1448
2216
 
@@ -1451,155 +2219,412 @@ export async function createPageRenderer({
1451
2219
  try { return src ? new URL(src, page.url).href : null; } catch { return null; }
1452
2220
  }
1453
2221
 
1454
- function videoState(element) {
1455
- const state = page.videos.get(element);
1456
- return { paused: !state?.playing, currentTime: state?.time ?? 0, duration: state?.duration ?? NaN };
2222
+ // ---- media: <video> and <audio> share one pipeline. An <audio> is media
2223
+ // whose picture is not shown (its box is only the 300x32 control bar), and
2224
+ // a video's own sound plays too. wasmpeg decodes (frames and PCM); PCM goes
2225
+ // to the controller, which plays it through PulseAudio paced to real time.
2226
+ // One clock: `base` is the position where the current run started and
2227
+ // `clockStart` when it did. Frames are drawn when the clock reaches them.
2228
+ // wasmpeg seeks video (to a keyframe) but not audio, so an audio seek
2229
+ // decodes forward and skips (from the start again when going back).
2230
+
2231
+ /** HTMLMediaElement state for page script. */
2232
+ function mediaState(element) {
2233
+ const state = page.media.get(element);
2234
+ return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
2235
+ duration: state?.duration ?? NaN };
1457
2236
  }
1458
2237
 
1459
- function pauseVideo(element) {
1460
- const state = page.videos.get(element);
1461
- if (!state) return;
1462
- if (!state.decoder) {
1463
- closeVideo(state);
1464
- page.videos.delete(element);
1465
- rasterized.list = null;
1466
- render();
1467
- return;
2238
+ /** The element's playback state, created (not loaded) on first use. */
2239
+ function mediaStateFor(element) {
2240
+ const resource = page.resources.get(element);
2241
+ const url = videoSource(element);
2242
+ if (!videoDecoder || !resource || !url) return null;
2243
+ let state = page.media.get(element);
2244
+ if (state && state.url !== url) {
2245
+ closeMedia(state);
2246
+ state = null;
1468
2247
  }
1469
- state.playing = false;
1470
- state.resource.playing = false;
1471
- hostClearTimeout(state.timer);
2248
+ 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 };
2252
+ page.media.set(element, state);
2253
+ }
2254
+ return state;
2255
+ }
2256
+
2257
+ /** Current position in seconds. */
2258
+ function mediaTime(state) {
2259
+ const running = state.playing && state.clockStart !== null ? (hostPerformance.now() - state.clockStart) / 1000 : 0;
2260
+ const time = state.base + running;
2261
+ return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
2262
+ }
2263
+
2264
+ /** Fires a media event (play, pause, ended, seeked, timeupdate...) at the element. */
2265
+ function mediaEvent(state, type) {
2266
+ if (state.document !== page.document || !page.realm) return;
2267
+ try {
2268
+ page.realm.dispatch(state.element, new page.window.Event(type));
2269
+ } catch {}
2270
+ }
2271
+
2272
+ /** The control bar and frame redraw without a relayout. */
2273
+ function repaintMedia() {
1472
2274
  rasterized.list = null;
1473
2275
  render();
1474
2276
  }
1475
2277
 
1476
- async function playVideo(element) {
1477
- if (!videoDecoder) return;
1478
- const resource = page.resources.get(element);
1479
- const url = videoSource(element);
1480
- if (!resource || !url) return;
1481
- let state = page.videos.get(element);
1482
- if (state?.playing) return;
1483
- if (!state || state.url !== url) {
1484
- if (state) closeVideo(state);
1485
- const generation = loadGeneration;
1486
- state = { element, url, resource, decoder: null, image: null, timer: null, playing: true,
1487
- time: 0, duration: NaN, generation, document: page.document };
1488
- page.videos.set(element, state);
1489
- resource.playing = true;
1490
- rasterized.list = null;
1491
- render();
1492
- try {
1493
- const response = await fetchResource(url, "video");
1494
- if (!response.ok) throw new Error(`video ${url} failed with HTTP ${response.status}`);
1495
- if (generation !== loadGeneration || state.document !== page.document || !state.playing) return;
1496
- state.decoder = await videoDecoder.decode(response.body, { format: "mp4" });
1497
- trace?.(`video decoded ${url}: ${state.decoder.width}x${state.decoder.height}`);
1498
- if (generation !== loadGeneration || state.document !== page.document || !state.playing) {
1499
- state.decoder.close();
1500
- return;
1501
- }
1502
- const metadata = await videoDecoder.probe(response.body);
1503
- state.duration = metadata.duration ?? NaN;
1504
- resource.width = state.decoder.width;
1505
- resource.height = state.decoder.height;
1506
- page.dirty = true;
1507
- refresh();
1508
- } catch (error) {
1509
- if (state.document === page.document) reportError(error);
1510
- closeVideo(state);
1511
- page.videos.delete(element);
1512
- resource.playing = false;
1513
- rasterized.list = null;
1514
- render();
2278
+ /** Fetches (once), probes, and opens the decoders for what the file holds. */
2279
+ function loadMedia(state) {
2280
+ state.loading ??= (async () => {
2281
+ if (!state.bytes) {
2282
+ const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
2283
+ if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
2284
+ state.bytes = response.body;
2285
+ }
2286
+ if (!Number.isFinite(state.duration) && !state.probed) {
2287
+ const probe = await videoDecoder.probe(state.bytes);
2288
+ 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;
2292
+ state.resource.duration = state.duration;
2293
+ mediaEvent(state, "loadedmetadata");
2294
+ }
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;
2299
+ }
2300
+
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 } : {};
2305
+ }
2306
+
2307
+ async function openVideoDecoder(state) {
2308
+ state.video?.decoder.close();
2309
+ const decoder = await videoDecoder.decode(state.bytes, decodeOptions(state));
2310
+ state.video = { decoder, frameTime: 0, done: false };
2311
+ trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
2312
+ if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
2313
+ state.resource.width = decoder.width;
2314
+ state.resource.height = decoder.height;
2315
+ page.dirty = true;
2316
+ refresh();
2317
+ }
2318
+ }
2319
+
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 };
2324
+ trace?.(`audio decoded ${state.url}: ${decoder.sampleRate} Hz, ${decoder.channels} channels`);
2325
+ }
2326
+
2327
+ /** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
2328
+ function clickMedia(element, x, y) {
2329
+ const box = viewportBounds(element);
2330
+ const state = page.media.get(element);
2331
+ const duration = state?.duration;
2332
+ if (box && Number.isFinite(duration) && duration > 0) {
2333
+ const layout = mediaBarLayout(box.width);
2334
+ const onBar = element.tagName === "AUDIO" || y - box.top >= box.height - MEDIA_BAR_HEIGHT;
2335
+ const relative = x - box.left;
2336
+ if (onBar && relative >= layout.trackStart - 6) {
2337
+ const fraction = Math.min(1, Math.max(0, (relative - layout.trackStart) / (layout.trackEnd - layout.trackStart)));
2338
+ void seekMedia(element, fraction * duration);
1515
2339
  return;
1516
2340
  }
1517
- } else {
1518
- state.playing = true;
1519
- resource.playing = true;
1520
2341
  }
1521
- drawNextVideoFrame(state);
2342
+ if (state?.playing) pauseMedia(element);
2343
+ else void playMedia(element);
1522
2344
  }
1523
2345
 
1524
- function drawNextVideoFrame(state) {
1525
- if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
1526
- const started = hostPerformance.now();
1527
- const width = Math.min(480, state.decoder.width);
1528
- const height = Math.max(1, Math.round(width * state.decoder.height / state.decoder.width));
1529
- const pixels = state.decoder.nextFrame(width, height);
1530
- if (state.time === 0) trace?.(`video first frame ${state.url}: ${pixels?.length ?? 0} bytes`);
1531
- if (!pixels) {
1532
- state.playing = false;
1533
- state.resource.playing = false;
1534
- rasterized.list = null;
1535
- render();
2346
+ async function playMedia(element) {
2347
+ const state = mediaStateFor(element);
2348
+ if (!state || state.playing) return;
2349
+ setMediaPlaying(state, true);
2350
+ try {
2351
+ await loadMedia(state);
2352
+ if (state.ended) {
2353
+ state.ended = false;
2354
+ await positionMedia(state, 0);
2355
+ } else if (state.needsPosition) {
2356
+ // After a pause the audio decoder is ahead of where playing resumes.
2357
+ await positionMedia(state, state.base, { videoToo: false });
2358
+ }
2359
+ state.needsPosition = false;
2360
+ } catch (error) {
2361
+ if (state.document === page.document) reportError(error);
2362
+ closeMedia(state);
2363
+ page.media.delete(element);
2364
+ repaintMedia();
2365
+ return;
2366
+ }
2367
+ if (!state.playing || state.document !== page.document) return;
2368
+ if (!state.video && !state.sound) {
2369
+ // Nothing this engine can play (audio with no audio output).
2370
+ setMediaPlaying(state, false);
2371
+ reportError(new Error(`media ${state.url}: nothing to play`));
2372
+ return;
2373
+ }
2374
+ startMedia(state);
2375
+ mediaEvent(state, "play");
2376
+ }
2377
+
2378
+ function pauseMedia(element) {
2379
+ const state = page.media.get(element);
2380
+ if (!state?.playing) return;
2381
+ state.base = mediaTime(state);
2382
+ stopClock(state);
2383
+ state.needsPosition = true;
2384
+ setMediaPlaying(state, false);
2385
+ mediaEvent(state, "pause");
2386
+ }
2387
+
2388
+ async function seekMedia(element, seconds) {
2389
+ const state = mediaStateFor(element);
2390
+ if (!state) return;
2391
+ const wasPlaying = state.playing;
2392
+ if (wasPlaying) stopClock(state);
2393
+ try {
2394
+ await loadMedia(state);
2395
+ const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
2396
+ state.ended = false;
2397
+ await positionMedia(state, target);
2398
+ state.needsPosition = false;
2399
+ // A paused video shows the frame it landed on.
2400
+ if (state.video && !wasPlaying) drawVideoFrame(state, true);
2401
+ } catch (error) {
2402
+ reportError(error);
1536
2403
  return;
1537
2404
  }
2405
+ if (wasPlaying && state.playing && state.document === page.document) startMedia(state);
2406
+ state.resource.time = mediaTime(state);
2407
+ repaintMedia();
2408
+ mediaEvent(state, "seeked");
2409
+ mediaEvent(state, "timeupdate");
2410
+ }
2411
+
2412
+ /** Moves the decoders (and the clock's base) to `seconds`. */
2413
+ async function positionMedia(state, seconds, { videoToo = true } = {}) {
2414
+ 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
+ }
2421
+ if (state.sound) await positionAudio(state, seconds);
2422
+ }
2423
+
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;
2453
+ return;
2454
+ }
2455
+ current.position += frames;
2456
+ current.pending = null;
2457
+ }
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;
2465
+ return;
2466
+ }
2467
+ current.position += frames;
2468
+ }
2469
+ }
2470
+
2471
+ /** Starts the clock, the audio stream and the frame/time ticks. */
2472
+ function startMedia(state) {
2473
+ state.clockStart = hostPerformance.now();
2474
+ if (state.sound && !state.sound.done) openAudioStream(state);
2475
+ mediaTick(state);
2476
+ }
2477
+
2478
+ function stopClock(state) {
2479
+ hostClearTimeout(state.timer);
2480
+ state.timer = null;
2481
+ 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;
2486
+ }
2487
+ }
2488
+
2489
+ /** Draws the frames the clock has reached, updates time, ends when everything has played. */
2490
+ function mediaTick(state) {
2491
+ if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
2492
+ const now = mediaTime(state);
2493
+ const video = state.video;
2494
+ if (video && !video.done && video.frameTime <= now) {
2495
+ // 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;
2499
+ }
2500
+ }
2501
+ state.resource.time = now;
2502
+ repaintMedia();
2503
+ if (Math.floor(now * 4) !== state.lastTimeUpdate) {
2504
+ state.lastTimeUpdate = Math.floor(now * 4);
2505
+ mediaEvent(state, "timeupdate");
2506
+ }
2507
+ const videoFinished = !video || video.done;
2508
+ const soundFinished = !state.sound || state.sound.done;
2509
+ // The clock at the duration ends it too: after a seek, frames restart at a
2510
+ // 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;
2513
+ if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
2514
+ const delay = video && !video.done ? Math.max(5, (video.frameTime - mediaTime(state)) * 1000) : 250;
2515
+ state.timer = hostSetTimeout(() => mediaTick(state), delay);
2516
+ }
2517
+
2518
+ /** Decodes the next frame; shows it when `show`. False at the end of the stream. */
2519
+ function drawVideoFrame(state, show) {
2520
+ const decoder = state.video.decoder;
2521
+ 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;
2525
+ state.video.frameTime += 1 / (decoder.fps || 30);
2526
+ if (!show) return true;
1538
2527
  const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
1539
2528
  colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
1540
- if (!image) return pauseVideo(state.element);
1541
- const previous = state.image;
1542
- state.image = image;
2529
+ if (!image) return true;
2530
+ state.resource.image?.delete();
1543
2531
  state.resource.image = image;
1544
2532
  state.resource.bitmapWidth = width;
1545
2533
  state.resource.bitmapHeight = height;
1546
- state.time += 1 / (state.decoder.fps || 30);
1547
- rasterized.list = null;
1548
- render();
1549
- previous?.delete();
1550
- if (state.playing) state.timer = hostSetTimeout(() => drawNextVideoFrame(state),
1551
- Math.max(1, 1000 / (state.decoder.fps || 30) - (hostPerformance.now() - started)));
2534
+ return true;
2535
+ }
2536
+
2537
+ function openAudioStream(state) {
2538
+ const sound = state.sound;
2539
+ sound.finishing = false;
2540
+ const name = new URL(state.url).pathname.split("/").pop() || "audio";
2541
+ 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") {
2545
+ sound.id = null;
2546
+ sound.done = true;
2547
+ mediaTick(state);
2548
+ } else if (event.event === "error") {
2549
+ sound.id = null;
2550
+ sound.done = true;
2551
+ reportError(new Error(`audio: ${event.message}`));
2552
+ // Video plays on without sound; audio alone stops.
2553
+ if (!state.video) pauseMedia(state.element);
2554
+ }
2555
+ });
2556
+ sound.id = id;
2557
+ }
2558
+
2559
+ /** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
2560
+ function feedAudio(state, bytes) {
2561
+ const sound = state.sound;
2562
+ if (!state.playing || sound.id === null || sound.finishing) return;
2563
+ const { channels, sampleRate } = sound.decoder;
2564
+ const frameBytes = channels * 2;
2565
+ for (let sent = 0; sent + frameBytes <= bytes;) {
2566
+ let samples = sound.pending;
2567
+ sound.pending = null;
2568
+ samples ??= sound.decoder.nextSamples();
2569
+ if (!samples) {
2570
+ sound.finishing = true;
2571
+ audio.finish(sound.id);
2572
+ return;
2573
+ }
2574
+ const [taken, remaining] = splitSamples(samples, channels, bytes - sent);
2575
+ if (remaining.length) sound.pending = remaining;
2576
+ sound.history.push({ start: sound.position, samples: taken });
2577
+ sound.position += taken.length / channels;
2578
+ while (sound.history.length > 1 && sound.position - sound.history[1].start > AUDIO_HISTORY_SECONDS * sampleRate) {
2579
+ sound.history.shift();
2580
+ }
2581
+ const pcm = floatToS16(taken);
2582
+ audio.write(sound.id, pcm);
2583
+ sent += pcm.length;
2584
+ }
2585
+ }
2586
+
2587
+ function endMedia(state) {
2588
+ stopClock(state);
2589
+ state.ended = true;
2590
+ state.base = Number.isFinite(state.duration) ? state.duration : state.base;
2591
+ setMediaPlaying(state, false);
2592
+ mediaEvent(state, "pause");
2593
+ mediaEvent(state, "ended");
2594
+ }
2595
+
2596
+ function setMediaPlaying(state, playing) {
2597
+ state.playing = playing;
2598
+ state.resource.playing = playing;
2599
+ state.resource.time = mediaTime(state);
2600
+ if (state.document === page.document) repaintMedia();
1552
2601
  }
1553
2602
 
1554
- function closeVideo(state) {
2603
+ function closeMedia(state) {
2604
+ stopClock(state);
1555
2605
  state.playing = false;
1556
- hostClearTimeout(state.timer);
1557
- state.decoder?.close();
1558
- state.image?.delete();
2606
+ state.video?.decoder.close();
2607
+ state.video = null;
2608
+ state.sound?.decoder.close();
2609
+ state.sound = null;
2610
+ state.resource.image?.delete();
1559
2611
  state.resource.image = null;
1560
2612
  state.resource.playing = false;
1561
2613
  }
1562
2614
 
1563
2615
  function disposeVideos() {
1564
- for (const state of page.videos.values()) closeVideo(state);
1565
- page.videos.clear();
2616
+ for (const state of page.media.values()) closeMedia(state);
2617
+ page.media.clear();
1566
2618
  }
1567
2619
 
1568
- async function hydrateGitHubBranchInfo(generation) {
1569
- if (new URL(page.url).hostname !== "github.com") return;
1570
- const document = page.document;
1571
- const bar = document.querySelector('[data-testid="branch-info-bar"]');
1572
- const embedded = document.querySelector('script[data-target="react-app.embeddedData"]');
1573
- if (!bar?.querySelector(".Skeleton") || !embedded) return;
1574
- try {
1575
- const payload = JSON.parse(embedded.textContent ?? "").payload;
1576
- const fork = payload?.codeViewLayoutRoute?.repo;
1577
- const parent = payload?.sidebarAbout?.repo?.parentRepo;
1578
- const branch = payload?.codeViewRepoRoute?.refInfo;
1579
- if (!fork?.isFork || !parent?.ownerLogin || !parent?.name || branch?.refType !== "branch") return;
1580
- const repoUrl = `https://api.github.com/repos/${encodeURIComponent(parent.ownerLogin)}/${encodeURIComponent(parent.name)}`;
1581
- const compareUrl = `${repoUrl}/compare/HEAD...${encodeURIComponent(fork.ownerLogin)}:${encodeURIComponent(branch.name)}`;
1582
- const [repoResponse, compareResponse] = await Promise.all([
1583
- fetchResource(repoUrl, "metadata"), fetchResource(compareUrl, "metadata"),
1584
- ]);
1585
- if (!repoResponse.ok || !compareResponse.ok) return;
1586
- const decoder = new TextDecoder();
1587
- const defaultBranch = JSON.parse(decoder.decode(repoResponse.body)).default_branch;
1588
- const comparison = JSON.parse(decoder.decode(compareResponse.body));
1589
- const ahead = comparison.ahead_by;
1590
- const behind = comparison.behind_by;
1591
- if (!defaultBranch || !Number.isInteger(ahead) || !Number.isInteger(behind)) return;
1592
- if (generation !== loadGeneration || document !== page.document || !bar.querySelector(".Skeleton")) return;
1593
- const baseName = `${parent.ownerLogin}/${parent.name}:${defaultBranch}`;
1594
- const status = behind > 0
1595
- ? `This branch is ${ahead} commits ahead of and ${behind} commits behind ${baseName}.`
1596
- : `This branch is ${ahead} commits ahead of ${baseName}.`;
1597
- bar.textContent = status;
1598
- page.dirty = true;
1599
- refresh();
1600
- } catch {
1601
- // GitHub's own client can still replace the loading placeholder.
2620
+ function concatSamples(parts) {
2621
+ const out = new Float32Array(parts.reduce((sum, part) => sum + part.length, 0));
2622
+ let offset = 0;
2623
+ for (const part of parts) {
2624
+ out.set(part, offset);
2625
+ offset += part.length;
1602
2626
  }
2627
+ return out;
1603
2628
  }
1604
2629
 
1605
2630
  function queueImageResources() {
@@ -1631,7 +2656,25 @@ export async function createPageRenderer({
1631
2656
  }
1632
2657
  page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
1633
2658
  page.decodedImages.push(image);
1634
- page.dirty = true;
2659
+ const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
2660
+ const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
2661
+ .find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
2662
+ const cssWidth = node?.style?.width;
2663
+ const cssHeight = node?.style?.height;
2664
+ const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
2665
+ const specifiedHeight = typeof cssHeight === "number" ? cssHeight : node?.heightAttribute;
2666
+ // When both used dimensions were already fixed, decoded pixels only
2667
+ // change paint. The retained layout remains authoritative and the
2668
+ // display list resolves the current resource by DOM node.
2669
+ const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
2670
+ && Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
2671
+ if (paintOnly) {
2672
+ page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
2673
+ page.needsPaint = true;
2674
+ } else {
2675
+ page.dirty = true;
2676
+ page.resourceLayoutDirty = true;
2677
+ }
1635
2678
  refresh();
1636
2679
  }).catch(() => {
1637
2680
  // A failed image retains its fallback replaced-element dimensions.
@@ -1687,5 +2730,5 @@ function documentBackground(document, styleEngine) {
1687
2730
  }
1688
2731
 
1689
2732
  function emptyImageResources() {
1690
- return { map: new WeakMap(), decoded: [] };
2733
+ return { map: new WeakMap(), decoded: [], entries: [] };
1691
2734
  }