buninu-browser 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -23,19 +23,25 @@
|
|
|
23
23
|
* after the load event.
|
|
24
24
|
*/
|
|
25
25
|
import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
|
|
26
|
+
import { setCanvasKit } from "./canvas-2d.js";
|
|
26
27
|
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
27
28
|
import { parseHTMLDocument } from "../happy-dom/parser.js";
|
|
28
|
-
import {
|
|
29
|
+
import { LayoutEngine } from "../layout/text-layout.js";
|
|
29
30
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
30
|
-
import { elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
31
|
-
import { RenderTreeBuilder, renderTreeText } from "../render-tree/index.js";
|
|
32
|
-
import { StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
31
|
+
import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
32
|
+
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
33
|
+
import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
34
|
+
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
35
|
+
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
33
36
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
34
37
|
import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
|
|
35
|
-
import { createPageRealm } from "./page-realm.js";
|
|
38
|
+
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
39
|
+
import { originOf } from "./messaging.js";
|
|
36
40
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
37
41
|
import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
42
|
+
import { containerFormat } from "./media-source.js";
|
|
38
43
|
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
44
|
+
import { advanceAudio } from "../media/position-audio.js";
|
|
39
45
|
import { pianoNote, PianoMixer } from "../audio/piano.js";
|
|
40
46
|
|
|
41
47
|
// Captured at load: in host mode page scripts share this global object.
|
|
@@ -47,11 +53,114 @@ const hostClearInterval = globalThis.clearInterval;
|
|
|
47
53
|
|
|
48
54
|
/** How long subresources may take before a preview of the unstyled document is shown. */
|
|
49
55
|
export const PREVIEW_DELAY_MS = 300;
|
|
56
|
+
const MAX_COMPILED_COMPOUNDS = 10_000;
|
|
50
57
|
|
|
51
58
|
// Media controls: the bar's height over video, and where its parts sit.
|
|
52
59
|
const MEDIA_BAR_HEIGHT = 24;
|
|
60
|
+
// url(...) in a computed background-image.
|
|
61
|
+
const CSS_URL = /url\(\s*(["']?)(.*?)\1\s*\)/gis;
|
|
53
62
|
// Decoded audio already sent, kept so pause/resume continues exactly without decoding again.
|
|
54
63
|
const AUDIO_HISTORY_SECONDS = 3;
|
|
64
|
+
// After a stall, playing resumes once this much more media has arrived (or
|
|
65
|
+
// the wait gives up): resuming on the first bytes stalls again at once on a
|
|
66
|
+
// slow network.
|
|
67
|
+
const RESUME_BUFFER_SECONDS = 2;
|
|
68
|
+
const RESUME_BUFFER_MAX_WAIT_MS = 6000;
|
|
69
|
+
const RESUME_BUFFER_IDLE_MS = 400;
|
|
70
|
+
// Finding a seek's keyframe (see seekToKeyframe): how far back it may lie,
|
|
71
|
+
// and the size of the pictures compared.
|
|
72
|
+
const KEYFRAME_SEARCH_SECONDS = 10;
|
|
73
|
+
const KEYFRAME_PROBE_WIDTH = 32;
|
|
74
|
+
const KEYFRAME_PROBE_HEIGHT = 18;
|
|
75
|
+
// element → MediaSource, and the reverse. One renderer owns one page pipeline.
|
|
76
|
+
const elementSources = new WeakMap();
|
|
77
|
+
const sourceElements = new WeakMap();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A frame's window as other frames of the page reach it (messaging.js): its
|
|
81
|
+
* place among the frames, its document's origin, and delivery of messages to
|
|
82
|
+
* the realm of whatever document it shows. The page renderer showing the
|
|
83
|
+
* frame attaches itself; until then the frame shows nothing to post to.
|
|
84
|
+
*/
|
|
85
|
+
export function windowHandle(parent = null, name = "") {
|
|
86
|
+
let target = null;
|
|
87
|
+
// The browsing context name (window.name); it outlives navigations.
|
|
88
|
+
let contextName = String(name);
|
|
89
|
+
const handle = {
|
|
90
|
+
name: () => contextName,
|
|
91
|
+
setName(value) {
|
|
92
|
+
contextName = String(value);
|
|
93
|
+
},
|
|
94
|
+
parent: () => parent,
|
|
95
|
+
top: () => parent ? parent.top() : handle,
|
|
96
|
+
origin: () => target?.origin() ?? "null",
|
|
97
|
+
post: (message) => target?.post(message),
|
|
98
|
+
closed: () => target?.closed() ?? false,
|
|
99
|
+
children: () => target?.children() ?? [],
|
|
100
|
+
global: () => target?.global() ?? null,
|
|
101
|
+
document: () => target?.document() ?? null,
|
|
102
|
+
attach(renderer) {
|
|
103
|
+
target = renderer;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
return handle;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* CanvasKit and the bundled fonts, loaded once per process: a page and the
|
|
111
|
+
* documents of its iframes draw with the same CanvasKit instance (images
|
|
112
|
+
* cannot move between instances), and the files are read before the
|
|
113
|
+
* renderer's sandbox closes.
|
|
114
|
+
*/
|
|
115
|
+
let graphics = null;
|
|
116
|
+
// The same, once loaded: an embedding page makes its iframes' renderers with it synchronously.
|
|
117
|
+
let loadedGraphics = null;
|
|
118
|
+
export function loadGraphics() {
|
|
119
|
+
return graphics ??= (async () => {
|
|
120
|
+
const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
|
|
121
|
+
const CanvasKit = await CanvasKitInit({ wasmBinary });
|
|
122
|
+
// Page <canvas> uses this same instance. Two CanvasKit instances cannot share images.
|
|
123
|
+
setCanvasKit(CanvasKit);
|
|
124
|
+
const fontCatalogue = await Bun.file(new URL("fonts.json", fontDirectory)).json();
|
|
125
|
+
const uiFontNames = [fontCatalogue.sets.ui.regular, ...fontCatalogue.sets.ui.fallback];
|
|
126
|
+
const monospaceFontNames = [fontCatalogue.sets.terminal.regular, fontCatalogue.sets.terminal.bold];
|
|
127
|
+
const fontBuffers = await Promise.all([...uiFontNames, ...monospaceFontNames].map((name) =>
|
|
128
|
+
Bun.file(new URL(fontCatalogue.fonts[name].file, fontDirectory)).arrayBuffer()));
|
|
129
|
+
// A TypefaceFontProvider registers each file under an explicit family name.
|
|
130
|
+
// CanvasKit does not pick a static bold face by weight within one family, so
|
|
131
|
+
// the monospace bold file gets its own catalogue name and is chosen by weight.
|
|
132
|
+
const fontProvider = CanvasKit.TypefaceFontProvider.Make();
|
|
133
|
+
const loadedFamilies = new Map();
|
|
134
|
+
[...uiFontNames, ...monospaceFontNames].forEach((name, index) => {
|
|
135
|
+
const family = (fontCatalogue.fonts[name].families ?? [name])[0];
|
|
136
|
+
fontProvider.registerFont(fontBuffers[index], family);
|
|
137
|
+
loadedFamilies.set(family.toLowerCase(), family);
|
|
138
|
+
});
|
|
139
|
+
// One font collection for every paragraph: building each paragraph from
|
|
140
|
+
// the provider made a new collection, so Skia's font matching and fallback
|
|
141
|
+
// caches started cold every time (about 3x slower per paragraph).
|
|
142
|
+
const fontCollection = CanvasKit.FontCollection.Make();
|
|
143
|
+
fontCollection.setDefaultFontManager(fontProvider);
|
|
144
|
+
fontCollection.enableFontFallback();
|
|
145
|
+
|
|
146
|
+
const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
|
|
147
|
+
const sansSerifFamily = fontFamilies[0];
|
|
148
|
+
const monospaceFamily = fontCatalogue.sets.terminal.regular;
|
|
149
|
+
const monospaceBoldFamily = fontCatalogue.sets.terminal.bold;
|
|
150
|
+
// Maps CSS generic and common platform family names onto the bundled fonts.
|
|
151
|
+
const genericFamilies = new Map([
|
|
152
|
+
["monospace", monospaceFamily], ["ui-monospace", monospaceFamily], ["menlo", monospaceFamily],
|
|
153
|
+
["consolas", monospaceFamily], ["monaco", monospaceFamily], ["courier", monospaceFamily],
|
|
154
|
+
["courier new", monospaceFamily], ["sans-serif", sansSerifFamily], ["serif", sansSerifFamily],
|
|
155
|
+
["system-ui", sansSerifFamily], ["-apple-system", sansSerifFamily], ["blinkmacsystemfont", sansSerifFamily],
|
|
156
|
+
["ui-sans-serif", sansSerifFamily], ["arial", sansSerifFamily], ["helvetica", sansSerifFamily],
|
|
157
|
+
]);
|
|
158
|
+
return loadedGraphics = {
|
|
159
|
+
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
160
|
+
genericFamilies,
|
|
161
|
+
};
|
|
162
|
+
})();
|
|
163
|
+
}
|
|
55
164
|
|
|
56
165
|
/**
|
|
57
166
|
* Horizontal layout of a media control bar `width` CSS pixels wide: the
|
|
@@ -93,7 +202,12 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
|
93
202
|
* videoDecoder?: object | null,
|
|
94
203
|
* }} options
|
|
95
204
|
*/
|
|
96
|
-
export async function createPageRenderer({
|
|
205
|
+
export async function createPageRenderer(options) {
|
|
206
|
+
return buildPageRenderer(options, await loadGraphics());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** createPageRenderer with the graphics already loaded. */
|
|
210
|
+
function buildPageRenderer({
|
|
97
211
|
fetchResource: rawFetchResource,
|
|
98
212
|
requestNavigation = () => {},
|
|
99
213
|
requestDownload = () => {},
|
|
@@ -107,46 +221,20 @@ export async function createPageRenderer({
|
|
|
107
221
|
onFrame = null,
|
|
108
222
|
onLifecycle = null,
|
|
109
223
|
videoDecoder = null,
|
|
224
|
+
// Prepares a classic script on another thread: (source) => Promise<{ code, parsed, strict }>.
|
|
225
|
+
rewriteScript = null,
|
|
110
226
|
mobile = false,
|
|
111
227
|
verifyStyle = false,
|
|
112
228
|
fullStyle = false,
|
|
229
|
+
// An iframe's document (see syncFrames): frames go to the embedding page,
|
|
230
|
+
// which needs only the viewport, so nothing is painted ahead.
|
|
231
|
+
embedded = false,
|
|
232
|
+
// This document's window handle, made by the embedding page (see windowHandle).
|
|
233
|
+
frameWindow = null,
|
|
234
|
+
}, {
|
|
235
|
+
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
236
|
+
genericFamilies,
|
|
113
237
|
}) {
|
|
114
|
-
const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
|
|
115
|
-
const CanvasKit = await CanvasKitInit({ wasmBinary });
|
|
116
|
-
const fontCatalogue = await Bun.file(new URL("fonts.json", fontDirectory)).json();
|
|
117
|
-
const uiFontNames = [fontCatalogue.sets.ui.regular, ...fontCatalogue.sets.ui.fallback];
|
|
118
|
-
const monospaceFontNames = [fontCatalogue.sets.terminal.regular, fontCatalogue.sets.terminal.bold];
|
|
119
|
-
const fontBuffers = await Promise.all([...uiFontNames, ...monospaceFontNames].map((name) =>
|
|
120
|
-
Bun.file(new URL(fontCatalogue.fonts[name].file, fontDirectory)).arrayBuffer()));
|
|
121
|
-
// A TypefaceFontProvider registers each file under an explicit family name.
|
|
122
|
-
// CanvasKit does not pick a static bold face by weight within one family, so
|
|
123
|
-
// the monospace bold file gets its own catalogue name and is chosen by weight.
|
|
124
|
-
const fontProvider = CanvasKit.TypefaceFontProvider.Make();
|
|
125
|
-
const loadedFamilies = new Map();
|
|
126
|
-
[...uiFontNames, ...monospaceFontNames].forEach((name, index) => {
|
|
127
|
-
const family = (fontCatalogue.fonts[name].families ?? [name])[0];
|
|
128
|
-
fontProvider.registerFont(fontBuffers[index], family);
|
|
129
|
-
loadedFamilies.set(family.toLowerCase(), family);
|
|
130
|
-
});
|
|
131
|
-
// One font collection for every paragraph: building each paragraph from
|
|
132
|
-
// the provider made a new collection, so Skia's font matching and fallback
|
|
133
|
-
// caches started cold every time (about 3x slower per paragraph).
|
|
134
|
-
const fontCollection = CanvasKit.FontCollection.Make();
|
|
135
|
-
fontCollection.setDefaultFontManager(fontProvider);
|
|
136
|
-
fontCollection.enableFontFallback();
|
|
137
|
-
|
|
138
|
-
const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
|
|
139
|
-
const sansSerifFamily = fontFamilies[0];
|
|
140
|
-
const monospaceFamily = fontCatalogue.sets.terminal.regular;
|
|
141
|
-
const monospaceBoldFamily = fontCatalogue.sets.terminal.bold;
|
|
142
|
-
// Maps CSS generic and common platform family names onto the bundled fonts.
|
|
143
|
-
const genericFamilies = new Map([
|
|
144
|
-
["monospace", monospaceFamily], ["ui-monospace", monospaceFamily], ["menlo", monospaceFamily],
|
|
145
|
-
["consolas", monospaceFamily], ["monaco", monospaceFamily], ["courier", monospaceFamily],
|
|
146
|
-
["courier new", monospaceFamily], ["sans-serif", sansSerifFamily], ["serif", sansSerifFamily],
|
|
147
|
-
["system-ui", sansSerifFamily], ["-apple-system", sansSerifFamily], ["blinkmacsystemfont", sansSerifFamily],
|
|
148
|
-
["ui-sans-serif", sansSerifFamily], ["arial", sansSerifFamily], ["helvetica", sansSerifFamily],
|
|
149
|
-
]);
|
|
150
238
|
const resolvedFamilies = new Map();
|
|
151
239
|
/** First available family from a computed font-family list, then the UI fallbacks. */
|
|
152
240
|
function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
|
|
@@ -166,20 +254,42 @@ export async function createPageRenderer({
|
|
|
166
254
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
167
255
|
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
168
256
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
257
|
+
const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
169
258
|
const styleEngine = new StyleEngine();
|
|
170
259
|
// Incremental restyle: what may have changed style since the last
|
|
171
260
|
// computation. DOM mutations come from a MutationObserver; state that is
|
|
172
261
|
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
173
262
|
// changes it. `full` asks for a complete restyle.
|
|
174
|
-
const styleChanges = { full: true, roots: new Set(), records: [], observer: null, activeElement: null };
|
|
263
|
+
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
|
|
175
264
|
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
176
265
|
let lastStyleParts = "";
|
|
266
|
+
let laidOutSheets = null;
|
|
177
267
|
const sheetTraitsCache = new Map();
|
|
268
|
+
// Invalidation compounds compiled for matchesCompound() and matchSelectors().
|
|
269
|
+
const compiledCompounds = new Map();
|
|
178
270
|
let lastStyleReason = "";
|
|
179
271
|
let hasState = null;
|
|
180
272
|
let hasStateStale = false;
|
|
181
273
|
let structuralState = null;
|
|
182
274
|
let siblingState = null;
|
|
275
|
+
// iframes (see syncFrames), by element.
|
|
276
|
+
const frames = new Map();
|
|
277
|
+
let disposedRenderer = false;
|
|
278
|
+
const ownWindow = frameWindow ?? windowHandle(null);
|
|
279
|
+
ownWindow.attach({
|
|
280
|
+
// An iframe's about:blank document has the origin of the page that made it.
|
|
281
|
+
origin: () => page.url === "about:blank" && ownWindow.parent() ? ownWindow.parent().origin() : originOf(page.url),
|
|
282
|
+
post: (message) => page.realm?.receiveMessage(message),
|
|
283
|
+
closed: () => disposedRenderer,
|
|
284
|
+
children: () => [...frames.values()].map((frame) => frame.window),
|
|
285
|
+
global: () => page.realm?.pageGlobal ?? null,
|
|
286
|
+
document: () => page.realm?.documentBinding ?? null,
|
|
287
|
+
});
|
|
288
|
+
// Changes whenever a frame's image does, so the page is rasterized again.
|
|
289
|
+
let framesVersion = 0;
|
|
290
|
+
let framePaintTimer = null;
|
|
291
|
+
// The frame keyboard input goes to, after a click into it.
|
|
292
|
+
let focusedFrame = null;
|
|
183
293
|
const viewport = new ScrollViewport(800, 600);
|
|
184
294
|
const page = {
|
|
185
295
|
url: "about:blank",
|
|
@@ -199,16 +309,26 @@ export async function createPageRenderer({
|
|
|
199
309
|
hintsVisible: false,
|
|
200
310
|
styleSheets: [],
|
|
201
311
|
linkSheets: null,
|
|
312
|
+
// The document's scripts fetched ahead of their turn (see preloadScripts), by URL.
|
|
313
|
+
preloadedScripts: new Map(),
|
|
202
314
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
203
315
|
resources: new WeakMap(),
|
|
204
316
|
decodedImages: [],
|
|
205
317
|
imagesLoaded: null,
|
|
318
|
+
// CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
|
|
319
|
+
backgrounds: new Map(),
|
|
320
|
+
// Scroll offsets of scroll containers, by element (see setElementScroll).
|
|
321
|
+
elementScroll: new Map(),
|
|
322
|
+
// Bumped whenever boxes may have moved (layout, element scrolling), so
|
|
323
|
+
// IntersectionObserver checks can skip unchanged frames.
|
|
324
|
+
layoutVersion: 0,
|
|
206
325
|
// <video> and <audio> playback states, by element (see playMedia).
|
|
207
326
|
media: new Map(),
|
|
208
327
|
realm: null,
|
|
209
328
|
// "off" until the renderer process has set up its sandbox.
|
|
210
329
|
scripting: "off",
|
|
211
330
|
dirty: false,
|
|
331
|
+
resourceLayoutDirty: false,
|
|
212
332
|
userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
|
|
213
333
|
// document.cookie as last reported by the controller (non-HttpOnly only).
|
|
214
334
|
cookie: "",
|
|
@@ -343,6 +463,9 @@ export async function createPageRenderer({
|
|
|
343
463
|
// PaintController reusing cached display items
|
|
344
464
|
// (platform/graphics/paint/paint_controller.cc, BSD).
|
|
345
465
|
let rasterized = { list: null, overlay: "", background: "", scale: 0 };
|
|
466
|
+
let rasterSurface = null;
|
|
467
|
+
let rasterSurfaceWidth = 0;
|
|
468
|
+
let rasterSurfaceHeight = 0;
|
|
346
469
|
|
|
347
470
|
/** Hint overlay contents, as a comparable key. */
|
|
348
471
|
function overlayKey() {
|
|
@@ -355,6 +478,7 @@ export async function createPageRenderer({
|
|
|
355
478
|
function sameRasterInput() {
|
|
356
479
|
if (rasterized.scale !== page.deviceScaleFactor || rasterized.background !== page.backgroundColor) return false;
|
|
357
480
|
if (rasterized.overlay !== overlayKey()) return false;
|
|
481
|
+
if (rasterized.frames !== framesVersion) return false;
|
|
358
482
|
if (page.displayList?.items.some((item) => item.fixed)
|
|
359
483
|
&& (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
|
|
360
484
|
if (rasterized.list === page.displayList) return true;
|
|
@@ -383,7 +507,13 @@ export async function createPageRenderer({
|
|
|
383
507
|
const scale = page.deviceScaleFactor;
|
|
384
508
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
385
509
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
386
|
-
|
|
510
|
+
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
511
|
+
rasterSurface?.delete();
|
|
512
|
+
rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
|
|
513
|
+
rasterSurfaceWidth = pixelWidth;
|
|
514
|
+
rasterSurfaceHeight = pixelHeight;
|
|
515
|
+
}
|
|
516
|
+
const surface = rasterSurface;
|
|
387
517
|
if (!surface) throw new Error(`Unable to create a ${pixelWidth}x${pixelHeight} CanvasKit surface`);
|
|
388
518
|
const canvas = surface.getCanvas();
|
|
389
519
|
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
@@ -399,11 +529,10 @@ export async function createPageRenderer({
|
|
|
399
529
|
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
400
530
|
});
|
|
401
531
|
image.delete();
|
|
402
|
-
surface.delete();
|
|
403
532
|
page.needsPaint = false;
|
|
404
533
|
rasterized = {
|
|
405
534
|
list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
|
|
406
|
-
scrollX: viewport.x, scrollY: viewport.y,
|
|
535
|
+
scrollX: viewport.x, scrollY: viewport.y, frames: framesVersion,
|
|
407
536
|
};
|
|
408
537
|
return {
|
|
409
538
|
pixels,
|
|
@@ -451,7 +580,7 @@ export async function createPageRenderer({
|
|
|
451
580
|
|
|
452
581
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
453
582
|
function schedulePrepaint() {
|
|
454
|
-
if (!onFrame) return;
|
|
583
|
+
if (!onFrame || embedded) return;
|
|
455
584
|
hostClearTimeout(prepaintTimer);
|
|
456
585
|
prepaintTimer = hostSetTimeout(() => {
|
|
457
586
|
prepaintTimer = null;
|
|
@@ -521,10 +650,15 @@ export async function createPageRenderer({
|
|
|
521
650
|
else canvas.drawRect(deviceRect(rect), paint);
|
|
522
651
|
};
|
|
523
652
|
for (const item of visibleItems(displayList, view, viewport)) {
|
|
524
|
-
const saved = item.fixed || item.clipRect || item.opacity < 1 ? canvas.save() : null;
|
|
653
|
+
const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
|
|
525
654
|
try {
|
|
526
655
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
527
656
|
if (item.clipRect) canvas.clipRect(deviceRect(item.clipRect), CanvasKit.ClipOp.Intersect, true);
|
|
657
|
+
// A scale in document space, as a device-space matrix around the item's device coordinates.
|
|
658
|
+
if (item.transform) {
|
|
659
|
+
const { a, d, e, f } = item.transform;
|
|
660
|
+
canvas.concat([a, 0, ((a - 1) * view.x + e) * scale, 0, d, ((d - 1) * view.y + f) * scale, 0, 0, 1]);
|
|
661
|
+
}
|
|
528
662
|
if (item.opacity < 1) {
|
|
529
663
|
const layer = new CanvasKit.Paint();
|
|
530
664
|
layer.setAlphaf(item.opacity);
|
|
@@ -624,6 +758,30 @@ export async function createPageRenderer({
|
|
|
624
758
|
false,
|
|
625
759
|
);
|
|
626
760
|
canvas.restore();
|
|
761
|
+
} else if (item.op === "drawBackgroundImage") {
|
|
762
|
+
// Tiles from an image shader, repeating (or not) per axis.
|
|
763
|
+
const resource = displayList.images[item.image];
|
|
764
|
+
const { tile } = item;
|
|
765
|
+
canvas.save();
|
|
766
|
+
const clip = deviceRRect(item.rect, item.radii);
|
|
767
|
+
if (clip) canvas.clipRRect(clip, CanvasKit.ClipOp.Intersect, true);
|
|
768
|
+
const matrix = CanvasKit.Matrix.multiply(
|
|
769
|
+
CanvasKit.Matrix.translated((tile.x - view.x) * scale, (tile.y - view.y) * scale),
|
|
770
|
+
CanvasKit.Matrix.scaled(tile.width * scale / resource.width, tile.height * scale / resource.height),
|
|
771
|
+
);
|
|
772
|
+
const shader = resource.image.makeShaderOptions(
|
|
773
|
+
item.repeatX ? CanvasKit.TileMode.Repeat : CanvasKit.TileMode.Decal,
|
|
774
|
+
item.repeatY ? CanvasKit.TileMode.Repeat : CanvasKit.TileMode.Decal,
|
|
775
|
+
CanvasKit.FilterMode.Linear,
|
|
776
|
+
CanvasKit.MipmapMode.None,
|
|
777
|
+
matrix,
|
|
778
|
+
);
|
|
779
|
+
const paint = new CanvasKit.Paint();
|
|
780
|
+
paint.setShader(shader);
|
|
781
|
+
canvas.drawRect(deviceRect(item.bounds), paint);
|
|
782
|
+
paint.delete();
|
|
783
|
+
shader.delete();
|
|
784
|
+
canvas.restore();
|
|
627
785
|
} else if (item.op === "drawVideo") {
|
|
628
786
|
const resource = displayList.images[item.image];
|
|
629
787
|
const rect = deviceRect(item.rect);
|
|
@@ -845,19 +1003,24 @@ export async function createPageRenderer({
|
|
|
845
1003
|
|
|
846
1004
|
/** `linkSheets` (link element -> { url, text }) makes the sheet list follow the DOM; without it the given sheets are fixed. */
|
|
847
1005
|
/** Marks a node whose own state changed (its subtree may restyle); null asks for a full restyle. */
|
|
848
|
-
function markStyleDirty(node) {
|
|
1006
|
+
function markStyleDirty(node, features = [":state"]) {
|
|
849
1007
|
// Documents and fragments carry no state selectors read; their children's
|
|
850
1008
|
// changes arrive as mutation records.
|
|
851
1009
|
if (node?.nodeType === 9 || node?.nodeType === 11) return;
|
|
852
1010
|
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
853
1011
|
if (!element) styleChanges.full ||= node ? `touched ${node.nodeName}` : "unknown change";
|
|
854
|
-
else
|
|
1012
|
+
else {
|
|
1013
|
+
styleChanges.roots.add(element);
|
|
1014
|
+
const previous = styleChanges.rootFeatures.get(element);
|
|
1015
|
+
styleChanges.rootFeatures.set(element, previous ? [...new Set([...previous, ...features])] : features);
|
|
1016
|
+
}
|
|
855
1017
|
}
|
|
856
1018
|
|
|
857
1019
|
function observeStyleChanges(document) {
|
|
858
1020
|
styleChanges.observer?.disconnect();
|
|
859
1021
|
styleChanges.records = [];
|
|
860
1022
|
styleChanges.roots = new Set();
|
|
1023
|
+
styleChanges.rootFeatures = new Map();
|
|
861
1024
|
styleChanges.full = "new document";
|
|
862
1025
|
styleChanges.activeElement = null;
|
|
863
1026
|
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
@@ -882,6 +1045,7 @@ export async function createPageRenderer({
|
|
|
882
1045
|
sheets, sibling: any("sibling"), hasSiblings: any("hasSiblings"),
|
|
883
1046
|
hasCompounds: [...union("hasCompounds")], structuralCompounds: [...union("structuralCompounds")],
|
|
884
1047
|
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
1048
|
+
descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
|
|
885
1049
|
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
886
1050
|
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
887
1051
|
defined: any("defined"), target: any("target"),
|
|
@@ -898,70 +1062,7 @@ export async function createPageRenderer({
|
|
|
898
1062
|
function sheetTraitsFor(sheet) {
|
|
899
1063
|
let traits = sheetTraitsCache.get(sheet);
|
|
900
1064
|
if (traits) return traits;
|
|
901
|
-
|
|
902
|
-
const plain = text.replace(/\[[^\]]*\]/g, "[]").replace(/\{[^}]*\}/g, "{}");
|
|
903
|
-
const sibling = /[^\s~|^$*=\[]\s*[+~]\s*[^\s=]/.test(plain);
|
|
904
|
-
const hasCompounds = new Set();
|
|
905
|
-
let hasSiblings = false;
|
|
906
|
-
const selectorsOnly = text.replace(/\{[^}]*\}/g, "{}");
|
|
907
|
-
for (const block of selectorsOnly.matchAll(/[^{}]*:has\([^{]*\{/g)) {
|
|
908
|
-
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
909
|
-
for (const compound of compoundsOf(selector)) {
|
|
910
|
-
if (!compound.includes(":has(")) continue;
|
|
911
|
-
hasCompounds.add(compound.replace(/::[\w-]+(\([^)]*\))?$/, ""));
|
|
912
|
-
if (/:has\(\s*[+~]/.test(compound)) hasSiblings = true;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
// Structural pseudo-classes: which siblings a child list change can
|
|
917
|
-
// affect, and the compounds where they are not the subject (".a:last-child .b"),
|
|
918
|
-
// whose matches decide whether a sibling's subtree must restyle too.
|
|
919
|
-
const structural = /:(?:nth-child|nth-last-child|first-child|last-child|only-child|nth-of-type|nth-last-of-type|first-of-type|last-of-type|only-of-type)\b/;
|
|
920
|
-
const structuralCompounds = new Set();
|
|
921
|
-
for (const block of selectorsOnly.matchAll(/[^{}]*\{/g)) {
|
|
922
|
-
if (!structural.test(block[0])) continue;
|
|
923
|
-
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
924
|
-
const compounds = compoundsOf(selector);
|
|
925
|
-
for (const compound of compounds.slice(0, -1)) if (structural.test(compound)) structuralCompounds.add(compound);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
// Invalidation features (class ".x", id "#x", attribute "[x", tag "t:x",
|
|
929
|
-
// state ":state", anything "*") of compounds that are not a selector's
|
|
930
|
-
// subject: left of a descendant/child combinator, a change to them can
|
|
931
|
-
// restyle the element's subtree; left of + / ~, its later siblings.
|
|
932
|
-
const descendantFeatures = new Set();
|
|
933
|
-
const siblingFeatures = new Set();
|
|
934
|
-
// Selectors ending in a compound right of + / ~ that is not the subject
|
|
935
|
-
// (".a + .b" of ".a + .b .c"): a later sibling's subtree restyles only
|
|
936
|
-
// when its match of one of them changes.
|
|
937
|
-
const siblingTargetCompounds = new Set();
|
|
938
|
-
for (const block of selectorsOnly.matchAll(/[^{}@]*\{/g)) {
|
|
939
|
-
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
940
|
-
const parts = complexParts(selector);
|
|
941
|
-
parts.forEach(({ compound, combinator }, index) => {
|
|
942
|
-
const nested = /\([^)]*[\s>+~][^)]*\)/.test(compound); // :is(.a .b) hides combinators
|
|
943
|
-
const previous = parts[index - 1]?.combinator;
|
|
944
|
-
if ((previous === "+" || previous === "~") && index < parts.length - 1) {
|
|
945
|
-
// With what is left of it ("li:first-child + li"): the relation to
|
|
946
|
-
// the earlier sibling can end while the compound still matches.
|
|
947
|
-
const left = parts.slice(0, index).map((part) => `${part.compound} ${part.combinator === " " ? "" : part.combinator + " "}`).join("");
|
|
948
|
-
siblingTargetCompounds.add(left + compound);
|
|
949
|
-
}
|
|
950
|
-
if (index === parts.length - 1 && !nested) return;
|
|
951
|
-
const features = compoundFeatures(compound);
|
|
952
|
-
if (nested || combinator === " " || combinator === ">") for (const feature of features) descendantFeatures.add(feature);
|
|
953
|
-
if (nested || combinator === "+" || combinator === "~") for (const feature of features) siblingFeatures.add(feature);
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
traits = {
|
|
958
|
-
sibling, hasCompounds, hasSiblings, structuralCompounds, descendantFeatures, siblingFeatures, siblingTargetCompounds,
|
|
959
|
-
forward: /:(?:nth-child|nth-of-type|first-of-type|only-of-type)\b/.test(selectorsOnly),
|
|
960
|
-
firstChild: /:(?:first-child|only-child)\b/.test(selectorsOnly),
|
|
961
|
-
backward: /:(?:nth-last-child|nth-last-of-type|last-of-type|only-of-type)\b/.test(selectorsOnly),
|
|
962
|
-
lastChild: /:(?:last-child|only-child)\b/.test(selectorsOnly),
|
|
963
|
-
defined: text.includes(":defined"), target: /:target\b/.test(text),
|
|
964
|
-
};
|
|
1065
|
+
traits = sheetTraits(sheet);
|
|
965
1066
|
sheetTraitsCache.set(sheet, traits);
|
|
966
1067
|
return traits;
|
|
967
1068
|
}
|
|
@@ -973,11 +1074,14 @@ export async function createPageRenderer({
|
|
|
973
1074
|
*/
|
|
974
1075
|
function recordHasState(traits) {
|
|
975
1076
|
const reuse = hasState !== null;
|
|
1077
|
+
if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
|
|
976
1078
|
const record = (compounds, previous) => {
|
|
1079
|
+
const missing = compounds.filter((compound) => !(reuse && previous?.has(compound)));
|
|
1080
|
+
const matched = missing.length ? matchSelectors(page.document, missing, compiledCompounds) : null;
|
|
977
1081
|
const state = new Map();
|
|
978
1082
|
for (const compound of compounds) {
|
|
979
|
-
if (reuse && previous?.has(compound))
|
|
980
|
-
|
|
1083
|
+
if (reuse && previous?.has(compound)) state.set(compound, previous.get(compound));
|
|
1084
|
+
else if (matched.has(compound)) state.set(compound, matched.get(compound)); // invalid ones never match
|
|
981
1085
|
}
|
|
982
1086
|
return state;
|
|
983
1087
|
};
|
|
@@ -988,13 +1092,12 @@ export async function createPageRenderer({
|
|
|
988
1092
|
|
|
989
1093
|
/** Brings the recorded matches of an inserted subtree up to date (it may have been in the document before). */
|
|
990
1094
|
function recordInserted(root) {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
} catch { continue; }
|
|
1095
|
+
const states = [hasState, structuralState, siblingState].filter(Boolean);
|
|
1096
|
+
const now = matchSelectors(root, states.flatMap((state) => [...state.keys()]), compiledCompounds);
|
|
1097
|
+
for (const state of states) {
|
|
1098
|
+
for (const [compound, matched] of state) {
|
|
1099
|
+
const inside = now.get(compound);
|
|
1100
|
+
if (!inside) continue;
|
|
998
1101
|
for (const element of [root, ...root.getElementsByTagName("*")]) {
|
|
999
1102
|
if (inside.has(element)) matched.add(element); else matched.delete(element);
|
|
1000
1103
|
}
|
|
@@ -1003,12 +1106,24 @@ export async function createPageRenderer({
|
|
|
1003
1106
|
}
|
|
1004
1107
|
|
|
1005
1108
|
|
|
1109
|
+
/** element.matches(compound), with the compound compiled once (see matchSelectors). */
|
|
1110
|
+
function matchesCompound(element, compound) {
|
|
1111
|
+
let compiled = compiledCompounds.get(compound);
|
|
1112
|
+
if (compiled === undefined) {
|
|
1113
|
+
compiled = compileSelector(element, compound);
|
|
1114
|
+
if (compiled === undefined) return element.matches(compound);
|
|
1115
|
+
if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
|
|
1116
|
+
compiledCompounds.set(compound, compiled);
|
|
1117
|
+
}
|
|
1118
|
+
return compiled !== null && matchesCompiled(element, compiled);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1006
1121
|
/** Re-checks `element` against recorded compounds; whether any match changed (the state is updated). */
|
|
1007
1122
|
function matchesChanged(state, element) {
|
|
1008
1123
|
let changed = false;
|
|
1009
1124
|
for (const [compound, matched] of state ?? []) {
|
|
1010
1125
|
let now = false;
|
|
1011
|
-
try { now = element
|
|
1126
|
+
try { now = matchesCompound(element, compound); } catch {}
|
|
1012
1127
|
if (now === matched.has(element)) continue;
|
|
1013
1128
|
if (now) matched.add(element); else matched.delete(element);
|
|
1014
1129
|
changed = true;
|
|
@@ -1034,7 +1149,7 @@ export async function createPageRenderer({
|
|
|
1034
1149
|
checked.add(candidate);
|
|
1035
1150
|
for (const [compound, matched] of hasState) {
|
|
1036
1151
|
let now = false;
|
|
1037
|
-
try { now = candidate
|
|
1152
|
+
try { now = matchesCompound(candidate, compound); } catch {}
|
|
1038
1153
|
if (now === matched.has(candidate)) continue;
|
|
1039
1154
|
if (now) matched.add(candidate); else matched.delete(candidate);
|
|
1040
1155
|
add(candidate);
|
|
@@ -1055,6 +1170,10 @@ export async function createPageRenderer({
|
|
|
1055
1170
|
const records = styleChanges.records;
|
|
1056
1171
|
if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
|
|
1057
1172
|
const roots = styleChanges.roots;
|
|
1173
|
+
// Explicit roots mean that state-dependent selectors may need restyling;
|
|
1174
|
+
// they do not by themselves change render-tree content. DOM mutation
|
|
1175
|
+
// records below identify structural/content changes separately.
|
|
1176
|
+
let treeDirty = false;
|
|
1058
1177
|
let full = styleChanges.full;
|
|
1059
1178
|
const active = page.document.activeElement;
|
|
1060
1179
|
if (active !== styleChanges.activeElement) {
|
|
@@ -1081,11 +1200,22 @@ export async function createPageRenderer({
|
|
|
1081
1200
|
if (matchesChanged(siblingState, element)) roots.add(element);
|
|
1082
1201
|
};
|
|
1083
1202
|
const later = (element) => { for (let next = element?.nextElementSibling; next; next = next.nextElementSibling) laterSibling(next); };
|
|
1203
|
+
const featureMatches = (used, changedFeatures) => changedFeatures.some((feature) => feature === ":state"
|
|
1204
|
+
? [...used].some((candidate) => candidate.startsWith(":"))
|
|
1205
|
+
: used.has(feature));
|
|
1206
|
+
const stateAffectsDescendants = (element, changedFeatures) => {
|
|
1207
|
+
const states = changedFeatures.filter((feature) => feature.startsWith(":"));
|
|
1208
|
+
if (!states.length) return false;
|
|
1209
|
+
const available = new Set(elementFeatures(element));
|
|
1210
|
+
return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
|
|
1211
|
+
&& entry.required.every((feature) => available.has(feature)));
|
|
1212
|
+
};
|
|
1084
1213
|
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1085
1214
|
const changed = (element, features) => {
|
|
1086
1215
|
self.add(element);
|
|
1087
|
-
|
|
1088
|
-
if (traits.
|
|
1216
|
+
const ordinary = features.filter((feature) => !feature.startsWith(":"));
|
|
1217
|
+
if (featureMatches(traits.descendantFeatures, ordinary) || stateAffectsDescendants(element, features)) roots.add(element);
|
|
1218
|
+
if (traits.siblingFeatures.has("*") || featureMatches(traits.siblingFeatures, features)) later(element);
|
|
1089
1219
|
hasChanges(traits, element, add, hasChecked);
|
|
1090
1220
|
};
|
|
1091
1221
|
const touchSibling = (element) => {
|
|
@@ -1099,7 +1229,7 @@ export async function createPageRenderer({
|
|
|
1099
1229
|
// the records also describe; both start as a change of the element's state.
|
|
1100
1230
|
for (const element of [...roots]) {
|
|
1101
1231
|
roots.delete(element);
|
|
1102
|
-
if (!full) changed(element, [":state"]);
|
|
1232
|
+
if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
|
|
1103
1233
|
}
|
|
1104
1234
|
for (const record of records) {
|
|
1105
1235
|
if (full) break;
|
|
@@ -1107,6 +1237,10 @@ export async function createPageRenderer({
|
|
|
1107
1237
|
if (record.type === "attributes") {
|
|
1108
1238
|
if (target.nodeType !== 1) continue;
|
|
1109
1239
|
const name = record.attributeName;
|
|
1240
|
+
// data/ARIA/title changes have no direct render-tree representation;
|
|
1241
|
+
// selectors that observe them are handled by the style diff below.
|
|
1242
|
+
// Other attributes may alter controls, replaced elements or visibility.
|
|
1243
|
+
if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
|
|
1110
1244
|
const features = ["[" + name];
|
|
1111
1245
|
if (name === "class") {
|
|
1112
1246
|
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
@@ -1120,6 +1254,7 @@ export async function createPageRenderer({
|
|
|
1120
1254
|
}
|
|
1121
1255
|
changed(target, features);
|
|
1122
1256
|
} else if (record.type === "childList") {
|
|
1257
|
+
treeDirty = true;
|
|
1123
1258
|
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
1124
1259
|
// Inserted elements restyle; the parent itself may change (:empty);
|
|
1125
1260
|
// siblings around the change point may match structural
|
|
@@ -1150,6 +1285,7 @@ export async function createPageRenderer({
|
|
|
1150
1285
|
}
|
|
1151
1286
|
}
|
|
1152
1287
|
} else if (record.type === "characterData") {
|
|
1288
|
+
treeDirty = true;
|
|
1153
1289
|
// Only :empty and :has() can see text: the parent itself.
|
|
1154
1290
|
const parent = target.parentElement;
|
|
1155
1291
|
if (parent) {
|
|
@@ -1161,19 +1297,22 @@ export async function createPageRenderer({
|
|
|
1161
1297
|
if (!full) for (const node of topmostConnected(inserted, page.document)) recordInserted(node);
|
|
1162
1298
|
styleChanges.records = [];
|
|
1163
1299
|
styleChanges.roots = new Set();
|
|
1300
|
+
styleChanges.rootFeatures = new Map();
|
|
1164
1301
|
styleChanges.full = false;
|
|
1165
1302
|
lastStyleReason = full || `${roots.size} root(s), ${self.size} self`;
|
|
1166
1303
|
if (full) hasState = null; // recorded again after the full restyle
|
|
1167
|
-
return full ? null : { roots, self };
|
|
1304
|
+
return full ? null : { roots, self, treeDirty };
|
|
1168
1305
|
}
|
|
1169
1306
|
|
|
1170
1307
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1308
|
+
disposeFrames();
|
|
1171
1309
|
closePiano();
|
|
1172
1310
|
disposeVideos();
|
|
1173
1311
|
page.realm?.dispose();
|
|
1174
1312
|
page.realm = null;
|
|
1175
1313
|
page.window?.happyDOM?.abort?.();
|
|
1176
1314
|
for (const image of page.decodedImages) image.delete();
|
|
1315
|
+
disposeBackgrounds();
|
|
1177
1316
|
page.window = parsed.window;
|
|
1178
1317
|
page.document = parsed.document;
|
|
1179
1318
|
observeStyleChanges(parsed.document);
|
|
@@ -1185,10 +1324,13 @@ export async function createPageRenderer({
|
|
|
1185
1324
|
page.decodedImages = imageResources.decoded;
|
|
1186
1325
|
page.imagesLoaded = null;
|
|
1187
1326
|
imageRequests = new WeakMap();
|
|
1327
|
+
page.elementScroll = new Map();
|
|
1188
1328
|
const timing = styleAndLayout();
|
|
1329
|
+
syncFrames();
|
|
1330
|
+
queueBackgroundImages();
|
|
1189
1331
|
trace?.(`${page.url}: first style ${timing.style}ms, layout ${timing.layout}ms`);
|
|
1190
1332
|
viewport.scrollTo(0, 0);
|
|
1191
|
-
|
|
1333
|
+
setContentSize();
|
|
1192
1334
|
page.hints = [];
|
|
1193
1335
|
page.hintsVisible = false;
|
|
1194
1336
|
page.needsPaint = true;
|
|
@@ -1197,10 +1339,19 @@ export async function createPageRenderer({
|
|
|
1197
1339
|
/** Style, render tree, layout and display list of the current document. */
|
|
1198
1340
|
function styleAndLayout() {
|
|
1199
1341
|
const started = hostPerformance.now();
|
|
1342
|
+
const ms = (from, to) => Math.round(to - from);
|
|
1200
1343
|
const sheets = currentStyleSheets();
|
|
1201
1344
|
const environment = { width: page.width, height: page.height, mobile };
|
|
1202
1345
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1203
1346
|
const taken = hostPerformance.now();
|
|
1347
|
+
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
1348
|
+
// not actually change DOM, state, resources, or styles. Keep the retained
|
|
1349
|
+
// layout/display list in that case instead of laying the whole page out.
|
|
1350
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1351
|
+
&& changes.roots.size === 0 && changes.self.size === 0) {
|
|
1352
|
+
lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
|
|
1353
|
+
return { style: Math.round(taken - started), layout: 0, skipped: true };
|
|
1354
|
+
}
|
|
1204
1355
|
styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
|
|
1205
1356
|
const computed = hostPerformance.now();
|
|
1206
1357
|
if (!hasState || hasStateStale) {
|
|
@@ -1208,18 +1359,43 @@ export async function createPageRenderer({
|
|
|
1208
1359
|
hasStateStale = false;
|
|
1209
1360
|
}
|
|
1210
1361
|
const recorded = hostPerformance.now();
|
|
1362
|
+
// Attribute mutations used by application bookkeeping often match no
|
|
1363
|
+
// different rule and do not alter render-tree content. Once style has
|
|
1364
|
+
// proved that no element changed, retain layout and paint unchanged.
|
|
1365
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1366
|
+
&& !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
|
|
1367
|
+
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
|
|
1368
|
+
return { style: Math.round(recorded - started), layout: 0, skipped: true };
|
|
1369
|
+
}
|
|
1211
1370
|
if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
|
|
1212
1371
|
const verified = hostPerformance.now();
|
|
1213
1372
|
page.backgroundColor = documentBackground(page.document, styleEngine);
|
|
1214
|
-
|
|
1373
|
+
const nextRenderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
|
|
1215
1374
|
const styled = hostPerformance.now();
|
|
1216
|
-
const ms = (from, to) => Math.round(to - from);
|
|
1217
1375
|
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, tree ${ms(verified, styled)}`;
|
|
1218
|
-
|
|
1376
|
+
// MutationObserver also reports changes under non-rendered nodes such as
|
|
1377
|
+
// script and metadata elements. Compare the actual immutable layout input
|
|
1378
|
+
// after rebuilding it, so those changes do not trigger a full reflow.
|
|
1379
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets
|
|
1380
|
+
&& sameLayoutInput(page.renderTree.root, nextRenderTree.root, {
|
|
1381
|
+
ignoreResources: true,
|
|
1382
|
+
// A zero-element incremental pass retained every computed style.
|
|
1383
|
+
// Control placeholders may still wrap that style in a fresh object.
|
|
1384
|
+
ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
|
|
1385
|
+
})) {
|
|
1386
|
+
lastStyleParts += ", unchanged layout input";
|
|
1387
|
+
return { style: Math.round(styled - started), layout: 0, skipped: true };
|
|
1388
|
+
}
|
|
1389
|
+
page.renderTree = nextRenderTree;
|
|
1390
|
+
page.layout = layoutEngine.layout(page.renderTree, {
|
|
1219
1391
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1220
1392
|
});
|
|
1221
|
-
page.displayList =
|
|
1222
|
-
|
|
1393
|
+
page.displayList = paintList();
|
|
1394
|
+
// The content may now reach less far than a container was scrolled.
|
|
1395
|
+
if (clampElementScroll()) page.displayList = paintList();
|
|
1396
|
+
page.resourceLayoutDirty = false;
|
|
1397
|
+
laidOutSheets = sheets;
|
|
1398
|
+
setContentSize();
|
|
1223
1399
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
1224
1400
|
}
|
|
1225
1401
|
|
|
@@ -1244,12 +1420,31 @@ export async function createPageRenderer({
|
|
|
1244
1420
|
if (mismatches) reportError(`buninu-browser: incremental style: ${mismatches} element(s) differ from a full restyle`);
|
|
1245
1421
|
}
|
|
1246
1422
|
|
|
1423
|
+
/**
|
|
1424
|
+
* Brings style and layout up to date with the DOM. Whichever path asks (a
|
|
1425
|
+
* frame, or a script reading geometry or styles), the media and images the
|
|
1426
|
+
* DOM now names are requested first: a script that inserts images and
|
|
1427
|
+
* measures them at once must not leave them unloaded.
|
|
1428
|
+
*/
|
|
1247
1429
|
function rebuildRenderTree(reason = "update") {
|
|
1248
1430
|
const started = hostPerformance.now();
|
|
1249
1431
|
page.dirty = false;
|
|
1250
|
-
page.
|
|
1432
|
+
page.layoutVersion++;
|
|
1251
1433
|
page.title = page.document?.title ?? page.title;
|
|
1434
|
+
let staleImages = [];
|
|
1435
|
+
if (page.document) {
|
|
1436
|
+
initializeVideos(page.document, page.resources);
|
|
1437
|
+
staleImages = queueImageResources();
|
|
1438
|
+
}
|
|
1252
1439
|
const timing = styleAndLayout();
|
|
1440
|
+
for (const image of staleImages) {
|
|
1441
|
+
const index = page.decodedImages.indexOf(image);
|
|
1442
|
+
if (index >= 0) page.decodedImages.splice(index, 1);
|
|
1443
|
+
image.delete();
|
|
1444
|
+
}
|
|
1445
|
+
if (!timing.skipped) page.needsPaint = true;
|
|
1446
|
+
syncFrames();
|
|
1447
|
+
if (!timing.skipped) queueBackgroundImages();
|
|
1253
1448
|
lastRebuildMs = hostPerformance.now() - started;
|
|
1254
1449
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
1255
1450
|
+ ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
|
|
@@ -1308,6 +1503,16 @@ export async function createPageRenderer({
|
|
|
1308
1503
|
}
|
|
1309
1504
|
if (url === "about:blank") {
|
|
1310
1505
|
replaceDocument("", url);
|
|
1506
|
+
// An iframe's blank document is scripted from its page (through
|
|
1507
|
+
// contentWindow and contentDocument), so it has a realm.
|
|
1508
|
+
if (embedded && page.scripting !== "off") {
|
|
1509
|
+
page.realm = createPageRealm({ window: page.window, document: page.document, mode: page.scripting, hooks: realmHooks(generation) });
|
|
1510
|
+
render();
|
|
1511
|
+
commit.resolve();
|
|
1512
|
+
// No scripts of its own: it is complete at once, and its realm reports so.
|
|
1513
|
+
await page.realm.runDocumentScripts();
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1311
1516
|
render();
|
|
1312
1517
|
commit.resolve();
|
|
1313
1518
|
finish();
|
|
@@ -1329,6 +1534,7 @@ export async function createPageRenderer({
|
|
|
1329
1534
|
const linkSheets = isHtml ? new WeakMap() : null;
|
|
1330
1535
|
const styleSheets = isHtml ? loadStyleSheets(parsed.document, url, linkSheets) : Promise.resolve([]);
|
|
1331
1536
|
const images = isHtml ? loadImageResources(parsed.document, url) : Promise.resolve(emptyImageResources());
|
|
1537
|
+
page.preloadedScripts = isHtml && page.scripting !== "off" ? preloadScripts(parsed.document, url) : new Map();
|
|
1332
1538
|
let imagesReady = null;
|
|
1333
1539
|
images.then((loaded) => { imagesReady = loaded; });
|
|
1334
1540
|
let timer;
|
|
@@ -1362,8 +1568,11 @@ export async function createPageRenderer({
|
|
|
1362
1568
|
page.document.body.textContent = source;
|
|
1363
1569
|
rebuildRenderTree();
|
|
1364
1570
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1571
|
+
// An error status with a body shows that body, scripts included, as in
|
|
1572
|
+
// Chrome: sites serve real pages that way (Google's "unusual traffic"
|
|
1573
|
+
// check comes as 429). Only an empty response gets a note instead.
|
|
1574
|
+
if ((status < 200 || status > 299) && !source.trim()) {
|
|
1575
|
+
page.document.body.textContent = `HTTP ${status} ${statusText}`.trim();
|
|
1367
1576
|
rebuildRenderTree();
|
|
1368
1577
|
}
|
|
1369
1578
|
render();
|
|
@@ -1387,6 +1596,31 @@ export async function createPageRenderer({
|
|
|
1387
1596
|
}
|
|
1388
1597
|
}
|
|
1389
1598
|
|
|
1599
|
+
/**
|
|
1600
|
+
* Like a browser's preload scanner: starts fetching the document's external
|
|
1601
|
+
* scripts and declared module preloads (<link rel=modulepreload>) as soon
|
|
1602
|
+
* as it is parsed, while its stylesheets load and it is styled, instead of
|
|
1603
|
+
* when the scripts' turn comes and a module graph is discovered level by
|
|
1604
|
+
* level. Only the bytes are fetched early; fetchScript() takes them.
|
|
1605
|
+
*/
|
|
1606
|
+
function preloadScripts(document, documentUrl) {
|
|
1607
|
+
const preloaded = new Map();
|
|
1608
|
+
const preload = (href) => {
|
|
1609
|
+
let target;
|
|
1610
|
+
try { target = new URL(href, documentUrl); } catch { return; }
|
|
1611
|
+
if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
|
|
1612
|
+
const response = fetchResource(target.href, "script");
|
|
1613
|
+
response.catch(() => {}); // reported when the script runs
|
|
1614
|
+
preloaded.set(target.href, response);
|
|
1615
|
+
};
|
|
1616
|
+
for (const script of document.querySelectorAll("script[src]")) {
|
|
1617
|
+
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
1618
|
+
if (type === "module" || (CLASSIC_SCRIPT_TYPES.has(type) && !script.hasAttribute("nomodule"))) preload(script.getAttribute("src"));
|
|
1619
|
+
}
|
|
1620
|
+
for (const link of document.querySelectorAll("link[rel~='modulepreload'][href]")) preload(link.getAttribute("href"));
|
|
1621
|
+
return preloaded;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1390
1624
|
/** Frees what a superseded load fetched. */
|
|
1391
1625
|
async function discard(images, parsed = null) {
|
|
1392
1626
|
parsed?.window?.happyDOM?.abort?.();
|
|
@@ -1420,6 +1654,7 @@ export async function createPageRenderer({
|
|
|
1420
1654
|
page.decodedImages.push(resource.image);
|
|
1421
1655
|
}
|
|
1422
1656
|
page.dirty = true;
|
|
1657
|
+
page.resourceLayoutDirty = true;
|
|
1423
1658
|
scheduleFrame();
|
|
1424
1659
|
}, () => {});
|
|
1425
1660
|
}
|
|
@@ -1432,16 +1667,7 @@ export async function createPageRenderer({
|
|
|
1432
1667
|
|
|
1433
1668
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
1434
1669
|
function refresh() {
|
|
1435
|
-
if (page.dirty)
|
|
1436
|
-
initializeVideos(page.document, page.resources);
|
|
1437
|
-
const staleImages = queueImageResources();
|
|
1438
|
-
rebuildRenderTree();
|
|
1439
|
-
for (const image of staleImages) {
|
|
1440
|
-
const index = page.decodedImages.indexOf(image);
|
|
1441
|
-
if (index >= 0) page.decodedImages.splice(index, 1);
|
|
1442
|
-
image.delete();
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1670
|
+
if (page.dirty) rebuildRenderTree();
|
|
1445
1671
|
if (!page.needsPaint) return false;
|
|
1446
1672
|
render();
|
|
1447
1673
|
return true;
|
|
@@ -1512,14 +1738,22 @@ export async function createPageRenderer({
|
|
|
1512
1738
|
|
|
1513
1739
|
function realmHooks(generation) {
|
|
1514
1740
|
return {
|
|
1741
|
+
frames: {
|
|
1742
|
+
self: ownWindow,
|
|
1743
|
+
parent: ownWindow.parent(),
|
|
1744
|
+
top: ownWindow.top(),
|
|
1745
|
+
childOf: (element) => ensureFrame(element)?.window ?? null,
|
|
1746
|
+
},
|
|
1515
1747
|
definedChanged(name) {
|
|
1516
1748
|
// An upgrade changes :defined for that tag's elements only.
|
|
1517
|
-
if (selectorTraits.defined)
|
|
1749
|
+
if (selectorTraits.defined) {
|
|
1750
|
+
for (const element of page.document.getElementsByTagName(String(name))) markStyleDirty(element, [":defined"]);
|
|
1751
|
+
}
|
|
1518
1752
|
page.dirty = true;
|
|
1519
1753
|
scheduleFrame();
|
|
1520
1754
|
},
|
|
1521
|
-
touch(node = null) {
|
|
1522
|
-
markStyleDirty(node);
|
|
1755
|
+
touch(node = null, stateFeatures = []) {
|
|
1756
|
+
if (stateFeatures.length) markStyleDirty(node, stateFeatures);
|
|
1523
1757
|
page.dirty = true;
|
|
1524
1758
|
scheduleFrame();
|
|
1525
1759
|
},
|
|
@@ -1527,8 +1761,17 @@ export async function createPageRenderer({
|
|
|
1527
1761
|
playMedia,
|
|
1528
1762
|
pauseMedia,
|
|
1529
1763
|
seekMedia,
|
|
1764
|
+
attachMediaSrc,
|
|
1765
|
+
mediaReadyState,
|
|
1766
|
+
mediaNetworkState,
|
|
1767
|
+
mediaError,
|
|
1768
|
+
mediaBuffered,
|
|
1769
|
+
mediaAppended,
|
|
1770
|
+
mediaDuration,
|
|
1530
1771
|
lifecycle(name) {
|
|
1531
1772
|
if (generation !== loadGeneration) return;
|
|
1773
|
+
// The images load waited for are in the frame shown at load.
|
|
1774
|
+
if (name === "load") refresh();
|
|
1532
1775
|
onLifecycle?.(name, generation);
|
|
1533
1776
|
// Parsing and the deferred scripts are done: changes from async
|
|
1534
1777
|
// scripts and late images get frames, without waiting for load.
|
|
@@ -1537,7 +1780,7 @@ export async function createPageRenderer({
|
|
|
1537
1780
|
scheduleFrame();
|
|
1538
1781
|
}
|
|
1539
1782
|
},
|
|
1540
|
-
whenImagesLoaded: () => page.imagesLoaded,
|
|
1783
|
+
whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
1541
1784
|
afterTask() {},
|
|
1542
1785
|
boundsOf(element) {
|
|
1543
1786
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -1556,11 +1799,37 @@ export async function createPageRenderer({
|
|
|
1556
1799
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1557
1800
|
if (viewport.scrollTo(x, y)) render();
|
|
1558
1801
|
},
|
|
1802
|
+
elementScroll(element) {
|
|
1803
|
+
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
1804
|
+
return scrollerGeometry(element);
|
|
1805
|
+
},
|
|
1806
|
+
elementAt(x, y) {
|
|
1807
|
+
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
1808
|
+
if (!(x >= 0 && y >= 0 && x < page.width && y < page.height) || !page.displayList) return null;
|
|
1809
|
+
return elementAtPoint(page.renderTree, page.displayList, x, y, viewport);
|
|
1810
|
+
},
|
|
1811
|
+
setElementScroll(element, x, y) {
|
|
1812
|
+
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1813
|
+
setElementScroll(element, x, y);
|
|
1814
|
+
},
|
|
1815
|
+
scrollIntoView(element) {
|
|
1816
|
+
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1817
|
+
revealElement(element);
|
|
1818
|
+
},
|
|
1559
1819
|
computedStyle(element) {
|
|
1560
1820
|
if (!element) return null;
|
|
1561
1821
|
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
1562
1822
|
return styleEngine.get(element);
|
|
1563
1823
|
},
|
|
1824
|
+
layoutVersion() {
|
|
1825
|
+
if (page.dirty) rebuildRenderTree("intersection observer");
|
|
1826
|
+
return page.layoutVersion;
|
|
1827
|
+
},
|
|
1828
|
+
customProperties(element) {
|
|
1829
|
+
if (!element) return null;
|
|
1830
|
+
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
1831
|
+
return styleEngine.customPropertiesOf(element);
|
|
1832
|
+
},
|
|
1564
1833
|
navigate: (url) => navigateTo(String(url)),
|
|
1565
1834
|
activateLink(anchor) {
|
|
1566
1835
|
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
@@ -1570,52 +1839,105 @@ export async function createPageRenderer({
|
|
|
1570
1839
|
},
|
|
1571
1840
|
submitForm,
|
|
1572
1841
|
async fetchScript(url) {
|
|
1573
|
-
|
|
1842
|
+
// Blob URLs live in this document's registry. Host fetch only accepts
|
|
1843
|
+
// http(s) and s3, and a MediaSource URL has no script bytes.
|
|
1844
|
+
if (/^blob:/i.test(url)) {
|
|
1845
|
+
const stored = page.realm?.blobEntry?.(url);
|
|
1846
|
+
if (stored?.kind !== "blob") throw new TypeError("Failed to fetch");
|
|
1847
|
+
return new TextDecoder().decode(await stored.blob.arrayBuffer());
|
|
1848
|
+
}
|
|
1849
|
+
if (/^data:/i.test(url)) {
|
|
1574
1850
|
const response = await fetch(url);
|
|
1575
1851
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1576
1852
|
return response.text();
|
|
1577
1853
|
}
|
|
1578
|
-
const
|
|
1854
|
+
const preloaded = page.preloadedScripts.get(url);
|
|
1855
|
+
if (preloaded) page.preloadedScripts.delete(url);
|
|
1856
|
+
const response = await (preloaded ?? fetchResource(url, "script"));
|
|
1579
1857
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1580
1858
|
return new TextDecoder().decode(response.body);
|
|
1581
1859
|
},
|
|
1582
1860
|
reportError,
|
|
1583
1861
|
consoleMessage,
|
|
1584
1862
|
userAgent: () => page.userAgent,
|
|
1863
|
+
initScripts: () => page.initScripts ?? [],
|
|
1864
|
+
rewriteScript,
|
|
1585
1865
|
documentCookie: () => page.cookie,
|
|
1586
1866
|
setDocumentCookie(value) {
|
|
1587
1867
|
page.cookie = mergeDocumentCookie(page.cookie, String(value));
|
|
1588
1868
|
setCookie(String(value));
|
|
1589
1869
|
},
|
|
1590
1870
|
webSockets,
|
|
1591
|
-
|
|
1592
|
-
const
|
|
1593
|
-
|
|
1594
|
-
|
|
1871
|
+
pageFetch(request) {
|
|
1872
|
+
const networkPending = pageFetch(request);
|
|
1873
|
+
const result = networkPending.then((response) => {
|
|
1874
|
+
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
1875
|
+
return response;
|
|
1876
|
+
});
|
|
1877
|
+
result.abort = () => networkPending.abort?.();
|
|
1878
|
+
return result;
|
|
1595
1879
|
},
|
|
1596
1880
|
};
|
|
1597
1881
|
}
|
|
1598
1882
|
|
|
1883
|
+
/**
|
|
1884
|
+
* Form submission (HTML §4.10.21.3): GET puts the entries in the action's
|
|
1885
|
+
* query; POST sends them as the body, encoded per the form's enctype, and
|
|
1886
|
+
* the controller navigates with that request.
|
|
1887
|
+
*/
|
|
1599
1888
|
function submitForm(form, submitter = null) {
|
|
1600
1889
|
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1601
|
-
if (method !== "get") return;
|
|
1602
1890
|
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1603
1891
|
const url = new URL(action || page.url, page.url);
|
|
1892
|
+
const entries = [...new page.window.FormData(form, submitter)];
|
|
1893
|
+
if (method === "post") {
|
|
1894
|
+
const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
|
|
1895
|
+
requestNavigation(url.href, null, null, encodeFormBody(entries, enctype));
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (method !== "get") return;
|
|
1604
1899
|
url.search = "";
|
|
1605
|
-
for (const [name, value] of
|
|
1900
|
+
for (const [name, value] of entries) {
|
|
1606
1901
|
if (typeof value === "string") url.searchParams.append(name, value);
|
|
1607
1902
|
}
|
|
1608
1903
|
navigateTo(url.href);
|
|
1609
1904
|
}
|
|
1610
1905
|
|
|
1906
|
+
/** A POST form's entries as a request body and its content type. Files go as empty parts. */
|
|
1907
|
+
function encodeFormBody(entries, enctype) {
|
|
1908
|
+
const encoder = new TextEncoder();
|
|
1909
|
+
if (enctype === "multipart/form-data") {
|
|
1910
|
+
const boundary = `----buninuFormBoundary${crypto.randomUUID().replaceAll("-", "")}`;
|
|
1911
|
+
const quote = (text) => String(text).replace(/\r\n|\r|\n/g, "\r\n").replaceAll('"', "%22");
|
|
1912
|
+
let body = "";
|
|
1913
|
+
for (const [name, value] of entries) {
|
|
1914
|
+
const file = typeof value === "string" ? null : value;
|
|
1915
|
+
body += `--${boundary}\r\nContent-Disposition: form-data; name="${quote(name)}"`
|
|
1916
|
+
+ (file ? `; filename="${quote(file.name ?? "")}"\r\nContent-Type: application/octet-stream` : "")
|
|
1917
|
+
+ `\r\n\r\n${file ? "" : String(value).replace(/\r\n|\r|\n/g, "\r\n")}\r\n`;
|
|
1918
|
+
}
|
|
1919
|
+
body += `--${boundary}--\r\n`;
|
|
1920
|
+
return { method: "POST", body: encoder.encode(body), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
1921
|
+
}
|
|
1922
|
+
if (enctype === "text/plain") {
|
|
1923
|
+
const body = entries.map(([name, value]) => `${name}=${typeof value === "string" ? value : value.name ?? ""}\r\n`).join("");
|
|
1924
|
+
return { method: "POST", body: encoder.encode(body), contentType: "text/plain" };
|
|
1925
|
+
}
|
|
1926
|
+
const params = new URLSearchParams();
|
|
1927
|
+
for (const [name, value] of entries) params.append(name, typeof value === "string" ? value : value.name ?? "");
|
|
1928
|
+
return { method: "POST", body: encoder.encode(params.toString()), contentType: "application/x-www-form-urlencoded" };
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1611
1931
|
/** The border box of an element's layout box in viewport coordinates. */
|
|
1612
1932
|
function viewportBounds(element) {
|
|
1613
1933
|
let fixed = false;
|
|
1614
1934
|
for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
|
|
1615
1935
|
if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
|
|
1616
1936
|
}
|
|
1617
|
-
|
|
1618
|
-
const
|
|
1937
|
+
// Scroll containers around it move it too.
|
|
1938
|
+
const shift = scrollShift(element);
|
|
1939
|
+
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
1940
|
+
const scrollY = (fixed ? 0 : viewport.y) + shift.y;
|
|
1619
1941
|
let nodeId = null;
|
|
1620
1942
|
for (const node of page.renderTree.nodesById.values()) {
|
|
1621
1943
|
if (node.domNode === element && !node.pseudo) {
|
|
@@ -1696,7 +2018,26 @@ export async function createPageRenderer({
|
|
|
1696
2018
|
|
|
1697
2019
|
function collectHints() {
|
|
1698
2020
|
const chars = ["a", "s", "d", "f", "j", "k", "l"];
|
|
1699
|
-
|
|
2021
|
+
// Laid-out regions moved by the scroll containers around them, kept
|
|
2022
|
+
// where the element itself shows (not scrolled out of a container or covered).
|
|
2023
|
+
const regions = interactiveRegions(page.renderTree, page.layout, viewport, {})
|
|
2024
|
+
.map((region) => {
|
|
2025
|
+
const shift = scrollShift(region.element);
|
|
2026
|
+
if (!shift.x && !shift.y) return region;
|
|
2027
|
+
const rects = region.rects.map((rect) => ({
|
|
2028
|
+
...rect, left: rect.left - shift.x, right: rect.right - shift.x, top: rect.top - shift.y, bottom: rect.bottom - shift.y,
|
|
2029
|
+
}));
|
|
2030
|
+
return { element: region.element, rects };
|
|
2031
|
+
})
|
|
2032
|
+
.filter((region) => {
|
|
2033
|
+
const rect = region.rects[0];
|
|
2034
|
+
const cx = (rect.left + rect.right) / 2;
|
|
2035
|
+
const cy = (rect.top + rect.bottom) / 2;
|
|
2036
|
+
if (cx < 0 || cy < 0 || cx >= page.width || cy >= page.height) return false;
|
|
2037
|
+
const shown = page.displayList ? elementAtPoint(page.renderTree, page.displayList, cx, cy, viewport) : null;
|
|
2038
|
+
return !shown || shown === region.element || region.element.contains(shown) || shown.contains(region.element);
|
|
2039
|
+
})
|
|
2040
|
+
.slice(0, 49);
|
|
1700
2041
|
const labels = regions.length <= chars.length
|
|
1701
2042
|
? chars
|
|
1702
2043
|
: chars.flatMap((first) => chars.map((second) => first + second));
|
|
@@ -1731,9 +2072,14 @@ export async function createPageRenderer({
|
|
|
1731
2072
|
|
|
1732
2073
|
async function downloadBlob(url, name) {
|
|
1733
2074
|
try {
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
2075
|
+
const stored = page.realm?.blobEntry?.(url);
|
|
2076
|
+
let data;
|
|
2077
|
+
if (stored?.kind === "blob") data = new Uint8Array(await stored.blob.arrayBuffer());
|
|
2078
|
+
else {
|
|
2079
|
+
const response = await fetch(url);
|
|
2080
|
+
if (!response.ok) throw new Error(`blob download failed: HTTP ${response.status}`);
|
|
2081
|
+
data = new Uint8Array(await response.arrayBuffer());
|
|
2082
|
+
}
|
|
1737
2083
|
if (data.byteLength > 64 * 1024 * 1024) throw new Error("blob download exceeds 64 MB");
|
|
1738
2084
|
requestDownload({ url, name, data });
|
|
1739
2085
|
} catch (error) {
|
|
@@ -1750,6 +2096,10 @@ export async function createPageRenderer({
|
|
|
1750
2096
|
setUserAgent(userAgent) {
|
|
1751
2097
|
if (typeof userAgent === "string" && userAgent) page.userAgent = userAgent;
|
|
1752
2098
|
},
|
|
2099
|
+
/** Scripts every new document runs before its own (CDP Page.addScriptToEvaluateOnNewDocument). */
|
|
2100
|
+
setInitScripts(sources) {
|
|
2101
|
+
page.initScripts = Array.isArray(sources) ? sources.map(String) : [];
|
|
2102
|
+
},
|
|
1753
2103
|
/** Messages the page logged, for diagnostics. */
|
|
1754
2104
|
consoleMessages() {
|
|
1755
2105
|
return page.realm?.console ?? [];
|
|
@@ -1862,6 +2212,7 @@ export async function createPageRenderer({
|
|
|
1862
2212
|
return { x: viewport.x, y: viewport.y };
|
|
1863
2213
|
},
|
|
1864
2214
|
press(key, options = {}) {
|
|
2215
|
+
if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
|
|
1865
2216
|
refresh();
|
|
1866
2217
|
const pianoInput = page.document?.activeElement;
|
|
1867
2218
|
if (audio && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
|
|
@@ -1914,12 +2265,17 @@ export async function createPageRenderer({
|
|
|
1914
2265
|
return { x: viewport.x, y: viewport.y };
|
|
1915
2266
|
},
|
|
1916
2267
|
type(text) {
|
|
2268
|
+
if (focusedFrame?.renderer) return focusedFrame.renderer.type(text);
|
|
1917
2269
|
editFocusedText(String(text ?? ""));
|
|
1918
2270
|
refresh();
|
|
1919
2271
|
},
|
|
1920
2272
|
scrollOffset() {
|
|
1921
2273
|
return { x: viewport.x, y: viewport.y };
|
|
1922
2274
|
},
|
|
2275
|
+
/** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
|
|
2276
|
+
wheel(x, y, deltaX, deltaY, options = undefined) {
|
|
2277
|
+
return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
|
|
2278
|
+
},
|
|
1923
2279
|
/**
|
|
1924
2280
|
* Clicks at a viewport point: the page receives a click event first, and
|
|
1925
2281
|
* unless it calls preventDefault() a link under the point is returned for
|
|
@@ -1931,6 +2287,7 @@ export async function createPageRenderer({
|
|
|
1931
2287
|
// label) without a DOM mutation, so the next restyle is a full one.
|
|
1932
2288
|
markStyleDirty(null); // full: click
|
|
1933
2289
|
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
2290
|
+
let forwarded = false;
|
|
1934
2291
|
if (!region) {
|
|
1935
2292
|
// Some search forms draw a wide control around a narrow textarea and
|
|
1936
2293
|
// rely on script to forward clicks. Preserve that affordance when the
|
|
@@ -1940,13 +2297,34 @@ export async function createPageRenderer({
|
|
|
1940
2297
|
if (!bounds || x < bounds.left || x >= bounds.left + bounds.width
|
|
1941
2298
|
|| y < bounds.top || y >= bounds.top + bounds.height) continue;
|
|
1942
2299
|
const fields = form.querySelectorAll('textarea[inputmode="search"], input[type="search"]');
|
|
1943
|
-
if (fields.length === 1)
|
|
2300
|
+
if (fields.length === 1) {
|
|
2301
|
+
region = { element: fields[0] };
|
|
2302
|
+
forwarded = true;
|
|
2303
|
+
}
|
|
1944
2304
|
break;
|
|
1945
2305
|
}
|
|
1946
2306
|
}
|
|
1947
|
-
|
|
2307
|
+
// Pointer events go to the topmost element painted at the point, where
|
|
2308
|
+
// it is now (scroll containers move their content); the interactive
|
|
2309
|
+
// regions (laid out, unscrolled) stand in only where nothing is painted,
|
|
2310
|
+
// and a search form's forwarded field takes the click on the form.
|
|
2311
|
+
const precise = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2312
|
+
const target = forwarded ? region.element : precise ?? region?.element ?? null;
|
|
2313
|
+
if (!target) {
|
|
2314
|
+
focusedFrame = null;
|
|
2315
|
+
return null;
|
|
2316
|
+
}
|
|
2317
|
+
// A click inside an iframe belongs to its document alone.
|
|
2318
|
+
const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
|
|
2319
|
+
focusedFrame = inFrame?.frame ?? null;
|
|
2320
|
+
if (inFrame) {
|
|
2321
|
+
const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y);
|
|
2322
|
+
const href = typeof followed === "string" ? followed : followed?.fragmentFrom ? null : followed?.url;
|
|
2323
|
+
if (href && !followed?.download) void loadFrame(inFrame.frame, href);
|
|
2324
|
+
return null;
|
|
2325
|
+
}
|
|
1948
2326
|
if (page.url === "about:audio") {
|
|
1949
|
-
const key =
|
|
2327
|
+
const key = target.closest?.("[data-piano-note]");
|
|
1950
2328
|
const note = Number(key?.getAttribute("data-piano-note"));
|
|
1951
2329
|
if (key && Number.isInteger(note) && note >= 0 && note <= 127 && audio) {
|
|
1952
2330
|
playPiano(note);
|
|
@@ -1954,22 +2332,30 @@ export async function createPageRenderer({
|
|
|
1954
2332
|
return null;
|
|
1955
2333
|
}
|
|
1956
2334
|
}
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2335
|
+
// The press focuses the focusable element it lands in, unless mousedown was canceled.
|
|
2336
|
+
const focus = () => {
|
|
2337
|
+
const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
|
|
2338
|
+
if (focusable && !focusable.disabled) focusable.focus();
|
|
2339
|
+
};
|
|
2340
|
+
if (!page.realm) focus();
|
|
2341
|
+
else {
|
|
2342
|
+
// A press and release, then the click (UI Events §3.5).
|
|
2343
|
+
const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
|
|
2344
|
+
const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
2345
|
+
const pointer = { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
2346
|
+
page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
|
|
2347
|
+
if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
|
|
2348
|
+
page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
|
|
2349
|
+
page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
|
|
2350
|
+
const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
|
|
1965
2351
|
refresh();
|
|
1966
2352
|
if (prevented) return null;
|
|
1967
2353
|
}
|
|
1968
|
-
if (
|
|
1969
|
-
clickMedia(
|
|
2354
|
+
if (target.tagName === "AUDIO" || target.tagName === "VIDEO") {
|
|
2355
|
+
clickMedia(target, x, y);
|
|
1970
2356
|
return null;
|
|
1971
2357
|
}
|
|
1972
|
-
const anchor =
|
|
2358
|
+
const anchor = target.closest?.("a[href]");
|
|
1973
2359
|
if (!anchor) return null;
|
|
1974
2360
|
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
1975
2361
|
if (href.startsWith("javascript:")) {
|
|
@@ -2053,15 +2439,18 @@ export async function createPageRenderer({
|
|
|
2053
2439
|
return cloneableResult(value);
|
|
2054
2440
|
},
|
|
2055
2441
|
dispose() {
|
|
2442
|
+
disposedRenderer = true;
|
|
2056
2443
|
hostClearTimeout(prepaintTimer);
|
|
2057
2444
|
hostClearTimeout(frameTimer);
|
|
2445
|
+
disposeFrames();
|
|
2058
2446
|
page.realm?.dispose();
|
|
2059
2447
|
closePiano();
|
|
2060
2448
|
disposeVideos();
|
|
2449
|
+
rasterSurface?.delete();
|
|
2450
|
+
rasterSurface = null;
|
|
2061
2451
|
page.window?.happyDOM?.abort?.();
|
|
2062
2452
|
for (const image of page.decodedImages) image.delete();
|
|
2063
|
-
|
|
2064
|
-
fontProvider.delete();
|
|
2453
|
+
disposeBackgrounds();
|
|
2065
2454
|
},
|
|
2066
2455
|
};
|
|
2067
2456
|
|
|
@@ -2103,7 +2492,8 @@ export async function createPageRenderer({
|
|
|
2103
2492
|
const entry = { url, text: null };
|
|
2104
2493
|
linkSheets.set(element, entry);
|
|
2105
2494
|
const request = fetchResource(url, "stylesheet").then((response) => {
|
|
2106
|
-
|
|
2495
|
+
// Its url()s are relative to the sheet, not to the document.
|
|
2496
|
+
if (response.ok) entry.text = absolutizeStyleSheetUrls(new TextDecoder().decode(response.body), response.url || url);
|
|
2107
2497
|
}, () => {
|
|
2108
2498
|
// A failed stylesheet must not prevent the document itself from loading.
|
|
2109
2499
|
}).then(() => {
|
|
@@ -2151,6 +2541,401 @@ export async function createPageRenderer({
|
|
|
2151
2541
|
return { map, decoded, entries };
|
|
2152
2542
|
}
|
|
2153
2543
|
|
|
2544
|
+
// ---- iframes. Each same-origin iframe's document lives in a page renderer
|
|
2545
|
+
// of its own (embedded), sharing this process's CanvasKit: its frames are
|
|
2546
|
+
// cut to its viewport and drawn into the iframe's box through a resource
|
|
2547
|
+
// whose image is swapped as frames arrive. Other documents are not given to
|
|
2548
|
+
// this process (the controller checks), so their iframes stay empty.
|
|
2549
|
+
|
|
2550
|
+
/** Brings the frames in line with the document's iframes: their documents and sizes. */
|
|
2551
|
+
function syncFrames() {
|
|
2552
|
+
const document = page.document;
|
|
2553
|
+
const elements = document ? [...document.getElementsByTagName("iframe")] : [];
|
|
2554
|
+
if (!elements.length && !frames.size) return;
|
|
2555
|
+
const boxes = frameBoxes();
|
|
2556
|
+
for (const [element, frame] of frames) {
|
|
2557
|
+
if (!element.isConnected || element.ownerDocument !== document) disposeFrame(frame);
|
|
2558
|
+
}
|
|
2559
|
+
for (const element of elements) {
|
|
2560
|
+
const frame = ensureFrame(element);
|
|
2561
|
+
page.resources.set(element, frame.holder);
|
|
2562
|
+
// Setting the name attribute names the frame (HTML §4.8.5).
|
|
2563
|
+
const nameAttribute = element.getAttribute("name");
|
|
2564
|
+
if (nameAttribute !== frame.nameAttribute) {
|
|
2565
|
+
frame.nameAttribute = nameAttribute;
|
|
2566
|
+
frame.window.setName(nameAttribute ?? "");
|
|
2567
|
+
}
|
|
2568
|
+
frame.box = boxes.get(element) ?? null;
|
|
2569
|
+
const width = Math.max(1, Math.round(frame.box?.width ?? 300));
|
|
2570
|
+
const height = Math.max(1, Math.round(frame.box?.height ?? 150));
|
|
2571
|
+
if (!frame.size || frame.size.width !== width || frame.size.height !== height || frame.size.scale !== page.deviceScaleFactor) {
|
|
2572
|
+
frame.size = { width, height, scale: page.deviceScaleFactor };
|
|
2573
|
+
void frame.ready.then((renderer) => renderer?.resize(width, height, page.deviceScaleFactor));
|
|
2574
|
+
}
|
|
2575
|
+
const source = frameSource(element);
|
|
2576
|
+
if (source !== frame.source) {
|
|
2577
|
+
frame.source = source;
|
|
2578
|
+
void loadFrame(frame, source);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/** The frame of an iframe of this document, made on first need (a script may post to it at once). */
|
|
2584
|
+
function ensureFrame(element) {
|
|
2585
|
+
let frame = frames.get(element);
|
|
2586
|
+
if (frame) return frame;
|
|
2587
|
+
if (element?.tagName !== "IFRAME" || !element.isConnected || element.ownerDocument !== page.document) return null;
|
|
2588
|
+
frame = { element, renderer: null, ready: null, source: undefined, token: 0, size: null, box: null,
|
|
2589
|
+
window: windowHandle(ownWindow, element.getAttribute("name") ?? ""), holder: { width: 0, height: 0, image: null } };
|
|
2590
|
+
frame.nameAttribute = element.getAttribute("name");
|
|
2591
|
+
frames.set(element, frame);
|
|
2592
|
+
page.resources.set(element, frame.holder);
|
|
2593
|
+
// Made at once, as is its initial about:blank document (HTML §4.8.5): a
|
|
2594
|
+
// script can use its contentWindow as soon as the iframe is inserted.
|
|
2595
|
+
const renderer = buildPageRenderer({
|
|
2596
|
+
fetchResource: rawFetchResource,
|
|
2597
|
+
requestNavigation: (url, downloadName) => {
|
|
2598
|
+
if (downloadName === null) void loadFrame(frame, url);
|
|
2599
|
+
},
|
|
2600
|
+
requestDownload,
|
|
2601
|
+
reportError,
|
|
2602
|
+
pageFetch,
|
|
2603
|
+
webSockets,
|
|
2604
|
+
setCookie,
|
|
2605
|
+
consoleMessage,
|
|
2606
|
+
onFrame: (update) => showFrame(frame, update),
|
|
2607
|
+
onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
|
|
2608
|
+
videoDecoder,
|
|
2609
|
+
rewriteScript,
|
|
2610
|
+
mobile,
|
|
2611
|
+
embedded: true,
|
|
2612
|
+
frameWindow: frame.window,
|
|
2613
|
+
}, loadedGraphics);
|
|
2614
|
+
// Page scripts in "host" mode share one global object; a frame's scripts need their own.
|
|
2615
|
+
renderer.setScripting(page.scripting === "ses" ? "ses" : "off");
|
|
2616
|
+
renderer.setUserAgent(page.userAgent);
|
|
2617
|
+
renderer.setInitScripts(page.initScripts ?? []);
|
|
2618
|
+
frame.renderer = renderer;
|
|
2619
|
+
frame.ready = Promise.resolve(renderer);
|
|
2620
|
+
frame.loading = renderer.loadDocument({ url: "about:blank" }).then((generation) => {
|
|
2621
|
+
frame.generation = generation;
|
|
2622
|
+
frame.initialGeneration = generation;
|
|
2623
|
+
});
|
|
2624
|
+
// Then its src, at once: syncFrames only adjusts the size later. A frame
|
|
2625
|
+
// without one keeps its initial document, whose load the iframe fires;
|
|
2626
|
+
// otherwise only its src's load is fired.
|
|
2627
|
+
frame.source = frameSource(element);
|
|
2628
|
+
frame.silentInitialLoad = frame.source !== "about:blank";
|
|
2629
|
+
if (frame.source !== "about:blank") void loadFrame(frame, frame.source);
|
|
2630
|
+
return frame;
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
/** Each rendered iframe's content box, in document coordinates. */
|
|
2634
|
+
function frameBoxes() {
|
|
2635
|
+
const boxes = new Map();
|
|
2636
|
+
if (!page.layout || !page.renderTree) return boxes;
|
|
2637
|
+
for (const fragment of page.layout.fragments) {
|
|
2638
|
+
for (const run of fragment.runs) {
|
|
2639
|
+
if (run.type !== "image") continue;
|
|
2640
|
+
const element = page.renderTree.nodesById.get(run.nodeId)?.domNode;
|
|
2641
|
+
if (element?.tagName === "IFRAME") {
|
|
2642
|
+
boxes.set(element, { x: run.x, y: run.y ?? fragment.y, width: run.width, height: run.height });
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
return boxes;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/** The URL an iframe shows: its src resolved against the page, or about:blank. */
|
|
2650
|
+
function frameSource(element) {
|
|
2651
|
+
const src = element.getAttribute("src")?.trim();
|
|
2652
|
+
if (!src) return "about:blank";
|
|
2653
|
+
try {
|
|
2654
|
+
return new URL(src, page.url).href;
|
|
2655
|
+
} catch {
|
|
2656
|
+
return "about:blank";
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/** Loads a frame's document; a cross-origin one (or a failure) leaves it blank. */
|
|
2661
|
+
async function loadFrame(frame, url) {
|
|
2662
|
+
const token = ++frame.token;
|
|
2663
|
+
let document = { url: "about:blank" };
|
|
2664
|
+
let target = null;
|
|
2665
|
+
try { target = new URL(url); } catch { /* blank */ }
|
|
2666
|
+
if (target && ["http:", "https:"].includes(target.protocol) && target.origin === new URL(page.url).origin) {
|
|
2667
|
+
try {
|
|
2668
|
+
const response = await fetchResource(target.href, "iframe");
|
|
2669
|
+
document = {
|
|
2670
|
+
url: response.url || target.href,
|
|
2671
|
+
source: new TextDecoder().decode(response.body),
|
|
2672
|
+
contentType: response.contentType || "text/html",
|
|
2673
|
+
status: response.status,
|
|
2674
|
+
statusText: response.statusText ?? "",
|
|
2675
|
+
cookie: response.documentCookie ?? page.cookie,
|
|
2676
|
+
};
|
|
2677
|
+
} catch (error) {
|
|
2678
|
+
trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
const renderer = await frame.ready;
|
|
2682
|
+
if (!renderer || token !== frame.token || !frames.has(frame.element)) return;
|
|
2683
|
+
frame.loading = renderer.loadDocument(document).then((generation) => { frame.generation = generation; });
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
/** A frame's document painted: its viewport becomes the iframe's image. */
|
|
2687
|
+
function showFrame(frame, { tile, scroll, viewport: size }) {
|
|
2688
|
+
if (!frames.has(frame.element)) return;
|
|
2689
|
+
const { pixels, width, height } = cropViewport(tile, scroll.x, scroll.y, size.width, size.height);
|
|
2690
|
+
const image = CanvasKit.MakeImage({
|
|
2691
|
+
width, height, alphaType: CanvasKit.AlphaType.Unpremul, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
2692
|
+
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
2693
|
+
}, pixels, width * 4);
|
|
2694
|
+
if (!image) return;
|
|
2695
|
+
const first = !frame.holder.image;
|
|
2696
|
+
frame.holder.image?.delete();
|
|
2697
|
+
Object.assign(frame.holder, { width, height, image });
|
|
2698
|
+
framesVersion++;
|
|
2699
|
+
// The display list leaves out images that have nothing to show yet.
|
|
2700
|
+
if (first && page.layout) page.displayList = paintList();
|
|
2701
|
+
page.needsPaint = true;
|
|
2702
|
+
if (!framePaintTimer) {
|
|
2703
|
+
framePaintTimer = hostSetTimeout(() => {
|
|
2704
|
+
framePaintTimer = null;
|
|
2705
|
+
if (page.needsPaint && !page.loading) render();
|
|
2706
|
+
}, 16);
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/** A frame's document reached DOMContentLoaded or load: the iframe fires load. */
|
|
2711
|
+
function frameLifecycle(frame, name, generation) {
|
|
2712
|
+
if (name !== "load" || !frames.has(frame.element)) return;
|
|
2713
|
+
// A blank document loads within loadDocument, before its generation is known here.
|
|
2714
|
+
hostSetTimeout(() => void frame.loading?.then(() => {
|
|
2715
|
+
if (frame.generation !== generation || !frames.has(frame.element) || !page.realm) return;
|
|
2716
|
+
if (frame.silentInitialLoad && generation === frame.initialGeneration) return;
|
|
2717
|
+
page.realm.dispatch(frame.element, new page.window.Event("load"));
|
|
2718
|
+
refresh();
|
|
2719
|
+
}), 0);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
function disposeFrame(frame) {
|
|
2723
|
+
frames.delete(frame.element);
|
|
2724
|
+
if (focusedFrame === frame) focusedFrame = null;
|
|
2725
|
+
frame.token++;
|
|
2726
|
+
void frame.ready?.then((renderer) => renderer?.dispose());
|
|
2727
|
+
frame.holder.image?.delete();
|
|
2728
|
+
frame.holder.image = null;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
function disposeFrames() {
|
|
2732
|
+
for (const frame of [...frames.values()]) disposeFrame(frame);
|
|
2733
|
+
hostClearTimeout(framePaintTimer);
|
|
2734
|
+
framePaintTimer = null;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/** The frame under a viewport point, with the point in the frame's own viewport. */
|
|
2738
|
+
function frameAt(element, x, y) {
|
|
2739
|
+
const frame = frames.get(element);
|
|
2740
|
+
if (!frame?.renderer || !frame.box) return null;
|
|
2741
|
+
const shift = scrollShift(element);
|
|
2742
|
+
return { frame, x: x + viewport.x + shift.x - frame.box.x, y: y + viewport.y + shift.y - frame.box.y };
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// ---- Scroll containers (CSS Overflow 3 §3). Elements with overflow other
|
|
2746
|
+
// than visible or clip scroll their content: the offsets live here, paint
|
|
2747
|
+
// moves the content by them (see paintEffects), and the wheel, scripts and
|
|
2748
|
+
// scrollIntoView change them.
|
|
2749
|
+
|
|
2750
|
+
/** The page scrolls over its laid-out size and whatever overflows it (see displayList.extent). */
|
|
2751
|
+
function setContentSize() {
|
|
2752
|
+
const extent = page.displayList?.extent;
|
|
2753
|
+
viewport.setContentSize(Math.max(page.layout.width, extent?.width ?? 0), Math.max(page.layout.height, extent?.height ?? 0));
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function paintList() {
|
|
2757
|
+
return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function scrollOffsetOf(element) {
|
|
2761
|
+
return element ? page.elementScroll.get(element) ?? null : null;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/** The render node and layout box of an element, or null. */
|
|
2765
|
+
function layoutBoxOf(element) {
|
|
2766
|
+
if (!page.renderTree || !page.layout) return null;
|
|
2767
|
+
for (const node of page.renderTree.nodesById.values()) {
|
|
2768
|
+
if (node.domNode !== element || node.pseudo) continue;
|
|
2769
|
+
const box = page.layout.boxes.find((candidate) => candidate.nodeId === node.id && !candidate.inline);
|
|
2770
|
+
return box ? { node, box } : null;
|
|
2771
|
+
}
|
|
2772
|
+
return null;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
/**
|
|
2776
|
+
* A scroll container's scroll position and sizes (CSSOM View §6): its
|
|
2777
|
+
* padding box is the client area, its content's reach the scroll area.
|
|
2778
|
+
* Null for an element that is not one.
|
|
2779
|
+
*/
|
|
2780
|
+
function scrollerGeometry(element) {
|
|
2781
|
+
const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
|
|
2782
|
+
if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
|
|
2783
|
+
const found = layoutBoxOf(element);
|
|
2784
|
+
if (!found) return null;
|
|
2785
|
+
const { node, box } = found;
|
|
2786
|
+
const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
|
|
2787
|
+
const padding = style.padding ?? [0, 0, 0, 0];
|
|
2788
|
+
const client = {
|
|
2789
|
+
x: box.x + borderLeft,
|
|
2790
|
+
y: box.y + borderTop,
|
|
2791
|
+
width: Math.max(0, box.width - borderLeft - borderRight),
|
|
2792
|
+
height: Math.max(0, box.height - borderTop - borderBottom),
|
|
2793
|
+
};
|
|
2794
|
+
const extent = page.displayList?.scrollers?.get(node.id);
|
|
2795
|
+
// In-flow content gets the end padding; absolutely positioned boxes reach only as far as they do.
|
|
2796
|
+
const scrollWidth = Math.max(client.width, extent ? Math.max(extent.flowRight - client.x + (Number(padding[1]) || 0),
|
|
2797
|
+
extent.right - client.x) : 0);
|
|
2798
|
+
const scrollHeight = Math.max(client.height, extent ? Math.max(extent.flowBottom - client.y + (Number(padding[2]) || 0),
|
|
2799
|
+
extent.bottom - client.y) : 0);
|
|
2800
|
+
const offset = page.elementScroll.get(element) ?? { x: 0, y: 0 };
|
|
2801
|
+
return {
|
|
2802
|
+
x: offset.x,
|
|
2803
|
+
y: offset.y,
|
|
2804
|
+
clientWidth: client.width,
|
|
2805
|
+
clientHeight: client.height,
|
|
2806
|
+
scrollWidth,
|
|
2807
|
+
scrollHeight,
|
|
2808
|
+
maxX: style.overflowScrollX ? Math.max(0, scrollWidth - client.width) : 0,
|
|
2809
|
+
maxY: style.overflowScrollY ? Math.max(0, scrollHeight - client.height) : 0,
|
|
2810
|
+
user: { x: style.overflowScrollX === "auto", y: style.overflowScrollY === "auto" },
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/** Scrolls a scroll container to (x, y), clamped; returns whether it moved. */
|
|
2815
|
+
function setElementScroll(element, x, y) {
|
|
2816
|
+
const geometry = scrollerGeometry(element);
|
|
2817
|
+
if (!geometry) return false;
|
|
2818
|
+
const next = {
|
|
2819
|
+
x: Math.round(Math.min(geometry.maxX, Math.max(0, Number(x) || 0))),
|
|
2820
|
+
y: Math.round(Math.min(geometry.maxY, Math.max(0, Number(y) || 0))),
|
|
2821
|
+
};
|
|
2822
|
+
if (next.x === geometry.x && next.y === geometry.y) return false;
|
|
2823
|
+
if (next.x || next.y) page.elementScroll.set(element, next);
|
|
2824
|
+
else page.elementScroll.delete(element);
|
|
2825
|
+
page.layoutVersion++;
|
|
2826
|
+
page.displayList = paintList();
|
|
2827
|
+
page.needsPaint = true;
|
|
2828
|
+
// A scroll event at the element, as a task (CSSOM View §13.1).
|
|
2829
|
+
hostSetTimeout(() => {
|
|
2830
|
+
if (page.realm && element.isConnected) page.realm.dispatch(element, new page.window.Event("scroll"));
|
|
2831
|
+
}, 0);
|
|
2832
|
+
return true;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
/**
|
|
2836
|
+
* Keeps offsets within what each scroll container can scroll after a
|
|
2837
|
+
* relayout (measured on the display list just built); returns whether any changed.
|
|
2838
|
+
*/
|
|
2839
|
+
function clampElementScroll() {
|
|
2840
|
+
let changed = false;
|
|
2841
|
+
for (const [element, offset] of page.elementScroll) {
|
|
2842
|
+
const geometry = element.isConnected ? scrollerGeometry(element) : null;
|
|
2843
|
+
if (!geometry) {
|
|
2844
|
+
page.elementScroll.delete(element);
|
|
2845
|
+
changed = true;
|
|
2846
|
+
} else if (offset.x > geometry.maxX || offset.y > geometry.maxY) {
|
|
2847
|
+
page.elementScroll.set(element, { x: Math.min(offset.x, geometry.maxX), y: Math.min(offset.y, geometry.maxY) });
|
|
2848
|
+
changed = true;
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
return changed;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
/** How far the scroll containers around an element have moved it (to subtract from its layout position). */
|
|
2855
|
+
function scrollShift(element) {
|
|
2856
|
+
let x = 0;
|
|
2857
|
+
let y = 0;
|
|
2858
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
|
|
2859
|
+
if (node !== element) {
|
|
2860
|
+
const offset = page.elementScroll.get(node);
|
|
2861
|
+
if (offset) {
|
|
2862
|
+
x += offset.x;
|
|
2863
|
+
y += offset.y;
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
// Fixed content does not move with what is around it.
|
|
2867
|
+
if (styleEngine.get(node)?.position === "fixed") break;
|
|
2868
|
+
}
|
|
2869
|
+
return { x, y };
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
/**
|
|
2873
|
+
* The wheel at a viewport point (UI Events §5.4): a wheel event at the
|
|
2874
|
+
* element there; unless it is canceled, the scroll containers from there
|
|
2875
|
+
* outwards each take what they can still scroll of the delta and pass the
|
|
2876
|
+
* rest on (scroll chaining), an iframe's document included unless the
|
|
2877
|
+
* iframe says scrolling="no". Returns the delta left over for the page,
|
|
2878
|
+
* which the compositor scrolls (or, for an iframe's document, the frame here).
|
|
2879
|
+
*/
|
|
2880
|
+
function wheel(x, y, deltaX, deltaY, { scrollViewport = true } = {}) {
|
|
2881
|
+
refresh();
|
|
2882
|
+
let left = { x: deltaX, y: deltaY };
|
|
2883
|
+
const target = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2884
|
+
if (target?.tagName === "IFRAME") {
|
|
2885
|
+
const inFrame = frameAt(target, x, y);
|
|
2886
|
+
if (inFrame) {
|
|
2887
|
+
left = inFrame.frame.renderer.wheel(inFrame.x, inFrame.y, left.x, left.y, {
|
|
2888
|
+
scrollViewport: target.getAttribute("scrolling")?.toLowerCase() !== "no",
|
|
2889
|
+
});
|
|
2890
|
+
if (!left.x && !left.y) return left;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
if (target && page.realm) {
|
|
2894
|
+
const WheelEvent = page.window.WheelEvent ?? page.window.MouseEvent;
|
|
2895
|
+
const event = new WheelEvent("wheel", {
|
|
2896
|
+
bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, deltaX: left.x, deltaY: left.y, deltaMode: 0,
|
|
2897
|
+
});
|
|
2898
|
+
if (page.realm.dispatch(target, event)) return { x: 0, y: 0 };
|
|
2899
|
+
}
|
|
2900
|
+
let moved = false;
|
|
2901
|
+
for (let node = target; node && node.nodeType === 1 && (left.x || left.y); node = node.parentElement) {
|
|
2902
|
+
const geometry = scrollerGeometry(node);
|
|
2903
|
+
if (!geometry) continue;
|
|
2904
|
+
const nextX = geometry.user.x ? Math.min(geometry.maxX, Math.max(0, geometry.x + left.x)) : geometry.x;
|
|
2905
|
+
const nextY = geometry.user.y ? Math.min(geometry.maxY, Math.max(0, geometry.y + left.y)) : geometry.y;
|
|
2906
|
+
if (nextX === geometry.x && nextY === geometry.y) continue;
|
|
2907
|
+
setElementScroll(node, nextX, nextY);
|
|
2908
|
+
left = { x: left.x - (nextX - geometry.x), y: left.y - (nextY - geometry.y) };
|
|
2909
|
+
moved = true;
|
|
2910
|
+
}
|
|
2911
|
+
// An iframe's document scrolls its own viewport; the top-level page leaves that to the compositor.
|
|
2912
|
+
if (embedded && scrollViewport && (left.x || left.y)) {
|
|
2913
|
+
const before = { x: viewport.x, y: viewport.y };
|
|
2914
|
+
if (viewport.scrollBy(left.x, left.y)) {
|
|
2915
|
+
left = { x: left.x - (viewport.x - before.x), y: left.y - (viewport.y - before.y) };
|
|
2916
|
+
moved = true;
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
if (moved) render();
|
|
2920
|
+
return left;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/** Scrolls the scroll containers around an element, then the page, so that its top shows. */
|
|
2924
|
+
function revealElement(element) {
|
|
2925
|
+
for (let node = element.parentElement; node && node.nodeType === 1; node = node.parentElement) {
|
|
2926
|
+
const geometry = scrollerGeometry(node);
|
|
2927
|
+
if (!geometry) continue;
|
|
2928
|
+
const target = viewportBounds(element);
|
|
2929
|
+
const container = viewportBounds(node);
|
|
2930
|
+
if (!target || !container) continue;
|
|
2931
|
+
const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
|
|
2932
|
+
setElementScroll(node, geometry.x + target.left - (container.left + borderLeft), geometry.y + target.top - (container.top + borderTop));
|
|
2933
|
+
}
|
|
2934
|
+
const bounds = viewportBounds(element);
|
|
2935
|
+
if (bounds) viewport.scrollTo(viewport.x, viewport.y + bounds.top);
|
|
2936
|
+
render();
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2154
2939
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
2155
2940
|
function initializeVideos(document, resources) {
|
|
2156
2941
|
for (const element of document.querySelectorAll("video, audio")) {
|
|
@@ -2179,31 +2964,162 @@ export async function createPageRenderer({
|
|
|
2179
2964
|
/** HTMLMediaElement state for page script. */
|
|
2180
2965
|
function mediaState(element) {
|
|
2181
2966
|
const state = page.media.get(element);
|
|
2967
|
+
const decoder = state?.video?.decoder;
|
|
2182
2968
|
return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
|
|
2183
|
-
duration: state?.duration ?? NaN };
|
|
2969
|
+
duration: state?.duration ?? NaN, videoWidth: decoder?.width || 0, videoHeight: decoder?.height || 0 };
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
function mediaReadyState(element) {
|
|
2973
|
+
const state = page.media.get(element);
|
|
2974
|
+
if (!state?.probed) return 0;
|
|
2975
|
+
if (state.buffering) return 2;
|
|
2976
|
+
if (state.video || state.sound) return state.pipe && !state.pipe.done ? 3 : 4;
|
|
2977
|
+
return 1;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
function mediaNetworkState(element) {
|
|
2981
|
+
const state = page.media.get(element);
|
|
2982
|
+
const hasSrc = Boolean(videoSource(element) || elementSources.get(element));
|
|
2983
|
+
if (!state && !hasSrc) return 0;
|
|
2984
|
+
if (state?.error) return 3;
|
|
2985
|
+
if ((state?.pipe && !state.pipe.done) || state?.loading) return 2;
|
|
2986
|
+
return 1;
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function mediaError(element) {
|
|
2990
|
+
return page.media.get(element)?.error ?? null;
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
function mediaBuffered(element) {
|
|
2994
|
+
const state = page.media.get(element);
|
|
2995
|
+
const end = state?.mediaSource?.bufferedEnd?.() || state?.bufferedEnd || 0;
|
|
2996
|
+
return end > 0 ? { length: 1, start: 0, end } : { length: 0, start: 0, end: 0 };
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
function resolveMediaSource(value) {
|
|
3000
|
+
const realm = page.realm;
|
|
3001
|
+
if (!realm?.isMediaSource) return null;
|
|
3002
|
+
if (realm.isMediaSource(value)) return value;
|
|
3003
|
+
if (typeof value === "string") return realm.mediaSourceAt(value);
|
|
3004
|
+
return null;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/** A resource record for a media element created after the document was parsed. */
|
|
3008
|
+
function ensureMediaResource(element) {
|
|
3009
|
+
if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
|
|
3010
|
+
let resource = page.resources.get(element);
|
|
3011
|
+
if (resource) return resource;
|
|
3012
|
+
const audioOnly = element.tagName === "AUDIO";
|
|
3013
|
+
resource = {
|
|
3014
|
+
width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
|
|
3015
|
+
height: audioOnly ? 32 : Number(element.getAttribute("height")) || 150,
|
|
3016
|
+
bitmapWidth: 0, bitmapHeight: 0, image: null, playing: false, time: 0, duration: NaN,
|
|
3017
|
+
};
|
|
3018
|
+
page.resources.set(element, resource);
|
|
3019
|
+
page.dirty = true;
|
|
3020
|
+
return resource;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
/** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
|
|
3024
|
+
function attachMediaSrc(element, value) {
|
|
3025
|
+
if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
|
|
3026
|
+
const source = resolveMediaSource(value);
|
|
3027
|
+
if (!source) {
|
|
3028
|
+
elementSources.delete(element);
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
elementSources.set(element, source);
|
|
3032
|
+
sourceElements.set(source, element);
|
|
3033
|
+
if (source.readyState === "closed") {
|
|
3034
|
+
try {
|
|
3035
|
+
source.attach(typeof value === "string" ? value : source._objectURL);
|
|
3036
|
+
} catch {
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
const state = mediaStateFor(element);
|
|
3041
|
+
if (!state) return;
|
|
3042
|
+
state.mediaSource = source;
|
|
3043
|
+
if (Number.isFinite(source.duration)) {
|
|
3044
|
+
state.durationOverride = true;
|
|
3045
|
+
state.duration = source.duration;
|
|
3046
|
+
if (state.resource) state.resource.duration = source.duration;
|
|
3047
|
+
}
|
|
3048
|
+
bindSourcePipes(state);
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
function mediaAppended(source) {
|
|
3052
|
+
const element = sourceElements.get(source);
|
|
3053
|
+
if (!element) return;
|
|
3054
|
+
const state = page.media.get(element) ?? mediaStateFor(element);
|
|
3055
|
+
if (!state) return;
|
|
3056
|
+
state.mediaSource = source;
|
|
3057
|
+
bindSourcePipes(state);
|
|
3058
|
+
if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
|
|
3059
|
+
void refreshProbe(state);
|
|
3060
|
+
if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
function mediaDuration(source, seconds) {
|
|
3064
|
+
const element = sourceElements.get(source);
|
|
3065
|
+
const state = element && page.media.get(element);
|
|
3066
|
+
if (!state || !Number.isFinite(seconds)) return;
|
|
3067
|
+
state.durationOverride = true;
|
|
3068
|
+
state.duration = seconds;
|
|
3069
|
+
if (state.resource) state.resource.duration = seconds;
|
|
3070
|
+
mediaEvent(state, "durationchange");
|
|
2184
3071
|
}
|
|
2185
3072
|
|
|
2186
3073
|
/** The element's playback state, created (not loaded) on first use. */
|
|
2187
3074
|
function mediaStateFor(element) {
|
|
2188
|
-
const resource =
|
|
2189
|
-
|
|
2190
|
-
|
|
3075
|
+
const resource = ensureMediaResource(element);
|
|
3076
|
+
let source = elementSources.get(element) ?? null;
|
|
3077
|
+
const attributeUrl = videoSource(element);
|
|
3078
|
+
if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
|
|
3079
|
+
const url = attributeUrl ?? source?._objectURL ?? null;
|
|
3080
|
+
if (!videoDecoder || !resource || (!url && !source)) return null;
|
|
3081
|
+
if (source && source.readyState === "closed") {
|
|
3082
|
+
try {
|
|
3083
|
+
source.attach(url);
|
|
3084
|
+
elementSources.set(element, source);
|
|
3085
|
+
sourceElements.set(source, element);
|
|
3086
|
+
} catch {}
|
|
3087
|
+
}
|
|
2191
3088
|
let state = page.media.get(element);
|
|
2192
3089
|
if (state && state.url !== url) {
|
|
2193
3090
|
closeMedia(state);
|
|
3091
|
+
page.media.delete(element);
|
|
2194
3092
|
state = null;
|
|
2195
3093
|
}
|
|
2196
3094
|
if (!state) {
|
|
2197
|
-
state = { element, url, resource, bytes: null,
|
|
2198
|
-
|
|
2199
|
-
|
|
3095
|
+
state = { element, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
|
|
3096
|
+
loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
|
|
3097
|
+
video: null, sound: null, playing: false, ended: false,
|
|
3098
|
+
duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
|
|
3099
|
+
timer: null, lastTimeUpdate: -1, generation: loadGeneration, document: page.document,
|
|
3100
|
+
probed: false, hasVideo: false, hasAudio: false, buffering: false, refilling: null,
|
|
3101
|
+
seekSerial: 0, error: null, bufferedEnd: 0, contentType: "", videoMime: "", audioMime: "" };
|
|
2200
3102
|
page.media.set(element, state);
|
|
3103
|
+
if (Number.isFinite(source?.duration)) {
|
|
3104
|
+
state.durationOverride = true;
|
|
3105
|
+
resource.duration = source.duration;
|
|
3106
|
+
}
|
|
3107
|
+
} else if (source) {
|
|
3108
|
+
state.mediaSource = source;
|
|
2201
3109
|
}
|
|
2202
3110
|
return state;
|
|
2203
3111
|
}
|
|
2204
3112
|
|
|
2205
3113
|
/** Current position in seconds. */
|
|
2206
3114
|
function mediaTime(state) {
|
|
3115
|
+
const sound = state.sound;
|
|
3116
|
+
if (state.playing && sound?.id != null && !sound.done) {
|
|
3117
|
+
const time = sound.playedTime ?? sound.streamStart / sound.decoder.sampleRate;
|
|
3118
|
+
return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
|
|
3119
|
+
}
|
|
3120
|
+
// A known audio track which hasn't arrived must not leave its picture
|
|
3121
|
+
// running independently during a network outage.
|
|
3122
|
+
if (state.playing && state.hasAudio && audio && !sound) return state.base;
|
|
2207
3123
|
const running = state.playing && state.clockStart !== null ? (hostPerformance.now() - state.clockStart) / 1000 : 0;
|
|
2208
3124
|
const time = state.base + running;
|
|
2209
3125
|
return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
|
|
@@ -2223,39 +3139,192 @@ export async function createPageRenderer({
|
|
|
2223
3139
|
render();
|
|
2224
3140
|
}
|
|
2225
3141
|
|
|
2226
|
-
|
|
3142
|
+
function isPipe(value) {
|
|
3143
|
+
return Boolean(value && typeof value.waitFor === "function" && typeof value.bytes === "function");
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
function bindSourcePipes(state) {
|
|
3147
|
+
const pipes = state.mediaSource?.streamPipes?.();
|
|
3148
|
+
if (!pipes) return;
|
|
3149
|
+
if (pipes.video) state.videoPipe = pipes.video;
|
|
3150
|
+
if (pipes.audio) state.audioPipe = pipes.audio;
|
|
3151
|
+
state.pipe = state.videoPipe ?? state.audioPipe ?? state.pipe;
|
|
3152
|
+
state.videoMime = pipes.videoMime || state.videoMime;
|
|
3153
|
+
state.audioMime = pipes.audioMime || state.audioMime;
|
|
3154
|
+
state.audioHasMedia = pipes.audioReady;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
/** Fetches (or attaches MSE pipes), probes a prefix, and opens decoders. */
|
|
2227
3158
|
function loadMedia(state) {
|
|
2228
3159
|
state.loading ??= (async () => {
|
|
2229
|
-
if (
|
|
3160
|
+
if (state.mediaSource) {
|
|
3161
|
+
await state.mediaSource.waitForPipes();
|
|
3162
|
+
bindSourcePipes(state);
|
|
3163
|
+
}
|
|
3164
|
+
if (!state.pipe && !state.bytes && !state.mediaSource) {
|
|
2230
3165
|
const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
|
|
2231
3166
|
if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
|
|
2232
|
-
state.
|
|
3167
|
+
state.contentType = response.contentType ?? "";
|
|
3168
|
+
if (isPipe(response.body)) {
|
|
3169
|
+
state.pipe = response.body;
|
|
3170
|
+
state.videoPipe = state.pipe;
|
|
3171
|
+
state.audioPipe = state.pipe;
|
|
3172
|
+
} else {
|
|
3173
|
+
state.bytes = response.body;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
await ensureProbed(state);
|
|
3177
|
+
if (!state.mediaSource) watchProgressiveMetadata(state);
|
|
3178
|
+
if (state.hasVideo && !state.video) await openVideoDecoder(state);
|
|
3179
|
+
// A separate audio buffer often arrives as an init segment, or empty.
|
|
3180
|
+
// That is not a broken video: picture plays, and sound attaches later.
|
|
3181
|
+
if (state.hasAudio && audio && !state.sound) {
|
|
3182
|
+
try {
|
|
3183
|
+
let opened = await openAudioDecoder(state);
|
|
3184
|
+
// With separate MSE buffers the audio init segment often arrives
|
|
3185
|
+
// before its first media segment. Starting the video clock here
|
|
3186
|
+
// makes ensureAudio seek past the opening words once sound becomes
|
|
3187
|
+
// decodable. Briefly wait for that first segment so both start at 0.
|
|
3188
|
+
const audioPipe = state.audioPipe ?? state.pipe;
|
|
3189
|
+
const separateAudio = state.audioPipe && state.audioPipe !== state.videoPipe;
|
|
3190
|
+
if (state.video && audioPipe && !audioPipe.done && !audioPipe.error
|
|
3191
|
+
&& (!opened || (separateAudio && !state.audioHasMedia))) {
|
|
3192
|
+
const deadline = hostPerformance.now() + 750;
|
|
3193
|
+
do {
|
|
3194
|
+
const mark = audioPipe.size + 1;
|
|
3195
|
+
const remaining = Math.max(0, deadline - hostPerformance.now());
|
|
3196
|
+
await Promise.race([
|
|
3197
|
+
audioPipe.waitFor(mark),
|
|
3198
|
+
new Promise((resolve) => hostSetTimeout(resolve, remaining)),
|
|
3199
|
+
]);
|
|
3200
|
+
bindSourcePipes(state);
|
|
3201
|
+
} while (separateAudio && !state.audioHasMedia && !audioPipe.done && !audioPipe.error
|
|
3202
|
+
&& hostPerformance.now() < deadline);
|
|
3203
|
+
if (!audioPipe.error) opened = await openAudioDecoder(state);
|
|
3204
|
+
}
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
if (!state.video) throw error;
|
|
3207
|
+
reportError(error);
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
})().finally(() => { state.loading = null; });
|
|
3211
|
+
return state.loading;
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
/** Probe until the growing body exposes a picture or sample rate, or ends. */
|
|
3215
|
+
async function ensureProbed(state) {
|
|
3216
|
+
if (state.probed) return;
|
|
3217
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
3218
|
+
let want = 1024;
|
|
3219
|
+
let lastError = null;
|
|
3220
|
+
while (true) {
|
|
3221
|
+
if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
|
|
3222
|
+
if (pipe?.error) throw pipe.error;
|
|
3223
|
+
const bytes = pipe ? pipe.bytes() : state.bytes;
|
|
3224
|
+
if (!bytes?.byteLength) {
|
|
3225
|
+
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
3226
|
+
continue;
|
|
2233
3227
|
}
|
|
2234
|
-
|
|
2235
|
-
const probe = await videoDecoder.probe(
|
|
3228
|
+
try {
|
|
3229
|
+
const probe = await videoDecoder.probe(bytes);
|
|
3230
|
+
const videoReady = probe.video?.width > 0;
|
|
3231
|
+
const audioReady = probe.audio?.sampleRate > 0;
|
|
3232
|
+
if (!videoReady && !audioReady) {
|
|
3233
|
+
if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
|
|
3234
|
+
want = Math.max(bytes.byteLength + 1, want * 2);
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
if (!state.durationOverride) state.duration = probe.duration ?? NaN;
|
|
3238
|
+
state.hasVideo = Boolean(state.showsPicture && videoReady);
|
|
3239
|
+
state.hasAudio = Boolean(audioReady || (state.audioPipe && state.audioPipe !== pipe));
|
|
2236
3240
|
state.probed = true;
|
|
2237
|
-
|
|
2238
|
-
state.
|
|
2239
|
-
state.
|
|
3241
|
+
const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : 0.001;
|
|
3242
|
+
state.bufferedEnd = end;
|
|
3243
|
+
state.mediaSource?.noteBuffered(end);
|
|
2240
3244
|
state.resource.duration = state.duration;
|
|
2241
3245
|
mediaEvent(state, "loadedmetadata");
|
|
3246
|
+
return;
|
|
3247
|
+
} catch (error) {
|
|
3248
|
+
lastError = error;
|
|
3249
|
+
if (!pipe || pipe.done) throw lastError;
|
|
3250
|
+
want = Math.max(bytes.byteLength + 1, want * 2);
|
|
2242
3251
|
}
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
/** A later MSE append can extend the duration and the buffered range. */
|
|
3256
|
+
async function refreshProbe(state) {
|
|
3257
|
+
if (state.probing) return;
|
|
3258
|
+
state.probing = true;
|
|
3259
|
+
try {
|
|
3260
|
+
if (!state.probed) {
|
|
3261
|
+
await ensureProbed(state);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
3265
|
+
if (!pipe?.size) return;
|
|
3266
|
+
try {
|
|
3267
|
+
const probe = await videoDecoder.probe(pipe.bytes());
|
|
3268
|
+
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
3269
|
+
state.duration = probe.duration;
|
|
3270
|
+
state.resource.duration = probe.duration;
|
|
3271
|
+
mediaEvent(state, "durationchange");
|
|
3272
|
+
}
|
|
3273
|
+
const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
|
|
3274
|
+
state.bufferedEnd = end;
|
|
3275
|
+
state.mediaSource?.noteBuffered(end);
|
|
3276
|
+
if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
|
|
3277
|
+
if (probe.audio?.sampleRate > 0) state.hasAudio = true;
|
|
3278
|
+
} catch {}
|
|
3279
|
+
} finally {
|
|
3280
|
+
state.probing = false;
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
/** Refresh duration as a progressive response grows, especially MP3. */
|
|
3285
|
+
function watchProgressiveMetadata(state) {
|
|
3286
|
+
const pipe = state.pipe;
|
|
3287
|
+
if (!pipe || pipe.done || state.metadataWatch) return;
|
|
3288
|
+
state.metadataWatch = (async () => {
|
|
3289
|
+
let mark = Math.max(pipe.size + 1, pipe.size * 2);
|
|
3290
|
+
while (state.document === page.document && !pipe.error) {
|
|
3291
|
+
await pipe.waitFor(mark);
|
|
3292
|
+
if (state.document !== page.document || pipe.error) return;
|
|
3293
|
+
await refreshProbe(state);
|
|
3294
|
+
if (pipe.done) return;
|
|
3295
|
+
mark = Math.max(pipe.size + 64 * 1024, pipe.size * 2);
|
|
3296
|
+
}
|
|
3297
|
+
})().catch((error) => reportError(error)).finally(() => {
|
|
3298
|
+
state.metadataWatch = null;
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// Content-probed when the URL has a container extension. Blob MSE URLs do
|
|
3303
|
+
// not, so the SourceBuffer MIME selects the demuxer.
|
|
3304
|
+
function decodeOptions(mime, url) {
|
|
3305
|
+
let path = "";
|
|
3306
|
+
try {
|
|
3307
|
+
path = url ? new URL(url).pathname : "";
|
|
3308
|
+
} catch {}
|
|
3309
|
+
const hint = formatHint(path);
|
|
3310
|
+
if (hint) return { format: hint };
|
|
3311
|
+
if (/\.(mp4|m4v|m4a|webm|mkv|ogg|ogv|mp3|wav|aac|flac)$/i.test(path)) return {};
|
|
3312
|
+
const format = containerFormat(mime);
|
|
3313
|
+
return format ? { format } : {};
|
|
2247
3314
|
}
|
|
2248
3315
|
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
const hint = formatHint(new URL(state.url).pathname);
|
|
2252
|
-
return hint ? { format: hint } : {};
|
|
3316
|
+
function snapshot(pipe, bytes) {
|
|
3317
|
+
return pipe ? pipe.bytes() : bytes;
|
|
2253
3318
|
}
|
|
2254
3319
|
|
|
2255
3320
|
async function openVideoDecoder(state) {
|
|
3321
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
3322
|
+
const generation = pipe?.generation ?? 0;
|
|
3323
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3324
|
+
if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
|
|
2256
3325
|
state.video?.decoder.close();
|
|
2257
|
-
const decoder = await videoDecoder.decode(
|
|
2258
|
-
state.video = { decoder, frameTime: 0, done: false };
|
|
3326
|
+
const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
|
|
3327
|
+
state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation };
|
|
2259
3328
|
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
2260
3329
|
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
2261
3330
|
state.resource.width = decoder.width;
|
|
@@ -2265,11 +3334,108 @@ export async function createPageRenderer({
|
|
|
2265
3334
|
}
|
|
2266
3335
|
}
|
|
2267
3336
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
3337
|
+
/**
|
|
3338
|
+
* Opens the audio decoder on the current snapshot.
|
|
3339
|
+
* Returns false when the pipe can still grow into something decodable
|
|
3340
|
+
* (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
|
|
3341
|
+
* The previous decoder stays until a new one succeeds.
|
|
3342
|
+
*/
|
|
3343
|
+
async function openAudioDecoder(state, { keepStream = false } = {}) {
|
|
3344
|
+
const serial = state.seekSerial;
|
|
3345
|
+
const operation = (state.audioOpenSerial ?? 0) + 1;
|
|
3346
|
+
state.audioOpenSerial = operation;
|
|
3347
|
+
const pipe = state.audioPipe ?? state.pipe;
|
|
3348
|
+
const generation = pipe?.generation ?? 0;
|
|
3349
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3350
|
+
const pending = pipe && !pipe.done && !pipe.error;
|
|
3351
|
+
if (!bytes?.byteLength) {
|
|
3352
|
+
if (pending) return false;
|
|
3353
|
+
throw new Error(`media ${state.url}: no audio bytes`);
|
|
3354
|
+
}
|
|
3355
|
+
let decoder;
|
|
3356
|
+
try {
|
|
3357
|
+
decoder = await videoDecoder.decodeAudio(bytes, decodeOptions(state.audioMime || state.contentType, state.url));
|
|
3358
|
+
} catch (error) {
|
|
3359
|
+
if (pending || (pipe && pipe.generation !== generation)) return false;
|
|
3360
|
+
throw error;
|
|
3361
|
+
}
|
|
3362
|
+
let firstSamples = null;
|
|
3363
|
+
try {
|
|
3364
|
+
firstSamples = decoder.nextSamples();
|
|
3365
|
+
} catch (error) {
|
|
3366
|
+
decoder.close();
|
|
3367
|
+
if (pending || (pipe && pipe.generation !== generation)) return false;
|
|
3368
|
+
throw error;
|
|
3369
|
+
}
|
|
3370
|
+
if (!firstSamples && pending) {
|
|
3371
|
+
decoder.close();
|
|
3372
|
+
return false;
|
|
3373
|
+
}
|
|
3374
|
+
if (state.document !== page.document || state.seekSerial !== serial || state.audioOpenSerial !== operation) {
|
|
3375
|
+
decoder.close();
|
|
3376
|
+
return false;
|
|
3377
|
+
}
|
|
3378
|
+
const previous = state.sound;
|
|
3379
|
+
const previousId = previous?.id ?? null;
|
|
3380
|
+
if (keepStream && previousId != null
|
|
3381
|
+
&& (previous.decoder.sampleRate !== decoder.sampleRate || previous.decoder.channels !== decoder.channels)) {
|
|
3382
|
+
decoder.close();
|
|
3383
|
+
throw new Error("audio format changed while output stream is active");
|
|
3384
|
+
}
|
|
3385
|
+
previous?.decoder.close();
|
|
3386
|
+
state.sound = { decoder, id: null, position: 0, pending: firstSamples, history: [], finishing: false, done: false,
|
|
3387
|
+
pipe, openedGeneration: generation };
|
|
3388
|
+
if (keepStream && previousId != null) {
|
|
3389
|
+
// The output stream carries on with the new decoder's samples.
|
|
3390
|
+
Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
|
|
3391
|
+
playedTime: previous.playedTime, owed: previous.owed ?? 0 });
|
|
3392
|
+
} else if (previousId != null) audio?.close(previousId);
|
|
2272
3393
|
trace?.(`audio decoded ${state.url}: ${decoder.sampleRate} Hz, ${decoder.channels} channels`);
|
|
3394
|
+
return true;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/** Attaches sound once a separate audio buffer becomes decodable. */
|
|
3398
|
+
function ensureAudio(state) {
|
|
3399
|
+
// loadMedia/refillMedia own decoder replacement. An append arriving while
|
|
3400
|
+
// either awaits bytes must not start a second opener and reset live audio
|
|
3401
|
+
// back to the first packet.
|
|
3402
|
+
if (state.loading || state.refilling || state.sound || state.audioWatch || !state.hasAudio || !audio) return;
|
|
3403
|
+
const pipe = state.audioPipe ?? state.pipe;
|
|
3404
|
+
if (!pipe) return;
|
|
3405
|
+
state.audioWatch = (async () => {
|
|
3406
|
+
try {
|
|
3407
|
+
while (state.playing && !state.sound && state.document === page.document) {
|
|
3408
|
+
let opened = false;
|
|
3409
|
+
try {
|
|
3410
|
+
opened = await openAudioDecoder(state);
|
|
3411
|
+
} catch (error) {
|
|
3412
|
+
if (state.video) reportError(error);
|
|
3413
|
+
else if (state.document === page.document) failMedia(state, error, 4);
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
if (opened && state.sound && state.playing && state.document === page.document) {
|
|
3417
|
+
await positionAudio(state, mediaTime(state));
|
|
3418
|
+
if (state.sound && !state.sound.id) openAudioStream(state);
|
|
3419
|
+
return;
|
|
3420
|
+
}
|
|
3421
|
+
if (state.sound || pipe.done || pipe.error) return;
|
|
3422
|
+
const size = pipe.size;
|
|
3423
|
+
await pipe.waitFor(size + 1);
|
|
3424
|
+
}
|
|
3425
|
+
} finally {
|
|
3426
|
+
state.audioWatch = null;
|
|
3427
|
+
}
|
|
3428
|
+
})();
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
/**
|
|
3432
|
+
* The decoder was opened on a snapshot that is not the finished body.
|
|
3433
|
+
* null and a truncated-file error both mean "wait", not "ended".
|
|
3434
|
+
*/
|
|
3435
|
+
function needsMore(track) {
|
|
3436
|
+
const pipe = track?.pipe;
|
|
3437
|
+
if (!pipe || pipe.error) return false;
|
|
3438
|
+
return pipe.generation !== track.openedGeneration || !pipe.done;
|
|
2273
3439
|
}
|
|
2274
3440
|
|
|
2275
3441
|
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
@@ -2294,6 +3460,7 @@ export async function createPageRenderer({
|
|
|
2294
3460
|
async function playMedia(element) {
|
|
2295
3461
|
const state = mediaStateFor(element);
|
|
2296
3462
|
if (!state || state.playing) return;
|
|
3463
|
+
state.error = null;
|
|
2297
3464
|
setMediaPlaying(state, true);
|
|
2298
3465
|
try {
|
|
2299
3466
|
await loadMedia(state);
|
|
@@ -2302,21 +3469,18 @@ export async function createPageRenderer({
|
|
|
2302
3469
|
await positionMedia(state, 0);
|
|
2303
3470
|
} else if (state.needsPosition) {
|
|
2304
3471
|
// After a pause the audio decoder is ahead of where playing resumes.
|
|
2305
|
-
await
|
|
3472
|
+
const moved = await skipPlayedAudio(state);
|
|
3473
|
+
await positionMedia(state, state.base, { videoToo: moved });
|
|
2306
3474
|
}
|
|
2307
3475
|
state.needsPosition = false;
|
|
2308
3476
|
} catch (error) {
|
|
2309
|
-
if (state.document === page.document)
|
|
2310
|
-
closeMedia(state);
|
|
2311
|
-
page.media.delete(element);
|
|
2312
|
-
repaintMedia();
|
|
3477
|
+
if (state.document === page.document) failMedia(state, error, state.pipe?.error ? 2 : 4);
|
|
2313
3478
|
return;
|
|
2314
3479
|
}
|
|
2315
3480
|
if (!state.playing || state.document !== page.document) return;
|
|
2316
3481
|
if (!state.video && !state.sound) {
|
|
2317
3482
|
// Nothing this engine can play (audio with no audio output).
|
|
2318
|
-
|
|
2319
|
-
reportError(new Error(`media ${state.url}: nothing to play`));
|
|
3483
|
+
failMedia(state, new Error(`media ${state.url}: nothing to play`), 4);
|
|
2320
3484
|
return;
|
|
2321
3485
|
}
|
|
2322
3486
|
startMedia(state);
|
|
@@ -2327,6 +3491,7 @@ export async function createPageRenderer({
|
|
|
2327
3491
|
const state = page.media.get(element);
|
|
2328
3492
|
if (!state?.playing) return;
|
|
2329
3493
|
state.base = mediaTime(state);
|
|
3494
|
+
trace?.(`media pause at ${state.base.toFixed(3)}s`);
|
|
2330
3495
|
stopClock(state);
|
|
2331
3496
|
state.needsPosition = true;
|
|
2332
3497
|
setMediaPlaying(state, false);
|
|
@@ -2336,10 +3501,17 @@ export async function createPageRenderer({
|
|
|
2336
3501
|
async function seekMedia(element, seconds) {
|
|
2337
3502
|
const state = mediaStateFor(element);
|
|
2338
3503
|
if (!state) return;
|
|
3504
|
+
trace?.(`media seek to ${Number(seconds).toFixed(3)}s from ${mediaTime(state).toFixed(3)}s`);
|
|
3505
|
+
const serial = ++state.seekSerial;
|
|
2339
3506
|
const wasPlaying = state.playing;
|
|
2340
3507
|
if (wasPlaying) stopClock(state);
|
|
3508
|
+
// A seek goes where it is told, whatever the output still plays.
|
|
3509
|
+
state.outputEnd = null;
|
|
2341
3510
|
try {
|
|
3511
|
+
if (state.refilling) await state.refilling.catch(() => {});
|
|
3512
|
+
if (state.seekSerial !== serial) return;
|
|
2342
3513
|
await loadMedia(state);
|
|
3514
|
+
if (state.seekSerial !== serial) return;
|
|
2343
3515
|
const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
|
|
2344
3516
|
state.ended = false;
|
|
2345
3517
|
await positionMedia(state, target);
|
|
@@ -2360,80 +3532,149 @@ export async function createPageRenderer({
|
|
|
2360
3532
|
/** Moves the decoders (and the clock's base) to `seconds`. */
|
|
2361
3533
|
async function positionMedia(state, seconds, { videoToo = true } = {}) {
|
|
2362
3534
|
state.base = seconds;
|
|
2363
|
-
if (state.video && videoToo)
|
|
2364
|
-
if (state.video.done || seconds === 0) await openVideoDecoder(state);
|
|
2365
|
-
if (seconds > 0) state.video.decoder.seek(seconds * 1000);
|
|
2366
|
-
state.video.frameTime = seconds;
|
|
2367
|
-
state.video.done = false;
|
|
2368
|
-
}
|
|
3535
|
+
if (state.video && videoToo) await placeVideo(state, seconds);
|
|
2369
3536
|
if (state.sound) await positionAudio(state, seconds);
|
|
2370
3537
|
}
|
|
2371
3538
|
|
|
2372
|
-
/**
|
|
2373
|
-
async function
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
// Resume inside audio already decoded: take it from the history.
|
|
2381
|
-
const parts = [];
|
|
2382
|
-
for (const chunk of sound.history) {
|
|
2383
|
-
const frames = chunk.samples.length / channels;
|
|
2384
|
-
if (chunk.start + frames <= target) continue;
|
|
2385
|
-
parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
|
|
2386
|
-
}
|
|
2387
|
-
if (sound.pending) parts.push(sound.pending);
|
|
2388
|
-
sound.pending = concatSamples(parts);
|
|
2389
|
-
sound.position = target;
|
|
2390
|
-
sound.history = [];
|
|
2391
|
-
return;
|
|
2392
|
-
}
|
|
2393
|
-
if (target < sound.position) await openAudioDecoder(state);
|
|
2394
|
-
const current = state.sound;
|
|
2395
|
-
current.history = [];
|
|
2396
|
-
if (current.pending) {
|
|
2397
|
-
const frames = current.pending.length / channels;
|
|
2398
|
-
if (current.position + frames > target) {
|
|
2399
|
-
current.pending = current.pending.subarray((target - current.position) * channels);
|
|
2400
|
-
current.position = target;
|
|
3539
|
+
/** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
|
|
3540
|
+
async function placeVideo(state, seconds) {
|
|
3541
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
3542
|
+
if (!state.video || state.video.done || seconds === 0 || attempt > 0) await openVideoDecoder(state);
|
|
3543
|
+
try {
|
|
3544
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
|
|
3545
|
+
state.video.hideUntil = seconds;
|
|
3546
|
+
state.video.done = false;
|
|
2401
3547
|
return;
|
|
3548
|
+
} catch (error) {
|
|
3549
|
+
if (!needsMore(state.video)) throw error;
|
|
3550
|
+
const pipe = state.video.pipe;
|
|
3551
|
+
const size = pipe.size;
|
|
3552
|
+
await pipe.waitFor(size + 1);
|
|
3553
|
+
if (pipe.error) throw pipe.error;
|
|
3554
|
+
if (pipe.size === size) throw error;
|
|
2402
3555
|
}
|
|
2403
|
-
current.position += frames;
|
|
2404
|
-
current.pending = null;
|
|
2405
3556
|
}
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
/** Puts the audio decoder at `seconds`: from the kept history, by decoding ahead, or from the start. */
|
|
3560
|
+
async function positionAudio(state, seconds) {
|
|
3561
|
+
const transaction = {};
|
|
3562
|
+
state.audioPositioning = transaction;
|
|
3563
|
+
const serial = state.seekSerial;
|
|
3564
|
+
try {
|
|
3565
|
+
if (state.sound) trace?.(`audio positioned at ${seconds.toFixed(3)}s (was at ${(state.sound.position / state.sound.decoder.sampleRate).toFixed(3)}s)`);
|
|
3566
|
+
const sound = state.sound;
|
|
3567
|
+
const { sampleRate, channels } = sound.decoder;
|
|
3568
|
+
const target = Math.max(0, Math.round(seconds * sampleRate));
|
|
3569
|
+
sound.done = false;
|
|
3570
|
+
const historyStart = sound.history[0]?.start ?? Infinity;
|
|
3571
|
+
if (target >= historyStart && target < sound.position) {
|
|
3572
|
+
// Resume inside audio already decoded: take it from the history.
|
|
3573
|
+
const parts = [];
|
|
3574
|
+
for (const chunk of sound.history) {
|
|
3575
|
+
const frames = chunk.samples.length / channels;
|
|
3576
|
+
if (chunk.start + frames <= target) continue;
|
|
3577
|
+
parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
|
|
3578
|
+
}
|
|
3579
|
+
if (sound.pending) parts.push(sound.pending);
|
|
3580
|
+
sound.pending = concatSamples(parts);
|
|
3581
|
+
sound.position = target;
|
|
3582
|
+
sound.history = [];
|
|
2413
3583
|
return;
|
|
2414
3584
|
}
|
|
2415
|
-
|
|
3585
|
+
sound.history = [];
|
|
3586
|
+
const reached = await advanceAudio(() => state.sound, seconds, {
|
|
3587
|
+
needsMore,
|
|
3588
|
+
active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
|
|
3589
|
+
reopen: () => openAudioDecoder(state, { keepStream: true }),
|
|
3590
|
+
});
|
|
3591
|
+
if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
|
|
3592
|
+
throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
|
|
3593
|
+
}
|
|
3594
|
+
} finally {
|
|
3595
|
+
if (state.audioPositioning === transaction) state.audioPositioning = null;
|
|
2416
3596
|
}
|
|
2417
3597
|
}
|
|
2418
3598
|
|
|
2419
3599
|
/** Starts the clock, the audio stream and the frame/time ticks. */
|
|
2420
3600
|
function startMedia(state) {
|
|
3601
|
+
// An output stream held through a stall (see refillMedia) is already
|
|
3602
|
+
// open: the clock goes on from where its audio is, and what it asked for
|
|
3603
|
+
// meanwhile is sent now.
|
|
3604
|
+
if (state.sound && !state.sound.done && state.sound.id != null) {
|
|
3605
|
+
const sound = state.sound;
|
|
3606
|
+
state.base = mediaTime(state);
|
|
3607
|
+
state.clockStart = hostPerformance.now();
|
|
3608
|
+
if (state.resyncAudio) audio.resync?.(sound.id);
|
|
3609
|
+
state.resyncAudio = false;
|
|
3610
|
+
const owed = sound.owed ?? 0;
|
|
3611
|
+
sound.owed = 0;
|
|
3612
|
+
if (owed > 0) feedAudio(state, owed);
|
|
3613
|
+
mediaTick(state);
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
// Opening a PulseAudio stream is asynchronous. Advancing the video clock
|
|
3617
|
+
// before the output is ready used to make a later decoder reopen seek
|
|
3618
|
+
// audio past PCM which had not actually reached the output yet. Let the
|
|
3619
|
+
// output stream establish the common A/V epoch instead.
|
|
3620
|
+
if (state.sound && !state.sound.done) {
|
|
3621
|
+
state.clockStart = null;
|
|
3622
|
+
state.awaitingAudio = true;
|
|
3623
|
+
openAudioStream(state);
|
|
3624
|
+
// A misbehaving output server must not freeze video forever. Normally
|
|
3625
|
+
// ready/request arrives almost immediately; this is only a safety net.
|
|
3626
|
+
state.audioClockTimer = hostSetTimeout(() => startAudioClock(state, state.sound), 500);
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
2421
3629
|
state.clockStart = hostPerformance.now();
|
|
2422
|
-
if (state.
|
|
3630
|
+
if (state.hasAudio) ensureAudio(state);
|
|
2423
3631
|
mediaTick(state);
|
|
2424
3632
|
}
|
|
2425
3633
|
|
|
2426
|
-
|
|
3634
|
+
/** Releases a clock which was waiting for audio output to become writable. */
|
|
3635
|
+
function startAudioClock(state, sound) {
|
|
3636
|
+
if (!state.awaitingAudio || state.sound !== sound || !state.playing) return;
|
|
3637
|
+
state.awaitingAudio = false;
|
|
3638
|
+
hostClearTimeout(state.audioClockTimer);
|
|
3639
|
+
state.audioClockTimer = null;
|
|
3640
|
+
state.clockStart = hostPerformance.now();
|
|
3641
|
+
mediaTick(state);
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
function stopClock(state, { keepAudio = false } = {}) {
|
|
2427
3645
|
hostClearTimeout(state.timer);
|
|
2428
3646
|
state.timer = null;
|
|
2429
3647
|
state.clockStart = null;
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
3648
|
+
state.awaitingAudio = false;
|
|
3649
|
+
hostClearTimeout(state.audioClockTimer);
|
|
3650
|
+
state.audioClockTimer = null;
|
|
3651
|
+
state.outputEnd = null;
|
|
3652
|
+
if (state.sound?.id != null && !keepAudio) {
|
|
3653
|
+
const sound = state.sound;
|
|
3654
|
+
trace?.(`audio stream closed at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s written, clock ${mediaTime(state).toFixed(3)}s`);
|
|
3655
|
+
const { sampleRate, channels } = sound.decoder;
|
|
3656
|
+
const start = sound.streamStart ?? sound.position;
|
|
3657
|
+
// The output may go on playing the PCM it already holds (some servers
|
|
3658
|
+
// drain a closed stream): playing resumes after it, not twice.
|
|
3659
|
+
state.outputEnd = Promise.resolve(audio.close(sound.id))
|
|
3660
|
+
.then((sentBytes) => (start + (Number(sentBytes) || 0) / (2 * channels)) / sampleRate, () => null);
|
|
3661
|
+
sound.id = null;
|
|
3662
|
+
sound.finishing = false;
|
|
2434
3663
|
}
|
|
2435
3664
|
}
|
|
2436
3665
|
|
|
3666
|
+
/**
|
|
3667
|
+
* Moves the media clock past the audio the closed output stream still
|
|
3668
|
+
* plays (see stopClock). True when it moved, so video must follow.
|
|
3669
|
+
*/
|
|
3670
|
+
async function skipPlayedAudio(state) {
|
|
3671
|
+
const end = await (state.outputEnd ?? null);
|
|
3672
|
+
state.outputEnd = null;
|
|
3673
|
+
if (!Number.isFinite(end) || end <= state.base) return false;
|
|
3674
|
+
state.base = Number.isFinite(state.duration) ? Math.min(state.duration, end) : end;
|
|
3675
|
+
return true;
|
|
3676
|
+
}
|
|
3677
|
+
|
|
2437
3678
|
/** Draws the frames the clock has reached, updates time, ends when everything has played. */
|
|
2438
3679
|
function mediaTick(state) {
|
|
2439
3680
|
if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
|
|
@@ -2441,9 +3682,34 @@ export async function createPageRenderer({
|
|
|
2441
3682
|
const video = state.video;
|
|
2442
3683
|
if (video && !video.done && video.frameTime <= now) {
|
|
2443
3684
|
// Late frames are decoded but only the newest is shown.
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
3685
|
+
const decodeDeadline = hostPerformance.now() + 100;
|
|
3686
|
+
while (video.frameTime <= mediaTime(state) && !video.done) {
|
|
3687
|
+
const current = mediaTime(state);
|
|
3688
|
+
const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= current;
|
|
3689
|
+
// If this tick has spent its decode budget, decode one final frame
|
|
3690
|
+
// for display before yielding. Otherwise a video that can never quite
|
|
3691
|
+
// catch the wall clock discards every frame forever while its controls
|
|
3692
|
+
// continue to advance.
|
|
3693
|
+
const yielding = hostPerformance.now() >= decodeDeadline;
|
|
3694
|
+
// Frames before where the picture already was (or a seek's target)
|
|
3695
|
+
// are decoded unshown: the decoder restarted at an earlier keyframe.
|
|
3696
|
+
const reached = video.frameTime + 1e-6 >= (video.hideUntil ?? 0);
|
|
3697
|
+
let step;
|
|
3698
|
+
try {
|
|
3699
|
+
step = drawVideoFrame(state, reached && (!nextIsLate || yielding));
|
|
3700
|
+
} catch (error) {
|
|
3701
|
+
failMedia(state, error, 3);
|
|
3702
|
+
return;
|
|
3703
|
+
}
|
|
3704
|
+
if (step === "stall") {
|
|
3705
|
+
void refillMedia(state, video);
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
if (!step) video.done = true;
|
|
3709
|
+
// Keep tasks responsive even when software decoding is slower than
|
|
3710
|
+
// real time. The zero-delay continuation resumes catching up against
|
|
3711
|
+
// a freshly read clock instead of accumulating this task's duration.
|
|
3712
|
+
if (yielding && video.frameTime <= mediaTime(state)) break;
|
|
2447
3713
|
}
|
|
2448
3714
|
}
|
|
2449
3715
|
state.resource.time = now;
|
|
@@ -2456,22 +3722,49 @@ export async function createPageRenderer({
|
|
|
2456
3722
|
const soundFinished = !state.sound || state.sound.done;
|
|
2457
3723
|
// The clock at the duration ends it too: after a seek, frames restart at a
|
|
2458
3724
|
// keyframe before the target, and wasmpeg does not tell their timestamps.
|
|
2459
|
-
|
|
2460
|
-
|
|
3725
|
+
// A prefix can already report the full duration, so the clock must not
|
|
3726
|
+
// run the element out while bytes are still arriving.
|
|
3727
|
+
const pastEnd = !state.buffering && Number.isFinite(state.duration) && now >= state.duration;
|
|
3728
|
+
const pipeOpen = state.pipe && !state.pipe.done && !state.pipe.error;
|
|
3729
|
+
if (pastEnd && pipeOpen) {
|
|
3730
|
+
const stale = state.video && needsMore(state.video) ? state.video
|
|
3731
|
+
: state.sound && needsMore(state.sound) ? state.sound : null;
|
|
3732
|
+
void refillMedia(state, stale);
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
if (videoFinished && soundFinished && pipeOpen) {
|
|
3736
|
+
const stalled = video?.done && needsMore(video) ? video
|
|
3737
|
+
: state.sound?.done && needsMore(state.sound) ? state.sound : null;
|
|
3738
|
+
void refillMedia(state, stalled);
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
2461
3741
|
if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
|
|
2462
|
-
const delay = video && !video.done ? Math.max(
|
|
3742
|
+
const delay = video && !video.done ? Math.max(0, (video.frameTime - mediaTime(state)) * 1000) : 250;
|
|
2463
3743
|
state.timer = hostSetTimeout(() => mediaTick(state), delay);
|
|
2464
3744
|
}
|
|
2465
3745
|
|
|
2466
|
-
/**
|
|
3746
|
+
/**
|
|
3747
|
+
* Decodes the next frame; shows it when `show`.
|
|
3748
|
+
* True when a frame was produced, false at a real end, "stall" when the
|
|
3749
|
+
* pipe still owes bytes (a short mp4 throws AVERROR_INVALIDDATA there).
|
|
3750
|
+
*/
|
|
2467
3751
|
function drawVideoFrame(state, show) {
|
|
2468
|
-
const
|
|
3752
|
+
const video = state.video;
|
|
3753
|
+
const decoder = video.decoder;
|
|
2469
3754
|
const width = Math.min(480, decoder.width);
|
|
2470
|
-
const height = Math.max(1, Math.round(width * decoder.height / decoder.width));
|
|
2471
|
-
|
|
2472
|
-
|
|
3755
|
+
const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
|
|
3756
|
+
let pixels;
|
|
3757
|
+
try {
|
|
3758
|
+
pixels = decoder.nextFrame(width, height);
|
|
3759
|
+
} catch (error) {
|
|
3760
|
+
if (needsMore(video)) return "stall";
|
|
3761
|
+
throw error;
|
|
3762
|
+
}
|
|
3763
|
+
if (!pixels) return needsMore(video) ? "stall" : false;
|
|
3764
|
+
const shownTime = state.video.frameTime;
|
|
2473
3765
|
state.video.frameTime += 1 / (decoder.fps || 30);
|
|
2474
3766
|
if (!show) return true;
|
|
3767
|
+
state.video.shownTime = shownTime;
|
|
2475
3768
|
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
2476
3769
|
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
2477
3770
|
if (!image) return true;
|
|
@@ -2486,12 +3779,46 @@ export async function createPageRenderer({
|
|
|
2486
3779
|
const sound = state.sound;
|
|
2487
3780
|
sound.finishing = false;
|
|
2488
3781
|
const name = new URL(state.url).pathname.split("/").pop() || "audio";
|
|
3782
|
+
// Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
|
|
3783
|
+
sound.streamStart = sound.position;
|
|
3784
|
+
trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
|
|
2489
3785
|
const id = audio.open({ rate: sound.decoder.sampleRate, channels: sound.decoder.channels, name }, (event) => {
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
3786
|
+
// The stream outlives a decoder reopened during a stall (see
|
|
3787
|
+
// refillMedia), so events go to whichever sound holds it now.
|
|
3788
|
+
const sound = state.sound;
|
|
3789
|
+
if (!sound || sound.id !== id) return;
|
|
3790
|
+
if (event.event === "timing") {
|
|
3791
|
+
if (!Number.isFinite(event.seconds) || event.seconds < 0) return;
|
|
3792
|
+
sound.playedTime = (sound.streamStart / sound.decoder.sampleRate) + event.seconds;
|
|
3793
|
+
state.base = sound.playedTime;
|
|
3794
|
+
sound.timingError = false;
|
|
3795
|
+
const now = hostPerformance.now();
|
|
3796
|
+
if (!state.lastSyncTrace || now - state.lastSyncTrace >= 1000) {
|
|
3797
|
+
state.lastSyncTrace = now;
|
|
3798
|
+
trace?.(`media sync: audio played ${sound.playedTime.toFixed(3)}s, video shown ${(state.video?.shownTime ?? 0).toFixed(3)}s,`
|
|
3799
|
+
+ ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`);
|
|
3800
|
+
}
|
|
3801
|
+
} else if (event.event === "timing-error") {
|
|
3802
|
+
// Do not silently switch to a free-running clock on missing feedback.
|
|
3803
|
+
if (!sound.timingError) reportError(new Error(`audio timing unavailable; playback clock held: ${event.message}`));
|
|
3804
|
+
sound.timingError = true;
|
|
3805
|
+
} else if (event.event === "request") {
|
|
3806
|
+
// A request is the first point at which PulseAudio has created the
|
|
3807
|
+
// playback stream. Queue its PCM before releasing the clock: feeding
|
|
3808
|
+
// a decoder opened on a growing snapshot can itself start a refill,
|
|
3809
|
+
// in which case that replacement stream must establish the epoch.
|
|
3810
|
+
feedAudio(state, event.bytes);
|
|
3811
|
+
startAudioClock(state, sound);
|
|
3812
|
+
} else if (event.event === "ready") {
|
|
3813
|
+
// Some servers create a writable stream with an initial grant of zero,
|
|
3814
|
+
// so no request is sent until playback advances.
|
|
3815
|
+
startAudioClock(state, sound);
|
|
3816
|
+
} else if (event.event === "ended") {
|
|
3817
|
+
state.base = sound.playedTime ?? state.base;
|
|
3818
|
+
state.clockStart = hostPerformance.now();
|
|
2493
3819
|
sound.id = null;
|
|
2494
3820
|
sound.done = true;
|
|
3821
|
+
startAudioClock(state, sound);
|
|
2495
3822
|
mediaTick(state);
|
|
2496
3823
|
} else if (event.event === "error") {
|
|
2497
3824
|
sound.id = null;
|
|
@@ -2499,6 +3826,7 @@ export async function createPageRenderer({
|
|
|
2499
3826
|
reportError(new Error(`audio: ${event.message}`));
|
|
2500
3827
|
// Video plays on without sound; audio alone stops.
|
|
2501
3828
|
if (!state.video) pauseMedia(state.element);
|
|
3829
|
+
else startAudioClock(state, sound);
|
|
2502
3830
|
}
|
|
2503
3831
|
});
|
|
2504
3832
|
sound.id = id;
|
|
@@ -2508,13 +3836,37 @@ export async function createPageRenderer({
|
|
|
2508
3836
|
function feedAudio(state, bytes) {
|
|
2509
3837
|
const sound = state.sound;
|
|
2510
3838
|
if (!state.playing || sound.id === null || sound.finishing) return;
|
|
3839
|
+
// While buffering the output plays what it holds and then nothing, as
|
|
3840
|
+
// the video waits; what it asks for is sent when playing resumes.
|
|
3841
|
+
if (state.buffering || state.audioPositioning) {
|
|
3842
|
+
sound.owed = (sound.owed ?? 0) + bytes;
|
|
3843
|
+
return;
|
|
3844
|
+
}
|
|
2511
3845
|
const { channels, sampleRate } = sound.decoder;
|
|
2512
3846
|
const frameBytes = channels * 2;
|
|
3847
|
+
sound.lastRequest = bytes;
|
|
2513
3848
|
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
2514
3849
|
let samples = sound.pending;
|
|
2515
3850
|
sound.pending = null;
|
|
2516
|
-
samples ??= sound.decoder.nextSamples();
|
|
2517
3851
|
if (!samples) {
|
|
3852
|
+
try {
|
|
3853
|
+
samples = sound.decoder.nextSamples();
|
|
3854
|
+
} catch (error) {
|
|
3855
|
+
if (needsMore(sound)) {
|
|
3856
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
3857
|
+
void refillMedia(state, sound);
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3860
|
+
failMedia(state, error, 3);
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
if (!samples) {
|
|
3865
|
+
if (needsMore(sound)) {
|
|
3866
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
3867
|
+
void refillMedia(state, sound);
|
|
3868
|
+
return;
|
|
3869
|
+
}
|
|
2518
3870
|
sound.finishing = true;
|
|
2519
3871
|
audio.finish(sound.id);
|
|
2520
3872
|
return;
|
|
@@ -2532,6 +3884,195 @@ export async function createPageRenderer({
|
|
|
2532
3884
|
}
|
|
2533
3885
|
}
|
|
2534
3886
|
|
|
3887
|
+
/**
|
|
3888
|
+
* While bytes keep arriving, waits until `pipe` holds about
|
|
3889
|
+
* RESUME_BUFFER_SECONDS more media than `position` needs (estimated from
|
|
3890
|
+
* the bytes per second so far). Stops waiting when it is finished, when
|
|
3891
|
+
* nothing arrived for RESUME_BUFFER_IDLE_MS (that burst is over: waiting
|
|
3892
|
+
* for the next could take long), or after RESUME_BUFFER_MAX_WAIT_MS.
|
|
3893
|
+
*/
|
|
3894
|
+
async function bufferAhead(pipe, position) {
|
|
3895
|
+
const deadline = hostPerformance.now() + RESUME_BUFFER_MAX_WAIT_MS;
|
|
3896
|
+
// The pipe ends near `position` when starved; early on that says little,
|
|
3897
|
+
// so the rate is taken over at least a few seconds (an underestimate).
|
|
3898
|
+
const bytesPerSecond = pipe.size / Math.max(4, position);
|
|
3899
|
+
const goal = pipe.size + bytesPerSecond * RESUME_BUFFER_SECONDS;
|
|
3900
|
+
while (!pipe.done && !pipe.error && pipe.size < goal) {
|
|
3901
|
+
const wait = Math.min(RESUME_BUFFER_IDLE_MS, deadline - hostPerformance.now());
|
|
3902
|
+
if (wait <= 0) return;
|
|
3903
|
+
const size = pipe.size;
|
|
3904
|
+
let timer = null;
|
|
3905
|
+
await Promise.race([pipe.waitFor(size + 1), new Promise((resolve) => { timer = hostSetTimeout(resolve, wait); })]);
|
|
3906
|
+
hostClearTimeout(timer);
|
|
3907
|
+
if (pipe.size === size) return;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
/**
|
|
3912
|
+
* Seeks `decoder` to the keyframe at or before `seconds` and returns that
|
|
3913
|
+
* keyframe's time. wasmpeg seeks to the keyframe without telling its
|
|
3914
|
+
* timestamp; taking the target as the position would show every later
|
|
3915
|
+
* frame early by the distance to the keyframe, and the picture would trail
|
|
3916
|
+
* the sound. Seeks to the same keyframe yield the same first picture, so a
|
|
3917
|
+
* binary search over seek targets finds where that keyframe starts (to a
|
|
3918
|
+
* frame). The frames from there to the clock are then decoded unshown.
|
|
3919
|
+
*/
|
|
3920
|
+
function seekToKeyframe(decoder, seconds) {
|
|
3921
|
+
// wasmpeg fails a seek to 0 once frames were decoded; 1 ms reaches the
|
|
3922
|
+
// first keyframe as well.
|
|
3923
|
+
const seek = (time) => decoder.seek(Math.max(1, time * 1000));
|
|
3924
|
+
const frame = 1 / (decoder.fps || 30);
|
|
3925
|
+
if (!(seconds >= frame)) {
|
|
3926
|
+
seek(0);
|
|
3927
|
+
return 0;
|
|
3928
|
+
}
|
|
3929
|
+
const probe = (time) => {
|
|
3930
|
+
seek(time);
|
|
3931
|
+
return pictureKey(decoder.nextFrame(KEYFRAME_PROBE_WIDTH, KEYFRAME_PROBE_HEIGHT));
|
|
3932
|
+
};
|
|
3933
|
+
const target = probe(seconds);
|
|
3934
|
+
let keyframe = seconds;
|
|
3935
|
+
if (target !== null) {
|
|
3936
|
+
let earlier = Math.max(0, seconds - KEYFRAME_SEARCH_SECONDS);
|
|
3937
|
+
if (earlier > 0 && probe(earlier) !== target) {
|
|
3938
|
+
// The keyframe lies in (earlier, keyframe]: narrow it to a frame.
|
|
3939
|
+
while (keyframe - earlier > frame) {
|
|
3940
|
+
const middle = (earlier + keyframe) / 2;
|
|
3941
|
+
if (probe(middle) === target) keyframe = middle;
|
|
3942
|
+
else earlier = middle;
|
|
3943
|
+
}
|
|
3944
|
+
} else {
|
|
3945
|
+
keyframe = earlier;
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
seek(keyframe);
|
|
3949
|
+
return keyframe;
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
function pictureKey(pixels) {
|
|
3953
|
+
if (!pixels) return null;
|
|
3954
|
+
let hash = 0x811c9dc5;
|
|
3955
|
+
for (let index = 0; index < pixels.length; index += 3) hash = Math.imul(hash ^ pixels[index], 0x01000193);
|
|
3956
|
+
return hash >>> 0;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
/**
|
|
3960
|
+
* The decoder ran off the end of the snapshot it was opened on. Freeze the
|
|
3961
|
+
* clock, wait for more bytes, reopen on the new snapshot and seek back.
|
|
3962
|
+
* Retain the output stream and outstanding credits while positioning the
|
|
3963
|
+
* new snapshot. The reported output cursor remains the A/V master clock.
|
|
3964
|
+
*/
|
|
3965
|
+
function refillMedia(state, stalledTrack = null) {
|
|
3966
|
+
if (state.refilling) return state.refilling;
|
|
3967
|
+
const serial = state.seekSerial;
|
|
3968
|
+
state.buffering = true;
|
|
3969
|
+
state.base = mediaTime(state);
|
|
3970
|
+
// The audio output stays open through a stall: it plays the PCM it holds
|
|
3971
|
+
// and then runs dry, and playing resumes right after that PCM. Closing
|
|
3972
|
+
// and reopening it instead loses track of what was heard (a closed
|
|
3973
|
+
// stream may still play out its device buffer, which then plays twice).
|
|
3974
|
+
const holdAudio = Boolean(stalledTrack && state.sound && state.sound.id != null && !state.sound.finishing);
|
|
3975
|
+
const stall = { at: hostPerformance.now(), base: state.base };
|
|
3976
|
+
stopClock(state, { keepAudio: holdAudio });
|
|
3977
|
+
// The first await lets `job` be assigned before the body looks at it,
|
|
3978
|
+
// so a stall inside startMedia can begin a new refill.
|
|
3979
|
+
const job = (async () => {
|
|
3980
|
+
await Promise.resolve();
|
|
3981
|
+
try {
|
|
3982
|
+
const pipe = stalledTrack?.pipe ?? state.video?.pipe ?? state.sound?.pipe ?? state.pipe;
|
|
3983
|
+
const mark = (pipe?.size ?? 0) + 1;
|
|
3984
|
+
// An append may have arrived after this decoder's snapshot was
|
|
3985
|
+
// opened but before it reached EOF. Consume that generation now;
|
|
3986
|
+
// waiting for one more append creates a segment-sized pause.
|
|
3987
|
+
const hasNewSnapshot = Boolean(stalledTrack && pipe
|
|
3988
|
+
&& pipe.generation !== stalledTrack.openedGeneration);
|
|
3989
|
+
// Some demuxers report EOF at an fMP4/WebM fragment boundary even
|
|
3990
|
+
// though the same snapshot contains later fragments. Reopen once at
|
|
3991
|
+
// each position/generation; if it makes no progress, wait for append.
|
|
3992
|
+
const retry = state.lastRefill;
|
|
3993
|
+
const canRetrySnapshot = Boolean(stalledTrack && pipe && !hasNewSnapshot
|
|
3994
|
+
&& (!retry || retry.pipe !== pipe || retry.generation !== pipe.generation
|
|
3995
|
+
|| state.base > retry.time + 0.25));
|
|
3996
|
+
if (pipe && !pipe.done && !hasNewSnapshot && !canRetrySnapshot) {
|
|
3997
|
+
await pipe.waitFor(mark);
|
|
3998
|
+
// Starved by a slow network, the smallest refill stalls again at
|
|
3999
|
+
// once: buffer a few seconds ahead first, as browsers do before
|
|
4000
|
+
// leaving "waiting".
|
|
4001
|
+
await bufferAhead(pipe, state.base);
|
|
4002
|
+
}
|
|
4003
|
+
if (state.seekSerial !== serial || state.document !== page.document || !state.playing) return;
|
|
4004
|
+
if (pipe?.error) throw pipe.error;
|
|
4005
|
+
// Where the audio is now: it kept playing what it held after the stall.
|
|
4006
|
+
const held = holdAudio && state.sound?.id != null ? state.sound : null;
|
|
4007
|
+
const writtenEnd = held ? held.position / held.decoder.sampleRate : null;
|
|
4008
|
+
if (held) {
|
|
4009
|
+
state.base = mediaTime(state);
|
|
4010
|
+
} else {
|
|
4011
|
+
await skipPlayedAudio(state);
|
|
4012
|
+
}
|
|
4013
|
+
if (state.seekSerial !== serial || state.document !== page.document || !state.playing) return;
|
|
4014
|
+
const time = state.base;
|
|
4015
|
+
trace?.(`media stall (${stalledTrack === state.video ? "video" : stalledTrack === state.sound ? "audio" : "end"}): resume at ${time.toFixed(3)}s`
|
|
4016
|
+
+ `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
|
|
4017
|
+
if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
|
|
4018
|
+
// A video decoder still decoding is left alone: reopened, it restarts
|
|
4019
|
+
// at a keyframe. Audio reopens on any newer snapshot (it may share the
|
|
4020
|
+
// stalled pipe), which positionAudio continues sample-exactly.
|
|
4021
|
+
const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
|
|
4022
|
+
const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
|
|
4023
|
+
|| (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
|
|
4024
|
+
if (refillVideo) {
|
|
4025
|
+
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
4026
|
+
// The picture must not go back to the keyframe the decoder restarts at.
|
|
4027
|
+
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
4028
|
+
await openVideoDecoder(state);
|
|
4029
|
+
state.video.hideUntil = hideUntil;
|
|
4030
|
+
try {
|
|
4031
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
|
|
4032
|
+
} catch {
|
|
4033
|
+
state.video.frameTime = 0;
|
|
4034
|
+
}
|
|
4035
|
+
state.video.done = false;
|
|
4036
|
+
}
|
|
4037
|
+
if (refillAudio) {
|
|
4038
|
+
let opened = false;
|
|
4039
|
+
try {
|
|
4040
|
+
opened = await openAudioDecoder(state, { keepStream: Boolean(held) });
|
|
4041
|
+
} catch (error) {
|
|
4042
|
+
if (!state.video) throw error;
|
|
4043
|
+
reportError(error);
|
|
4044
|
+
}
|
|
4045
|
+
// A held stream continues right after the PCM it was given.
|
|
4046
|
+
if (opened) await positionAudio(state, held ? writtenEnd : time);
|
|
4047
|
+
else if (state.playing && !state.sound) ensureAudio(state);
|
|
4048
|
+
}
|
|
4049
|
+
// Drop this job before startMedia so a new stall can refill.
|
|
4050
|
+
if (state.refilling === job) state.refilling = null;
|
|
4051
|
+
state.buffering = false;
|
|
4052
|
+
if (state.playing && state.seekSerial === serial && state.document === page.document) startMedia(state);
|
|
4053
|
+
} catch (error) {
|
|
4054
|
+
if (state.document === page.document) failMedia(state, error, 3);
|
|
4055
|
+
} finally {
|
|
4056
|
+
if (state.refilling === job) {
|
|
4057
|
+
state.refilling = null;
|
|
4058
|
+
state.buffering = false;
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
})();
|
|
4062
|
+
state.refilling = job;
|
|
4063
|
+
return job;
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
function failMedia(state, error, code) {
|
|
4067
|
+
state.error = { code, message: String(error?.message ?? error) };
|
|
4068
|
+
closeMedia(state);
|
|
4069
|
+
state.playing = false;
|
|
4070
|
+
if (state.resource) state.resource.playing = false;
|
|
4071
|
+
mediaEvent(state, "error");
|
|
4072
|
+
reportError(error);
|
|
4073
|
+
repaintMedia();
|
|
4074
|
+
}
|
|
4075
|
+
|
|
2535
4076
|
function endMedia(state) {
|
|
2536
4077
|
stopClock(state);
|
|
2537
4078
|
state.ended = true;
|
|
@@ -2549,6 +4090,8 @@ export async function createPageRenderer({
|
|
|
2549
4090
|
}
|
|
2550
4091
|
|
|
2551
4092
|
function closeMedia(state) {
|
|
4093
|
+
state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
|
|
4094
|
+
state.audioPositioning = null;
|
|
2552
4095
|
stopClock(state);
|
|
2553
4096
|
state.playing = false;
|
|
2554
4097
|
state.video?.decoder.close();
|
|
@@ -2575,6 +4118,58 @@ export async function createPageRenderer({
|
|
|
2575
4118
|
return out;
|
|
2576
4119
|
}
|
|
2577
4120
|
|
|
4121
|
+
/** The decoded CSS background image a url() names, if it has arrived. */
|
|
4122
|
+
function backgroundResource(url) {
|
|
4123
|
+
let absolute;
|
|
4124
|
+
try { absolute = new URL(url, page.url).href; } catch { return null; }
|
|
4125
|
+
return page.backgrounds.get(absolute)?.resource ?? null;
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
/**
|
|
4129
|
+
* Requests the background images the laid-out document uses and has not
|
|
4130
|
+
* asked for yet. They change only paint: each repaints the page on arrival.
|
|
4131
|
+
*/
|
|
4132
|
+
function queueBackgroundImages() {
|
|
4133
|
+
const tree = page.renderTree;
|
|
4134
|
+
if (!tree) return;
|
|
4135
|
+
const document = page.document;
|
|
4136
|
+
const seen = new Set();
|
|
4137
|
+
for (const node of tree.nodesById.values()) {
|
|
4138
|
+
const value = node.style?.backgroundImage;
|
|
4139
|
+
if (!value || seen.has(value) || !value.includes("url(")) continue;
|
|
4140
|
+
seen.add(value);
|
|
4141
|
+
for (const match of value.matchAll(CSS_URL)) {
|
|
4142
|
+
let url;
|
|
4143
|
+
try { url = new URL(match[2], page.url).href; } catch { continue; }
|
|
4144
|
+
if (page.backgrounds.has(url)) continue;
|
|
4145
|
+
const entry = { resource: null, loaded: null };
|
|
4146
|
+
page.backgrounds.set(url, entry);
|
|
4147
|
+
entry.loaded = fetchResource(url, "image").then(async (response) => {
|
|
4148
|
+
if (!response.ok || page.backgrounds.get(url) !== entry) return;
|
|
4149
|
+
const bytes = response.body;
|
|
4150
|
+
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
4151
|
+
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
4152
|
+
if (!image) return;
|
|
4153
|
+
if (page.backgrounds.get(url) !== entry || document !== page.document) {
|
|
4154
|
+
image.delete();
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
entry.resource = Object.freeze({ width, height, image, url });
|
|
4158
|
+
if (page.layout) page.displayList = paintList();
|
|
4159
|
+
page.needsPaint = true;
|
|
4160
|
+
scheduleFrame();
|
|
4161
|
+
}).catch(() => {
|
|
4162
|
+
// A broken background image paints nothing.
|
|
4163
|
+
});
|
|
4164
|
+
}
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
function disposeBackgrounds() {
|
|
4169
|
+
for (const entry of page.backgrounds.values()) entry.resource?.image.delete();
|
|
4170
|
+
page.backgrounds = new Map();
|
|
4171
|
+
}
|
|
4172
|
+
|
|
2578
4173
|
function queueImageResources() {
|
|
2579
4174
|
const document = page.document;
|
|
2580
4175
|
const generation = loadGeneration;
|
|
@@ -2604,7 +4199,25 @@ export async function createPageRenderer({
|
|
|
2604
4199
|
}
|
|
2605
4200
|
page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
|
|
2606
4201
|
page.decodedImages.push(image);
|
|
2607
|
-
page.
|
|
4202
|
+
const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
|
|
4203
|
+
const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
|
|
4204
|
+
.find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
|
|
4205
|
+
const cssWidth = node?.style?.width;
|
|
4206
|
+
const cssHeight = node?.style?.height;
|
|
4207
|
+
const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
|
|
4208
|
+
const specifiedHeight = typeof cssHeight === "number" ? cssHeight : node?.heightAttribute;
|
|
4209
|
+
// When both used dimensions were already fixed, decoded pixels only
|
|
4210
|
+
// change paint. The retained layout remains authoritative and the
|
|
4211
|
+
// display list resolves the current resource by DOM node.
|
|
4212
|
+
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
4213
|
+
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
4214
|
+
if (paintOnly) {
|
|
4215
|
+
page.displayList = paintList();
|
|
4216
|
+
page.needsPaint = true;
|
|
4217
|
+
} else {
|
|
4218
|
+
page.dirty = true;
|
|
4219
|
+
page.resourceLayoutDirty = true;
|
|
4220
|
+
}
|
|
2608
4221
|
refresh();
|
|
2609
4222
|
}).catch(() => {
|
|
2610
4223
|
// A failed image retains its fallback replaced-element dimensions.
|
|
@@ -2614,114 +4227,6 @@ export async function createPageRenderer({
|
|
|
2614
4227
|
}
|
|
2615
4228
|
}
|
|
2616
4229
|
|
|
2617
|
-
/** Splits at top-level `separator` (outside (), [] and quotes). */
|
|
2618
|
-
function splitTopLevelText(text, separator) {
|
|
2619
|
-
const parts = [];
|
|
2620
|
-
let depth = 0;
|
|
2621
|
-
let quote = "";
|
|
2622
|
-
let start = 0;
|
|
2623
|
-
for (let index = 0; index < text.length; index++) {
|
|
2624
|
-
const character = text[index];
|
|
2625
|
-
if (quote) { if (character === "\\") index++; else if (character === quote) quote = ""; continue; }
|
|
2626
|
-
if (character === '"' || character === "'") quote = character;
|
|
2627
|
-
else if (character === "\\") index++;
|
|
2628
|
-
else if (character === "(" || character === "[") depth++;
|
|
2629
|
-
else if (character === ")" || character === "]") depth--;
|
|
2630
|
-
else if (character === separator && depth === 0) { parts.push(text.slice(start, index).trim()); start = index + 1; }
|
|
2631
|
-
}
|
|
2632
|
-
parts.push(text.slice(start).trim());
|
|
2633
|
-
return parts.filter(Boolean);
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
const STATE_PSEUDO = /:(?:checked|indeterminate|default|disabled|enabled|placeholder-shown|valid|invalid|in-range|out-of-range|required|optional|read-only|read-write|user-valid|user-invalid|autofill|open|popover-open|modal|target|focus|focus-visible|focus-within|hover|active|visited|link|any-link|defined|fullscreen)\b/;
|
|
2637
|
-
|
|
2638
|
-
/** CSS escapes in an identifier (".\\:popover-open", "\\31 a") resolved. */
|
|
2639
|
-
function cssUnescape(text) {
|
|
2640
|
-
return text.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/\\(.)/g, "$1");
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
/** Invalidation features a compound selector mentions (see selectorTraitsFor). */
|
|
2644
|
-
function compoundFeatures(compound) {
|
|
2645
|
-
const features = [];
|
|
2646
|
-
const plain = compound.replace(/\[[^\]]*\]/g, (attribute) => {
|
|
2647
|
-
const name = /^\[\s*([\w-]+)/.exec(attribute)?.[1];
|
|
2648
|
-
if (name) features.push("[" + name.toLowerCase());
|
|
2649
|
-
return "";
|
|
2650
|
-
});
|
|
2651
|
-
for (const match of plain.matchAll(/([.#])((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w\u00a0-\uffff-])+)/g)) features.push(match[1] + cssUnescape(match[2]));
|
|
2652
|
-
const tag = /^(?:[a-zA-Z][\w-]*)/.exec(plain)?.[0];
|
|
2653
|
-
if (tag) features.push("t:" + tag.toLowerCase());
|
|
2654
|
-
if (STATE_PSEUDO.test(plain)) features.push(":state");
|
|
2655
|
-
// Nothing but a universal selector or pseudo-classes: it can match anything.
|
|
2656
|
-
if (!features.length || /^\*/.test(plain)) features.push("*");
|
|
2657
|
-
return features;
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
/** The features an element has (for a sibling it inserts next to, for example). */
|
|
2661
|
-
function elementFeatures(element) {
|
|
2662
|
-
const features = ["t:" + element.localName];
|
|
2663
|
-
const id = element.getAttribute("id");
|
|
2664
|
-
if (id) features.push("#" + id);
|
|
2665
|
-
for (const name of element.classList) features.push("." + name);
|
|
2666
|
-
for (const attribute of element.attributes) features.push("[" + attribute.name);
|
|
2667
|
-
return features;
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
/** A complex selector's compounds with the combinator after each (" ", ">", "+", "~", or null for the subject). */
|
|
2671
|
-
function complexParts(selector) {
|
|
2672
|
-
const parts = [];
|
|
2673
|
-
let depth = 0;
|
|
2674
|
-
let quote = "";
|
|
2675
|
-
let current = "";
|
|
2676
|
-
let combinator = null;
|
|
2677
|
-
const flush = () => {
|
|
2678
|
-
if (current) parts.push({ compound: current, combinator: null });
|
|
2679
|
-
current = "";
|
|
2680
|
-
};
|
|
2681
|
-
for (let index = 0; index < selector.length; index++) {
|
|
2682
|
-
const character = selector[index];
|
|
2683
|
-
if (quote) { current += character; if (character === "\\") current += selector[++index] ?? ""; else if (character === quote) quote = ""; continue; }
|
|
2684
|
-
if (character === '"' || character === "'") quote = character;
|
|
2685
|
-
if (character === "\\") { current += character + (selector[++index] ?? ""); continue; }
|
|
2686
|
-
if (character === "(" || character === "[") depth++;
|
|
2687
|
-
else if (character === ")" || character === "]") depth--;
|
|
2688
|
-
if (depth === 0 && /[\s>+~]/.test(character)) {
|
|
2689
|
-
flush();
|
|
2690
|
-
combinator = character.trim() ? character : combinator ?? " ";
|
|
2691
|
-
if (character.trim() && parts.length) parts.at(-1).combinator = character;
|
|
2692
|
-
else if (parts.length && parts.at(-1).combinator === null) parts.at(-1).combinator = " ";
|
|
2693
|
-
continue;
|
|
2694
|
-
}
|
|
2695
|
-
current += character;
|
|
2696
|
-
}
|
|
2697
|
-
flush();
|
|
2698
|
-
return parts;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
/** The compound selectors of a complex selector (split at top-level combinators). */
|
|
2702
|
-
function compoundsOf(selector) {
|
|
2703
|
-
const compounds = [];
|
|
2704
|
-
let depth = 0;
|
|
2705
|
-
let quote = "";
|
|
2706
|
-
let current = "";
|
|
2707
|
-
for (let index = 0; index < selector.length; index++) {
|
|
2708
|
-
const character = selector[index];
|
|
2709
|
-
if (quote) { current += character; if (character === "\\") current += selector[++index] ?? ""; else if (character === quote) quote = ""; continue; }
|
|
2710
|
-
if (character === '"' || character === "'") quote = character;
|
|
2711
|
-
if (character === "\\") { current += character + (selector[++index] ?? ""); continue; }
|
|
2712
|
-
if (character === "(" || character === "[") depth++;
|
|
2713
|
-
else if (character === ")" || character === "]") depth--;
|
|
2714
|
-
if (depth === 0 && /[\s>+~]/.test(character)) {
|
|
2715
|
-
if (current) compounds.push(current);
|
|
2716
|
-
current = "";
|
|
2717
|
-
continue;
|
|
2718
|
-
}
|
|
2719
|
-
current += character;
|
|
2720
|
-
}
|
|
2721
|
-
if (current) compounds.push(current);
|
|
2722
|
-
return compounds;
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
4230
|
function canvasColor(CanvasKit, value) {
|
|
2726
4231
|
const match = /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/.exec(value);
|
|
2727
4232
|
return match
|