buninu-browser 0.2.1 → 0.2.3
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/ai-debug.log +5537 -0
- 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/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- 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 +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- 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/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- 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 +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -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 +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- 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 +14 -0
- package/test/page-scripts.test.js +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -0
- 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/setup.js +5 -0
- package/test/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- 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,22 +23,32 @@
|
|
|
23
23
|
* after the load event.
|
|
24
24
|
*/
|
|
25
25
|
import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
|
|
26
|
+
import { setCanvasKit } from "./canvas-2d.js";
|
|
26
27
|
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
27
28
|
import { parseHTMLDocument } from "../happy-dom/parser.js";
|
|
28
29
|
import { LayoutEngine } from "../layout/text-layout.js";
|
|
29
30
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
30
|
-
import { elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
31
|
+
import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
32
|
+
import { beginImageLoad, finishImageLoad } from "./image-loading.js";
|
|
33
|
+
import { dispatchPointerMove } from "./pointer-move.js";
|
|
34
|
+
import { keyboardInput } from "./keyboard-input.js";
|
|
35
|
+
import { playbackEvent } from "./media-playback-events.js";
|
|
31
36
|
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
32
|
-
import { matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
37
|
+
import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
33
38
|
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
34
39
|
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
35
40
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
36
41
|
import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
|
|
37
42
|
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
43
|
+
import { originOf } from "./messaging.js";
|
|
38
44
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
39
45
|
import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
46
|
+
import { containerFormat } from "./media-source.js";
|
|
40
47
|
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
41
|
-
import {
|
|
48
|
+
import { advanceAudio } from "../media/position-audio.js";
|
|
49
|
+
import { seekVideoKeyframe } from "../media/video-keyframe.js";
|
|
50
|
+
import { PianoMixer } from "../audio/piano.js";
|
|
51
|
+
import { handlePianoInput } from "./piano-input.js";
|
|
42
52
|
|
|
43
53
|
// Captured at load: in host mode page scripts share this global object.
|
|
44
54
|
const hostPerformance = globalThis.performance;
|
|
@@ -53,8 +63,105 @@ const MAX_COMPILED_COMPOUNDS = 10_000;
|
|
|
53
63
|
|
|
54
64
|
// Media controls: the bar's height over video, and where its parts sit.
|
|
55
65
|
const MEDIA_BAR_HEIGHT = 24;
|
|
66
|
+
// url(...) in a computed background-image.
|
|
67
|
+
const CSS_URL = /url\(\s*(["']?)(.*?)\1\s*\)/gis;
|
|
56
68
|
// Decoded audio already sent, kept so pause/resume continues exactly without decoding again.
|
|
57
69
|
const AUDIO_HISTORY_SECONDS = 3;
|
|
70
|
+
// After a stall, playing resumes once this much more media has arrived (or
|
|
71
|
+
// the wait gives up): resuming on the first bytes stalls again at once on a
|
|
72
|
+
// slow network.
|
|
73
|
+
const RESUME_BUFFER_SECONDS = 2;
|
|
74
|
+
const RESUME_BUFFER_MAX_WAIT_MS = 6000;
|
|
75
|
+
const RESUME_BUFFER_IDLE_MS = 400;
|
|
76
|
+
// element → MediaSource, and the reverse. One renderer owns one page pipeline.
|
|
77
|
+
const elementSources = new WeakMap();
|
|
78
|
+
const sourceElements = new WeakMap();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A frame's window as other frames of the page reach it (messaging.js): its
|
|
82
|
+
* place among the frames, its document's origin, and delivery of messages to
|
|
83
|
+
* the realm of whatever document it shows. The page renderer showing the
|
|
84
|
+
* frame attaches itself; until then the frame shows nothing to post to.
|
|
85
|
+
*/
|
|
86
|
+
export function windowHandle(parent = null, name = "") {
|
|
87
|
+
let target = null;
|
|
88
|
+
// The browsing context name (window.name); it outlives navigations.
|
|
89
|
+
let contextName = String(name);
|
|
90
|
+
const handle = {
|
|
91
|
+
name: () => contextName,
|
|
92
|
+
setName(value) {
|
|
93
|
+
contextName = String(value);
|
|
94
|
+
},
|
|
95
|
+
parent: () => parent,
|
|
96
|
+
top: () => parent ? parent.top() : handle,
|
|
97
|
+
origin: () => target?.origin() ?? "null",
|
|
98
|
+
post: (message) => target?.post(message),
|
|
99
|
+
closed: () => target?.closed() ?? false,
|
|
100
|
+
children: () => target?.children() ?? [],
|
|
101
|
+
global: () => target?.global() ?? null,
|
|
102
|
+
document: () => target?.document() ?? null,
|
|
103
|
+
attach(renderer) {
|
|
104
|
+
target = renderer;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
return handle;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* CanvasKit and the bundled fonts, loaded once per process: a page and the
|
|
112
|
+
* documents of its iframes draw with the same CanvasKit instance (images
|
|
113
|
+
* cannot move between instances), and the files are read before the
|
|
114
|
+
* renderer's sandbox closes.
|
|
115
|
+
*/
|
|
116
|
+
let graphics = null;
|
|
117
|
+
// The same, once loaded: an embedding page makes its iframes' renderers with it synchronously.
|
|
118
|
+
let loadedGraphics = null;
|
|
119
|
+
export function loadGraphics() {
|
|
120
|
+
return graphics ??= (async () => {
|
|
121
|
+
const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
|
|
122
|
+
const CanvasKit = await CanvasKitInit({ wasmBinary });
|
|
123
|
+
// Page <canvas> uses this same instance. Two CanvasKit instances cannot share images.
|
|
124
|
+
setCanvasKit(CanvasKit);
|
|
125
|
+
const fontCatalogue = await Bun.file(new URL("fonts.json", fontDirectory)).json();
|
|
126
|
+
const uiFontNames = [fontCatalogue.sets.ui.regular, ...fontCatalogue.sets.ui.fallback];
|
|
127
|
+
const monospaceFontNames = [fontCatalogue.sets.terminal.regular, fontCatalogue.sets.terminal.bold];
|
|
128
|
+
const fontBuffers = await Promise.all([...uiFontNames, ...monospaceFontNames].map((name) =>
|
|
129
|
+
Bun.file(new URL(fontCatalogue.fonts[name].file, fontDirectory)).arrayBuffer()));
|
|
130
|
+
// A TypefaceFontProvider registers each file under an explicit family name.
|
|
131
|
+
// CanvasKit does not pick a static bold face by weight within one family, so
|
|
132
|
+
// the monospace bold file gets its own catalogue name and is chosen by weight.
|
|
133
|
+
const fontProvider = CanvasKit.TypefaceFontProvider.Make();
|
|
134
|
+
const loadedFamilies = new Map();
|
|
135
|
+
[...uiFontNames, ...monospaceFontNames].forEach((name, index) => {
|
|
136
|
+
const family = (fontCatalogue.fonts[name].families ?? [name])[0];
|
|
137
|
+
fontProvider.registerFont(fontBuffers[index], family);
|
|
138
|
+
loadedFamilies.set(family.toLowerCase(), family);
|
|
139
|
+
});
|
|
140
|
+
// One font collection for every paragraph: building each paragraph from
|
|
141
|
+
// the provider made a new collection, so Skia's font matching and fallback
|
|
142
|
+
// caches started cold every time (about 3x slower per paragraph).
|
|
143
|
+
const fontCollection = CanvasKit.FontCollection.Make();
|
|
144
|
+
fontCollection.setDefaultFontManager(fontProvider);
|
|
145
|
+
fontCollection.enableFontFallback();
|
|
146
|
+
|
|
147
|
+
const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
|
|
148
|
+
const sansSerifFamily = fontFamilies[0];
|
|
149
|
+
const monospaceFamily = fontCatalogue.sets.terminal.regular;
|
|
150
|
+
const monospaceBoldFamily = fontCatalogue.sets.terminal.bold;
|
|
151
|
+
// Maps CSS generic and common platform family names onto the bundled fonts.
|
|
152
|
+
const genericFamilies = new Map([
|
|
153
|
+
["monospace", monospaceFamily], ["ui-monospace", monospaceFamily], ["menlo", monospaceFamily],
|
|
154
|
+
["consolas", monospaceFamily], ["monaco", monospaceFamily], ["courier", monospaceFamily],
|
|
155
|
+
["courier new", monospaceFamily], ["sans-serif", sansSerifFamily], ["serif", sansSerifFamily],
|
|
156
|
+
["system-ui", sansSerifFamily], ["-apple-system", sansSerifFamily], ["blinkmacsystemfont", sansSerifFamily],
|
|
157
|
+
["ui-sans-serif", sansSerifFamily], ["arial", sansSerifFamily], ["helvetica", sansSerifFamily],
|
|
158
|
+
]);
|
|
159
|
+
return loadedGraphics = {
|
|
160
|
+
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
161
|
+
genericFamilies,
|
|
162
|
+
};
|
|
163
|
+
})();
|
|
164
|
+
}
|
|
58
165
|
|
|
59
166
|
/**
|
|
60
167
|
* Horizontal layout of a media control bar `width` CSS pixels wide: the
|
|
@@ -96,7 +203,12 @@ const fontDirectory = new URL("../../usr/share/fonts/", import.meta.url);
|
|
|
96
203
|
* videoDecoder?: object | null,
|
|
97
204
|
* }} options
|
|
98
205
|
*/
|
|
99
|
-
export async function createPageRenderer({
|
|
206
|
+
export async function createPageRenderer(options) {
|
|
207
|
+
return buildPageRenderer(options, await loadGraphics());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** createPageRenderer with the graphics already loaded. */
|
|
211
|
+
function buildPageRenderer({
|
|
100
212
|
fetchResource: rawFetchResource,
|
|
101
213
|
requestNavigation = () => {},
|
|
102
214
|
requestDownload = () => {},
|
|
@@ -108,48 +220,23 @@ export async function createPageRenderer({
|
|
|
108
220
|
consoleMessage = null,
|
|
109
221
|
trace = null,
|
|
110
222
|
onFrame = null,
|
|
223
|
+
onVideo = null,
|
|
111
224
|
onLifecycle = null,
|
|
112
225
|
videoDecoder = null,
|
|
226
|
+
// Prepares a classic script on another thread: (source) => Promise<{ code, parsed, strict }>.
|
|
227
|
+
rewriteScript = null,
|
|
113
228
|
mobile = false,
|
|
114
229
|
verifyStyle = false,
|
|
115
230
|
fullStyle = false,
|
|
231
|
+
// An iframe's document (see syncFrames): frames go to the embedding page,
|
|
232
|
+
// which needs only the viewport, so nothing is painted ahead.
|
|
233
|
+
embedded = false,
|
|
234
|
+
// This document's window handle, made by the embedding page (see windowHandle).
|
|
235
|
+
frameWindow = null,
|
|
236
|
+
}, {
|
|
237
|
+
CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
|
|
238
|
+
genericFamilies,
|
|
116
239
|
}) {
|
|
117
|
-
const wasmBinary = await Bun.file(new URL("canvaskit.wasm", canvasKitDirectory)).arrayBuffer();
|
|
118
|
-
const CanvasKit = await CanvasKitInit({ wasmBinary });
|
|
119
|
-
const fontCatalogue = await Bun.file(new URL("fonts.json", fontDirectory)).json();
|
|
120
|
-
const uiFontNames = [fontCatalogue.sets.ui.regular, ...fontCatalogue.sets.ui.fallback];
|
|
121
|
-
const monospaceFontNames = [fontCatalogue.sets.terminal.regular, fontCatalogue.sets.terminal.bold];
|
|
122
|
-
const fontBuffers = await Promise.all([...uiFontNames, ...monospaceFontNames].map((name) =>
|
|
123
|
-
Bun.file(new URL(fontCatalogue.fonts[name].file, fontDirectory)).arrayBuffer()));
|
|
124
|
-
// A TypefaceFontProvider registers each file under an explicit family name.
|
|
125
|
-
// CanvasKit does not pick a static bold face by weight within one family, so
|
|
126
|
-
// the monospace bold file gets its own catalogue name and is chosen by weight.
|
|
127
|
-
const fontProvider = CanvasKit.TypefaceFontProvider.Make();
|
|
128
|
-
const loadedFamilies = new Map();
|
|
129
|
-
[...uiFontNames, ...monospaceFontNames].forEach((name, index) => {
|
|
130
|
-
const family = (fontCatalogue.fonts[name].families ?? [name])[0];
|
|
131
|
-
fontProvider.registerFont(fontBuffers[index], family);
|
|
132
|
-
loadedFamilies.set(family.toLowerCase(), family);
|
|
133
|
-
});
|
|
134
|
-
// One font collection for every paragraph: building each paragraph from
|
|
135
|
-
// the provider made a new collection, so Skia's font matching and fallback
|
|
136
|
-
// caches started cold every time (about 3x slower per paragraph).
|
|
137
|
-
const fontCollection = CanvasKit.FontCollection.Make();
|
|
138
|
-
fontCollection.setDefaultFontManager(fontProvider);
|
|
139
|
-
fontCollection.enableFontFallback();
|
|
140
|
-
|
|
141
|
-
const fontFamilies = uiFontNames.map((name) => (fontCatalogue.fonts[name].families ?? [name])[0]);
|
|
142
|
-
const sansSerifFamily = fontFamilies[0];
|
|
143
|
-
const monospaceFamily = fontCatalogue.sets.terminal.regular;
|
|
144
|
-
const monospaceBoldFamily = fontCatalogue.sets.terminal.bold;
|
|
145
|
-
// Maps CSS generic and common platform family names onto the bundled fonts.
|
|
146
|
-
const genericFamilies = new Map([
|
|
147
|
-
["monospace", monospaceFamily], ["ui-monospace", monospaceFamily], ["menlo", monospaceFamily],
|
|
148
|
-
["consolas", monospaceFamily], ["monaco", monospaceFamily], ["courier", monospaceFamily],
|
|
149
|
-
["courier new", monospaceFamily], ["sans-serif", sansSerifFamily], ["serif", sansSerifFamily],
|
|
150
|
-
["system-ui", sansSerifFamily], ["-apple-system", sansSerifFamily], ["blinkmacsystemfont", sansSerifFamily],
|
|
151
|
-
["ui-sans-serif", sansSerifFamily], ["arial", sansSerifFamily], ["helvetica", sansSerifFamily],
|
|
152
|
-
]);
|
|
153
240
|
const resolvedFamilies = new Map();
|
|
154
241
|
/** First available family from a computed font-family list, then the UI fallbacks. */
|
|
155
242
|
function resolveFontFamilies(families = ["sans-serif"], weight = 400) {
|
|
@@ -187,6 +274,24 @@ export async function createPageRenderer({
|
|
|
187
274
|
let hasStateStale = false;
|
|
188
275
|
let structuralState = null;
|
|
189
276
|
let siblingState = null;
|
|
277
|
+
// iframes (see syncFrames), by element.
|
|
278
|
+
const frames = new Map();
|
|
279
|
+
let disposedRenderer = false;
|
|
280
|
+
const ownWindow = frameWindow ?? windowHandle(null);
|
|
281
|
+
ownWindow.attach({
|
|
282
|
+
// An iframe's about:blank document has the origin of the page that made it.
|
|
283
|
+
origin: () => page.url === "about:blank" && ownWindow.parent() ? ownWindow.parent().origin() : originOf(page.url),
|
|
284
|
+
post: (message) => page.realm?.receiveMessage(message),
|
|
285
|
+
closed: () => disposedRenderer,
|
|
286
|
+
children: () => [...frames.values()].map((frame) => frame.window),
|
|
287
|
+
global: () => page.realm?.pageGlobal ?? null,
|
|
288
|
+
document: () => page.realm?.documentBinding ?? null,
|
|
289
|
+
});
|
|
290
|
+
// Changes whenever a frame's image does, so the page is rasterized again.
|
|
291
|
+
let framesVersion = 0;
|
|
292
|
+
let framePaintTimer = null;
|
|
293
|
+
// The frame keyboard input goes to, after a click into it.
|
|
294
|
+
let focusedFrame = null;
|
|
190
295
|
const viewport = new ScrollViewport(800, 600);
|
|
191
296
|
const page = {
|
|
192
297
|
url: "about:blank",
|
|
@@ -212,6 +317,13 @@ export async function createPageRenderer({
|
|
|
212
317
|
resources: new WeakMap(),
|
|
213
318
|
decodedImages: [],
|
|
214
319
|
imagesLoaded: null,
|
|
320
|
+
// CSS background images by absolute URL: { resource, loaded } (see queueBackgroundImages).
|
|
321
|
+
backgrounds: new Map(),
|
|
322
|
+
// Scroll offsets of scroll containers, by element (see setElementScroll).
|
|
323
|
+
elementScroll: new Map(),
|
|
324
|
+
// Bumped whenever boxes may have moved (layout, element scrolling), so
|
|
325
|
+
// IntersectionObserver checks can skip unchanged frames.
|
|
326
|
+
layoutVersion: 0,
|
|
215
327
|
// <video> and <audio> playback states, by element (see playMedia).
|
|
216
328
|
media: new Map(),
|
|
217
329
|
realm: null,
|
|
@@ -353,6 +465,10 @@ export async function createPageRenderer({
|
|
|
353
465
|
// PaintController reusing cached display items
|
|
354
466
|
// (platform/graphics/paint/paint_controller.cc, BSD).
|
|
355
467
|
let rasterized = { list: null, overlay: "", background: "", scale: 0 };
|
|
468
|
+
let nextVideoPresentation = 1;
|
|
469
|
+
let rasterSurface = null;
|
|
470
|
+
let rasterSurfaceWidth = 0;
|
|
471
|
+
let rasterSurfaceHeight = 0;
|
|
356
472
|
|
|
357
473
|
/** Hint overlay contents, as a comparable key. */
|
|
358
474
|
function overlayKey() {
|
|
@@ -365,6 +481,7 @@ export async function createPageRenderer({
|
|
|
365
481
|
function sameRasterInput() {
|
|
366
482
|
if (rasterized.scale !== page.deviceScaleFactor || rasterized.background !== page.backgroundColor) return false;
|
|
367
483
|
if (rasterized.overlay !== overlayKey()) return false;
|
|
484
|
+
if (rasterized.frames !== framesVersion) return false;
|
|
368
485
|
if (page.displayList?.items.some((item) => item.fixed)
|
|
369
486
|
&& (rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y)) return false;
|
|
370
487
|
if (rasterized.list === page.displayList) return true;
|
|
@@ -375,6 +492,7 @@ export async function createPageRenderer({
|
|
|
375
492
|
|
|
376
493
|
/** Rasterizes the viewport and hands it to the compositor. */
|
|
377
494
|
function render() {
|
|
495
|
+
if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
|
|
378
496
|
const width = Math.max(1, Math.floor(page.width));
|
|
379
497
|
const height = Math.max(1, Math.floor(page.height));
|
|
380
498
|
const region = { x: viewport.x, y: viewport.y, width, height };
|
|
@@ -393,7 +511,14 @@ export async function createPageRenderer({
|
|
|
393
511
|
const scale = page.deviceScaleFactor;
|
|
394
512
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
395
513
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
396
|
-
|
|
514
|
+
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
515
|
+
// MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
|
|
516
|
+
rasterSurface?.dispose();
|
|
517
|
+
rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
|
|
518
|
+
rasterSurfaceWidth = pixelWidth;
|
|
519
|
+
rasterSurfaceHeight = pixelHeight;
|
|
520
|
+
}
|
|
521
|
+
const surface = rasterSurface;
|
|
397
522
|
if (!surface) throw new Error(`Unable to create a ${pixelWidth}x${pixelHeight} CanvasKit surface`);
|
|
398
523
|
const canvas = surface.getCanvas();
|
|
399
524
|
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
@@ -409,11 +534,11 @@ export async function createPageRenderer({
|
|
|
409
534
|
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
410
535
|
});
|
|
411
536
|
image.delete();
|
|
412
|
-
surface
|
|
537
|
+
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
413
538
|
page.needsPaint = false;
|
|
414
539
|
rasterized = {
|
|
415
540
|
list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
|
|
416
|
-
scrollX: viewport.x, scrollY: viewport.y,
|
|
541
|
+
scrollX: viewport.x, scrollY: viewport.y, frames: framesVersion,
|
|
417
542
|
};
|
|
418
543
|
return {
|
|
419
544
|
pixels,
|
|
@@ -425,6 +550,7 @@ export async function createPageRenderer({
|
|
|
425
550
|
height: region.height,
|
|
426
551
|
scale,
|
|
427
552
|
background: backgroundBytes(page.backgroundColor),
|
|
553
|
+
videoLayer,
|
|
428
554
|
};
|
|
429
555
|
}
|
|
430
556
|
|
|
@@ -461,7 +587,7 @@ export async function createPageRenderer({
|
|
|
461
587
|
|
|
462
588
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
463
589
|
function schedulePrepaint() {
|
|
464
|
-
if (!onFrame) return;
|
|
590
|
+
if (!onFrame || embedded) return;
|
|
465
591
|
hostClearTimeout(prepaintTimer);
|
|
466
592
|
prepaintTimer = hostSetTimeout(() => {
|
|
467
593
|
prepaintTimer = null;
|
|
@@ -504,7 +630,7 @@ export async function createPageRenderer({
|
|
|
504
630
|
/** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
|
|
505
631
|
const paintFailures = new Set();
|
|
506
632
|
|
|
507
|
-
function paintDisplayList(canvas, displayList, view, scale) {
|
|
633
|
+
function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
|
|
508
634
|
if (!displayList) return;
|
|
509
635
|
const pixel = (value) => Math.round(value * scale);
|
|
510
636
|
const deviceRect = (rect) => CanvasKit.XYWHRect(
|
|
@@ -531,10 +657,18 @@ export async function createPageRenderer({
|
|
|
531
657
|
else canvas.drawRect(deviceRect(rect), paint);
|
|
532
658
|
};
|
|
533
659
|
for (const item of visibleItems(displayList, view, viewport)) {
|
|
534
|
-
const saved = item.fixed || item.clipRect || item.opacity < 1 ? canvas.save() : null;
|
|
660
|
+
const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
|
|
535
661
|
try {
|
|
536
662
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
537
663
|
if (item.clipRect) canvas.clipRect(deviceRect(item.clipRect), CanvasKit.ClipOp.Intersect, true);
|
|
664
|
+
for (const clip of item.roundedClips ?? []) {
|
|
665
|
+
canvas.clipRRect(deviceRRect(clip.rect, clip.radii), CanvasKit.ClipOp.Intersect, true);
|
|
666
|
+
}
|
|
667
|
+
// A scale in document space, as a device-space matrix around the item's device coordinates.
|
|
668
|
+
if (item.transform) {
|
|
669
|
+
const { a, d, e, f } = item.transform;
|
|
670
|
+
canvas.concat([a, 0, ((a - 1) * view.x + e) * scale, 0, d, ((d - 1) * view.y + f) * scale, 0, 0, 1]);
|
|
671
|
+
}
|
|
538
672
|
if (item.opacity < 1) {
|
|
539
673
|
const layer = new CanvasKit.Paint();
|
|
540
674
|
layer.setAlphaf(item.opacity);
|
|
@@ -634,6 +768,30 @@ export async function createPageRenderer({
|
|
|
634
768
|
false,
|
|
635
769
|
);
|
|
636
770
|
canvas.restore();
|
|
771
|
+
} else if (item.op === "drawBackgroundImage") {
|
|
772
|
+
// Tiles from an image shader, repeating (or not) per axis.
|
|
773
|
+
const resource = displayList.images[item.image];
|
|
774
|
+
const { tile } = item;
|
|
775
|
+
canvas.save();
|
|
776
|
+
const clip = deviceRRect(item.rect, item.radii);
|
|
777
|
+
if (clip) canvas.clipRRect(clip, CanvasKit.ClipOp.Intersect, true);
|
|
778
|
+
const matrix = CanvasKit.Matrix.multiply(
|
|
779
|
+
CanvasKit.Matrix.translated((tile.x - view.x) * scale, (tile.y - view.y) * scale),
|
|
780
|
+
CanvasKit.Matrix.scaled(tile.width * scale / resource.width, tile.height * scale / resource.height),
|
|
781
|
+
);
|
|
782
|
+
const shader = resource.image.makeShaderOptions(
|
|
783
|
+
item.repeatX ? CanvasKit.TileMode.Repeat : CanvasKit.TileMode.Decal,
|
|
784
|
+
item.repeatY ? CanvasKit.TileMode.Repeat : CanvasKit.TileMode.Decal,
|
|
785
|
+
CanvasKit.FilterMode.Linear,
|
|
786
|
+
CanvasKit.MipmapMode.None,
|
|
787
|
+
matrix,
|
|
788
|
+
);
|
|
789
|
+
const paint = new CanvasKit.Paint();
|
|
790
|
+
paint.setShader(shader);
|
|
791
|
+
canvas.drawRect(deviceRect(item.bounds), paint);
|
|
792
|
+
paint.delete();
|
|
793
|
+
shader.delete();
|
|
794
|
+
canvas.restore();
|
|
637
795
|
} else if (item.op === "drawVideo") {
|
|
638
796
|
const resource = displayList.images[item.image];
|
|
639
797
|
const rect = deviceRect(item.rect);
|
|
@@ -642,7 +800,10 @@ export async function createPageRenderer({
|
|
|
642
800
|
canvas.clipRect(rect, CanvasKit.ClipOp.Intersect, true);
|
|
643
801
|
paint.setColor(CanvasKit.Color(0, 0, 0, 1));
|
|
644
802
|
canvas.drawRect(rect, paint);
|
|
645
|
-
if (resource
|
|
803
|
+
if (videoOverride?.resource === resource) {
|
|
804
|
+
paint.setColor(CanvasKit.Color(videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, 1));
|
|
805
|
+
canvas.drawRect(rect, paint);
|
|
806
|
+
} else if (resource?.image) {
|
|
646
807
|
canvas.drawImageRect(resource.image,
|
|
647
808
|
CanvasKit.XYWHRect(0, 0, resource.bitmapWidth ?? resource.width, resource.bitmapHeight ?? resource.height),
|
|
648
809
|
rect, null, false);
|
|
@@ -650,7 +811,7 @@ export async function createPageRenderer({
|
|
|
650
811
|
const centerX = rect[0] + (rect[2] - rect[0]) / 2;
|
|
651
812
|
const centerY = rect[1] + (rect[3] - rect[1]) / 2;
|
|
652
813
|
paint.setColor(CanvasKit.Color(0, 0, 0, 0.65));
|
|
653
|
-
if (!resource?.playing) {
|
|
814
|
+
if (item.controls && !resource?.playing) {
|
|
654
815
|
canvas.drawCircle(centerX, centerY, 22 * scale, paint);
|
|
655
816
|
paint.setColor(CanvasKit.Color(255, 255, 255, 1));
|
|
656
817
|
const path = CanvasKit.Path.MakeFromSVGString(
|
|
@@ -658,7 +819,7 @@ export async function createPageRenderer({
|
|
|
658
819
|
if (path) { canvas.drawPath(path, paint); path.delete(); }
|
|
659
820
|
}
|
|
660
821
|
// The control bar (play/pause, time, seekable progress) once playing or known.
|
|
661
|
-
if (resource?.playing || Number.isFinite(resource?.duration)) {
|
|
822
|
+
if (item.controls && (resource?.playing || Number.isFinite(resource?.duration))) {
|
|
662
823
|
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
|
|
663
824
|
item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
|
|
664
825
|
}
|
|
@@ -1157,12 +1318,14 @@ export async function createPageRenderer({
|
|
|
1157
1318
|
}
|
|
1158
1319
|
|
|
1159
1320
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1321
|
+
disposeFrames();
|
|
1160
1322
|
closePiano();
|
|
1161
1323
|
disposeVideos();
|
|
1162
1324
|
page.realm?.dispose();
|
|
1163
1325
|
page.realm = null;
|
|
1164
1326
|
page.window?.happyDOM?.abort?.();
|
|
1165
1327
|
for (const image of page.decodedImages) image.delete();
|
|
1328
|
+
disposeBackgrounds();
|
|
1166
1329
|
page.window = parsed.window;
|
|
1167
1330
|
page.document = parsed.document;
|
|
1168
1331
|
observeStyleChanges(parsed.document);
|
|
@@ -1174,10 +1337,13 @@ export async function createPageRenderer({
|
|
|
1174
1337
|
page.decodedImages = imageResources.decoded;
|
|
1175
1338
|
page.imagesLoaded = null;
|
|
1176
1339
|
imageRequests = new WeakMap();
|
|
1340
|
+
page.elementScroll = new Map();
|
|
1177
1341
|
const timing = styleAndLayout();
|
|
1342
|
+
syncFrames();
|
|
1343
|
+
queueBackgroundImages();
|
|
1178
1344
|
trace?.(`${page.url}: first style ${timing.style}ms, layout ${timing.layout}ms`);
|
|
1179
1345
|
viewport.scrollTo(0, 0);
|
|
1180
|
-
|
|
1346
|
+
setContentSize();
|
|
1181
1347
|
page.hints = [];
|
|
1182
1348
|
page.hintsVisible = false;
|
|
1183
1349
|
page.needsPaint = true;
|
|
@@ -1237,10 +1403,12 @@ export async function createPageRenderer({
|
|
|
1237
1403
|
page.layout = layoutEngine.layout(page.renderTree, {
|
|
1238
1404
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1239
1405
|
});
|
|
1240
|
-
page.displayList =
|
|
1406
|
+
page.displayList = paintList();
|
|
1407
|
+
// The content may now reach less far than a container was scrolled.
|
|
1408
|
+
if (clampElementScroll()) page.displayList = paintList();
|
|
1241
1409
|
page.resourceLayoutDirty = false;
|
|
1242
1410
|
laidOutSheets = sheets;
|
|
1243
|
-
|
|
1411
|
+
setContentSize();
|
|
1244
1412
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
1245
1413
|
}
|
|
1246
1414
|
|
|
@@ -1265,12 +1433,31 @@ export async function createPageRenderer({
|
|
|
1265
1433
|
if (mismatches) reportError(`buninu-browser: incremental style: ${mismatches} element(s) differ from a full restyle`);
|
|
1266
1434
|
}
|
|
1267
1435
|
|
|
1436
|
+
/**
|
|
1437
|
+
* Brings style and layout up to date with the DOM. Whichever path asks (a
|
|
1438
|
+
* frame, or a script reading geometry or styles), the media and images the
|
|
1439
|
+
* DOM now names are requested first: a script that inserts images and
|
|
1440
|
+
* measures them at once must not leave them unloaded.
|
|
1441
|
+
*/
|
|
1268
1442
|
function rebuildRenderTree(reason = "update") {
|
|
1269
1443
|
const started = hostPerformance.now();
|
|
1270
1444
|
page.dirty = false;
|
|
1445
|
+
page.layoutVersion++;
|
|
1271
1446
|
page.title = page.document?.title ?? page.title;
|
|
1447
|
+
let staleImages = [];
|
|
1448
|
+
if (page.document) {
|
|
1449
|
+
initializeVideos(page.document, page.resources);
|
|
1450
|
+
staleImages = queueImageResources();
|
|
1451
|
+
}
|
|
1272
1452
|
const timing = styleAndLayout();
|
|
1453
|
+
for (const image of staleImages) {
|
|
1454
|
+
const index = page.decodedImages.indexOf(image);
|
|
1455
|
+
if (index >= 0) page.decodedImages.splice(index, 1);
|
|
1456
|
+
image.delete();
|
|
1457
|
+
}
|
|
1273
1458
|
if (!timing.skipped) page.needsPaint = true;
|
|
1459
|
+
syncFrames();
|
|
1460
|
+
if (!timing.skipped) queueBackgroundImages();
|
|
1274
1461
|
lastRebuildMs = hostPerformance.now() - started;
|
|
1275
1462
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
1276
1463
|
+ ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
|
|
@@ -1329,6 +1516,16 @@ export async function createPageRenderer({
|
|
|
1329
1516
|
}
|
|
1330
1517
|
if (url === "about:blank") {
|
|
1331
1518
|
replaceDocument("", url);
|
|
1519
|
+
// An iframe's blank document is scripted from its page (through
|
|
1520
|
+
// contentWindow and contentDocument), so it has a realm.
|
|
1521
|
+
if (embedded && page.scripting !== "off") {
|
|
1522
|
+
page.realm = createPageRealm({ window: page.window, document: page.document, mode: page.scripting, hooks: realmHooks(generation) });
|
|
1523
|
+
render();
|
|
1524
|
+
commit.resolve();
|
|
1525
|
+
// No scripts of its own: it is complete at once, and its realm reports so.
|
|
1526
|
+
await page.realm.runDocumentScripts();
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1332
1529
|
render();
|
|
1333
1530
|
commit.resolve();
|
|
1334
1531
|
finish();
|
|
@@ -1384,8 +1581,11 @@ export async function createPageRenderer({
|
|
|
1384
1581
|
page.document.body.textContent = source;
|
|
1385
1582
|
rebuildRenderTree();
|
|
1386
1583
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1584
|
+
// An error status with a body shows that body, scripts included, as in
|
|
1585
|
+
// Chrome: sites serve real pages that way (Google's "unusual traffic"
|
|
1586
|
+
// check comes as 429). Only an empty response gets a note instead.
|
|
1587
|
+
if ((status < 200 || status > 299) && !source.trim()) {
|
|
1588
|
+
page.document.body.textContent = `HTTP ${status} ${statusText}`.trim();
|
|
1389
1589
|
rebuildRenderTree();
|
|
1390
1590
|
}
|
|
1391
1591
|
render();
|
|
@@ -1465,6 +1665,7 @@ export async function createPageRenderer({
|
|
|
1465
1665
|
}
|
|
1466
1666
|
page.resources.set(element, resource);
|
|
1467
1667
|
page.decodedImages.push(resource.image);
|
|
1668
|
+
finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
|
|
1468
1669
|
}
|
|
1469
1670
|
page.dirty = true;
|
|
1470
1671
|
page.resourceLayoutDirty = true;
|
|
@@ -1480,16 +1681,7 @@ export async function createPageRenderer({
|
|
|
1480
1681
|
|
|
1481
1682
|
/** Brings style, layout and raster up to date; returns whether it repainted. */
|
|
1482
1683
|
function refresh() {
|
|
1483
|
-
if (page.dirty)
|
|
1484
|
-
initializeVideos(page.document, page.resources);
|
|
1485
|
-
const staleImages = queueImageResources();
|
|
1486
|
-
rebuildRenderTree();
|
|
1487
|
-
for (const image of staleImages) {
|
|
1488
|
-
const index = page.decodedImages.indexOf(image);
|
|
1489
|
-
if (index >= 0) page.decodedImages.splice(index, 1);
|
|
1490
|
-
image.delete();
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1684
|
+
if (page.dirty) rebuildRenderTree();
|
|
1493
1685
|
if (!page.needsPaint) return false;
|
|
1494
1686
|
render();
|
|
1495
1687
|
return true;
|
|
@@ -1501,13 +1693,7 @@ export async function createPageRenderer({
|
|
|
1501
1693
|
if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
|
|
1502
1694
|
// Mobile IMEs often send committed text without a usable keydown (key 229
|
|
1503
1695
|
// or Unidentified). Handle that before the normal beforeinput insertion.
|
|
1504
|
-
if (!backspace && element
|
|
1505
|
-
const note = pianoNote(text);
|
|
1506
|
-
if (note !== null) {
|
|
1507
|
-
playPiano(note);
|
|
1508
|
-
return;
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1696
|
+
if (!backspace && handlePianoInput(element, { text }, audio ? playPiano : null)) return;
|
|
1511
1697
|
const value = String(element.value ?? "");
|
|
1512
1698
|
let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
|
|
1513
1699
|
const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
|
|
@@ -1560,6 +1746,12 @@ export async function createPageRenderer({
|
|
|
1560
1746
|
|
|
1561
1747
|
function realmHooks(generation) {
|
|
1562
1748
|
return {
|
|
1749
|
+
frames: {
|
|
1750
|
+
self: ownWindow,
|
|
1751
|
+
parent: ownWindow.parent(),
|
|
1752
|
+
top: ownWindow.top(),
|
|
1753
|
+
childOf: (element) => ensureFrame(element)?.window ?? null,
|
|
1754
|
+
},
|
|
1563
1755
|
definedChanged(name) {
|
|
1564
1756
|
// An upgrade changes :defined for that tag's elements only.
|
|
1565
1757
|
if (selectorTraits.defined) {
|
|
@@ -1577,8 +1769,17 @@ export async function createPageRenderer({
|
|
|
1577
1769
|
playMedia,
|
|
1578
1770
|
pauseMedia,
|
|
1579
1771
|
seekMedia,
|
|
1772
|
+
attachMediaSrc,
|
|
1773
|
+
mediaReadyState,
|
|
1774
|
+
mediaNetworkState,
|
|
1775
|
+
mediaError,
|
|
1776
|
+
mediaBuffered,
|
|
1777
|
+
mediaAppended,
|
|
1778
|
+
mediaDuration,
|
|
1580
1779
|
lifecycle(name) {
|
|
1581
1780
|
if (generation !== loadGeneration) return;
|
|
1781
|
+
// The images load waited for are in the frame shown at load.
|
|
1782
|
+
if (name === "load") refresh();
|
|
1582
1783
|
onLifecycle?.(name, generation);
|
|
1583
1784
|
// Parsing and the deferred scripts are done: changes from async
|
|
1584
1785
|
// scripts and late images get frames, without waiting for load.
|
|
@@ -1587,7 +1788,7 @@ export async function createPageRenderer({
|
|
|
1587
1788
|
scheduleFrame();
|
|
1588
1789
|
}
|
|
1589
1790
|
},
|
|
1590
|
-
whenImagesLoaded: () => page.imagesLoaded,
|
|
1791
|
+
whenImagesLoaded: () => Promise.all([page.imagesLoaded, ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
|
|
1591
1792
|
afterTask() {},
|
|
1592
1793
|
boundsOf(element) {
|
|
1593
1794
|
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
@@ -1606,10 +1807,41 @@ export async function createPageRenderer({
|
|
|
1606
1807
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1607
1808
|
if (viewport.scrollTo(x, y)) render();
|
|
1608
1809
|
},
|
|
1810
|
+
elementScroll(element) {
|
|
1811
|
+
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
1812
|
+
return scrollerGeometry(element);
|
|
1813
|
+
},
|
|
1814
|
+
elementAt(x, y) {
|
|
1815
|
+
if (page.dirty) rebuildRenderTree("script read geometry");
|
|
1816
|
+
if (!(x >= 0 && y >= 0 && x < page.width && y < page.height) || !page.displayList) return null;
|
|
1817
|
+
return elementAtPoint(page.renderTree, page.displayList, x, y, viewport);
|
|
1818
|
+
},
|
|
1819
|
+
setElementScroll(element, x, y) {
|
|
1820
|
+
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1821
|
+
setElementScroll(element, x, y);
|
|
1822
|
+
},
|
|
1823
|
+
scrollIntoView(element) {
|
|
1824
|
+
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
1825
|
+
revealElement(element);
|
|
1826
|
+
},
|
|
1609
1827
|
computedStyle(element) {
|
|
1610
1828
|
if (!element) return null;
|
|
1611
1829
|
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
1612
|
-
|
|
1830
|
+
const style = styleEngine.get(element);
|
|
1831
|
+
if (style?.padding.some(value => typeof value === "object")) {
|
|
1832
|
+
const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
|
|
1833
|
+
if (box) return { ...style, padding: box.style.padding };
|
|
1834
|
+
}
|
|
1835
|
+
return style;
|
|
1836
|
+
},
|
|
1837
|
+
layoutVersion() {
|
|
1838
|
+
if (page.dirty) rebuildRenderTree("intersection observer");
|
|
1839
|
+
return page.layoutVersion;
|
|
1840
|
+
},
|
|
1841
|
+
customProperties(element) {
|
|
1842
|
+
if (!element) return null;
|
|
1843
|
+
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
1844
|
+
return styleEngine.customPropertiesOf(element);
|
|
1613
1845
|
},
|
|
1614
1846
|
navigate: (url) => navigateTo(String(url)),
|
|
1615
1847
|
activateLink(anchor) {
|
|
@@ -1620,7 +1852,14 @@ export async function createPageRenderer({
|
|
|
1620
1852
|
},
|
|
1621
1853
|
submitForm,
|
|
1622
1854
|
async fetchScript(url) {
|
|
1623
|
-
|
|
1855
|
+
// Blob URLs live in this document's registry. Host fetch only accepts
|
|
1856
|
+
// http(s) and s3, and a MediaSource URL has no script bytes.
|
|
1857
|
+
if (/^blob:/i.test(url)) {
|
|
1858
|
+
const stored = page.realm?.blobEntry?.(url);
|
|
1859
|
+
if (stored?.kind !== "blob") throw new TypeError("Failed to fetch");
|
|
1860
|
+
return new TextDecoder().decode(await stored.blob.arrayBuffer());
|
|
1861
|
+
}
|
|
1862
|
+
if (/^data:/i.test(url)) {
|
|
1624
1863
|
const response = await fetch(url);
|
|
1625
1864
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1626
1865
|
return response.text();
|
|
@@ -1634,40 +1873,84 @@ export async function createPageRenderer({
|
|
|
1634
1873
|
reportError,
|
|
1635
1874
|
consoleMessage,
|
|
1636
1875
|
userAgent: () => page.userAgent,
|
|
1876
|
+
initScripts: () => page.initScripts ?? [],
|
|
1877
|
+
rewriteScript,
|
|
1637
1878
|
documentCookie: () => page.cookie,
|
|
1638
1879
|
setDocumentCookie(value) {
|
|
1639
1880
|
page.cookie = mergeDocumentCookie(page.cookie, String(value));
|
|
1640
1881
|
setCookie(String(value));
|
|
1641
1882
|
},
|
|
1642
1883
|
webSockets,
|
|
1643
|
-
|
|
1644
|
-
const
|
|
1645
|
-
|
|
1646
|
-
|
|
1884
|
+
pageFetch(request) {
|
|
1885
|
+
const networkPending = pageFetch(request);
|
|
1886
|
+
const result = networkPending.then((response) => {
|
|
1887
|
+
if (typeof response.documentCookie === "string") page.cookie = response.documentCookie;
|
|
1888
|
+
return response;
|
|
1889
|
+
});
|
|
1890
|
+
result.abort = () => networkPending.abort?.();
|
|
1891
|
+
return result;
|
|
1647
1892
|
},
|
|
1648
1893
|
};
|
|
1649
1894
|
}
|
|
1650
1895
|
|
|
1896
|
+
/**
|
|
1897
|
+
* Form submission (HTML §4.10.21.3): GET puts the entries in the action's
|
|
1898
|
+
* query; POST sends them as the body, encoded per the form's enctype, and
|
|
1899
|
+
* the controller navigates with that request.
|
|
1900
|
+
*/
|
|
1651
1901
|
function submitForm(form, submitter = null) {
|
|
1652
1902
|
const method = (submitter?.getAttribute("formmethod") ?? form.getAttribute("method") ?? "get").toLowerCase();
|
|
1653
|
-
if (method !== "get") return;
|
|
1654
1903
|
const action = submitter?.getAttribute("formaction") ?? form.getAttribute("action") ?? page.url;
|
|
1655
1904
|
const url = new URL(action || page.url, page.url);
|
|
1905
|
+
const entries = [...new page.window.FormData(form, submitter)];
|
|
1906
|
+
if (method === "post") {
|
|
1907
|
+
const enctype = (submitter?.getAttribute("formenctype") ?? form.getAttribute("enctype") ?? "").toLowerCase();
|
|
1908
|
+
requestNavigation(url.href, null, null, encodeFormBody(entries, enctype));
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
if (method !== "get") return;
|
|
1656
1912
|
url.search = "";
|
|
1657
|
-
for (const [name, value] of
|
|
1913
|
+
for (const [name, value] of entries) {
|
|
1658
1914
|
if (typeof value === "string") url.searchParams.append(name, value);
|
|
1659
1915
|
}
|
|
1660
1916
|
navigateTo(url.href);
|
|
1661
1917
|
}
|
|
1662
1918
|
|
|
1919
|
+
/** A POST form's entries as a request body and its content type. Files go as empty parts. */
|
|
1920
|
+
function encodeFormBody(entries, enctype) {
|
|
1921
|
+
const encoder = new TextEncoder();
|
|
1922
|
+
if (enctype === "multipart/form-data") {
|
|
1923
|
+
const boundary = `----buninuFormBoundary${crypto.randomUUID().replaceAll("-", "")}`;
|
|
1924
|
+
const quote = (text) => String(text).replace(/\r\n|\r|\n/g, "\r\n").replaceAll('"', "%22");
|
|
1925
|
+
let body = "";
|
|
1926
|
+
for (const [name, value] of entries) {
|
|
1927
|
+
const file = typeof value === "string" ? null : value;
|
|
1928
|
+
body += `--${boundary}\r\nContent-Disposition: form-data; name="${quote(name)}"`
|
|
1929
|
+
+ (file ? `; filename="${quote(file.name ?? "")}"\r\nContent-Type: application/octet-stream` : "")
|
|
1930
|
+
+ `\r\n\r\n${file ? "" : String(value).replace(/\r\n|\r|\n/g, "\r\n")}\r\n`;
|
|
1931
|
+
}
|
|
1932
|
+
body += `--${boundary}--\r\n`;
|
|
1933
|
+
return { method: "POST", body: encoder.encode(body), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
1934
|
+
}
|
|
1935
|
+
if (enctype === "text/plain") {
|
|
1936
|
+
const body = entries.map(([name, value]) => `${name}=${typeof value === "string" ? value : value.name ?? ""}\r\n`).join("");
|
|
1937
|
+
return { method: "POST", body: encoder.encode(body), contentType: "text/plain" };
|
|
1938
|
+
}
|
|
1939
|
+
const params = new URLSearchParams();
|
|
1940
|
+
for (const [name, value] of entries) params.append(name, typeof value === "string" ? value : value.name ?? "");
|
|
1941
|
+
return { method: "POST", body: encoder.encode(params.toString()), contentType: "application/x-www-form-urlencoded" };
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1663
1944
|
/** The border box of an element's layout box in viewport coordinates. */
|
|
1664
1945
|
function viewportBounds(element) {
|
|
1665
1946
|
let fixed = false;
|
|
1666
1947
|
for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
|
|
1667
1948
|
if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
|
|
1668
1949
|
}
|
|
1669
|
-
|
|
1670
|
-
const
|
|
1950
|
+
// Scroll containers around it move it too.
|
|
1951
|
+
const shift = scrollShift(element);
|
|
1952
|
+
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
1953
|
+
const scrollY = (fixed ? 0 : viewport.y) + shift.y;
|
|
1671
1954
|
let nodeId = null;
|
|
1672
1955
|
for (const node of page.renderTree.nodesById.values()) {
|
|
1673
1956
|
if (node.domNode === element && !node.pseudo) {
|
|
@@ -1748,7 +2031,26 @@ export async function createPageRenderer({
|
|
|
1748
2031
|
|
|
1749
2032
|
function collectHints() {
|
|
1750
2033
|
const chars = ["a", "s", "d", "f", "j", "k", "l"];
|
|
1751
|
-
|
|
2034
|
+
// Laid-out regions moved by the scroll containers around them, kept
|
|
2035
|
+
// where the element itself shows (not scrolled out of a container or covered).
|
|
2036
|
+
const regions = interactiveRegions(page.renderTree, page.layout, viewport, {})
|
|
2037
|
+
.map((region) => {
|
|
2038
|
+
const shift = scrollShift(region.element);
|
|
2039
|
+
if (!shift.x && !shift.y) return region;
|
|
2040
|
+
const rects = region.rects.map((rect) => ({
|
|
2041
|
+
...rect, left: rect.left - shift.x, right: rect.right - shift.x, top: rect.top - shift.y, bottom: rect.bottom - shift.y,
|
|
2042
|
+
}));
|
|
2043
|
+
return { element: region.element, rects };
|
|
2044
|
+
})
|
|
2045
|
+
.filter((region) => {
|
|
2046
|
+
const rect = region.rects[0];
|
|
2047
|
+
const cx = (rect.left + rect.right) / 2;
|
|
2048
|
+
const cy = (rect.top + rect.bottom) / 2;
|
|
2049
|
+
if (cx < 0 || cy < 0 || cx >= page.width || cy >= page.height) return false;
|
|
2050
|
+
const shown = page.displayList ? elementAtPoint(page.renderTree, page.displayList, cx, cy, viewport) : null;
|
|
2051
|
+
return !shown || shown === region.element || region.element.contains(shown) || shown.contains(region.element);
|
|
2052
|
+
})
|
|
2053
|
+
.slice(0, 49);
|
|
1752
2054
|
const labels = regions.length <= chars.length
|
|
1753
2055
|
? chars
|
|
1754
2056
|
: chars.flatMap((first) => chars.map((second) => first + second));
|
|
@@ -1783,9 +2085,14 @@ export async function createPageRenderer({
|
|
|
1783
2085
|
|
|
1784
2086
|
async function downloadBlob(url, name) {
|
|
1785
2087
|
try {
|
|
1786
|
-
const
|
|
1787
|
-
|
|
1788
|
-
|
|
2088
|
+
const stored = page.realm?.blobEntry?.(url);
|
|
2089
|
+
let data;
|
|
2090
|
+
if (stored?.kind === "blob") data = new Uint8Array(await stored.blob.arrayBuffer());
|
|
2091
|
+
else {
|
|
2092
|
+
const response = await fetch(url);
|
|
2093
|
+
if (!response.ok) throw new Error(`blob download failed: HTTP ${response.status}`);
|
|
2094
|
+
data = new Uint8Array(await response.arrayBuffer());
|
|
2095
|
+
}
|
|
1789
2096
|
if (data.byteLength > 64 * 1024 * 1024) throw new Error("blob download exceeds 64 MB");
|
|
1790
2097
|
requestDownload({ url, name, data });
|
|
1791
2098
|
} catch (error) {
|
|
@@ -1802,6 +2109,10 @@ export async function createPageRenderer({
|
|
|
1802
2109
|
setUserAgent(userAgent) {
|
|
1803
2110
|
if (typeof userAgent === "string" && userAgent) page.userAgent = userAgent;
|
|
1804
2111
|
},
|
|
2112
|
+
/** Scripts every new document runs before its own (CDP Page.addScriptToEvaluateOnNewDocument). */
|
|
2113
|
+
setInitScripts(sources) {
|
|
2114
|
+
page.initScripts = Array.isArray(sources) ? sources.map(String) : [];
|
|
2115
|
+
},
|
|
1805
2116
|
/** Messages the page logged, for diagnostics. */
|
|
1806
2117
|
consoleMessages() {
|
|
1807
2118
|
return page.realm?.console ?? [];
|
|
@@ -1914,21 +2225,18 @@ export async function createPageRenderer({
|
|
|
1914
2225
|
return { x: viewport.x, y: viewport.y };
|
|
1915
2226
|
},
|
|
1916
2227
|
press(key, options = {}) {
|
|
2228
|
+
if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
|
|
1917
2229
|
refresh();
|
|
1918
2230
|
const pianoInput = page.document?.activeElement;
|
|
1919
|
-
if (audio
|
|
1920
|
-
|
|
1921
|
-
if (note !== null && !options.repeat && !options.modifiers) {
|
|
1922
|
-
playPiano(note);
|
|
1923
|
-
return { x: viewport.x, y: viewport.y };
|
|
1924
|
-
}
|
|
2231
|
+
if (handlePianoInput(pianoInput, { ...options, key }, audio ? playPiano : null)) {
|
|
2232
|
+
return { x: viewport.x, y: viewport.y };
|
|
1925
2233
|
}
|
|
1926
2234
|
// Pages see keydown/keyup first; preventDefault() cancels the default scroll.
|
|
1927
2235
|
if (page.realm) {
|
|
1928
2236
|
const target = page.document.activeElement ?? page.document.body;
|
|
1929
2237
|
const modifiers = Number(options?.modifiers) || 0;
|
|
1930
2238
|
const init = {
|
|
1931
|
-
|
|
2239
|
+
...keyboardInput(key, options), repeat: Boolean(options?.repeat),
|
|
1932
2240
|
altKey: Boolean(modifiers & 1), ctrlKey: Boolean(modifiers & 2),
|
|
1933
2241
|
metaKey: Boolean(modifiers & 4), shiftKey: Boolean(modifiers & 8),
|
|
1934
2242
|
bubbles: true, cancelable: true,
|
|
@@ -1966,12 +2274,44 @@ export async function createPageRenderer({
|
|
|
1966
2274
|
return { x: viewport.x, y: viewport.y };
|
|
1967
2275
|
},
|
|
1968
2276
|
type(text) {
|
|
2277
|
+
if (focusedFrame?.renderer) return focusedFrame.renderer.type(text);
|
|
1969
2278
|
editFocusedText(String(text ?? ""));
|
|
1970
2279
|
refresh();
|
|
1971
2280
|
},
|
|
1972
2281
|
scrollOffset() {
|
|
1973
2282
|
return { x: viewport.x, y: viewport.y };
|
|
1974
2283
|
},
|
|
2284
|
+
videoReport(report) {
|
|
2285
|
+
const state = [...page.media.values()].find(state => state.video?.presentation?.id === report.id);
|
|
2286
|
+
if (!state || !state.playing) return;
|
|
2287
|
+
if (report.error) {
|
|
2288
|
+
state.presentationFailed = true;
|
|
2289
|
+
stopVideoPresentation(state);
|
|
2290
|
+
const serial = state.seekSerial;
|
|
2291
|
+
void placeVideo(state, mediaTime(state)).then(() => {
|
|
2292
|
+
if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
|
|
2293
|
+
}).catch(reportError);
|
|
2294
|
+
} else if (Number.isFinite(report.end)) {
|
|
2295
|
+
state.video.frameTime = report.end;
|
|
2296
|
+
if (needsMore(state.video)) void refillMedia(state, state.video);
|
|
2297
|
+
else {
|
|
2298
|
+
readPresentedFrame(state);
|
|
2299
|
+
stopVideoPresentation(state);
|
|
2300
|
+
state.video.done = true;
|
|
2301
|
+
}
|
|
2302
|
+
} else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
|
|
2303
|
+
},
|
|
2304
|
+
/** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
|
|
2305
|
+
wheel(x, y, deltaX, deltaY, options = undefined) {
|
|
2306
|
+
return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
|
|
2307
|
+
},
|
|
2308
|
+
/** Move the pointer in viewport coordinates, including boundary events. */
|
|
2309
|
+
move(x, y) {
|
|
2310
|
+
refresh();
|
|
2311
|
+
const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport);
|
|
2312
|
+
dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y));
|
|
2313
|
+
refresh();
|
|
2314
|
+
},
|
|
1975
2315
|
/**
|
|
1976
2316
|
* Clicks at a viewport point: the page receives a click event first, and
|
|
1977
2317
|
* unless it calls preventDefault() a link under the point is returned for
|
|
@@ -1983,6 +2323,7 @@ export async function createPageRenderer({
|
|
|
1983
2323
|
// label) without a DOM mutation, so the next restyle is a full one.
|
|
1984
2324
|
markStyleDirty(null); // full: click
|
|
1985
2325
|
let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
|
|
2326
|
+
let forwarded = false;
|
|
1986
2327
|
if (!region) {
|
|
1987
2328
|
// Some search forms draw a wide control around a narrow textarea and
|
|
1988
2329
|
// rely on script to forward clicks. Preserve that affordance when the
|
|
@@ -1992,13 +2333,34 @@ export async function createPageRenderer({
|
|
|
1992
2333
|
if (!bounds || x < bounds.left || x >= bounds.left + bounds.width
|
|
1993
2334
|
|| y < bounds.top || y >= bounds.top + bounds.height) continue;
|
|
1994
2335
|
const fields = form.querySelectorAll('textarea[inputmode="search"], input[type="search"]');
|
|
1995
|
-
if (fields.length === 1)
|
|
2336
|
+
if (fields.length === 1) {
|
|
2337
|
+
region = { element: fields[0] };
|
|
2338
|
+
forwarded = true;
|
|
2339
|
+
}
|
|
1996
2340
|
break;
|
|
1997
2341
|
}
|
|
1998
2342
|
}
|
|
1999
|
-
|
|
2343
|
+
// Pointer events go to the topmost element painted at the point, where
|
|
2344
|
+
// it is now (scroll containers move their content); the interactive
|
|
2345
|
+
// regions (laid out, unscrolled) stand in only where nothing is painted,
|
|
2346
|
+
// and a search form's forwarded field takes the click on the form.
|
|
2347
|
+
const precise = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2348
|
+
const target = forwarded ? region.element : precise ?? region?.element ?? null;
|
|
2349
|
+
if (!target) {
|
|
2350
|
+
focusedFrame = null;
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
// A click inside an iframe belongs to its document alone.
|
|
2354
|
+
const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
|
|
2355
|
+
focusedFrame = inFrame?.frame ?? null;
|
|
2356
|
+
if (inFrame) {
|
|
2357
|
+
const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y);
|
|
2358
|
+
const href = typeof followed === "string" ? followed : followed?.fragmentFrom ? null : followed?.url;
|
|
2359
|
+
if (href && !followed?.download) void loadFrame(inFrame.frame, href);
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2000
2362
|
if (page.url === "about:audio") {
|
|
2001
|
-
const key =
|
|
2363
|
+
const key = target.closest?.("[data-piano-note]");
|
|
2002
2364
|
const note = Number(key?.getAttribute("data-piano-note"));
|
|
2003
2365
|
if (key && Number.isInteger(note) && note >= 0 && note <= 127 && audio) {
|
|
2004
2366
|
playPiano(note);
|
|
@@ -2006,22 +2368,30 @@ export async function createPageRenderer({
|
|
|
2006
2368
|
return null;
|
|
2007
2369
|
}
|
|
2008
2370
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2371
|
+
// The press focuses the focusable element it lands in, unless mousedown was canceled.
|
|
2372
|
+
const focus = () => {
|
|
2373
|
+
const focusable = target.closest?.("input, textarea, select, button, [tabindex]");
|
|
2374
|
+
if (focusable && !focusable.disabled) focusable.focus();
|
|
2375
|
+
};
|
|
2376
|
+
if (!page.realm) focus();
|
|
2377
|
+
else {
|
|
2378
|
+
// A press and release, then the click (UI Events §3.5).
|
|
2379
|
+
const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
|
|
2380
|
+
const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
|
|
2381
|
+
const pointer = { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true };
|
|
2382
|
+
page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
|
|
2383
|
+
if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
|
|
2384
|
+
page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
|
|
2385
|
+
page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
|
|
2386
|
+
const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
|
|
2017
2387
|
refresh();
|
|
2018
2388
|
if (prevented) return null;
|
|
2019
2389
|
}
|
|
2020
|
-
if (
|
|
2021
|
-
clickMedia(
|
|
2390
|
+
if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
|
|
2391
|
+
clickMedia(target, x, y);
|
|
2022
2392
|
return null;
|
|
2023
2393
|
}
|
|
2024
|
-
const anchor =
|
|
2394
|
+
const anchor = target.closest?.("a[href]");
|
|
2025
2395
|
if (!anchor) return null;
|
|
2026
2396
|
const href = new URL(anchor.getAttribute("href"), page.url).href;
|
|
2027
2397
|
if (href.startsWith("javascript:")) {
|
|
@@ -2105,15 +2475,18 @@ export async function createPageRenderer({
|
|
|
2105
2475
|
return cloneableResult(value);
|
|
2106
2476
|
},
|
|
2107
2477
|
dispose() {
|
|
2478
|
+
disposedRenderer = true;
|
|
2108
2479
|
hostClearTimeout(prepaintTimer);
|
|
2109
2480
|
hostClearTimeout(frameTimer);
|
|
2481
|
+
disposeFrames();
|
|
2110
2482
|
page.realm?.dispose();
|
|
2111
2483
|
closePiano();
|
|
2112
2484
|
disposeVideos();
|
|
2485
|
+
rasterSurface?.dispose();
|
|
2486
|
+
rasterSurface = null;
|
|
2113
2487
|
page.window?.happyDOM?.abort?.();
|
|
2114
2488
|
for (const image of page.decodedImages) image.delete();
|
|
2115
|
-
|
|
2116
|
-
fontProvider.delete();
|
|
2489
|
+
disposeBackgrounds();
|
|
2117
2490
|
},
|
|
2118
2491
|
};
|
|
2119
2492
|
|
|
@@ -2155,13 +2528,15 @@ export async function createPageRenderer({
|
|
|
2155
2528
|
const entry = { url, text: null };
|
|
2156
2529
|
linkSheets.set(element, entry);
|
|
2157
2530
|
const request = fetchResource(url, "stylesheet").then((response) => {
|
|
2158
|
-
|
|
2531
|
+
// Its url()s are relative to the sheet, not to the document.
|
|
2532
|
+
if (response.ok) entry.text = absolutizeStyleSheetUrls(new TextDecoder().decode(response.body), response.url || url);
|
|
2159
2533
|
}, () => {
|
|
2160
2534
|
// A failed stylesheet must not prevent the document itself from loading.
|
|
2161
2535
|
}).then(() => {
|
|
2162
|
-
if (
|
|
2536
|
+
if (document !== page.document || linkSheets.get(element) !== entry) return;
|
|
2163
2537
|
page.dirty = true;
|
|
2164
2538
|
scheduleFrame();
|
|
2539
|
+
element.dispatchEvent(new page.window.Event(entry.text === null ? "error" : "load"));
|
|
2165
2540
|
});
|
|
2166
2541
|
pending?.push(request);
|
|
2167
2542
|
}
|
|
@@ -2184,25 +2559,423 @@ export async function createPageRenderer({
|
|
|
2184
2559
|
await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
|
|
2185
2560
|
const src = element.getAttribute("src");
|
|
2186
2561
|
if (!src) return;
|
|
2562
|
+
beginImageLoad(element, src);
|
|
2187
2563
|
try {
|
|
2188
2564
|
const response = await fetchResource(new URL(src, documentUrl).href, "image");
|
|
2189
|
-
if (!response.ok)
|
|
2565
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2190
2566
|
// Untrusted image bytes are decoded here, inside the renderer.
|
|
2191
2567
|
const bytes = response.body;
|
|
2192
2568
|
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
2193
2569
|
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
2194
|
-
if (!image)
|
|
2570
|
+
if (!image) throw new Error("Image decoding failed");
|
|
2195
2571
|
const resource = Object.freeze({ width, height, image, url: response.url });
|
|
2572
|
+
finishImageLoad(element, src, resource);
|
|
2196
2573
|
map.set(element, resource);
|
|
2197
2574
|
decoded.push(image);
|
|
2198
2575
|
entries.push({ element, src, resource });
|
|
2199
2576
|
} catch {
|
|
2200
2577
|
// Broken images keep their fallback replaced-element dimensions.
|
|
2578
|
+
finishImageLoad(element, src, null);
|
|
2201
2579
|
}
|
|
2202
2580
|
}));
|
|
2203
2581
|
return { map, decoded, entries };
|
|
2204
2582
|
}
|
|
2205
2583
|
|
|
2584
|
+
// ---- iframes. Each same-origin iframe's document lives in a page renderer
|
|
2585
|
+
// of its own (embedded), sharing this process's CanvasKit: its frames are
|
|
2586
|
+
// cut to its viewport and drawn into the iframe's box through a resource
|
|
2587
|
+
// whose image is swapped as frames arrive. Other documents are not given to
|
|
2588
|
+
// this process (the controller checks), so their iframes stay empty.
|
|
2589
|
+
|
|
2590
|
+
/** Brings the frames in line with the document's iframes: their documents and sizes. */
|
|
2591
|
+
function syncFrames() {
|
|
2592
|
+
const document = page.document;
|
|
2593
|
+
const elements = document ? [...document.getElementsByTagName("iframe")] : [];
|
|
2594
|
+
if (!elements.length && !frames.size) return;
|
|
2595
|
+
const boxes = frameBoxes();
|
|
2596
|
+
for (const [element, frame] of frames) {
|
|
2597
|
+
if (!element.isConnected || element.ownerDocument !== document) disposeFrame(frame);
|
|
2598
|
+
}
|
|
2599
|
+
for (const element of elements) {
|
|
2600
|
+
const frame = ensureFrame(element);
|
|
2601
|
+
page.resources.set(element, frame.holder);
|
|
2602
|
+
// Setting the name attribute names the frame (HTML §4.8.5).
|
|
2603
|
+
const nameAttribute = element.getAttribute("name");
|
|
2604
|
+
if (nameAttribute !== frame.nameAttribute) {
|
|
2605
|
+
frame.nameAttribute = nameAttribute;
|
|
2606
|
+
frame.window.setName(nameAttribute ?? "");
|
|
2607
|
+
}
|
|
2608
|
+
frame.box = boxes.get(element) ?? null;
|
|
2609
|
+
const width = Math.max(1, Math.round(frame.box?.width ?? 300));
|
|
2610
|
+
const height = Math.max(1, Math.round(frame.box?.height ?? 150));
|
|
2611
|
+
if (!frame.size || frame.size.width !== width || frame.size.height !== height || frame.size.scale !== page.deviceScaleFactor) {
|
|
2612
|
+
frame.size = { width, height, scale: page.deviceScaleFactor };
|
|
2613
|
+
void frame.ready.then((renderer) => renderer?.resize(width, height, page.deviceScaleFactor));
|
|
2614
|
+
}
|
|
2615
|
+
const source = frameSource(element);
|
|
2616
|
+
if (source !== frame.source) {
|
|
2617
|
+
frame.source = source;
|
|
2618
|
+
void loadFrame(frame, source);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/** The frame of an iframe of this document, made on first need (a script may post to it at once). */
|
|
2624
|
+
function ensureFrame(element) {
|
|
2625
|
+
let frame = frames.get(element);
|
|
2626
|
+
if (frame) return frame;
|
|
2627
|
+
if (element?.tagName !== "IFRAME" || !element.isConnected || element.ownerDocument !== page.document) return null;
|
|
2628
|
+
frame = { element, renderer: null, ready: null, source: undefined, token: 0, size: null, box: null,
|
|
2629
|
+
window: windowHandle(ownWindow, element.getAttribute("name") ?? ""), holder: { width: 0, height: 0, image: null } };
|
|
2630
|
+
frame.nameAttribute = element.getAttribute("name");
|
|
2631
|
+
frames.set(element, frame);
|
|
2632
|
+
page.resources.set(element, frame.holder);
|
|
2633
|
+
// Made at once, as is its initial about:blank document (HTML §4.8.5): a
|
|
2634
|
+
// script can use its contentWindow as soon as the iframe is inserted.
|
|
2635
|
+
const renderer = buildPageRenderer({
|
|
2636
|
+
fetchResource: rawFetchResource,
|
|
2637
|
+
requestNavigation: (url, downloadName) => {
|
|
2638
|
+
if (downloadName === null) void loadFrame(frame, url);
|
|
2639
|
+
},
|
|
2640
|
+
requestDownload,
|
|
2641
|
+
reportError,
|
|
2642
|
+
pageFetch,
|
|
2643
|
+
webSockets,
|
|
2644
|
+
setCookie,
|
|
2645
|
+
consoleMessage,
|
|
2646
|
+
onFrame: (update) => showFrame(frame, update),
|
|
2647
|
+
onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
|
|
2648
|
+
videoDecoder,
|
|
2649
|
+
rewriteScript,
|
|
2650
|
+
mobile,
|
|
2651
|
+
embedded: true,
|
|
2652
|
+
frameWindow: frame.window,
|
|
2653
|
+
}, loadedGraphics);
|
|
2654
|
+
// Page scripts in "host" mode share one global object; a frame's scripts need their own.
|
|
2655
|
+
renderer.setScripting(page.scripting === "ses" ? "ses" : "off");
|
|
2656
|
+
renderer.setUserAgent(page.userAgent);
|
|
2657
|
+
renderer.setInitScripts(page.initScripts ?? []);
|
|
2658
|
+
frame.renderer = renderer;
|
|
2659
|
+
frame.ready = Promise.resolve(renderer);
|
|
2660
|
+
frame.loading = renderer.loadDocument({ url: "about:blank" }).then((generation) => {
|
|
2661
|
+
frame.generation = generation;
|
|
2662
|
+
frame.initialGeneration = generation;
|
|
2663
|
+
});
|
|
2664
|
+
// Then its src, at once: syncFrames only adjusts the size later. A frame
|
|
2665
|
+
// without one keeps its initial document, whose load the iframe fires;
|
|
2666
|
+
// otherwise only its src's load is fired.
|
|
2667
|
+
frame.source = frameSource(element);
|
|
2668
|
+
frame.silentInitialLoad = frame.source !== "about:blank";
|
|
2669
|
+
if (frame.source !== "about:blank") void loadFrame(frame, frame.source);
|
|
2670
|
+
return frame;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/** Each rendered iframe's content box, in document coordinates. */
|
|
2674
|
+
function frameBoxes() {
|
|
2675
|
+
const boxes = new Map();
|
|
2676
|
+
if (!page.layout || !page.renderTree) return boxes;
|
|
2677
|
+
for (const fragment of page.layout.fragments) {
|
|
2678
|
+
for (const run of fragment.runs) {
|
|
2679
|
+
if (run.type !== "image") continue;
|
|
2680
|
+
const element = page.renderTree.nodesById.get(run.nodeId)?.domNode;
|
|
2681
|
+
if (element?.tagName === "IFRAME") {
|
|
2682
|
+
boxes.set(element, { x: run.x, y: run.y ?? fragment.y, width: run.width, height: run.height });
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
return boxes;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
/** The URL an iframe shows: its src resolved against the page, or about:blank. */
|
|
2690
|
+
function frameSource(element) {
|
|
2691
|
+
const src = element.getAttribute("src")?.trim();
|
|
2692
|
+
if (!src) return "about:blank";
|
|
2693
|
+
try {
|
|
2694
|
+
return new URL(src, page.url).href;
|
|
2695
|
+
} catch {
|
|
2696
|
+
return "about:blank";
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
/** Loads a frame's document; a cross-origin one (or a failure) leaves it blank. */
|
|
2701
|
+
async function loadFrame(frame, url) {
|
|
2702
|
+
const token = ++frame.token;
|
|
2703
|
+
let document = { url: "about:blank" };
|
|
2704
|
+
let target = null;
|
|
2705
|
+
try { target = new URL(url); } catch { /* blank */ }
|
|
2706
|
+
if (target && ["http:", "https:"].includes(target.protocol) && target.origin === new URL(page.url).origin) {
|
|
2707
|
+
try {
|
|
2708
|
+
const response = await fetchResource(target.href, "iframe");
|
|
2709
|
+
document = {
|
|
2710
|
+
url: response.url || target.href,
|
|
2711
|
+
source: new TextDecoder().decode(response.body),
|
|
2712
|
+
contentType: response.contentType || "text/html",
|
|
2713
|
+
status: response.status,
|
|
2714
|
+
statusText: response.statusText ?? "",
|
|
2715
|
+
cookie: response.documentCookie ?? page.cookie,
|
|
2716
|
+
};
|
|
2717
|
+
} catch (error) {
|
|
2718
|
+
trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
const renderer = await frame.ready;
|
|
2722
|
+
if (!renderer || token !== frame.token || !frames.has(frame.element)) return;
|
|
2723
|
+
frame.loading = renderer.loadDocument(document).then((generation) => { frame.generation = generation; });
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/** A frame's document painted: its viewport becomes the iframe's image. */
|
|
2727
|
+
function showFrame(frame, { tile, scroll, viewport: size }) {
|
|
2728
|
+
if (!frames.has(frame.element)) return;
|
|
2729
|
+
const { pixels, width, height } = cropViewport(tile, scroll.x, scroll.y, size.width, size.height);
|
|
2730
|
+
const image = CanvasKit.MakeImage({
|
|
2731
|
+
width, height, alphaType: CanvasKit.AlphaType.Unpremul, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
2732
|
+
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
2733
|
+
}, pixels, width * 4);
|
|
2734
|
+
if (!image) return;
|
|
2735
|
+
const first = !frame.holder.image;
|
|
2736
|
+
frame.holder.image?.delete();
|
|
2737
|
+
Object.assign(frame.holder, { width, height, image });
|
|
2738
|
+
framesVersion++;
|
|
2739
|
+
// The display list leaves out images that have nothing to show yet.
|
|
2740
|
+
if (first && page.layout) page.displayList = paintList();
|
|
2741
|
+
page.needsPaint = true;
|
|
2742
|
+
if (!framePaintTimer) {
|
|
2743
|
+
framePaintTimer = hostSetTimeout(() => {
|
|
2744
|
+
framePaintTimer = null;
|
|
2745
|
+
if (page.needsPaint && !page.loading) render();
|
|
2746
|
+
}, 16);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
/** A frame's document reached DOMContentLoaded or load: the iframe fires load. */
|
|
2751
|
+
function frameLifecycle(frame, name, generation) {
|
|
2752
|
+
if (name !== "load" || !frames.has(frame.element)) return;
|
|
2753
|
+
// A blank document loads within loadDocument, before its generation is known here.
|
|
2754
|
+
hostSetTimeout(() => void frame.loading?.then(() => {
|
|
2755
|
+
if (frame.generation !== generation || !frames.has(frame.element) || !page.realm) return;
|
|
2756
|
+
if (frame.silentInitialLoad && generation === frame.initialGeneration) return;
|
|
2757
|
+
page.realm.dispatch(frame.element, new page.window.Event("load"));
|
|
2758
|
+
refresh();
|
|
2759
|
+
}), 0);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
function disposeFrame(frame) {
|
|
2763
|
+
frames.delete(frame.element);
|
|
2764
|
+
if (focusedFrame === frame) focusedFrame = null;
|
|
2765
|
+
frame.token++;
|
|
2766
|
+
void frame.ready?.then((renderer) => renderer?.dispose());
|
|
2767
|
+
frame.holder.image?.delete();
|
|
2768
|
+
frame.holder.image = null;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
function disposeFrames() {
|
|
2772
|
+
for (const frame of [...frames.values()]) disposeFrame(frame);
|
|
2773
|
+
hostClearTimeout(framePaintTimer);
|
|
2774
|
+
framePaintTimer = null;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/** The frame under a viewport point, with the point in the frame's own viewport. */
|
|
2778
|
+
function frameAt(element, x, y) {
|
|
2779
|
+
const frame = frames.get(element);
|
|
2780
|
+
if (!frame?.renderer || !frame.box) return null;
|
|
2781
|
+
const shift = scrollShift(element);
|
|
2782
|
+
return { frame, x: x + viewport.x + shift.x - frame.box.x, y: y + viewport.y + shift.y - frame.box.y };
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// ---- Scroll containers (CSS Overflow 3 §3). Elements with overflow other
|
|
2786
|
+
// than visible or clip scroll their content: the offsets live here, paint
|
|
2787
|
+
// moves the content by them (see paintEffects), and the wheel, scripts and
|
|
2788
|
+
// scrollIntoView change them.
|
|
2789
|
+
|
|
2790
|
+
/** The page scrolls over its laid-out size and whatever overflows it (see displayList.extent). */
|
|
2791
|
+
function setContentSize() {
|
|
2792
|
+
const extent = page.displayList?.extent;
|
|
2793
|
+
viewport.setContentSize(Math.max(page.layout.width, extent?.width ?? 0), Math.max(page.layout.height, extent?.height ?? 0));
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function paintList() {
|
|
2797
|
+
return buildDisplayList(page.layout, page.renderTree, page.resources, backgroundResource, scrollOffsetOf);
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function scrollOffsetOf(element) {
|
|
2801
|
+
return element ? page.elementScroll.get(element) ?? null : null;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
/** The render node and layout box of an element, or null. */
|
|
2805
|
+
function layoutBoxOf(element) {
|
|
2806
|
+
if (!page.renderTree || !page.layout) return null;
|
|
2807
|
+
for (const node of page.renderTree.nodesById.values()) {
|
|
2808
|
+
if (node.domNode !== element || node.pseudo) continue;
|
|
2809
|
+
const box = page.layout.boxes.find((candidate) => candidate.nodeId === node.id && !candidate.inline);
|
|
2810
|
+
return box ? { node, box } : null;
|
|
2811
|
+
}
|
|
2812
|
+
return null;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/**
|
|
2816
|
+
* A scroll container's scroll position and sizes (CSSOM View §6): its
|
|
2817
|
+
* padding box is the client area, its content's reach the scroll area.
|
|
2818
|
+
* Null for an element that is not one.
|
|
2819
|
+
*/
|
|
2820
|
+
function scrollerGeometry(element) {
|
|
2821
|
+
const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
|
|
2822
|
+
if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
|
|
2823
|
+
const found = layoutBoxOf(element);
|
|
2824
|
+
if (!found) return null;
|
|
2825
|
+
const { node, box } = found;
|
|
2826
|
+
const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
|
|
2827
|
+
const padding = style.padding ?? [0, 0, 0, 0];
|
|
2828
|
+
const client = {
|
|
2829
|
+
x: box.x + borderLeft,
|
|
2830
|
+
y: box.y + borderTop,
|
|
2831
|
+
width: Math.max(0, box.width - borderLeft - borderRight),
|
|
2832
|
+
height: Math.max(0, box.height - borderTop - borderBottom),
|
|
2833
|
+
};
|
|
2834
|
+
const extent = page.displayList?.scrollers?.get(node.id);
|
|
2835
|
+
// In-flow content gets the end padding; absolutely positioned boxes reach only as far as they do.
|
|
2836
|
+
const scrollWidth = Math.max(client.width, extent ? Math.max(extent.flowRight - client.x + (Number(padding[1]) || 0),
|
|
2837
|
+
extent.right - client.x) : 0);
|
|
2838
|
+
const scrollHeight = Math.max(client.height, extent ? Math.max(extent.flowBottom - client.y + (Number(padding[2]) || 0),
|
|
2839
|
+
extent.bottom - client.y) : 0);
|
|
2840
|
+
const offset = page.elementScroll.get(element) ?? { x: 0, y: 0 };
|
|
2841
|
+
return {
|
|
2842
|
+
x: offset.x,
|
|
2843
|
+
y: offset.y,
|
|
2844
|
+
clientWidth: client.width,
|
|
2845
|
+
clientHeight: client.height,
|
|
2846
|
+
scrollWidth,
|
|
2847
|
+
scrollHeight,
|
|
2848
|
+
maxX: style.overflowScrollX ? Math.max(0, scrollWidth - client.width) : 0,
|
|
2849
|
+
maxY: style.overflowScrollY ? Math.max(0, scrollHeight - client.height) : 0,
|
|
2850
|
+
user: { x: style.overflowScrollX === "auto", y: style.overflowScrollY === "auto" },
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
/** Scrolls a scroll container to (x, y), clamped; returns whether it moved. */
|
|
2855
|
+
function setElementScroll(element, x, y) {
|
|
2856
|
+
const geometry = scrollerGeometry(element);
|
|
2857
|
+
if (!geometry) return false;
|
|
2858
|
+
const next = {
|
|
2859
|
+
x: Math.round(Math.min(geometry.maxX, Math.max(0, Number(x) || 0))),
|
|
2860
|
+
y: Math.round(Math.min(geometry.maxY, Math.max(0, Number(y) || 0))),
|
|
2861
|
+
};
|
|
2862
|
+
if (next.x === geometry.x && next.y === geometry.y) return false;
|
|
2863
|
+
if (next.x || next.y) page.elementScroll.set(element, next);
|
|
2864
|
+
else page.elementScroll.delete(element);
|
|
2865
|
+
page.layoutVersion++;
|
|
2866
|
+
page.displayList = paintList();
|
|
2867
|
+
page.needsPaint = true;
|
|
2868
|
+
// A scroll event at the element, as a task (CSSOM View §13.1).
|
|
2869
|
+
hostSetTimeout(() => {
|
|
2870
|
+
if (page.realm && element.isConnected) page.realm.dispatch(element, new page.window.Event("scroll"));
|
|
2871
|
+
}, 0);
|
|
2872
|
+
return true;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/**
|
|
2876
|
+
* Keeps offsets within what each scroll container can scroll after a
|
|
2877
|
+
* relayout (measured on the display list just built); returns whether any changed.
|
|
2878
|
+
*/
|
|
2879
|
+
function clampElementScroll() {
|
|
2880
|
+
let changed = false;
|
|
2881
|
+
for (const [element, offset] of page.elementScroll) {
|
|
2882
|
+
const geometry = element.isConnected ? scrollerGeometry(element) : null;
|
|
2883
|
+
if (!geometry) {
|
|
2884
|
+
page.elementScroll.delete(element);
|
|
2885
|
+
changed = true;
|
|
2886
|
+
} else if (offset.x > geometry.maxX || offset.y > geometry.maxY) {
|
|
2887
|
+
page.elementScroll.set(element, { x: Math.min(offset.x, geometry.maxX), y: Math.min(offset.y, geometry.maxY) });
|
|
2888
|
+
changed = true;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
return changed;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/** How far the scroll containers around an element have moved it (to subtract from its layout position). */
|
|
2895
|
+
function scrollShift(element) {
|
|
2896
|
+
let x = 0;
|
|
2897
|
+
let y = 0;
|
|
2898
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
|
|
2899
|
+
if (node !== element) {
|
|
2900
|
+
const offset = page.elementScroll.get(node);
|
|
2901
|
+
if (offset) {
|
|
2902
|
+
x += offset.x;
|
|
2903
|
+
y += offset.y;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
// Fixed content does not move with what is around it.
|
|
2907
|
+
if (styleEngine.get(node)?.position === "fixed") break;
|
|
2908
|
+
}
|
|
2909
|
+
return { x, y };
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
/**
|
|
2913
|
+
* The wheel at a viewport point (UI Events §5.4): a wheel event at the
|
|
2914
|
+
* element there; unless it is canceled, the scroll containers from there
|
|
2915
|
+
* outwards each take what they can still scroll of the delta and pass the
|
|
2916
|
+
* rest on (scroll chaining), an iframe's document included unless the
|
|
2917
|
+
* iframe says scrolling="no". Returns the delta left over for the page,
|
|
2918
|
+
* which the compositor scrolls (or, for an iframe's document, the frame here).
|
|
2919
|
+
*/
|
|
2920
|
+
function wheel(x, y, deltaX, deltaY, { scrollViewport = true } = {}) {
|
|
2921
|
+
refresh();
|
|
2922
|
+
let left = { x: deltaX, y: deltaY };
|
|
2923
|
+
const target = page.displayList ? elementAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
|
|
2924
|
+
if (target?.tagName === "IFRAME") {
|
|
2925
|
+
const inFrame = frameAt(target, x, y);
|
|
2926
|
+
if (inFrame) {
|
|
2927
|
+
left = inFrame.frame.renderer.wheel(inFrame.x, inFrame.y, left.x, left.y, {
|
|
2928
|
+
scrollViewport: target.getAttribute("scrolling")?.toLowerCase() !== "no",
|
|
2929
|
+
});
|
|
2930
|
+
if (!left.x && !left.y) return left;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (target && page.realm) {
|
|
2934
|
+
const WheelEvent = page.window.WheelEvent ?? page.window.MouseEvent;
|
|
2935
|
+
const event = new WheelEvent("wheel", {
|
|
2936
|
+
bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, deltaX: left.x, deltaY: left.y, deltaMode: 0,
|
|
2937
|
+
});
|
|
2938
|
+
if (page.realm.dispatch(target, event)) return { x: 0, y: 0 };
|
|
2939
|
+
}
|
|
2940
|
+
let moved = false;
|
|
2941
|
+
for (let node = target; node && node.nodeType === 1 && (left.x || left.y); node = node.parentElement) {
|
|
2942
|
+
const geometry = scrollerGeometry(node);
|
|
2943
|
+
if (!geometry) continue;
|
|
2944
|
+
const nextX = geometry.user.x ? Math.min(geometry.maxX, Math.max(0, geometry.x + left.x)) : geometry.x;
|
|
2945
|
+
const nextY = geometry.user.y ? Math.min(geometry.maxY, Math.max(0, geometry.y + left.y)) : geometry.y;
|
|
2946
|
+
if (nextX === geometry.x && nextY === geometry.y) continue;
|
|
2947
|
+
setElementScroll(node, nextX, nextY);
|
|
2948
|
+
left = { x: left.x - (nextX - geometry.x), y: left.y - (nextY - geometry.y) };
|
|
2949
|
+
moved = true;
|
|
2950
|
+
}
|
|
2951
|
+
// An iframe's document scrolls its own viewport; the top-level page leaves that to the compositor.
|
|
2952
|
+
if (embedded && scrollViewport && (left.x || left.y)) {
|
|
2953
|
+
const before = { x: viewport.x, y: viewport.y };
|
|
2954
|
+
if (viewport.scrollBy(left.x, left.y)) {
|
|
2955
|
+
left = { x: left.x - (viewport.x - before.x), y: left.y - (viewport.y - before.y) };
|
|
2956
|
+
moved = true;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
if (moved) render();
|
|
2960
|
+
return left;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
/** Scrolls the scroll containers around an element, then the page, so that its top shows. */
|
|
2964
|
+
function revealElement(element) {
|
|
2965
|
+
for (let node = element.parentElement; node && node.nodeType === 1; node = node.parentElement) {
|
|
2966
|
+
const geometry = scrollerGeometry(node);
|
|
2967
|
+
if (!geometry) continue;
|
|
2968
|
+
const target = viewportBounds(element);
|
|
2969
|
+
const container = viewportBounds(node);
|
|
2970
|
+
if (!target || !container) continue;
|
|
2971
|
+
const [borderTop, , , borderLeft] = styleEngine.get(node)?.borderWidths ?? [0, 0, 0, 0];
|
|
2972
|
+
setElementScroll(node, geometry.x + target.left - (container.left + borderLeft), geometry.y + target.top - (container.top + borderTop));
|
|
2973
|
+
}
|
|
2974
|
+
const bounds = viewportBounds(element);
|
|
2975
|
+
if (bounds) viewport.scrollTo(viewport.x, viewport.y + bounds.top);
|
|
2976
|
+
render();
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2206
2979
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
2207
2980
|
function initializeVideos(document, resources) {
|
|
2208
2981
|
for (const element of document.querySelectorAll("video, audio")) {
|
|
@@ -2231,31 +3004,162 @@ export async function createPageRenderer({
|
|
|
2231
3004
|
/** HTMLMediaElement state for page script. */
|
|
2232
3005
|
function mediaState(element) {
|
|
2233
3006
|
const state = page.media.get(element);
|
|
3007
|
+
const decoder = state?.video?.decoder;
|
|
2234
3008
|
return { paused: !state?.playing, ended: Boolean(state?.ended), currentTime: state ? mediaTime(state) : 0,
|
|
2235
|
-
duration: state?.duration ?? NaN };
|
|
3009
|
+
duration: state?.duration ?? NaN, videoWidth: decoder?.width || 0, videoHeight: decoder?.height || 0 };
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
function mediaReadyState(element) {
|
|
3013
|
+
const state = page.media.get(element);
|
|
3014
|
+
if (!state?.probed) return 0;
|
|
3015
|
+
if (state.buffering) return 2;
|
|
3016
|
+
if (state.video || state.sound) return state.pipe && !state.pipe.done ? 3 : 4;
|
|
3017
|
+
return 1;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
function mediaNetworkState(element) {
|
|
3021
|
+
const state = page.media.get(element);
|
|
3022
|
+
const hasSrc = Boolean(videoSource(element) || elementSources.get(element));
|
|
3023
|
+
if (!state && !hasSrc) return 0;
|
|
3024
|
+
if (state?.error) return 3;
|
|
3025
|
+
if ((state?.pipe && !state.pipe.done) || state?.loading) return 2;
|
|
3026
|
+
return 1;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function mediaError(element) {
|
|
3030
|
+
return page.media.get(element)?.error ?? null;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
function mediaBuffered(element) {
|
|
3034
|
+
const state = page.media.get(element);
|
|
3035
|
+
const end = state?.mediaSource?.bufferedEnd?.() || state?.bufferedEnd || 0;
|
|
3036
|
+
return end > 0 ? { length: 1, start: 0, end } : { length: 0, start: 0, end: 0 };
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function resolveMediaSource(value) {
|
|
3040
|
+
const realm = page.realm;
|
|
3041
|
+
if (!realm?.isMediaSource) return null;
|
|
3042
|
+
if (realm.isMediaSource(value)) return value;
|
|
3043
|
+
if (typeof value === "string") return realm.mediaSourceAt(value);
|
|
3044
|
+
return null;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
/** A resource record for a media element created after the document was parsed. */
|
|
3048
|
+
function ensureMediaResource(element) {
|
|
3049
|
+
if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
|
|
3050
|
+
let resource = page.resources.get(element);
|
|
3051
|
+
if (resource) return resource;
|
|
3052
|
+
const audioOnly = element.tagName === "AUDIO";
|
|
3053
|
+
resource = {
|
|
3054
|
+
width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
|
|
3055
|
+
height: audioOnly ? 32 : Number(element.getAttribute("height")) || 150,
|
|
3056
|
+
bitmapWidth: 0, bitmapHeight: 0, image: null, playing: false, time: 0, duration: NaN,
|
|
3057
|
+
};
|
|
3058
|
+
page.resources.set(element, resource);
|
|
3059
|
+
page.dirty = true;
|
|
3060
|
+
return resource;
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
/** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
|
|
3064
|
+
function attachMediaSrc(element, value) {
|
|
3065
|
+
if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
|
|
3066
|
+
const source = resolveMediaSource(value);
|
|
3067
|
+
if (!source) {
|
|
3068
|
+
elementSources.delete(element);
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
elementSources.set(element, source);
|
|
3072
|
+
sourceElements.set(source, element);
|
|
3073
|
+
if (source.readyState === "closed") {
|
|
3074
|
+
try {
|
|
3075
|
+
source.attach(typeof value === "string" ? value : source._objectURL);
|
|
3076
|
+
} catch {
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
const state = mediaStateFor(element);
|
|
3081
|
+
if (!state) return;
|
|
3082
|
+
state.mediaSource = source;
|
|
3083
|
+
if (Number.isFinite(source.duration)) {
|
|
3084
|
+
state.durationOverride = true;
|
|
3085
|
+
state.duration = source.duration;
|
|
3086
|
+
if (state.resource) state.resource.duration = source.duration;
|
|
3087
|
+
}
|
|
3088
|
+
bindSourcePipes(state);
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
function mediaAppended(source) {
|
|
3092
|
+
const element = sourceElements.get(source);
|
|
3093
|
+
if (!element) return;
|
|
3094
|
+
const state = page.media.get(element) ?? mediaStateFor(element);
|
|
3095
|
+
if (!state) return;
|
|
3096
|
+
state.mediaSource = source;
|
|
3097
|
+
bindSourcePipes(state);
|
|
3098
|
+
if (state.audioPipe && state.audioPipe !== state.videoPipe) state.hasAudio = true;
|
|
3099
|
+
void refreshProbe(state);
|
|
3100
|
+
if (state.playing && state.hasAudio && !state.sound) ensureAudio(state);
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
function mediaDuration(source, seconds) {
|
|
3104
|
+
const element = sourceElements.get(source);
|
|
3105
|
+
const state = element && page.media.get(element);
|
|
3106
|
+
if (!state || !Number.isFinite(seconds)) return;
|
|
3107
|
+
state.durationOverride = true;
|
|
3108
|
+
state.duration = seconds;
|
|
3109
|
+
if (state.resource) state.resource.duration = seconds;
|
|
3110
|
+
mediaEvent(state, "durationchange");
|
|
2236
3111
|
}
|
|
2237
3112
|
|
|
2238
3113
|
/** The element's playback state, created (not loaded) on first use. */
|
|
2239
3114
|
function mediaStateFor(element) {
|
|
2240
|
-
const resource =
|
|
2241
|
-
|
|
2242
|
-
|
|
3115
|
+
const resource = ensureMediaResource(element);
|
|
3116
|
+
let source = elementSources.get(element) ?? null;
|
|
3117
|
+
const attributeUrl = videoSource(element);
|
|
3118
|
+
if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
|
|
3119
|
+
const url = attributeUrl ?? source?._objectURL ?? null;
|
|
3120
|
+
if (!videoDecoder || !resource || (!url && !source)) return null;
|
|
3121
|
+
if (source && source.readyState === "closed") {
|
|
3122
|
+
try {
|
|
3123
|
+
source.attach(url);
|
|
3124
|
+
elementSources.set(element, source);
|
|
3125
|
+
sourceElements.set(source, element);
|
|
3126
|
+
} catch {}
|
|
3127
|
+
}
|
|
2243
3128
|
let state = page.media.get(element);
|
|
2244
3129
|
if (state && state.url !== url) {
|
|
2245
3130
|
closeMedia(state);
|
|
3131
|
+
page.media.delete(element);
|
|
2246
3132
|
state = null;
|
|
2247
3133
|
}
|
|
2248
3134
|
if (!state) {
|
|
2249
|
-
state = { element, url, resource, bytes: null,
|
|
2250
|
-
|
|
2251
|
-
|
|
3135
|
+
state = { element, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
|
|
3136
|
+
loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
|
|
3137
|
+
video: null, sound: null, playing: false, ended: false,
|
|
3138
|
+
duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
|
|
3139
|
+
timer: null, lastTimeUpdate: -1, generation: loadGeneration, document: page.document,
|
|
3140
|
+
probed: false, hasVideo: false, hasAudio: false, buffering: false, refilling: null,
|
|
3141
|
+
seekSerial: 0, error: null, bufferedEnd: 0, contentType: "", videoMime: "", audioMime: "" };
|
|
2252
3142
|
page.media.set(element, state);
|
|
3143
|
+
if (Number.isFinite(source?.duration)) {
|
|
3144
|
+
state.durationOverride = true;
|
|
3145
|
+
resource.duration = source.duration;
|
|
3146
|
+
}
|
|
3147
|
+
} else if (source) {
|
|
3148
|
+
state.mediaSource = source;
|
|
2253
3149
|
}
|
|
2254
3150
|
return state;
|
|
2255
3151
|
}
|
|
2256
3152
|
|
|
2257
3153
|
/** Current position in seconds. */
|
|
2258
3154
|
function mediaTime(state) {
|
|
3155
|
+
const sound = state.sound;
|
|
3156
|
+
if (state.playing && sound?.id != null && !sound.done) {
|
|
3157
|
+
const time = sound.playedTime ?? sound.streamStart / sound.decoder.sampleRate;
|
|
3158
|
+
return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
|
|
3159
|
+
}
|
|
3160
|
+
// A known audio track which hasn't arrived must not leave its picture
|
|
3161
|
+
// running independently during a network outage.
|
|
3162
|
+
if (state.playing && state.hasAudio && audio && !sound) return state.base;
|
|
2259
3163
|
const running = state.playing && state.clockStart !== null ? (hostPerformance.now() - state.clockStart) / 1000 : 0;
|
|
2260
3164
|
const time = state.base + running;
|
|
2261
3165
|
return Number.isFinite(state.duration) ? Math.min(state.duration, time) : time;
|
|
@@ -2271,43 +3175,275 @@ export async function createPageRenderer({
|
|
|
2271
3175
|
|
|
2272
3176
|
/** The control bar and frame redraw without a relayout. */
|
|
2273
3177
|
function repaintMedia() {
|
|
3178
|
+
// The compositor replaces this video's pixels independently. Page changes
|
|
3179
|
+
// still repaint normally; native controls get their periodic time update.
|
|
3180
|
+
if ([...page.media.values()].some(state => state.video?.presentation && state.playing)
|
|
3181
|
+
&& !page.dirty && !page.needsPaint) return;
|
|
2274
3182
|
rasterized.list = null;
|
|
2275
3183
|
render();
|
|
2276
3184
|
}
|
|
2277
3185
|
|
|
2278
|
-
|
|
3186
|
+
function stopVideoPresentation(state) {
|
|
3187
|
+
const presentation = state.video?.presentation;
|
|
3188
|
+
if (!presentation) return;
|
|
3189
|
+
readPresentedFrame(state);
|
|
3190
|
+
onVideo?.({ type: "video-stop", id: presentation.id });
|
|
3191
|
+
state.video.presentation = null;
|
|
3192
|
+
state.video.needsLocalPosition = true;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
function readPresentedFrame(state) {
|
|
3196
|
+
const p = state.video?.presentation;
|
|
3197
|
+
if (!p) return;
|
|
3198
|
+
const meta = new Int32Array(p.shared, 0, 4);
|
|
3199
|
+
const serial = Atomics.load(meta, 0);
|
|
3200
|
+
if (serial % 2 || !serial || serial === p.serial) return;
|
|
3201
|
+
const width = Atomics.load(meta, 1), height = Atomics.load(meta, 2);
|
|
3202
|
+
if (width < 1 || height < 1 || width * height > 480 * 480) return;
|
|
3203
|
+
const pixels = new Uint8Array(p.shared, 16, width * height * 4).slice();
|
|
3204
|
+
if (serial !== Atomics.load(meta, 0)) return;
|
|
3205
|
+
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
3206
|
+
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
3207
|
+
if (!image) return;
|
|
3208
|
+
state.resource.image?.delete();
|
|
3209
|
+
Object.assign(state.resource, { image, bitmapWidth: width, bitmapHeight: height });
|
|
3210
|
+
p.serial = serial;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
function makeVideoLayer(canvas, surface, region, scale, width, height) {
|
|
3214
|
+
if (!onVideo) return null;
|
|
3215
|
+
const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
|
|
3216
|
+
const item = items.length === 1 ? items[0] : null;
|
|
3217
|
+
const resource = item && page.displayList.images[item.image];
|
|
3218
|
+
const state = [...page.media.values()].find(state => state.resource === resource);
|
|
3219
|
+
const eligible = state?.playing && state.video && !state.video.done && !state.buffering
|
|
3220
|
+
&& !state.presentationFailed && !item.controls && !item.fixed
|
|
3221
|
+
&& (!item.transform || (item.transform.a > 0 && item.transform.d > 0));
|
|
3222
|
+
for (const other of page.media.values()) if (!eligible || other !== state) stopVideoPresentation(other);
|
|
3223
|
+
if (!eligible) return null;
|
|
3224
|
+
const video = state.video;
|
|
3225
|
+
if (!video.presentation) {
|
|
3226
|
+
const id = nextVideoPresentation++;
|
|
3227
|
+
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
3228
|
+
video.presentation = { id, shared, serial: 0 };
|
|
3229
|
+
onVideo({ type: "video-open", id, shared, bytes: snapshot(video.pipe, state.bytes),
|
|
3230
|
+
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
3231
|
+
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
3232
|
+
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
3233
|
+
}
|
|
3234
|
+
const rect = { ...item.rect };
|
|
3235
|
+
if (item.transform) {
|
|
3236
|
+
rect.x = rect.x * item.transform.a + item.transform.e;
|
|
3237
|
+
rect.y = rect.y * item.transform.d + item.transform.f;
|
|
3238
|
+
rect.width *= item.transform.a;
|
|
3239
|
+
rect.height *= item.transform.d;
|
|
3240
|
+
}
|
|
3241
|
+
const raster = white => {
|
|
3242
|
+
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
3243
|
+
paintDisplayList(canvas, page.displayList, region, scale, { resource, white });
|
|
3244
|
+
drawHints(canvas, region);
|
|
3245
|
+
surface.flush();
|
|
3246
|
+
const image = surface.makeImageSnapshot();
|
|
3247
|
+
const pixels = image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
3248
|
+
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
|
|
3249
|
+
image.delete();
|
|
3250
|
+
return pixels;
|
|
3251
|
+
};
|
|
3252
|
+
const base = raster(false), mask = raster(true);
|
|
3253
|
+
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
3254
|
+
return { id: video.presentation.id, base, mask, rect: {
|
|
3255
|
+
x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
|
|
3256
|
+
width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
|
|
3257
|
+
} };
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
function isPipe(value) {
|
|
3261
|
+
return Boolean(value && typeof value.waitFor === "function" && typeof value.bytes === "function");
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
function bindSourcePipes(state) {
|
|
3265
|
+
const pipes = state.mediaSource?.streamPipes?.();
|
|
3266
|
+
if (!pipes) return;
|
|
3267
|
+
if (pipes.video) state.videoPipe = pipes.video;
|
|
3268
|
+
if (pipes.audio) state.audioPipe = pipes.audio;
|
|
3269
|
+
state.pipe = state.videoPipe ?? state.audioPipe ?? state.pipe;
|
|
3270
|
+
state.videoMime = pipes.videoMime || state.videoMime;
|
|
3271
|
+
state.audioMime = pipes.audioMime || state.audioMime;
|
|
3272
|
+
state.audioHasMedia = pipes.audioReady;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
/** Fetches (or attaches MSE pipes), probes a prefix, and opens decoders. */
|
|
2279
3276
|
function loadMedia(state) {
|
|
2280
3277
|
state.loading ??= (async () => {
|
|
2281
|
-
if (
|
|
3278
|
+
if (state.mediaSource) {
|
|
3279
|
+
await state.mediaSource.waitForPipes();
|
|
3280
|
+
bindSourcePipes(state);
|
|
3281
|
+
}
|
|
3282
|
+
if (!state.pipe && !state.bytes && !state.mediaSource) {
|
|
2282
3283
|
const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
|
|
2283
3284
|
if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
|
|
2284
|
-
state.
|
|
3285
|
+
state.contentType = response.contentType ?? "";
|
|
3286
|
+
if (isPipe(response.body)) {
|
|
3287
|
+
state.pipe = response.body;
|
|
3288
|
+
state.videoPipe = state.pipe;
|
|
3289
|
+
state.audioPipe = state.pipe;
|
|
3290
|
+
} else {
|
|
3291
|
+
state.bytes = response.body;
|
|
3292
|
+
}
|
|
2285
3293
|
}
|
|
2286
|
-
|
|
2287
|
-
|
|
3294
|
+
await ensureProbed(state);
|
|
3295
|
+
if (!state.mediaSource) watchProgressiveMetadata(state);
|
|
3296
|
+
if (state.hasVideo && !state.video) await openVideoDecoder(state);
|
|
3297
|
+
// A separate audio buffer often arrives as an init segment, or empty.
|
|
3298
|
+
// That is not a broken video: picture plays, and sound attaches later.
|
|
3299
|
+
if (state.hasAudio && audio && !state.sound) {
|
|
3300
|
+
try {
|
|
3301
|
+
let opened = await openAudioDecoder(state);
|
|
3302
|
+
// With separate MSE buffers the audio init segment often arrives
|
|
3303
|
+
// before its first media segment. Starting the video clock here
|
|
3304
|
+
// makes ensureAudio seek past the opening words once sound becomes
|
|
3305
|
+
// decodable. Briefly wait for that first segment so both start at 0.
|
|
3306
|
+
const audioPipe = state.audioPipe ?? state.pipe;
|
|
3307
|
+
const separateAudio = state.audioPipe && state.audioPipe !== state.videoPipe;
|
|
3308
|
+
if (state.video && audioPipe && !audioPipe.done && !audioPipe.error
|
|
3309
|
+
&& (!opened || (separateAudio && !state.audioHasMedia))) {
|
|
3310
|
+
const deadline = hostPerformance.now() + 750;
|
|
3311
|
+
do {
|
|
3312
|
+
const mark = audioPipe.size + 1;
|
|
3313
|
+
const remaining = Math.max(0, deadline - hostPerformance.now());
|
|
3314
|
+
await Promise.race([
|
|
3315
|
+
audioPipe.waitFor(mark),
|
|
3316
|
+
new Promise((resolve) => hostSetTimeout(resolve, remaining)),
|
|
3317
|
+
]);
|
|
3318
|
+
bindSourcePipes(state);
|
|
3319
|
+
} while (separateAudio && !state.audioHasMedia && !audioPipe.done && !audioPipe.error
|
|
3320
|
+
&& hostPerformance.now() < deadline);
|
|
3321
|
+
if (!audioPipe.error) opened = await openAudioDecoder(state);
|
|
3322
|
+
}
|
|
3323
|
+
} catch (error) {
|
|
3324
|
+
if (!state.video) throw error;
|
|
3325
|
+
reportError(error);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
})().finally(() => { state.loading = null; });
|
|
3329
|
+
return state.loading;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
/** Probe until the growing body exposes a picture or sample rate, or ends. */
|
|
3333
|
+
async function ensureProbed(state) {
|
|
3334
|
+
if (state.probed) return;
|
|
3335
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
3336
|
+
let want = 1024;
|
|
3337
|
+
let lastError = null;
|
|
3338
|
+
while (true) {
|
|
3339
|
+
if (pipe) await pipe.waitFor(pipe.done ? pipe.size : Math.max(1, Math.min(want, pipe.size + 1)));
|
|
3340
|
+
if (pipe?.error) throw pipe.error;
|
|
3341
|
+
const bytes = pipe ? pipe.bytes() : state.bytes;
|
|
3342
|
+
if (!bytes?.byteLength) {
|
|
3343
|
+
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
3344
|
+
continue;
|
|
3345
|
+
}
|
|
3346
|
+
try {
|
|
3347
|
+
const probe = await videoDecoder.probe(bytes);
|
|
3348
|
+
const videoReady = probe.video?.width > 0;
|
|
3349
|
+
const audioReady = probe.audio?.sampleRate > 0;
|
|
3350
|
+
if (!videoReady && !audioReady) {
|
|
3351
|
+
if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
|
|
3352
|
+
want = Math.max(bytes.byteLength + 1, want * 2);
|
|
3353
|
+
continue;
|
|
3354
|
+
}
|
|
3355
|
+
if (!state.durationOverride) state.duration = probe.duration ?? NaN;
|
|
3356
|
+
state.hasVideo = Boolean(state.showsPicture && videoReady);
|
|
3357
|
+
state.hasAudio = Boolean(audioReady || (state.audioPipe && state.audioPipe !== pipe));
|
|
2288
3358
|
state.probed = true;
|
|
2289
|
-
|
|
2290
|
-
state.
|
|
2291
|
-
state.
|
|
3359
|
+
const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : 0.001;
|
|
3360
|
+
state.bufferedEnd = end;
|
|
3361
|
+
state.mediaSource?.noteBuffered(end);
|
|
2292
3362
|
state.resource.duration = state.duration;
|
|
2293
3363
|
mediaEvent(state, "loadedmetadata");
|
|
3364
|
+
return;
|
|
3365
|
+
} catch (error) {
|
|
3366
|
+
lastError = error;
|
|
3367
|
+
if (!pipe || pipe.done) throw lastError;
|
|
3368
|
+
want = Math.max(bytes.byteLength + 1, want * 2);
|
|
2294
3369
|
}
|
|
2295
|
-
|
|
2296
|
-
if (state.hasAudio && audio && !state.sound) await openAudioDecoder(state);
|
|
2297
|
-
})().finally(() => { state.loading = null; });
|
|
2298
|
-
return state.loading;
|
|
3370
|
+
}
|
|
2299
3371
|
}
|
|
2300
3372
|
|
|
2301
|
-
|
|
2302
|
-
function
|
|
2303
|
-
|
|
2304
|
-
|
|
3373
|
+
/** A later MSE append can extend the duration and the buffered range. */
|
|
3374
|
+
async function refreshProbe(state) {
|
|
3375
|
+
if (state.probing) return;
|
|
3376
|
+
state.probing = true;
|
|
3377
|
+
try {
|
|
3378
|
+
if (!state.probed) {
|
|
3379
|
+
await ensureProbed(state);
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
3383
|
+
if (!pipe?.size) return;
|
|
3384
|
+
try {
|
|
3385
|
+
const probe = await videoDecoder.probe(pipe.bytes());
|
|
3386
|
+
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
3387
|
+
state.duration = probe.duration;
|
|
3388
|
+
state.resource.duration = probe.duration;
|
|
3389
|
+
mediaEvent(state, "durationchange");
|
|
3390
|
+
}
|
|
3391
|
+
const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
|
|
3392
|
+
state.bufferedEnd = end;
|
|
3393
|
+
state.mediaSource?.noteBuffered(end);
|
|
3394
|
+
if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
|
|
3395
|
+
if (probe.audio?.sampleRate > 0) state.hasAudio = true;
|
|
3396
|
+
} catch {}
|
|
3397
|
+
} finally {
|
|
3398
|
+
state.probing = false;
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
/** Refresh duration as a progressive response grows, especially MP3. */
|
|
3403
|
+
function watchProgressiveMetadata(state) {
|
|
3404
|
+
const pipe = state.pipe;
|
|
3405
|
+
if (!pipe || pipe.done || state.metadataWatch) return;
|
|
3406
|
+
state.metadataWatch = (async () => {
|
|
3407
|
+
let mark = Math.max(pipe.size + 1, pipe.size * 2);
|
|
3408
|
+
while (state.document === page.document && !pipe.error) {
|
|
3409
|
+
await pipe.waitFor(mark);
|
|
3410
|
+
if (state.document !== page.document || pipe.error) return;
|
|
3411
|
+
await refreshProbe(state);
|
|
3412
|
+
if (pipe.done) return;
|
|
3413
|
+
mark = Math.max(pipe.size + 64 * 1024, pipe.size * 2);
|
|
3414
|
+
}
|
|
3415
|
+
})().catch((error) => reportError(error)).finally(() => {
|
|
3416
|
+
state.metadataWatch = null;
|
|
3417
|
+
});
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
// Content-probed when the URL has a container extension. Blob MSE URLs do
|
|
3421
|
+
// not, so the SourceBuffer MIME selects the demuxer.
|
|
3422
|
+
function decodeOptions(mime, url) {
|
|
3423
|
+
let path = "";
|
|
3424
|
+
try {
|
|
3425
|
+
path = url ? new URL(url).pathname : "";
|
|
3426
|
+
} catch {}
|
|
3427
|
+
const hint = formatHint(path);
|
|
3428
|
+
if (hint) return { format: hint };
|
|
3429
|
+
if (/\.(mp4|m4v|m4a|webm|mkv|ogg|ogv|mp3|wav|aac|flac)$/i.test(path)) return {};
|
|
3430
|
+
const format = containerFormat(mime);
|
|
3431
|
+
return format ? { format } : {};
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
function snapshot(pipe, bytes) {
|
|
3435
|
+
return pipe ? pipe.bytes() : bytes;
|
|
2305
3436
|
}
|
|
2306
3437
|
|
|
2307
3438
|
async function openVideoDecoder(state) {
|
|
3439
|
+
stopVideoPresentation(state);
|
|
3440
|
+
const pipe = state.videoPipe ?? state.pipe;
|
|
3441
|
+
const generation = pipe?.generation ?? 0;
|
|
3442
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3443
|
+
if (!bytes?.byteLength) throw new Error(`media ${state.url}: no video bytes`);
|
|
2308
3444
|
state.video?.decoder.close();
|
|
2309
|
-
const decoder = await videoDecoder.decode(
|
|
2310
|
-
state.video = { decoder, frameTime: 0, done: false };
|
|
3445
|
+
const decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
|
|
3446
|
+
state.video = { decoder, frameTime: 0, done: false, pipe, openedGeneration: generation };
|
|
2311
3447
|
trace?.(`video decoded ${state.url}: ${decoder.width}x${decoder.height}`);
|
|
2312
3448
|
if (state.resource.width !== decoder.width || state.resource.height !== decoder.height) {
|
|
2313
3449
|
state.resource.width = decoder.width;
|
|
@@ -2317,11 +3453,108 @@ export async function createPageRenderer({
|
|
|
2317
3453
|
}
|
|
2318
3454
|
}
|
|
2319
3455
|
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
3456
|
+
/**
|
|
3457
|
+
* Opens the audio decoder on the current snapshot.
|
|
3458
|
+
* Returns false when the pipe can still grow into something decodable
|
|
3459
|
+
* (no bytes yet, or audio_open hit EOF / invalid data on a short prefix).
|
|
3460
|
+
* The previous decoder stays until a new one succeeds.
|
|
3461
|
+
*/
|
|
3462
|
+
async function openAudioDecoder(state, { keepStream = false } = {}) {
|
|
3463
|
+
const serial = state.seekSerial;
|
|
3464
|
+
const operation = (state.audioOpenSerial ?? 0) + 1;
|
|
3465
|
+
state.audioOpenSerial = operation;
|
|
3466
|
+
const pipe = state.audioPipe ?? state.pipe;
|
|
3467
|
+
const generation = pipe?.generation ?? 0;
|
|
3468
|
+
const bytes = snapshot(pipe, state.bytes);
|
|
3469
|
+
const pending = pipe && !pipe.done && !pipe.error;
|
|
3470
|
+
if (!bytes?.byteLength) {
|
|
3471
|
+
if (pending) return false;
|
|
3472
|
+
throw new Error(`media ${state.url}: no audio bytes`);
|
|
3473
|
+
}
|
|
3474
|
+
let decoder;
|
|
3475
|
+
try {
|
|
3476
|
+
decoder = await videoDecoder.decodeAudio(bytes, decodeOptions(state.audioMime || state.contentType, state.url));
|
|
3477
|
+
} catch (error) {
|
|
3478
|
+
if (pending || (pipe && pipe.generation !== generation)) return false;
|
|
3479
|
+
throw error;
|
|
3480
|
+
}
|
|
3481
|
+
let firstSamples = null;
|
|
3482
|
+
try {
|
|
3483
|
+
firstSamples = decoder.nextSamples();
|
|
3484
|
+
} catch (error) {
|
|
3485
|
+
decoder.close();
|
|
3486
|
+
if (pending || (pipe && pipe.generation !== generation)) return false;
|
|
3487
|
+
throw error;
|
|
3488
|
+
}
|
|
3489
|
+
if (!firstSamples && pending) {
|
|
3490
|
+
decoder.close();
|
|
3491
|
+
return false;
|
|
3492
|
+
}
|
|
3493
|
+
if (state.document !== page.document || state.seekSerial !== serial || state.audioOpenSerial !== operation) {
|
|
3494
|
+
decoder.close();
|
|
3495
|
+
return false;
|
|
3496
|
+
}
|
|
3497
|
+
const previous = state.sound;
|
|
3498
|
+
const previousId = previous?.id ?? null;
|
|
3499
|
+
if (keepStream && previousId != null
|
|
3500
|
+
&& (previous.decoder.sampleRate !== decoder.sampleRate || previous.decoder.channels !== decoder.channels)) {
|
|
3501
|
+
decoder.close();
|
|
3502
|
+
throw new Error("audio format changed while output stream is active");
|
|
3503
|
+
}
|
|
3504
|
+
previous?.decoder.close();
|
|
3505
|
+
state.sound = { decoder, id: null, position: 0, pending: firstSamples, history: [], finishing: false, done: false,
|
|
3506
|
+
pipe, openedGeneration: generation };
|
|
3507
|
+
if (keepStream && previousId != null) {
|
|
3508
|
+
// The output stream carries on with the new decoder's samples.
|
|
3509
|
+
Object.assign(state.sound, { id: previousId, streamStart: previous.streamStart,
|
|
3510
|
+
playedTime: previous.playedTime, owed: previous.owed ?? 0 });
|
|
3511
|
+
} else if (previousId != null) audio?.close(previousId);
|
|
2324
3512
|
trace?.(`audio decoded ${state.url}: ${decoder.sampleRate} Hz, ${decoder.channels} channels`);
|
|
3513
|
+
return true;
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
/** Attaches sound once a separate audio buffer becomes decodable. */
|
|
3517
|
+
function ensureAudio(state) {
|
|
3518
|
+
// loadMedia/refillMedia own decoder replacement. An append arriving while
|
|
3519
|
+
// either awaits bytes must not start a second opener and reset live audio
|
|
3520
|
+
// back to the first packet.
|
|
3521
|
+
if (state.loading || state.refilling || state.sound || state.audioWatch || !state.hasAudio || !audio) return;
|
|
3522
|
+
const pipe = state.audioPipe ?? state.pipe;
|
|
3523
|
+
if (!pipe) return;
|
|
3524
|
+
state.audioWatch = (async () => {
|
|
3525
|
+
try {
|
|
3526
|
+
while (state.playing && !state.sound && state.document === page.document) {
|
|
3527
|
+
let opened = false;
|
|
3528
|
+
try {
|
|
3529
|
+
opened = await openAudioDecoder(state);
|
|
3530
|
+
} catch (error) {
|
|
3531
|
+
if (state.video) reportError(error);
|
|
3532
|
+
else if (state.document === page.document) failMedia(state, error, 4);
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
if (opened && state.sound && state.playing && state.document === page.document) {
|
|
3536
|
+
await positionAudio(state, mediaTime(state));
|
|
3537
|
+
if (state.sound && !state.sound.id) openAudioStream(state);
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
if (state.sound || pipe.done || pipe.error) return;
|
|
3541
|
+
const size = pipe.size;
|
|
3542
|
+
await pipe.waitFor(size + 1);
|
|
3543
|
+
}
|
|
3544
|
+
} finally {
|
|
3545
|
+
state.audioWatch = null;
|
|
3546
|
+
}
|
|
3547
|
+
})();
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
/**
|
|
3551
|
+
* The decoder was opened on a snapshot that is not the finished body.
|
|
3552
|
+
* null and a truncated-file error both mean "wait", not "ended".
|
|
3553
|
+
*/
|
|
3554
|
+
function needsMore(track) {
|
|
3555
|
+
const pipe = track?.pipe;
|
|
3556
|
+
if (!pipe || pipe.error) return false;
|
|
3557
|
+
return pipe.generation !== track.openedGeneration || !pipe.done;
|
|
2325
3558
|
}
|
|
2326
3559
|
|
|
2327
3560
|
/** A click on the media: on its progress track it seeks, anywhere else it plays or pauses. */
|
|
@@ -2346,7 +3579,9 @@ export async function createPageRenderer({
|
|
|
2346
3579
|
async function playMedia(element) {
|
|
2347
3580
|
const state = mediaStateFor(element);
|
|
2348
3581
|
if (!state || state.playing) return;
|
|
3582
|
+
state.error = null;
|
|
2349
3583
|
setMediaPlaying(state, true);
|
|
3584
|
+
playbackEvent(state, "play", mediaEvent);
|
|
2350
3585
|
try {
|
|
2351
3586
|
await loadMedia(state);
|
|
2352
3587
|
if (state.ended) {
|
|
@@ -2354,44 +3589,48 @@ export async function createPageRenderer({
|
|
|
2354
3589
|
await positionMedia(state, 0);
|
|
2355
3590
|
} else if (state.needsPosition) {
|
|
2356
3591
|
// After a pause the audio decoder is ahead of where playing resumes.
|
|
2357
|
-
await
|
|
3592
|
+
const moved = await skipPlayedAudio(state);
|
|
3593
|
+
await positionMedia(state, state.base, { videoToo: moved });
|
|
2358
3594
|
}
|
|
2359
3595
|
state.needsPosition = false;
|
|
2360
3596
|
} catch (error) {
|
|
2361
|
-
if (state.document === page.document)
|
|
2362
|
-
closeMedia(state);
|
|
2363
|
-
page.media.delete(element);
|
|
2364
|
-
repaintMedia();
|
|
3597
|
+
if (state.document === page.document) failMedia(state, error, state.pipe?.error ? 2 : 4);
|
|
2365
3598
|
return;
|
|
2366
3599
|
}
|
|
2367
3600
|
if (!state.playing || state.document !== page.document) return;
|
|
2368
3601
|
if (!state.video && !state.sound) {
|
|
2369
3602
|
// Nothing this engine can play (audio with no audio output).
|
|
2370
|
-
|
|
2371
|
-
reportError(new Error(`media ${state.url}: nothing to play`));
|
|
3603
|
+
failMedia(state, new Error(`media ${state.url}: nothing to play`), 4);
|
|
2372
3604
|
return;
|
|
2373
3605
|
}
|
|
2374
3606
|
startMedia(state);
|
|
2375
|
-
mediaEvent(state, "play");
|
|
2376
3607
|
}
|
|
2377
3608
|
|
|
2378
3609
|
function pauseMedia(element) {
|
|
2379
3610
|
const state = page.media.get(element);
|
|
2380
3611
|
if (!state?.playing) return;
|
|
2381
3612
|
state.base = mediaTime(state);
|
|
3613
|
+
trace?.(`media pause at ${state.base.toFixed(3)}s`);
|
|
2382
3614
|
stopClock(state);
|
|
2383
3615
|
state.needsPosition = true;
|
|
2384
3616
|
setMediaPlaying(state, false);
|
|
2385
|
-
|
|
3617
|
+
playbackEvent(state, "pause", mediaEvent);
|
|
2386
3618
|
}
|
|
2387
3619
|
|
|
2388
3620
|
async function seekMedia(element, seconds) {
|
|
2389
3621
|
const state = mediaStateFor(element);
|
|
2390
3622
|
if (!state) return;
|
|
3623
|
+
trace?.(`media seek to ${Number(seconds).toFixed(3)}s from ${mediaTime(state).toFixed(3)}s`);
|
|
3624
|
+
const serial = ++state.seekSerial;
|
|
2391
3625
|
const wasPlaying = state.playing;
|
|
2392
3626
|
if (wasPlaying) stopClock(state);
|
|
3627
|
+
// A seek goes where it is told, whatever the output still plays.
|
|
3628
|
+
state.outputEnd = null;
|
|
2393
3629
|
try {
|
|
3630
|
+
if (state.refilling) await state.refilling.catch(() => {});
|
|
3631
|
+
if (state.seekSerial !== serial) return;
|
|
2394
3632
|
await loadMedia(state);
|
|
3633
|
+
if (state.seekSerial !== serial) return;
|
|
2395
3634
|
const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
|
|
2396
3635
|
state.ended = false;
|
|
2397
3636
|
await positionMedia(state, target);
|
|
@@ -2412,93 +3651,205 @@ export async function createPageRenderer({
|
|
|
2412
3651
|
/** Moves the decoders (and the clock's base) to `seconds`. */
|
|
2413
3652
|
async function positionMedia(state, seconds, { videoToo = true } = {}) {
|
|
2414
3653
|
state.base = seconds;
|
|
2415
|
-
if (state.video && videoToo)
|
|
2416
|
-
if (state.video.done || seconds === 0) await openVideoDecoder(state);
|
|
2417
|
-
if (seconds > 0) state.video.decoder.seek(seconds * 1000);
|
|
2418
|
-
state.video.frameTime = seconds;
|
|
2419
|
-
state.video.done = false;
|
|
2420
|
-
}
|
|
3654
|
+
if (state.video && videoToo) await placeVideo(state, seconds);
|
|
2421
3655
|
if (state.sound) await positionAudio(state, seconds);
|
|
2422
3656
|
}
|
|
2423
3657
|
|
|
2424
|
-
/**
|
|
2425
|
-
async function
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
// Resume inside audio already decoded: take it from the history.
|
|
2433
|
-
const parts = [];
|
|
2434
|
-
for (const chunk of sound.history) {
|
|
2435
|
-
const frames = chunk.samples.length / channels;
|
|
2436
|
-
if (chunk.start + frames <= target) continue;
|
|
2437
|
-
parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
|
|
2438
|
-
}
|
|
2439
|
-
if (sound.pending) parts.push(sound.pending);
|
|
2440
|
-
sound.pending = concatSamples(parts);
|
|
2441
|
-
sound.position = target;
|
|
2442
|
-
sound.history = [];
|
|
2443
|
-
return;
|
|
2444
|
-
}
|
|
2445
|
-
if (target < sound.position) await openAudioDecoder(state);
|
|
2446
|
-
const current = state.sound;
|
|
2447
|
-
current.history = [];
|
|
2448
|
-
if (current.pending) {
|
|
2449
|
-
const frames = current.pending.length / channels;
|
|
2450
|
-
if (current.position + frames > target) {
|
|
2451
|
-
current.pending = current.pending.subarray((target - current.position) * channels);
|
|
2452
|
-
current.position = target;
|
|
3658
|
+
/** Seek, reopening on a longer snapshot when the target is not in this prefix yet. */
|
|
3659
|
+
async function placeVideo(state, seconds) {
|
|
3660
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
3661
|
+
if (!state.video || state.video.done || seconds === 0 || attempt > 0) await openVideoDecoder(state);
|
|
3662
|
+
try {
|
|
3663
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds);
|
|
3664
|
+
state.video.hideUntil = seconds;
|
|
3665
|
+
state.video.done = false;
|
|
2453
3666
|
return;
|
|
3667
|
+
} catch (error) {
|
|
3668
|
+
if (!needsMore(state.video)) throw error;
|
|
3669
|
+
const pipe = state.video.pipe;
|
|
3670
|
+
const size = pipe.size;
|
|
3671
|
+
await pipe.waitFor(size + 1);
|
|
3672
|
+
if (pipe.error) throw pipe.error;
|
|
3673
|
+
if (pipe.size === size) throw error;
|
|
2454
3674
|
}
|
|
2455
|
-
current.position += frames;
|
|
2456
|
-
current.pending = null;
|
|
2457
3675
|
}
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
/** Puts the audio decoder at `seconds`: from the kept history, by decoding ahead, or from the start. */
|
|
3679
|
+
async function positionAudio(state, seconds) {
|
|
3680
|
+
const transaction = {};
|
|
3681
|
+
state.audioPositioning = transaction;
|
|
3682
|
+
const serial = state.seekSerial;
|
|
3683
|
+
try {
|
|
3684
|
+
if (state.sound) trace?.(`audio positioned at ${seconds.toFixed(3)}s (was at ${(state.sound.position / state.sound.decoder.sampleRate).toFixed(3)}s)`);
|
|
3685
|
+
const sound = state.sound;
|
|
3686
|
+
const { sampleRate, channels } = sound.decoder;
|
|
3687
|
+
const target = Math.max(0, Math.round(seconds * sampleRate));
|
|
3688
|
+
sound.done = false;
|
|
3689
|
+
const historyStart = sound.history[0]?.start ?? Infinity;
|
|
3690
|
+
if (target >= historyStart && target < sound.position) {
|
|
3691
|
+
// Resume inside audio already decoded: take it from the history.
|
|
3692
|
+
const parts = [];
|
|
3693
|
+
for (const chunk of sound.history) {
|
|
3694
|
+
const frames = chunk.samples.length / channels;
|
|
3695
|
+
if (chunk.start + frames <= target) continue;
|
|
3696
|
+
parts.push(chunk.start >= target ? chunk.samples : chunk.samples.subarray((target - chunk.start) * channels));
|
|
3697
|
+
}
|
|
3698
|
+
if (sound.pending) parts.push(sound.pending);
|
|
3699
|
+
sound.pending = concatSamples(parts);
|
|
3700
|
+
sound.position = target;
|
|
3701
|
+
sound.history = [];
|
|
2465
3702
|
return;
|
|
2466
3703
|
}
|
|
2467
|
-
|
|
3704
|
+
sound.history = [];
|
|
3705
|
+
const reached = await advanceAudio(() => state.sound, seconds, {
|
|
3706
|
+
needsMore,
|
|
3707
|
+
active: () => state.audioPositioning === transaction && state.seekSerial === serial && state.document === page.document,
|
|
3708
|
+
reopen: () => openAudioDecoder(state, { keepStream: true }),
|
|
3709
|
+
});
|
|
3710
|
+
if (!reached && state.audioPositioning === transaction && state.seekSerial === serial) {
|
|
3711
|
+
throw new Error(`audio could not reach ${seconds.toFixed(3)}s; refusing to replay an earlier prefix`);
|
|
3712
|
+
}
|
|
3713
|
+
} finally {
|
|
3714
|
+
if (state.audioPositioning === transaction) state.audioPositioning = null;
|
|
2468
3715
|
}
|
|
2469
3716
|
}
|
|
2470
3717
|
|
|
2471
3718
|
/** Starts the clock, the audio stream and the frame/time ticks. */
|
|
2472
3719
|
function startMedia(state) {
|
|
3720
|
+
hostClearTimeout(state.timer);
|
|
3721
|
+
state.timer = null;
|
|
3722
|
+
// An output stream held through a stall (see refillMedia) is already
|
|
3723
|
+
// open: the clock goes on from where its audio is, and what it asked for
|
|
3724
|
+
// meanwhile is sent now.
|
|
3725
|
+
if (state.sound && !state.sound.done && state.sound.id != null) {
|
|
3726
|
+
const sound = state.sound;
|
|
3727
|
+
state.base = mediaTime(state);
|
|
3728
|
+
state.clockStart = hostPerformance.now();
|
|
3729
|
+
if (state.resyncAudio) audio.resync?.(sound.id);
|
|
3730
|
+
state.resyncAudio = false;
|
|
3731
|
+
const owed = sound.owed ?? 0;
|
|
3732
|
+
sound.owed = 0;
|
|
3733
|
+
if (owed > 0) feedAudio(state, owed);
|
|
3734
|
+
mediaTick(state);
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
// Opening a PulseAudio stream is asynchronous. Advancing the video clock
|
|
3738
|
+
// before the output is ready used to make a later decoder reopen seek
|
|
3739
|
+
// audio past PCM which had not actually reached the output yet. Let the
|
|
3740
|
+
// output stream establish the common A/V epoch instead.
|
|
3741
|
+
if (state.sound && !state.sound.done) {
|
|
3742
|
+
state.clockStart = null;
|
|
3743
|
+
state.awaitingAudio = true;
|
|
3744
|
+
openAudioStream(state);
|
|
3745
|
+
// A misbehaving output server must not freeze video forever. Normally
|
|
3746
|
+
// ready/request arrives almost immediately; this is only a safety net.
|
|
3747
|
+
state.audioClockTimer = hostSetTimeout(() => startAudioClock(state, state.sound), 500);
|
|
3748
|
+
return;
|
|
3749
|
+
}
|
|
3750
|
+
state.clockStart = hostPerformance.now();
|
|
3751
|
+
if (state.hasAudio) ensureAudio(state);
|
|
3752
|
+
mediaTick(state);
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
/** Releases a clock which was waiting for audio output to become writable. */
|
|
3756
|
+
function startAudioClock(state, sound) {
|
|
3757
|
+
if (!state.awaitingAudio || state.sound !== sound || !state.playing) return;
|
|
3758
|
+
state.awaitingAudio = false;
|
|
3759
|
+
hostClearTimeout(state.audioClockTimer);
|
|
3760
|
+
state.audioClockTimer = null;
|
|
2473
3761
|
state.clockStart = hostPerformance.now();
|
|
2474
|
-
if (state.sound && !state.sound.done) openAudioStream(state);
|
|
2475
3762
|
mediaTick(state);
|
|
2476
3763
|
}
|
|
2477
3764
|
|
|
2478
|
-
function stopClock(state) {
|
|
3765
|
+
function stopClock(state, { keepAudio = false } = {}) {
|
|
3766
|
+
stopVideoPresentation(state);
|
|
2479
3767
|
hostClearTimeout(state.timer);
|
|
2480
3768
|
state.timer = null;
|
|
2481
3769
|
state.clockStart = null;
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
3770
|
+
state.awaitingAudio = false;
|
|
3771
|
+
hostClearTimeout(state.audioClockTimer);
|
|
3772
|
+
state.audioClockTimer = null;
|
|
3773
|
+
state.outputEnd = null;
|
|
3774
|
+
if (state.sound?.id != null && !keepAudio) {
|
|
3775
|
+
const sound = state.sound;
|
|
3776
|
+
trace?.(`audio stream closed at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s written, clock ${mediaTime(state).toFixed(3)}s`);
|
|
3777
|
+
const { sampleRate, channels } = sound.decoder;
|
|
3778
|
+
const start = sound.streamStart ?? sound.position;
|
|
3779
|
+
// The output may go on playing the PCM it already holds (some servers
|
|
3780
|
+
// drain a closed stream): playing resumes after it, not twice.
|
|
3781
|
+
state.outputEnd = Promise.resolve(audio.close(sound.id))
|
|
3782
|
+
.then((sentBytes) => (start + (Number(sentBytes) || 0) / (2 * channels)) / sampleRate, () => null);
|
|
3783
|
+
sound.id = null;
|
|
3784
|
+
sound.finishing = false;
|
|
2486
3785
|
}
|
|
2487
3786
|
}
|
|
2488
3787
|
|
|
3788
|
+
/**
|
|
3789
|
+
* Moves the media clock past the audio the closed output stream still
|
|
3790
|
+
* plays (see stopClock). True when it moved, so video must follow.
|
|
3791
|
+
*/
|
|
3792
|
+
async function skipPlayedAudio(state) {
|
|
3793
|
+
const end = await (state.outputEnd ?? null);
|
|
3794
|
+
state.outputEnd = null;
|
|
3795
|
+
if (!Number.isFinite(end) || end <= state.base) return false;
|
|
3796
|
+
state.base = Number.isFinite(state.duration) ? Math.min(state.duration, end) : end;
|
|
3797
|
+
return true;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
2489
3800
|
/** Draws the frames the clock has reached, updates time, ends when everything has played. */
|
|
2490
3801
|
function mediaTick(state) {
|
|
2491
3802
|
if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
|
|
2492
3803
|
const now = mediaTime(state);
|
|
2493
3804
|
const video = state.video;
|
|
2494
|
-
if (video && !video.
|
|
3805
|
+
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
3806
|
+
try {
|
|
3807
|
+
video.frameTime = seekToKeyframe(video.decoder, now);
|
|
3808
|
+
video.hideUntil = now;
|
|
3809
|
+
video.needsLocalPosition = false;
|
|
3810
|
+
} catch (error) { failMedia(state, error, 3); return; }
|
|
3811
|
+
}
|
|
3812
|
+
if (video?.presentation) {
|
|
3813
|
+
// Decode and present on the compositor thread. This tick only updates
|
|
3814
|
+
// DOM media state; it must not decode the same movie a second time.
|
|
3815
|
+
video.frameTime = now + 1 / (video.decoder.fps || 30);
|
|
3816
|
+
} else if (video && !video.done && video.frameTime <= now) {
|
|
2495
3817
|
// Late frames are decoded but only the newest is shown.
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
3818
|
+
const decodeDeadline = hostPerformance.now() + 100;
|
|
3819
|
+
while (video.frameTime <= mediaTime(state) && !video.done) {
|
|
3820
|
+
const current = mediaTime(state);
|
|
3821
|
+
const nextIsLate = video.frameTime + 1 / (video.decoder.fps || 30) <= current;
|
|
3822
|
+
// If this tick has spent its decode budget, decode one final frame
|
|
3823
|
+
// for display before yielding. Otherwise a video that can never quite
|
|
3824
|
+
// catch the wall clock discards every frame forever while its controls
|
|
3825
|
+
// continue to advance.
|
|
3826
|
+
const yielding = hostPerformance.now() >= decodeDeadline;
|
|
3827
|
+
// Frames before where the picture already was (or a seek's target)
|
|
3828
|
+
// are decoded unshown: the decoder restarted at an earlier keyframe.
|
|
3829
|
+
const reached = video.frameTime + 1e-6 >= (video.hideUntil ?? 0);
|
|
3830
|
+
let step;
|
|
3831
|
+
try {
|
|
3832
|
+
step = drawVideoFrame(state, reached && (!nextIsLate || yielding));
|
|
3833
|
+
} catch (error) {
|
|
3834
|
+
failMedia(state, error, 3);
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
if (step === "stall") {
|
|
3838
|
+
void refillMedia(state, video);
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3841
|
+
if (!step) video.done = true;
|
|
3842
|
+
// Keep tasks responsive even when software decoding is slower than
|
|
3843
|
+
// real time. The zero-delay continuation resumes catching up against
|
|
3844
|
+
// a freshly read clock instead of accumulating this task's duration.
|
|
3845
|
+
if (yielding && video.frameTime <= mediaTime(state)) break;
|
|
2499
3846
|
}
|
|
2500
3847
|
}
|
|
2501
3848
|
state.resource.time = now;
|
|
3849
|
+
if (!state.buffering && !state.awaitingAudio) {
|
|
3850
|
+
playbackEvent(state, "playing", mediaEvent);
|
|
3851
|
+
if (!state.playing) return;
|
|
3852
|
+
}
|
|
2502
3853
|
repaintMedia();
|
|
2503
3854
|
if (Math.floor(now * 4) !== state.lastTimeUpdate) {
|
|
2504
3855
|
state.lastTimeUpdate = Math.floor(now * 4);
|
|
@@ -2508,22 +3859,49 @@ export async function createPageRenderer({
|
|
|
2508
3859
|
const soundFinished = !state.sound || state.sound.done;
|
|
2509
3860
|
// The clock at the duration ends it too: after a seek, frames restart at a
|
|
2510
3861
|
// keyframe before the target, and wasmpeg does not tell their timestamps.
|
|
2511
|
-
|
|
2512
|
-
|
|
3862
|
+
// A prefix can already report the full duration, so the clock must not
|
|
3863
|
+
// run the element out while bytes are still arriving.
|
|
3864
|
+
const pastEnd = !state.buffering && Number.isFinite(state.duration) && now >= state.duration;
|
|
3865
|
+
const pipeOpen = state.pipe && !state.pipe.done && !state.pipe.error;
|
|
3866
|
+
if (pastEnd && pipeOpen) {
|
|
3867
|
+
const stale = state.video && needsMore(state.video) ? state.video
|
|
3868
|
+
: state.sound && needsMore(state.sound) ? state.sound : null;
|
|
3869
|
+
void refillMedia(state, stale);
|
|
3870
|
+
return;
|
|
3871
|
+
}
|
|
3872
|
+
if (videoFinished && soundFinished && pipeOpen) {
|
|
3873
|
+
const stalled = video?.done && needsMore(video) ? video
|
|
3874
|
+
: state.sound?.done && needsMore(state.sound) ? state.sound : null;
|
|
3875
|
+
void refillMedia(state, stalled);
|
|
3876
|
+
return;
|
|
3877
|
+
}
|
|
2513
3878
|
if ((videoFinished && soundFinished) || (pastEnd && soundFinished)) return endMedia(state);
|
|
2514
|
-
const delay = video && !video.done ? Math.max(
|
|
3879
|
+
const delay = video && !video.done ? Math.max(0, (video.frameTime - mediaTime(state)) * 1000) : 250;
|
|
2515
3880
|
state.timer = hostSetTimeout(() => mediaTick(state), delay);
|
|
2516
3881
|
}
|
|
2517
3882
|
|
|
2518
|
-
/**
|
|
3883
|
+
/**
|
|
3884
|
+
* Decodes the next frame; shows it when `show`.
|
|
3885
|
+
* True when a frame was produced, false at a real end, "stall" when the
|
|
3886
|
+
* pipe still owes bytes (a short mp4 throws AVERROR_INVALIDDATA there).
|
|
3887
|
+
*/
|
|
2519
3888
|
function drawVideoFrame(state, show) {
|
|
2520
|
-
const
|
|
3889
|
+
const video = state.video;
|
|
3890
|
+
const decoder = video.decoder;
|
|
2521
3891
|
const width = Math.min(480, decoder.width);
|
|
2522
|
-
const height = Math.max(1, Math.round(width * decoder.height / decoder.width));
|
|
2523
|
-
|
|
2524
|
-
|
|
3892
|
+
const height = Math.max(1, Math.round(width * decoder.height / Math.max(1, decoder.width)));
|
|
3893
|
+
let pixels;
|
|
3894
|
+
try {
|
|
3895
|
+
pixels = decoder.nextFrame(width, height);
|
|
3896
|
+
} catch (error) {
|
|
3897
|
+
if (needsMore(video)) return "stall";
|
|
3898
|
+
throw error;
|
|
3899
|
+
}
|
|
3900
|
+
if (!pixels) return needsMore(video) ? "stall" : false;
|
|
3901
|
+
const shownTime = state.video.frameTime;
|
|
2525
3902
|
state.video.frameTime += 1 / (decoder.fps || 30);
|
|
2526
3903
|
if (!show) return true;
|
|
3904
|
+
state.video.shownTime = shownTime;
|
|
2527
3905
|
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
2528
3906
|
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
2529
3907
|
if (!image) return true;
|
|
@@ -2538,12 +3916,47 @@ export async function createPageRenderer({
|
|
|
2538
3916
|
const sound = state.sound;
|
|
2539
3917
|
sound.finishing = false;
|
|
2540
3918
|
const name = new URL(state.url).pathname.split("/").pop() || "audio";
|
|
3919
|
+
// Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
|
|
3920
|
+
sound.streamStart = sound.position;
|
|
3921
|
+
trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
|
|
2541
3922
|
const id = audio.open({ rate: sound.decoder.sampleRate, channels: sound.decoder.channels, name }, (event) => {
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
3923
|
+
// The stream outlives a decoder reopened during a stall (see
|
|
3924
|
+
// refillMedia), so events go to whichever sound holds it now.
|
|
3925
|
+
const sound = state.sound;
|
|
3926
|
+
if (!sound || sound.id !== id) return;
|
|
3927
|
+
if (event.event === "timing") {
|
|
3928
|
+
if (!Number.isFinite(event.seconds) || event.seconds < 0) return;
|
|
3929
|
+
sound.playedTime = (sound.streamStart / sound.decoder.sampleRate) + event.seconds;
|
|
3930
|
+
state.base = sound.playedTime;
|
|
3931
|
+
sound.timingError = false;
|
|
3932
|
+
const now = hostPerformance.now();
|
|
3933
|
+
if (!state.lastSyncTrace || now - state.lastSyncTrace >= 1000) {
|
|
3934
|
+
state.lastSyncTrace = now;
|
|
3935
|
+
trace?.(`media sync: audio played ${sound.playedTime.toFixed(3)}s, video shown ${(state.video?.shownTime ?? 0).toFixed(3)}s,`
|
|
3936
|
+
+ ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`
|
|
3937
|
+
+ `, PCM reserve ${Number(event.bufferedSeconds ?? 0).toFixed(3)}s`);
|
|
3938
|
+
}
|
|
3939
|
+
} else if (event.event === "timing-error") {
|
|
3940
|
+
// Do not silently switch to a free-running clock on missing feedback.
|
|
3941
|
+
if (!sound.timingError) reportError(new Error(`audio timing unavailable; playback clock held: ${event.message}`));
|
|
3942
|
+
sound.timingError = true;
|
|
3943
|
+
} else if (event.event === "request") {
|
|
3944
|
+
// A request is the first point at which PulseAudio has created the
|
|
3945
|
+
// playback stream. Queue its PCM before releasing the clock: feeding
|
|
3946
|
+
// a decoder opened on a growing snapshot can itself start a refill,
|
|
3947
|
+
// in which case that replacement stream must establish the epoch.
|
|
3948
|
+
feedAudio(state, event.bytes);
|
|
3949
|
+
startAudioClock(state, sound);
|
|
3950
|
+
} else if (event.event === "ready") {
|
|
3951
|
+
// Some servers create a writable stream with an initial grant of zero,
|
|
3952
|
+
// so no request is sent until playback advances.
|
|
3953
|
+
startAudioClock(state, sound);
|
|
3954
|
+
} else if (event.event === "ended") {
|
|
3955
|
+
state.base = sound.playedTime ?? state.base;
|
|
3956
|
+
state.clockStart = hostPerformance.now();
|
|
2545
3957
|
sound.id = null;
|
|
2546
3958
|
sound.done = true;
|
|
3959
|
+
startAudioClock(state, sound);
|
|
2547
3960
|
mediaTick(state);
|
|
2548
3961
|
} else if (event.event === "error") {
|
|
2549
3962
|
sound.id = null;
|
|
@@ -2551,22 +3964,49 @@ export async function createPageRenderer({
|
|
|
2551
3964
|
reportError(new Error(`audio: ${event.message}`));
|
|
2552
3965
|
// Video plays on without sound; audio alone stops.
|
|
2553
3966
|
if (!state.video) pauseMedia(state.element);
|
|
3967
|
+
else startAudioClock(state, sound);
|
|
2554
3968
|
}
|
|
2555
3969
|
});
|
|
2556
3970
|
sound.id = id;
|
|
3971
|
+
if (state.video?.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
|
|
3972
|
+
audioId: id, audioStart: sound.streamStart / sound.decoder.sampleRate, time: mediaTime(state) });
|
|
2557
3973
|
}
|
|
2558
3974
|
|
|
2559
3975
|
/** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
|
|
2560
3976
|
function feedAudio(state, bytes) {
|
|
2561
3977
|
const sound = state.sound;
|
|
2562
3978
|
if (!state.playing || sound.id === null || sound.finishing) return;
|
|
3979
|
+
// While buffering the output plays what it holds and then nothing, as
|
|
3980
|
+
// the video waits; what it asks for is sent when playing resumes.
|
|
3981
|
+
if (state.buffering || state.refilling || state.audioPositioning) {
|
|
3982
|
+
sound.owed = (sound.owed ?? 0) + bytes;
|
|
3983
|
+
return;
|
|
3984
|
+
}
|
|
2563
3985
|
const { channels, sampleRate } = sound.decoder;
|
|
2564
3986
|
const frameBytes = channels * 2;
|
|
3987
|
+
sound.lastRequest = bytes;
|
|
2565
3988
|
for (let sent = 0; sent + frameBytes <= bytes;) {
|
|
2566
3989
|
let samples = sound.pending;
|
|
2567
3990
|
sound.pending = null;
|
|
2568
|
-
samples ??= sound.decoder.nextSamples();
|
|
2569
3991
|
if (!samples) {
|
|
3992
|
+
try {
|
|
3993
|
+
samples = sound.decoder.nextSamples();
|
|
3994
|
+
} catch (error) {
|
|
3995
|
+
if (needsMore(sound)) {
|
|
3996
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
3997
|
+
void refillMedia(state, sound);
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
failMedia(state, error, 3);
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
if (!samples) {
|
|
4005
|
+
if (needsMore(sound)) {
|
|
4006
|
+
sound.owed = (sound.owed ?? 0) + bytes - sent;
|
|
4007
|
+
void refillMedia(state, sound);
|
|
4008
|
+
return;
|
|
4009
|
+
}
|
|
2570
4010
|
sound.finishing = true;
|
|
2571
4011
|
audio.finish(sound.id);
|
|
2572
4012
|
return;
|
|
@@ -2584,12 +4024,182 @@ export async function createPageRenderer({
|
|
|
2584
4024
|
}
|
|
2585
4025
|
}
|
|
2586
4026
|
|
|
4027
|
+
/**
|
|
4028
|
+
* While bytes keep arriving, waits until `pipe` holds about
|
|
4029
|
+
* RESUME_BUFFER_SECONDS more media than `position` needs (estimated from
|
|
4030
|
+
* the bytes per second so far). Stops waiting when it is finished, when
|
|
4031
|
+
* nothing arrived for RESUME_BUFFER_IDLE_MS (that burst is over: waiting
|
|
4032
|
+
* for the next could take long), or after RESUME_BUFFER_MAX_WAIT_MS.
|
|
4033
|
+
*/
|
|
4034
|
+
async function bufferAhead(pipe, position) {
|
|
4035
|
+
const deadline = hostPerformance.now() + RESUME_BUFFER_MAX_WAIT_MS;
|
|
4036
|
+
// The pipe ends near `position` when starved; early on that says little,
|
|
4037
|
+
// so the rate is taken over at least a few seconds (an underestimate).
|
|
4038
|
+
const bytesPerSecond = pipe.size / Math.max(4, position);
|
|
4039
|
+
const goal = pipe.size + bytesPerSecond * RESUME_BUFFER_SECONDS;
|
|
4040
|
+
while (!pipe.done && !pipe.error && pipe.size < goal) {
|
|
4041
|
+
const wait = Math.min(RESUME_BUFFER_IDLE_MS, deadline - hostPerformance.now());
|
|
4042
|
+
if (wait <= 0) return;
|
|
4043
|
+
const size = pipe.size;
|
|
4044
|
+
let timer = null;
|
|
4045
|
+
await Promise.race([pipe.waitFor(size + 1), new Promise((resolve) => { timer = hostSetTimeout(resolve, wait); })]);
|
|
4046
|
+
hostClearTimeout(timer);
|
|
4047
|
+
if (pipe.size === size) return;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
/**
|
|
4052
|
+
* Seeks `decoder` to the keyframe at or before `seconds` and returns that
|
|
4053
|
+
* keyframe's time. wasmpeg seeks to the keyframe without telling its
|
|
4054
|
+
* timestamp; taking the target as the position would show every later
|
|
4055
|
+
* frame early by the distance to the keyframe, and the picture would trail
|
|
4056
|
+
* the sound. Seeks to the same keyframe yield the same first picture, so a
|
|
4057
|
+
* binary search over seek targets finds where that keyframe starts (to a
|
|
4058
|
+
* frame). The frames from there to the clock are then decoded unshown.
|
|
4059
|
+
*/
|
|
4060
|
+
function seekToKeyframe(decoder, seconds) {
|
|
4061
|
+
return seekVideoKeyframe(decoder, seconds);
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
/**
|
|
4065
|
+
* The decoder ran off the end of the snapshot it was opened on. Freeze the
|
|
4066
|
+
* clock, wait for more bytes, reopen on the new snapshot and seek back.
|
|
4067
|
+
* Retain the output stream and outstanding credits while positioning the
|
|
4068
|
+
* new snapshot. The reported output cursor remains the A/V master clock.
|
|
4069
|
+
*/
|
|
4070
|
+
function refillMedia(state, stalledTrack = null) {
|
|
4071
|
+
if (state.refilling) {
|
|
4072
|
+
// An audio prefetch may already be waiting for a later segment. If the
|
|
4073
|
+
// picture catches its own end meanwhile, it really must wait too.
|
|
4074
|
+
if (stalledTrack === state.video && !state.buffering) {
|
|
4075
|
+
state.buffering = true;
|
|
4076
|
+
stopClock(state, { keepAudio: true });
|
|
4077
|
+
if (state.playing) playbackEvent(state, "waiting", mediaEvent);
|
|
4078
|
+
}
|
|
4079
|
+
return state.refilling;
|
|
4080
|
+
}
|
|
4081
|
+
const serial = state.seekSerial;
|
|
4082
|
+
state.base = mediaTime(state);
|
|
4083
|
+
// The audio output stays open through a stall: it plays the PCM it holds
|
|
4084
|
+
// and then runs dry, and playing resumes right after that PCM. Closing
|
|
4085
|
+
// and reopening it instead loses track of what was heard (a closed
|
|
4086
|
+
// stream may still play out its device buffer, which then plays twice).
|
|
4087
|
+
const holdAudio = Boolean(stalledTrack && state.sound && state.sound.id != null && !state.sound.finishing);
|
|
4088
|
+
// PCM prefetch reaches the end of a snapshot ahead of actual playback.
|
|
4089
|
+
// Keep presenting video against measured audio while the controller plays
|
|
4090
|
+
// its reserve; a decoder needing bytes is not itself an output underrun.
|
|
4091
|
+
const prefetch = holdAudio && stalledTrack === state.sound;
|
|
4092
|
+
state.buffering = !prefetch;
|
|
4093
|
+
const stall = { at: hostPerformance.now(), base: state.base };
|
|
4094
|
+
if (!prefetch) {
|
|
4095
|
+
stopClock(state, { keepAudio: holdAudio });
|
|
4096
|
+
if (state.playing) playbackEvent(state, "waiting", mediaEvent);
|
|
4097
|
+
}
|
|
4098
|
+
// The first await lets `job` be assigned before the body looks at it,
|
|
4099
|
+
// so a stall inside startMedia can begin a new refill.
|
|
4100
|
+
const job = (async () => {
|
|
4101
|
+
await Promise.resolve();
|
|
4102
|
+
try {
|
|
4103
|
+
const pipe = stalledTrack?.pipe ?? state.video?.pipe ?? state.sound?.pipe ?? state.pipe;
|
|
4104
|
+
const mark = (pipe?.size ?? 0) + 1;
|
|
4105
|
+
// An append may have arrived after this decoder's snapshot was
|
|
4106
|
+
// opened but before it reached EOF. Consume that generation now;
|
|
4107
|
+
// waiting for one more append creates a segment-sized pause.
|
|
4108
|
+
const hasNewSnapshot = Boolean(stalledTrack && pipe
|
|
4109
|
+
&& pipe.generation !== stalledTrack.openedGeneration);
|
|
4110
|
+
// Some demuxers report EOF at an fMP4/WebM fragment boundary even
|
|
4111
|
+
// though the same snapshot contains later fragments. Reopen once at
|
|
4112
|
+
// each position/generation; if it makes no progress, wait for append.
|
|
4113
|
+
const retry = state.lastRefill;
|
|
4114
|
+
const canRetrySnapshot = Boolean(stalledTrack && pipe && !hasNewSnapshot
|
|
4115
|
+
&& (!retry || retry.pipe !== pipe || retry.generation !== pipe.generation
|
|
4116
|
+
|| state.base > retry.time + 0.25));
|
|
4117
|
+
if (pipe && !pipe.done && !hasNewSnapshot && !canRetrySnapshot) {
|
|
4118
|
+
await pipe.waitFor(mark);
|
|
4119
|
+
// Starved by a slow network, the smallest refill stalls again at
|
|
4120
|
+
// once: buffer a few seconds ahead first, as browsers do before
|
|
4121
|
+
// leaving "waiting".
|
|
4122
|
+
await bufferAhead(pipe, state.base);
|
|
4123
|
+
}
|
|
4124
|
+
if (state.seekSerial !== serial || state.document !== page.document || !state.playing) return;
|
|
4125
|
+
if (pipe?.error) throw pipe.error;
|
|
4126
|
+
// Where the audio is now: it kept playing what it held after the stall.
|
|
4127
|
+
const held = holdAudio && state.sound?.id != null ? state.sound : null;
|
|
4128
|
+
const writtenEnd = held ? held.position / held.decoder.sampleRate : null;
|
|
4129
|
+
if (held) {
|
|
4130
|
+
state.base = mediaTime(state);
|
|
4131
|
+
} else {
|
|
4132
|
+
await skipPlayedAudio(state);
|
|
4133
|
+
}
|
|
4134
|
+
if (state.seekSerial !== serial || state.document !== page.document || !state.playing) return;
|
|
4135
|
+
const time = state.base;
|
|
4136
|
+
trace?.(`media stall (${stalledTrack === state.video ? "video" : stalledTrack === state.sound ? "audio" : "end"}): resume at ${time.toFixed(3)}s`
|
|
4137
|
+
+ `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
|
|
4138
|
+
if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
|
|
4139
|
+
// A video decoder still decoding is left alone: reopened, it restarts
|
|
4140
|
+
// at a keyframe. Audio reopens on any newer snapshot (it may share the
|
|
4141
|
+
// stalled pipe), which positionAudio continues sample-exactly.
|
|
4142
|
+
const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
|
|
4143
|
+
const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
|
|
4144
|
+
|| (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
|
|
4145
|
+
if (refillVideo) {
|
|
4146
|
+
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
4147
|
+
// The picture must not go back to the keyframe the decoder restarts at.
|
|
4148
|
+
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
4149
|
+
await openVideoDecoder(state);
|
|
4150
|
+
state.video.hideUntil = hideUntil;
|
|
4151
|
+
try {
|
|
4152
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt);
|
|
4153
|
+
} catch {
|
|
4154
|
+
state.video.frameTime = 0;
|
|
4155
|
+
}
|
|
4156
|
+
state.video.done = false;
|
|
4157
|
+
}
|
|
4158
|
+
if (refillAudio) {
|
|
4159
|
+
let opened = false;
|
|
4160
|
+
try {
|
|
4161
|
+
opened = await openAudioDecoder(state, { keepStream: Boolean(held) });
|
|
4162
|
+
} catch (error) {
|
|
4163
|
+
if (!state.video) throw error;
|
|
4164
|
+
reportError(error);
|
|
4165
|
+
}
|
|
4166
|
+
// A held stream continues right after the PCM it was given.
|
|
4167
|
+
if (opened) await positionAudio(state, held ? writtenEnd : time);
|
|
4168
|
+
else if (state.playing && !state.sound) ensureAudio(state);
|
|
4169
|
+
}
|
|
4170
|
+
// Drop this job before startMedia so a new stall can refill.
|
|
4171
|
+
if (state.refilling === job) state.refilling = null;
|
|
4172
|
+
state.buffering = false;
|
|
4173
|
+
if (state.playing && state.seekSerial === serial && state.document === page.document) startMedia(state);
|
|
4174
|
+
} catch (error) {
|
|
4175
|
+
if (state.document === page.document) failMedia(state, error, 3);
|
|
4176
|
+
} finally {
|
|
4177
|
+
if (state.refilling === job) {
|
|
4178
|
+
state.refilling = null;
|
|
4179
|
+
state.buffering = false;
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
})();
|
|
4183
|
+
state.refilling = job;
|
|
4184
|
+
return job;
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
function failMedia(state, error, code) {
|
|
4188
|
+
state.error = { code, message: String(error?.message ?? error) };
|
|
4189
|
+
closeMedia(state);
|
|
4190
|
+
state.playing = false;
|
|
4191
|
+
if (state.resource) state.resource.playing = false;
|
|
4192
|
+
mediaEvent(state, "error");
|
|
4193
|
+
reportError(error);
|
|
4194
|
+
repaintMedia();
|
|
4195
|
+
}
|
|
4196
|
+
|
|
2587
4197
|
function endMedia(state) {
|
|
2588
4198
|
stopClock(state);
|
|
2589
4199
|
state.ended = true;
|
|
2590
4200
|
state.base = Number.isFinite(state.duration) ? state.duration : state.base;
|
|
2591
4201
|
setMediaPlaying(state, false);
|
|
2592
|
-
|
|
4202
|
+
playbackEvent(state, "pause", mediaEvent);
|
|
2593
4203
|
mediaEvent(state, "ended");
|
|
2594
4204
|
}
|
|
2595
4205
|
|
|
@@ -2601,6 +4211,8 @@ export async function createPageRenderer({
|
|
|
2601
4211
|
}
|
|
2602
4212
|
|
|
2603
4213
|
function closeMedia(state) {
|
|
4214
|
+
state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
|
|
4215
|
+
state.audioPositioning = null;
|
|
2604
4216
|
stopClock(state);
|
|
2605
4217
|
state.playing = false;
|
|
2606
4218
|
state.video?.decoder.close();
|
|
@@ -2627,6 +4239,58 @@ export async function createPageRenderer({
|
|
|
2627
4239
|
return out;
|
|
2628
4240
|
}
|
|
2629
4241
|
|
|
4242
|
+
/** The decoded CSS background image a url() names, if it has arrived. */
|
|
4243
|
+
function backgroundResource(url) {
|
|
4244
|
+
let absolute;
|
|
4245
|
+
try { absolute = new URL(url, page.url).href; } catch { return null; }
|
|
4246
|
+
return page.backgrounds.get(absolute)?.resource ?? null;
|
|
4247
|
+
}
|
|
4248
|
+
|
|
4249
|
+
/**
|
|
4250
|
+
* Requests the background images the laid-out document uses and has not
|
|
4251
|
+
* asked for yet. They change only paint: each repaints the page on arrival.
|
|
4252
|
+
*/
|
|
4253
|
+
function queueBackgroundImages() {
|
|
4254
|
+
const tree = page.renderTree;
|
|
4255
|
+
if (!tree) return;
|
|
4256
|
+
const document = page.document;
|
|
4257
|
+
const seen = new Set();
|
|
4258
|
+
for (const node of tree.nodesById.values()) {
|
|
4259
|
+
const value = node.style?.backgroundImage;
|
|
4260
|
+
if (!value || seen.has(value) || !value.includes("url(")) continue;
|
|
4261
|
+
seen.add(value);
|
|
4262
|
+
for (const match of value.matchAll(CSS_URL)) {
|
|
4263
|
+
let url;
|
|
4264
|
+
try { url = new URL(match[2], page.url).href; } catch { continue; }
|
|
4265
|
+
if (page.backgrounds.has(url)) continue;
|
|
4266
|
+
const entry = { resource: null, loaded: null };
|
|
4267
|
+
page.backgrounds.set(url, entry);
|
|
4268
|
+
entry.loaded = fetchResource(url, "image").then(async (response) => {
|
|
4269
|
+
if (!response.ok || page.backgrounds.get(url) !== entry) return;
|
|
4270
|
+
const bytes = response.body;
|
|
4271
|
+
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
4272
|
+
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
4273
|
+
if (!image) return;
|
|
4274
|
+
if (page.backgrounds.get(url) !== entry || document !== page.document) {
|
|
4275
|
+
image.delete();
|
|
4276
|
+
return;
|
|
4277
|
+
}
|
|
4278
|
+
entry.resource = Object.freeze({ width, height, image, url });
|
|
4279
|
+
if (page.layout) page.displayList = paintList();
|
|
4280
|
+
page.needsPaint = true;
|
|
4281
|
+
scheduleFrame();
|
|
4282
|
+
}).catch(() => {
|
|
4283
|
+
// A broken background image paints nothing.
|
|
4284
|
+
});
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
function disposeBackgrounds() {
|
|
4290
|
+
for (const entry of page.backgrounds.values()) entry.resource?.image.delete();
|
|
4291
|
+
page.backgrounds = new Map();
|
|
4292
|
+
}
|
|
4293
|
+
|
|
2630
4294
|
function queueImageResources() {
|
|
2631
4295
|
const document = page.document;
|
|
2632
4296
|
const generation = loadGeneration;
|
|
@@ -2644,18 +4308,20 @@ export async function createPageRenderer({
|
|
|
2644
4308
|
const oldImage = page.resources.get(element)?.image;
|
|
2645
4309
|
if (oldImage) staleImages.push(oldImage);
|
|
2646
4310
|
page.resources.delete(element);
|
|
4311
|
+
beginImageLoad(element, src);
|
|
2647
4312
|
fetchResource(url, "image").then(async (response) => {
|
|
2648
|
-
if (!response.ok)
|
|
4313
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2649
4314
|
const bytes = response.body;
|
|
2650
4315
|
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
2651
4316
|
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
2652
|
-
if (!image)
|
|
4317
|
+
if (!image) throw new Error("Image decoding failed");
|
|
2653
4318
|
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src) {
|
|
2654
4319
|
image.delete();
|
|
2655
4320
|
return;
|
|
2656
4321
|
}
|
|
2657
4322
|
page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
|
|
2658
4323
|
page.decodedImages.push(image);
|
|
4324
|
+
finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
|
|
2659
4325
|
const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
|
|
2660
4326
|
const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
|
|
2661
4327
|
.find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
|
|
@@ -2669,15 +4335,19 @@ export async function createPageRenderer({
|
|
|
2669
4335
|
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
2670
4336
|
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
2671
4337
|
if (paintOnly) {
|
|
2672
|
-
page.displayList =
|
|
4338
|
+
page.displayList = paintList();
|
|
2673
4339
|
page.needsPaint = true;
|
|
2674
4340
|
} else {
|
|
2675
4341
|
page.dirty = true;
|
|
2676
4342
|
page.resourceLayoutDirty = true;
|
|
2677
4343
|
}
|
|
2678
4344
|
refresh();
|
|
2679
|
-
}).catch(() => {
|
|
4345
|
+
}).catch((error) => {
|
|
2680
4346
|
// A failed image retains its fallback replaced-element dimensions.
|
|
4347
|
+
trace?.(`image failed ${url}: ${error?.message ?? error}`);
|
|
4348
|
+
if (generation === loadGeneration && document === page.document) {
|
|
4349
|
+
finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4350
|
+
}
|
|
2681
4351
|
});
|
|
2682
4352
|
}
|
|
2683
4353
|
return staleImages;
|