buninu-browser 0.5.1 → 0.7.0
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/README.md +13 -6
- package/index.js +10 -6
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +94 -46
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/cdp-viewers.test.js +43 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/index.test.js +1 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
|
@@ -28,6 +28,7 @@ import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
|
|
|
28
28
|
import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-2d.js";
|
|
29
29
|
import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
|
|
30
30
|
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
31
|
+
import { DocumentCookies } from "./document-cookies.js";
|
|
31
32
|
|
|
32
33
|
// qml handle begin: optional software shader canvas bridge
|
|
33
34
|
import {paintQmlCanvas} from './canvas-2d.js';
|
|
@@ -51,10 +52,12 @@ import { scrollAlignmentDelta } from "./scroll-into-view.js";
|
|
|
51
52
|
import { elementAtPoint, elementBounds, elementBox, hitTest, interactiveRegions, renderNodeAtPoint } from "../input/hit-test.js";
|
|
52
53
|
import { beginImageLoad, finishImageLoad } from "./image-loading.js";
|
|
53
54
|
import { createImageResourceLoader, imageCrossOrigin, imageRequestKey } from "./image-resources.js";
|
|
55
|
+
import { createResourceElements } from "./resource-elements.js";
|
|
54
56
|
import { DocumentFonts } from "./font-loading.js";
|
|
55
57
|
import { dispatchPointerMove } from "./pointer-move.js";
|
|
56
58
|
import { keyboardInput } from "./keyboard-input.js";
|
|
57
59
|
import { playbackEvent } from "./media-playback-events.js";
|
|
60
|
+
import { repaintLayout } from "../layout/repaint.js";
|
|
58
61
|
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
59
62
|
import { absolutizeStyleSheetUrls, extractFontFaces, matchSelectors, mediaQueryMatches, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
60
63
|
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
@@ -63,6 +66,8 @@ import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
|
63
66
|
import { backgroundBytes, rawFrame, captureViewport, cropViewport, encodeImageBase64 } from "../paint/frame.js";
|
|
64
67
|
import { FrameCaptureCache } from "../paint/frame-cache.js";
|
|
65
68
|
import { RasterReadback } from "../paint/raster-readback.js";
|
|
69
|
+
import { rasterVideoMask, videoMaskBounds, videoMaskItems, videoLayerGeometry } from "./video-mask.js";
|
|
70
|
+
import { compositeVideo } from "./video-presentation.js";
|
|
66
71
|
import { insetRadii, roundedRect } from "../paint/rounded-rect.js";
|
|
67
72
|
import { appendParagraphText } from "../paint/paragraph-text.js";
|
|
68
73
|
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
@@ -102,6 +107,8 @@ const RESUME_BUFFER_IDLE_MS = 400;
|
|
|
102
107
|
// element → MediaSource, and the reverse. One renderer owns one page pipeline.
|
|
103
108
|
const elementSources = new WeakMap();
|
|
104
109
|
const sourceElements = new WeakMap();
|
|
110
|
+
// srcObject is independent of the src attribute and takes precedence over it.
|
|
111
|
+
const elementSourceObjects = new WeakMap();
|
|
105
112
|
|
|
106
113
|
/**
|
|
107
114
|
* A frame's window as other frames of the page reach it (messaging.js): its
|
|
@@ -252,6 +259,8 @@ function buildPageRenderer({
|
|
|
252
259
|
webSockets = null,
|
|
253
260
|
audio = null,
|
|
254
261
|
setCookie = () => {},
|
|
262
|
+
documentCookies = new DocumentCookies(setCookie),
|
|
263
|
+
inheritedCookieUrl = null,
|
|
255
264
|
consoleMessage = null,
|
|
256
265
|
trace = null,
|
|
257
266
|
onFrame = null,
|
|
@@ -304,17 +313,23 @@ function buildPageRenderer({
|
|
|
304
313
|
return resolved;
|
|
305
314
|
}
|
|
306
315
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
307
|
-
|
|
308
|
-
const styleEngine = new StyleEngine({ measureText });
|
|
316
|
+
let layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
317
|
+
const styleEngine = new StyleEngine({ measureText, deferHidden: true });
|
|
309
318
|
// Incremental restyle: what may have changed style since the last
|
|
310
319
|
// computation. DOM mutations come from a MutationObserver; state that is
|
|
311
320
|
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
312
321
|
// changes it. `full` asks for a complete restyle.
|
|
313
322
|
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null,
|
|
314
323
|
activeElement: null, focusAncestors: new Set() };
|
|
324
|
+
let resourceElements = null;
|
|
315
325
|
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
316
326
|
let lastStyleParts = "";
|
|
317
327
|
let laidOutSheets = null;
|
|
328
|
+
let computedSheets = null;
|
|
329
|
+
let computedEnvironment = null;
|
|
330
|
+
// A style-only CSSOM read may consume mutation records before layout runs.
|
|
331
|
+
// Keep that work pending so a later geometry read cannot reuse stale boxes.
|
|
332
|
+
let styleLayoutPending = false;
|
|
318
333
|
const sheetTraitsCache = new Map();
|
|
319
334
|
// Invalidation compounds compiled for matchesCompound() and matchSelectors().
|
|
320
335
|
const compiledCompounds = new Map();
|
|
@@ -387,13 +402,17 @@ function buildPageRenderer({
|
|
|
387
402
|
dirty: false,
|
|
388
403
|
resourceLayoutDirty: false,
|
|
389
404
|
userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
|
|
390
|
-
// document.cookie as last reported by the controller (non-HttpOnly only).
|
|
391
|
-
cookie: "",
|
|
392
405
|
};
|
|
393
406
|
|
|
407
|
+
const cookieUrl = () => page.url === "about:blank" && inheritedCookieUrl ? inheritedCookieUrl() : page.url;
|
|
408
|
+
function acceptCookies(response, url = cookieUrl()) {
|
|
409
|
+
if (!documentCookies.accept(response.cookieState)) documentCookies.seed(url, response.documentCookie, true);
|
|
410
|
+
}
|
|
411
|
+
|
|
394
412
|
async function fetchResource(url, kind) {
|
|
413
|
+
const generation = loadGeneration;
|
|
395
414
|
const response = await rawFetchResource(url, kind);
|
|
396
|
-
if (
|
|
415
|
+
if (generation === loadGeneration) acceptCookies(response);
|
|
397
416
|
return response;
|
|
398
417
|
}
|
|
399
418
|
const loadImageResource = createImageResourceLoader(CanvasKit, fetchResource, pageFetch);
|
|
@@ -412,8 +431,13 @@ function buildPageRenderer({
|
|
|
412
431
|
changed() {
|
|
413
432
|
if (disposedRenderer) return;
|
|
414
433
|
clearFontCaches();
|
|
415
|
-
//
|
|
416
|
-
|
|
434
|
+
// Layout caches retain shaping, normal line-height and intrinsic
|
|
435
|
+
// sizes by style/node identity. A loaded typeface changes those even
|
|
436
|
+
// when CSS values stay the same; reset them only on font completion.
|
|
437
|
+
layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
438
|
+
// ch (including a root font-size) also changes computed CSS. Other
|
|
439
|
+
// documents need fresh layout, while retaining their existing styles.
|
|
440
|
+
if (styleEngine.usesFontMetrics) styleChanges.full = true;
|
|
417
441
|
page.dirty = true;
|
|
418
442
|
page.resourceLayoutDirty = true;
|
|
419
443
|
page.needsPaint = true;
|
|
@@ -761,6 +785,10 @@ function buildPageRenderer({
|
|
|
761
785
|
const scale = page.deviceScaleFactor;
|
|
762
786
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
763
787
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
788
|
+
// The independent compositor already reconstructs an opaque video from
|
|
789
|
+
// black/white masks. Reuse that path for the ordinary tile too, avoiding
|
|
790
|
+
// a large Skia image upscale and a second black pass on each UI repaint.
|
|
791
|
+
const blackPlan = blackVideoPlan(region);
|
|
764
792
|
const damage = canvasDamage(region);
|
|
765
793
|
if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
|
|
766
794
|
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
@@ -781,7 +809,7 @@ function buildPageRenderer({
|
|
|
781
809
|
// Clear and replay all layers inside the damage, including backgrounds
|
|
782
810
|
// and foreground occluders. Transparent canvas pixels must not leave trails.
|
|
783
811
|
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
784
|
-
paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
|
|
812
|
+
paintDisplayList(canvas, page.displayList, region, scale, blackPlan ? {resource:blackPlan.resource,white:false} : null, damage ? {
|
|
785
813
|
x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
|
|
786
814
|
} : region);
|
|
787
815
|
drawHints(canvas, region);
|
|
@@ -791,7 +819,26 @@ function buildPageRenderer({
|
|
|
791
819
|
let pixels;
|
|
792
820
|
try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
|
|
793
821
|
finally { image.delete(); }
|
|
794
|
-
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
822
|
+
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight, pixels, blackPlan?.resource);
|
|
823
|
+
if (blackPlan && !videoLayer) {
|
|
824
|
+
// An injected callback may invalidate presentation during the paint.
|
|
825
|
+
// Recover the ordinary pixels before publishing an unlayered tile.
|
|
826
|
+
canvas.save();
|
|
827
|
+
try {
|
|
828
|
+
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
829
|
+
paintDisplayList(canvas, page.displayList, region, scale);
|
|
830
|
+
drawHints(canvas, region);
|
|
831
|
+
} finally { canvas.restore(); }
|
|
832
|
+
surface.flush();
|
|
833
|
+
const retryImage = surface.makeImageSnapshot();
|
|
834
|
+
try { pixels = rasterReadback.read(retryImage, pixelWidth, pixelHeight); }
|
|
835
|
+
finally { retryImage.delete(); }
|
|
836
|
+
}
|
|
837
|
+
if (blackPlan && videoLayer) {
|
|
838
|
+
const composed = compositeVideo({pixels,pixelWidth,pixelHeight,x:region.x,y:region.y,width:region.width,height:region.height,scale,videoLayer},blackPlan.frame);
|
|
839
|
+
// Preserve the caller's shared readback allocation and tile contract.
|
|
840
|
+
pixels.set(composed.pixels);
|
|
841
|
+
}
|
|
795
842
|
page.needsPaint = false;
|
|
796
843
|
rasterRegion = {...region};
|
|
797
844
|
rasterized = {
|
|
@@ -845,22 +892,31 @@ function buildPageRenderer({
|
|
|
845
892
|
}
|
|
846
893
|
|
|
847
894
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
848
|
-
function schedulePrepaint() {
|
|
895
|
+
function schedulePrepaint(delay = 30) {
|
|
849
896
|
if (disposedRenderer || !onFrame || embedded) return;
|
|
850
897
|
hostClearTimeout(prepaintTimer);
|
|
851
898
|
prepaintTimer = hostSetTimeout(() => {
|
|
852
899
|
prepaintTimer = null;
|
|
853
|
-
// A pending relayout does not stop this: the current layout, even if
|
|
854
|
-
// slightly stale, is better than background where the user scrolled.
|
|
855
900
|
const width = Math.max(1, Math.floor(page.width));
|
|
856
901
|
const height = Math.max(1, Math.floor(page.height));
|
|
902
|
+
// The visible page already has pixels. Let loading and queued layout/
|
|
903
|
+
// paint settle before spending time expanding a speculative tile.
|
|
904
|
+
// Retry even if that work proves to be a no-op and never calls render.
|
|
905
|
+
// A stalled load backs off to one cheap check per second; a new paint
|
|
906
|
+
// or scroll resets the usual 30ms delay. Scrolling outside the tile
|
|
907
|
+
// still paints at that delay, using the current layout if necessary.
|
|
908
|
+
if ((page.loading || page.dirty || page.needsPaint || frameTimer)
|
|
909
|
+
&& tileCovers(lastTile, { x: viewport.x, y: viewport.y, width, height })) {
|
|
910
|
+
schedulePrepaint(Math.min(1000, Math.max(100, delay * 2)));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
857
913
|
// Still covered with a quarter viewport to spare in both directions
|
|
858
914
|
// (as far as the page goes).
|
|
859
915
|
const top = Math.max(0, viewport.y - height / 4);
|
|
860
916
|
const bottom = Math.max(viewport.y + height, Math.min(viewport.contentHeight, viewport.y + height * 1.25));
|
|
861
917
|
if (tileCovers(lastTile, { x: viewport.x, y: top, width, height: bottom - top }) && sameRasterInput()) return;
|
|
862
918
|
publish(rasterTile(prepaintRegion()));
|
|
863
|
-
},
|
|
919
|
+
}, delay);
|
|
864
920
|
}
|
|
865
921
|
|
|
866
922
|
/** A rendering opportunity for DOM changes nobody has asked to see yet. */
|
|
@@ -919,7 +975,10 @@ function buildPageRenderer({
|
|
|
919
975
|
if (rounded) canvas.drawRRect(rounded, paint);
|
|
920
976
|
else canvas.drawRect(deviceRect(rect), paint);
|
|
921
977
|
};
|
|
922
|
-
|
|
978
|
+
const paintItems = videoOverride && dirtyView !== view
|
|
979
|
+
? videoMaskItems(displayList, view, dirtyView, viewport)
|
|
980
|
+
: visibleItems(displayList, dirtyView, viewport);
|
|
981
|
+
for (const item of paintItems) {
|
|
923
982
|
const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
|
|
924
983
|
try {
|
|
925
984
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
@@ -1476,6 +1535,14 @@ function buildPageRenderer({
|
|
|
1476
1535
|
const records = styleChanges.records;
|
|
1477
1536
|
if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
|
|
1478
1537
|
|
|
1538
|
+
// Video controls are read from the live DOM when building the display
|
|
1539
|
+
// list. Their presence changes paint even when layout input is identical.
|
|
1540
|
+
if (records.some(record => record.type === "attributes" && record.attributeName === "controls"
|
|
1541
|
+
&& record.target?.tagName === "VIDEO")) {
|
|
1542
|
+
page.resourcePaintDirty = true;
|
|
1543
|
+
page.needsPaint = true;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1479
1546
|
// qml handle begin: QML visual attributes invalidate retained rendering
|
|
1480
1547
|
if (hasQmlDomImages(page.document) && qmlTextureVisibilityChanged(records)) {
|
|
1481
1548
|
page.resourcePaintDirty = true;
|
|
@@ -1649,6 +1716,7 @@ function buildPageRenderer({
|
|
|
1649
1716
|
|
|
1650
1717
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1651
1718
|
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
1719
|
+
resourceElements?.dispose();
|
|
1652
1720
|
resetInputCaret();
|
|
1653
1721
|
rasterReadback.dispose();
|
|
1654
1722
|
clearPaintParagraphs();
|
|
@@ -1667,6 +1735,10 @@ function buildPageRenderer({
|
|
|
1667
1735
|
page.window = parsed.window;
|
|
1668
1736
|
pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
|
|
1669
1737
|
page.document = parsed.document;
|
|
1738
|
+
resourceElements = createResourceElements(parsed.document);
|
|
1739
|
+
computedSheets = null;
|
|
1740
|
+
computedEnvironment = null;
|
|
1741
|
+
styleLayoutPending = false;
|
|
1670
1742
|
observeDocumentCanvases(parsed.document, (node) => {
|
|
1671
1743
|
if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
|
|
1672
1744
|
page.resourcePaintDirty = true;
|
|
@@ -1697,7 +1769,7 @@ function buildPageRenderer({
|
|
|
1697
1769
|
}
|
|
1698
1770
|
|
|
1699
1771
|
/** Style, render tree, layout and display list of the current document. */
|
|
1700
|
-
function styleAndLayout() {
|
|
1772
|
+
function styleAndLayout(styleOnly = false) {
|
|
1701
1773
|
const started = hostPerformance.now();
|
|
1702
1774
|
const ms = (from, to) => Math.round(to - from);
|
|
1703
1775
|
const sheets = currentStyleSheets();
|
|
@@ -1713,30 +1785,47 @@ function buildPageRenderer({
|
|
|
1713
1785
|
if (key !== fontDefinitionKey) {
|
|
1714
1786
|
fontDefinitionKey = key;
|
|
1715
1787
|
clearFontCaches();
|
|
1788
|
+
// Editing/removing a face can select another loaded face or fallback
|
|
1789
|
+
// without a font-load callback. Its metrics invalidate the same caches.
|
|
1790
|
+
layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
1791
|
+
if (styleEngine.usesFontMetrics) styleChanges.full = true;
|
|
1716
1792
|
page.resourceLayoutDirty = true;
|
|
1717
1793
|
}
|
|
1718
1794
|
}
|
|
1719
1795
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1720
1796
|
const taken = hostPerformance.now();
|
|
1797
|
+
if (styleOnly && computedSheets === sheets && computedEnvironment === environmentKey
|
|
1798
|
+
&& changes && changes.roots.size === 0 && changes.self.size === 0) {
|
|
1799
|
+
// Resource/geometry invalidation does not itself invalidate computed CSS.
|
|
1800
|
+
styleLayoutPending ||= changes.treeDirty;
|
|
1801
|
+
return { style: Math.round(taken - started), layout: 0, skipped: true };
|
|
1802
|
+
}
|
|
1721
1803
|
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
1722
1804
|
// not actually change DOM, state, resources, or styles. Keep the retained
|
|
1723
1805
|
// layout/display list in that case instead of laying the whole page out.
|
|
1724
|
-
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1806
|
+
if (!styleOnly && !styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1725
1807
|
&& changes.roots.size === 0 && changes.self.size === 0) {
|
|
1726
1808
|
lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
|
|
1727
|
-
return { style: Math.round(taken - started), layout: 0, skipped: true };
|
|
1809
|
+
return { style: Math.round(taken - started), layout: 0, skipped: true, geometryUnchanged: true };
|
|
1728
1810
|
}
|
|
1729
1811
|
styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
|
|
1812
|
+
computedSheets = sheets;
|
|
1813
|
+
computedEnvironment = environmentKey;
|
|
1730
1814
|
const computed = hostPerformance.now();
|
|
1731
1815
|
if (!hasState || hasStateStale) {
|
|
1732
1816
|
recordHasState(selectorTraitsFor(sheets));
|
|
1733
1817
|
hasStateStale = false;
|
|
1734
1818
|
}
|
|
1735
1819
|
const recorded = hostPerformance.now();
|
|
1820
|
+
if (styleOnly) {
|
|
1821
|
+
styleLayoutPending ||= !changes || changes.treeDirty || !styleEngine.incremental || styleEngine.restyled !== 0;
|
|
1822
|
+
if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
|
|
1823
|
+
return { style: Math.round(recorded - started), layout: 0 };
|
|
1824
|
+
}
|
|
1736
1825
|
// Attribute mutations used by application bookkeeping often match no
|
|
1737
1826
|
// different rule and do not alter render-tree content. Once style has
|
|
1738
1827
|
// proved that no element changed, retain layout and paint unchanged.
|
|
1739
|
-
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1828
|
+
if (!styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1740
1829
|
&& !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
|
|
1741
1830
|
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
|
|
1742
1831
|
return { style: Math.round(recorded - started), layout: 0, skipped: true };
|
|
@@ -1755,12 +1844,27 @@ function buildPageRenderer({
|
|
|
1755
1844
|
ignoreResources: true,
|
|
1756
1845
|
// A zero-element incremental pass retained every computed style.
|
|
1757
1846
|
// Control placeholders may still wrap that style in a fresh object.
|
|
1758
|
-
ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
|
|
1847
|
+
ignoreStyles: !styleLayoutPending && styleEngine.incremental && styleEngine.restyled === 0,
|
|
1759
1848
|
})) {
|
|
1849
|
+
styleLayoutPending = false;
|
|
1760
1850
|
lastStyleParts += ", unchanged layout input";
|
|
1761
1851
|
return { style: Math.round(styled - started), layout: 0, skipped: true };
|
|
1762
1852
|
}
|
|
1853
|
+
// Paint-only updates such as progress-bar scaling must not reflow the
|
|
1854
|
+
// entire document whenever a script reads geometry.
|
|
1855
|
+
const repainted = page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets
|
|
1856
|
+
? repaintLayout(page.layout, page.renderTree, nextRenderTree) : null;
|
|
1763
1857
|
page.renderTree = nextRenderTree;
|
|
1858
|
+
styleLayoutPending = false;
|
|
1859
|
+
if (repainted) {
|
|
1860
|
+
page.layout = repainted;
|
|
1861
|
+
page.displayList = paintList();
|
|
1862
|
+
page.resourcePaintDirty = false;
|
|
1863
|
+
if (clampElementScroll()) page.displayList = paintList();
|
|
1864
|
+
setContentSize();
|
|
1865
|
+
lastStyleParts += ", retained geometry";
|
|
1866
|
+
return { style: Math.round(styled - started), layout: 0 };
|
|
1867
|
+
}
|
|
1764
1868
|
page.layout = layoutEngine.layout(page.renderTree, {
|
|
1765
1869
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1766
1870
|
});
|
|
@@ -1805,7 +1909,6 @@ function buildPageRenderer({
|
|
|
1805
1909
|
function rebuildRenderTree(reason = "update") {
|
|
1806
1910
|
const started = hostPerformance.now();
|
|
1807
1911
|
page.dirty = false;
|
|
1808
|
-
page.layoutVersion++;
|
|
1809
1912
|
page.title = page.document?.title ?? page.title;
|
|
1810
1913
|
let staleImages = [];
|
|
1811
1914
|
if (page.document) {
|
|
@@ -1813,6 +1916,11 @@ function buildPageRenderer({
|
|
|
1813
1916
|
staleImages = queueImageResources();
|
|
1814
1917
|
}
|
|
1815
1918
|
const timing = styleAndLayout();
|
|
1919
|
+
// Metadata-only flushes retain the same boxes and styles. Advancing the
|
|
1920
|
+
// version would make every Resize/IntersectionObserver measure again.
|
|
1921
|
+
// Other skipped paths remain conservative; viewport/DPR changes are also
|
|
1922
|
+
// passed separately to layoutChanged, even when this version is unchanged.
|
|
1923
|
+
if (!timing.geometryUnchanged) page.layoutVersion++;
|
|
1816
1924
|
if (staleImages.length) {
|
|
1817
1925
|
const stale = new Set(staleImages);
|
|
1818
1926
|
page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
|
|
@@ -1859,7 +1967,7 @@ function buildPageRenderer({
|
|
|
1859
1967
|
}
|
|
1860
1968
|
|
|
1861
1969
|
async function runLoad({
|
|
1862
|
-
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", referrer = "",
|
|
1970
|
+
url, source = "", contentType = "text/html", status = 200, statusText = "", error = null, cookie = "", cookieState = null, referrer = "",
|
|
1863
1971
|
}, generation, commit) {
|
|
1864
1972
|
const current = () => generation === loadGeneration;
|
|
1865
1973
|
const finish = () => {
|
|
@@ -1878,7 +1986,7 @@ function buildPageRenderer({
|
|
|
1878
1986
|
// qml handle end
|
|
1879
1987
|
|
|
1880
1988
|
page.url = url;
|
|
1881
|
-
|
|
1989
|
+
if (!documentCookies.accept(cookieState)) documentCookies.seed(cookieUrl(), cookie, !embedded);
|
|
1882
1990
|
if (error) {
|
|
1883
1991
|
replaceDocument("", url);
|
|
1884
1992
|
page.title = "Network error";
|
|
@@ -2406,10 +2514,12 @@ function buildPageRenderer({
|
|
|
2406
2514
|
return { ...resource, originClean };
|
|
2407
2515
|
},
|
|
2408
2516
|
canvasFont(element, value) {
|
|
2517
|
+
if (page.dirty) styleAndLayout(true);
|
|
2409
2518
|
return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
|
|
2410
2519
|
styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
|
|
2411
2520
|
},
|
|
2412
2521
|
canvasText(element, request) {
|
|
2522
|
+
if (request.settings.direction === "inherit" && page.dirty) styleAndLayout(true);
|
|
2413
2523
|
const style = { ...request.settings.parsedFont,
|
|
2414
2524
|
direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
|
|
2415
2525
|
documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
|
|
@@ -2420,6 +2530,7 @@ function buildPageRenderer({
|
|
|
2420
2530
|
playMedia,
|
|
2421
2531
|
pauseMedia,
|
|
2422
2532
|
seekMedia,
|
|
2533
|
+
reselectMedia,
|
|
2423
2534
|
attachMediaSrc,
|
|
2424
2535
|
mediaReadyState,
|
|
2425
2536
|
mediaNetworkState,
|
|
@@ -2476,11 +2587,14 @@ function buildPageRenderer({
|
|
|
2476
2587
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
2477
2588
|
revealElement(element, options);
|
|
2478
2589
|
},
|
|
2479
|
-
computedStyle(element) {
|
|
2590
|
+
computedStyle(element, needsLayout = true) {
|
|
2480
2591
|
if (!element) return null;
|
|
2481
|
-
if (page.dirty)
|
|
2592
|
+
if (page.dirty) {
|
|
2593
|
+
if (needsLayout) rebuildRenderTree("getComputedStyle");
|
|
2594
|
+
else styleAndLayout(true);
|
|
2595
|
+
}
|
|
2482
2596
|
const style = styleEngine.get(element);
|
|
2483
|
-
if (style?.padding.some(value => typeof value === "object")) {
|
|
2597
|
+
if (needsLayout && style?.padding.some(value => typeof value === "object")) {
|
|
2484
2598
|
const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
|
|
2485
2599
|
if (box) return { ...style, padding: box.style.padding };
|
|
2486
2600
|
}
|
|
@@ -2490,9 +2604,9 @@ function buildPageRenderer({
|
|
|
2490
2604
|
if (page.dirty) rebuildRenderTree("intersection observer");
|
|
2491
2605
|
return page.layoutVersion;
|
|
2492
2606
|
},
|
|
2493
|
-
customProperties(element) {
|
|
2607
|
+
customProperties(element, updateStyle = true) {
|
|
2494
2608
|
if (!element) return null;
|
|
2495
|
-
if (page.dirty)
|
|
2609
|
+
if (updateStyle && page.dirty) styleAndLayout(true);
|
|
2496
2610
|
return styleEngine.customPropertiesOf(element);
|
|
2497
2611
|
},
|
|
2498
2612
|
navigate: (url) => navigateTo(String(url)),
|
|
@@ -2527,18 +2641,18 @@ function buildPageRenderer({
|
|
|
2527
2641
|
userAgent: () => page.userAgent,
|
|
2528
2642
|
initScripts: () => page.initScripts ?? [],
|
|
2529
2643
|
rewriteScript,
|
|
2530
|
-
documentCookie: () =>
|
|
2644
|
+
documentCookie: () => documentCookies.get(cookieUrl()),
|
|
2531
2645
|
setDocumentCookie(value) {
|
|
2532
|
-
|
|
2533
|
-
setCookie(String(value));
|
|
2646
|
+
documentCookies.set(cookieUrl(), value);
|
|
2534
2647
|
},
|
|
2535
2648
|
clipboardRead,
|
|
2536
2649
|
clipboardWrite,
|
|
2537
2650
|
webSockets,
|
|
2538
2651
|
pageFetch(request) {
|
|
2652
|
+
const generation = loadGeneration;
|
|
2539
2653
|
const networkPending = pageFetch(request);
|
|
2540
2654
|
const result = networkPending.then((response) => {
|
|
2541
|
-
if (
|
|
2655
|
+
if (generation === loadGeneration) acceptCookies(response);
|
|
2542
2656
|
return response;
|
|
2543
2657
|
});
|
|
2544
2658
|
result.abort = (reason) => networkPending.abort?.(reason);
|
|
@@ -2598,6 +2712,9 @@ function buildPageRenderer({
|
|
|
2598
2712
|
|
|
2599
2713
|
/** The border box of an element's layout box in viewport coordinates. */
|
|
2600
2714
|
function viewportBounds(element) {
|
|
2715
|
+
const box = elementBox(page.renderTree, page.layout, element)?.box;
|
|
2716
|
+
const content = box ? null : elementBounds(page.renderTree, page.layout, element);
|
|
2717
|
+
if (!box && !content) return null;
|
|
2601
2718
|
let fixed = false;
|
|
2602
2719
|
for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
|
|
2603
2720
|
if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
|
|
@@ -2606,12 +2723,8 @@ function buildPageRenderer({
|
|
|
2606
2723
|
const shift = scrollShift(element);
|
|
2607
2724
|
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
2608
2725
|
const scrollY = (fixed ? 0 : viewport.y) + shift.y;
|
|
2609
|
-
const box = elementBox(page.renderTree, page.layout, element)?.box;
|
|
2610
2726
|
if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
|
|
2611
|
-
|
|
2612
|
-
return content
|
|
2613
|
-
? { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height }
|
|
2614
|
-
: null;
|
|
2727
|
+
return { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height };
|
|
2615
2728
|
}
|
|
2616
2729
|
|
|
2617
2730
|
/**
|
|
@@ -2750,6 +2863,7 @@ function buildPageRenderer({
|
|
|
2750
2863
|
|
|
2751
2864
|
const api = {
|
|
2752
2865
|
loadDocument,
|
|
2866
|
+
updateCookies: state => documentCookies.accept(state),
|
|
2753
2867
|
/** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
|
|
2754
2868
|
setScripting(mode) {
|
|
2755
2869
|
page.scripting = ["ses", "host"].includes(mode) ? mode : "off";
|
|
@@ -2959,14 +3073,31 @@ function buildPageRenderer({
|
|
|
2959
3073
|
void placeVideo(state, mediaTime(state)).then(() => {
|
|
2960
3074
|
if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
|
|
2961
3075
|
}).catch(reportError);
|
|
3076
|
+
} else if (Number.isSafeInteger(report.continued)) {
|
|
3077
|
+
const video = state.video, p = video.presentation, continuation = p.continuation;
|
|
3078
|
+
if (!continuation?.decoder || report.revision !== p.revision + 1 || report.continued !== continuation.generation) return;
|
|
3079
|
+
video.decoder.close();
|
|
3080
|
+
video.decoder = continuation.decoder;
|
|
3081
|
+
continuation.decoder = null;
|
|
3082
|
+
Object.assign(video, { bytes: continuation.bytes, openedGeneration: continuation.generation,
|
|
3083
|
+
needsLocalPosition: true, done: false });
|
|
3084
|
+
p.revision = report.revision;
|
|
3085
|
+
p.continuation = null;
|
|
3086
|
+
p.pendingEnd = null;
|
|
3087
|
+
p.exhaustedAt = null;
|
|
3088
|
+
} else if ((report.revision ?? 0) !== state.video.presentation.revision) return;
|
|
3089
|
+
else if (report.continuationError) {
|
|
3090
|
+
failVideoContinuation(state, state.video.presentation);
|
|
3091
|
+
} else if (Number.isFinite(report.exhaustedAt)) {
|
|
3092
|
+
state.video.presentation.exhaustedAt = report.exhaustedAt;
|
|
3093
|
+
continueVideoSnapshot(state, report.exhaustedAt);
|
|
2962
3094
|
} else if (Number.isFinite(report.end)) {
|
|
2963
|
-
state.video.
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
state.
|
|
2969
|
-
}
|
|
3095
|
+
const p = state.video.presentation;
|
|
3096
|
+
// EOF can reach the page before its extension reaches the worker.
|
|
3097
|
+
// Defer that old revision's end until continuation succeeds or fails.
|
|
3098
|
+
if (p.continuation) p.pendingEnd = report.end;
|
|
3099
|
+
else if (p.exhaustedAt !== report.end || !continueVideoSnapshot(state, report.end))
|
|
3100
|
+
finishVideoPresentation(state, report.end);
|
|
2970
3101
|
} else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
|
|
2971
3102
|
},
|
|
2972
3103
|
/** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
|
|
@@ -3255,6 +3386,8 @@ function buildPageRenderer({
|
|
|
3255
3386
|
if (disposedRenderer) return;
|
|
3256
3387
|
resetInputCaret();
|
|
3257
3388
|
disposedRenderer = true;
|
|
3389
|
+
resourceElements?.dispose();
|
|
3390
|
+
resourceElements = null;
|
|
3258
3391
|
loadGeneration++;
|
|
3259
3392
|
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
3260
3393
|
hostClearTimeout(prepaintTimer);
|
|
@@ -3299,7 +3432,9 @@ function buildPageRenderer({
|
|
|
3299
3432
|
*/
|
|
3300
3433
|
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
3301
3434
|
const sheets = [];
|
|
3302
|
-
|
|
3435
|
+
const elements = document === page.document && resourceElements
|
|
3436
|
+
? resourceElements.get("sheets") : document.querySelectorAll("style, link[rel~='stylesheet']");
|
|
3437
|
+
for (const element of elements) {
|
|
3303
3438
|
if (!isCssStyleSheet(element)) continue;
|
|
3304
3439
|
const applies = styleSheetMatches(element);
|
|
3305
3440
|
if (element.tagName === "STYLE") {
|
|
@@ -3376,7 +3511,7 @@ function buildPageRenderer({
|
|
|
3376
3511
|
/** Brings the frames in line with the document's iframes: their documents and sizes. */
|
|
3377
3512
|
function syncFrames() {
|
|
3378
3513
|
const document = page.document;
|
|
3379
|
-
const elements = document ?
|
|
3514
|
+
const elements = document ? resourceElements.get("frames") : [];
|
|
3380
3515
|
if (!elements.length && !frames.size) return;
|
|
3381
3516
|
const boxes = frameBoxes();
|
|
3382
3517
|
for (const [element, frame] of frames) {
|
|
@@ -3433,6 +3568,8 @@ function buildPageRenderer({
|
|
|
3433
3568
|
clipboardWriteInput,
|
|
3434
3569
|
webSockets,
|
|
3435
3570
|
setCookie,
|
|
3571
|
+
documentCookies,
|
|
3572
|
+
inheritedCookieUrl: cookieUrl,
|
|
3436
3573
|
consoleMessage,
|
|
3437
3574
|
onFrame: (update) => showFrame(frame, update),
|
|
3438
3575
|
onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
|
|
@@ -3503,7 +3640,8 @@ function buildPageRenderer({
|
|
|
3503
3640
|
contentType: response.contentType || "text/html",
|
|
3504
3641
|
status: response.status,
|
|
3505
3642
|
statusText: response.statusText ?? "",
|
|
3506
|
-
cookie:
|
|
3643
|
+
cookie: documentCookies.get(target.href),
|
|
3644
|
+
cookieState: response.cookieState,
|
|
3507
3645
|
};
|
|
3508
3646
|
} catch (error) {
|
|
3509
3647
|
trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
|
|
@@ -3615,10 +3753,10 @@ function buildPageRenderer({
|
|
|
3615
3753
|
* Null for an element that is not one.
|
|
3616
3754
|
*/
|
|
3617
3755
|
function scrollerGeometry(element) {
|
|
3618
|
-
const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
|
|
3619
|
-
if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
|
|
3620
3756
|
const found = layoutBoxOf(element);
|
|
3621
3757
|
if (!found) return null;
|
|
3758
|
+
const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
|
|
3759
|
+
if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
|
|
3622
3760
|
const { node, box } = found;
|
|
3623
3761
|
const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
|
|
3624
3762
|
const padding = style.padding ?? [0, 0, 0, 0];
|
|
@@ -3787,7 +3925,9 @@ function buildPageRenderer({
|
|
|
3787
3925
|
|
|
3788
3926
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
3789
3927
|
function initializeVideos(document, resources) {
|
|
3790
|
-
|
|
3928
|
+
const elements = document === page.document && resourceElements
|
|
3929
|
+
? resourceElements.get("media") : document.querySelectorAll("video, audio");
|
|
3930
|
+
for (const element of elements) {
|
|
3791
3931
|
if (resources.get(element)) continue;
|
|
3792
3932
|
const audioOnly = element.tagName === "AUDIO";
|
|
3793
3933
|
resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
|
|
@@ -3853,6 +3993,45 @@ function buildPageRenderer({
|
|
|
3853
3993
|
return null;
|
|
3854
3994
|
}
|
|
3855
3995
|
|
|
3996
|
+
function selectedMediaInput(element) {
|
|
3997
|
+
return elementSourceObjects.get(element) ?? element.getAttribute("src")
|
|
3998
|
+
?? element.querySelector("source[src]")?.getAttribute("src") ?? null;
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
function selectedMediaSource(element, reselect = false) {
|
|
4002
|
+
const input = selectedMediaInput(element);
|
|
4003
|
+
const state = page.media.get(element);
|
|
4004
|
+
// A selected resource outlives object-URL revocation or a <base> change.
|
|
4005
|
+
// Explicit src assignment or load() selects again, even when raw src is
|
|
4006
|
+
// unchanged; passive metadata/playback must keep its original selection.
|
|
4007
|
+
if (!reselect && state?.input === input) return { input, source: state.mediaSource, url: state.url };
|
|
4008
|
+
const source = resolveMediaSource(typeof input === "string" ? videoSource(element) : input);
|
|
4009
|
+
return { input, source, url: typeof input === "string" ? videoSource(element) : source?._objectURL ?? null };
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
function linkMediaSource(element, source) {
|
|
4013
|
+
const previous = elementSources.get(element);
|
|
4014
|
+
if (previous && previous !== source && sourceElements.get(previous) === element) sourceElements.delete(previous);
|
|
4015
|
+
if (source) {
|
|
4016
|
+
elementSources.set(element, source);
|
|
4017
|
+
sourceElements.set(source, element);
|
|
4018
|
+
} else elementSources.delete(element);
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
function currentMediaState(state) {
|
|
4022
|
+
if (state.document !== page.document || page.media.get(state.element) !== state) return false;
|
|
4023
|
+
return selectedMediaInput(state.element) === state.input;
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
// Detached sources can still finish queued appends or change their duration.
|
|
4027
|
+
// Only the selected source in this document may notify its current state.
|
|
4028
|
+
function attachedMediaState(source) {
|
|
4029
|
+
const element = sourceElements.get(source);
|
|
4030
|
+
const state = element && page.media.get(element);
|
|
4031
|
+
return state && element.ownerDocument === page.document && elementSources.get(element) === source
|
|
4032
|
+
&& state.mediaSource === source && currentMediaState(state) ? state : null;
|
|
4033
|
+
}
|
|
4034
|
+
|
|
3856
4035
|
/** A resource record for a media element created after the document was parsed. */
|
|
3857
4036
|
function ensureMediaResource(element) {
|
|
3858
4037
|
if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
|
|
@@ -3871,24 +4050,17 @@ function buildPageRenderer({
|
|
|
3871
4050
|
|
|
3872
4051
|
/** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
|
|
3873
4052
|
function attachMediaSrc(element, value) {
|
|
3874
|
-
if (!element ||
|
|
3875
|
-
|
|
3876
|
-
if (
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
}
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
if (source.readyState === "closed") {
|
|
3883
|
-
try {
|
|
3884
|
-
source.attach(typeof value === "string" ? value : source._objectURL);
|
|
3885
|
-
} catch {
|
|
3886
|
-
return;
|
|
3887
|
-
}
|
|
3888
|
-
}
|
|
3889
|
-
const state = mediaStateFor(element);
|
|
4053
|
+
if (!element || element.ownerDocument !== page.document
|
|
4054
|
+
|| (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return;
|
|
4055
|
+
if (typeof value !== "string") {
|
|
4056
|
+
if (value == null) elementSourceObjects.delete(element);
|
|
4057
|
+
else elementSourceObjects.set(element, value);
|
|
4058
|
+
}
|
|
4059
|
+
// Updating an inactive src attribute must not replace srcObject playback.
|
|
4060
|
+
const state = mediaStateFor(element, typeof value !== "string" || !elementSourceObjects.has(element));
|
|
3890
4061
|
if (!state) return;
|
|
3891
|
-
state.mediaSource
|
|
4062
|
+
const source = state.mediaSource;
|
|
4063
|
+
if (!source) return;
|
|
3892
4064
|
if (Number.isFinite(source.duration)) {
|
|
3893
4065
|
state.durationOverride = true;
|
|
3894
4066
|
state.duration = source.duration;
|
|
@@ -3898,11 +4070,8 @@ function buildPageRenderer({
|
|
|
3898
4070
|
}
|
|
3899
4071
|
|
|
3900
4072
|
function mediaAppended(source) {
|
|
3901
|
-
const
|
|
3902
|
-
if (!element) return;
|
|
3903
|
-
const state = page.media.get(element) ?? mediaStateFor(element);
|
|
4073
|
+
const state = attachedMediaState(source);
|
|
3904
4074
|
if (!state) return;
|
|
3905
|
-
state.mediaSource = source;
|
|
3906
4075
|
bindSourcePipes(state);
|
|
3907
4076
|
// A later initialization segment can replace already-buffered video.
|
|
3908
4077
|
// Shorten the active presentation at its epoch boundary as soon as that
|
|
@@ -3926,8 +4095,7 @@ function buildPageRenderer({
|
|
|
3926
4095
|
}
|
|
3927
4096
|
|
|
3928
4097
|
function mediaDuration(source, seconds) {
|
|
3929
|
-
const
|
|
3930
|
-
const state = element && page.media.get(element);
|
|
4098
|
+
const state = attachedMediaState(source);
|
|
3931
4099
|
if (!state || !Number.isFinite(seconds)) return;
|
|
3932
4100
|
state.durationOverride = true;
|
|
3933
4101
|
state.duration = seconds;
|
|
@@ -3936,28 +4104,27 @@ function buildPageRenderer({
|
|
|
3936
4104
|
}
|
|
3937
4105
|
|
|
3938
4106
|
/** The element's playback state, created (not loaded) on first use. */
|
|
3939
|
-
function mediaStateFor(element) {
|
|
4107
|
+
function mediaStateFor(element, reselect = false) {
|
|
4108
|
+
if (!element || element.ownerDocument !== page.document) return null;
|
|
3940
4109
|
const resource = ensureMediaResource(element);
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
|
|
3944
|
-
const url = attributeUrl ?? source?._objectURL ?? null;
|
|
3945
|
-
if (!videoDecoder || !resource || (!url && !source)) return null;
|
|
3946
|
-
if (source && source.readyState === "closed") {
|
|
3947
|
-
try {
|
|
3948
|
-
source.attach(url);
|
|
3949
|
-
elementSources.set(element, source);
|
|
3950
|
-
sourceElements.set(source, element);
|
|
3951
|
-
} catch {}
|
|
3952
|
-
}
|
|
4110
|
+
const { input, source, url } = selectedMediaSource(element, reselect);
|
|
4111
|
+
linkMediaSource(element, source);
|
|
3953
4112
|
let state = page.media.get(element);
|
|
3954
|
-
if (state && state.
|
|
4113
|
+
if (state && (state.mediaSource !== source || (!source && (state.input !== input || reselect)))) {
|
|
3955
4114
|
closeMedia(state);
|
|
3956
4115
|
page.media.delete(element);
|
|
3957
4116
|
state = null;
|
|
4117
|
+
resource.duration = NaN;
|
|
4118
|
+
resource.time = 0;
|
|
4119
|
+
}
|
|
4120
|
+
if (source && source.readyState === "closed") {
|
|
4121
|
+
try {
|
|
4122
|
+
source.attach(url);
|
|
4123
|
+
} catch {}
|
|
3958
4124
|
}
|
|
4125
|
+
if (!videoDecoder || !resource || (!url && !source)) return null;
|
|
3959
4126
|
if (!state) {
|
|
3960
|
-
state = { element, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
|
|
4127
|
+
state = { element, input, url, resource, bytes: null, pipe: null, videoPipe: null, audioPipe: null,
|
|
3961
4128
|
loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
|
|
3962
4129
|
video: null, sound: null, playing: false, ended: false,
|
|
3963
4130
|
duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
|
|
@@ -3969,12 +4136,17 @@ function buildPageRenderer({
|
|
|
3969
4136
|
state.durationOverride = true;
|
|
3970
4137
|
resource.duration = source.duration;
|
|
3971
4138
|
}
|
|
3972
|
-
} else
|
|
3973
|
-
state.
|
|
4139
|
+
} else {
|
|
4140
|
+
state.input = input;
|
|
4141
|
+
state.url = url;
|
|
3974
4142
|
}
|
|
3975
4143
|
return state;
|
|
3976
4144
|
}
|
|
3977
4145
|
|
|
4146
|
+
function reselectMedia(element) {
|
|
4147
|
+
return mediaStateFor(element, true);
|
|
4148
|
+
}
|
|
4149
|
+
|
|
3978
4150
|
/** Current position in seconds. */
|
|
3979
4151
|
function mediaTime(state) {
|
|
3980
4152
|
const sound = state.sound;
|
|
@@ -4000,10 +4172,21 @@ function buildPageRenderer({
|
|
|
4000
4172
|
|
|
4001
4173
|
/** The control bar and frame redraw without a relayout. */
|
|
4002
4174
|
function repaintMedia() {
|
|
4003
|
-
//
|
|
4004
|
-
//
|
|
4005
|
-
|
|
4006
|
-
|
|
4175
|
+
// Independent presentation already replaces video pixels. Dirty page UI
|
|
4176
|
+
// must take the ordinary scheduled refresh, which rebuilds layout before
|
|
4177
|
+
// painting and coalesces changes; a media tick must not raster stale items.
|
|
4178
|
+
if (onFrame && !embedded
|
|
4179
|
+
&& [...page.media.values()].some(state => state.video?.presentation && state.playing)) {
|
|
4180
|
+
// Another media element can still own a native progress/control bar.
|
|
4181
|
+
// Its mutable resource time is not a DOM mutation, so invalidate paint.
|
|
4182
|
+
if (page.displayList?.items.some(item => item.op === "drawAudio"
|
|
4183
|
+
|| (item.op === "drawVideo" && item.controls))) {
|
|
4184
|
+
rasterized.list = null;
|
|
4185
|
+
page.needsPaint = true;
|
|
4186
|
+
}
|
|
4187
|
+
if (page.dirty || page.needsPaint) scheduleFrame();
|
|
4188
|
+
return;
|
|
4189
|
+
}
|
|
4007
4190
|
rasterized.list = null;
|
|
4008
4191
|
render();
|
|
4009
4192
|
}
|
|
@@ -4012,11 +4195,81 @@ function buildPageRenderer({
|
|
|
4012
4195
|
const presentation = state.video?.presentation;
|
|
4013
4196
|
if (!presentation) return;
|
|
4014
4197
|
readPresentedFrame(state);
|
|
4198
|
+
presentation.continuation?.decoder?.close();
|
|
4199
|
+
if (presentation.continuation) presentation.continuation.decoder = null;
|
|
4200
|
+
presentation.continuation = null;
|
|
4015
4201
|
onVideo?.({ type: "video-stop", id: presentation.id });
|
|
4016
4202
|
state.video.presentation = null;
|
|
4017
4203
|
state.video.needsLocalPosition = true;
|
|
4018
4204
|
}
|
|
4019
4205
|
|
|
4206
|
+
function finishVideoPresentation(state, end) {
|
|
4207
|
+
state.video.frameTime = end;
|
|
4208
|
+
if (needsMore(state.video)) void refillMedia(state, state.video);
|
|
4209
|
+
else {
|
|
4210
|
+
readPresentedFrame(state);
|
|
4211
|
+
stopVideoPresentation(state);
|
|
4212
|
+
state.video.done = true;
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
function failVideoContinuation(state, presentation) {
|
|
4217
|
+
if (state.video?.presentation !== presentation) return;
|
|
4218
|
+
presentation.continuation?.decoder?.close();
|
|
4219
|
+
if (presentation.continuation) presentation.continuation.decoder = null;
|
|
4220
|
+
presentation.continuation = null;
|
|
4221
|
+
const end = presentation.pendingEnd;
|
|
4222
|
+
presentation.pendingEnd = null;
|
|
4223
|
+
if (Number.isFinite(end)) finishVideoPresentation(state, end);
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
// A decoded snapshot can end before its queued pixels are displayed. If
|
|
4227
|
+
// bytes for the same source/configuration already exist, extend that worker
|
|
4228
|
+
// without dispatching waiting, stopping audio or replacing the raster mask.
|
|
4229
|
+
// The ordinary refill path still handles actual starvation and new epochs.
|
|
4230
|
+
function continueVideoSnapshot(state, boundary) {
|
|
4231
|
+
const video = state.video, p = video?.presentation, pipe = video?.pipe;
|
|
4232
|
+
if (!p || p.continuation || !state.playing || state.buffering || !pipe || pipe.error
|
|
4233
|
+
|| pipe !== (state.videoPipe ?? state.pipe) || boundary + 1e-6 >= video.endTime
|
|
4234
|
+
|| !Number.isSafeInteger(pipe.generation) || pipe.generation <= video.openedGeneration
|
|
4235
|
+
|| pipe.generation <= (p.attemptedGeneration ?? -1)) return false;
|
|
4236
|
+
const generation = pipe.generation;
|
|
4237
|
+
let selected, bytes;
|
|
4238
|
+
try {
|
|
4239
|
+
selected = pipe.snapshotAt?.(boundary);
|
|
4240
|
+
if (selected && (selected.id !== video.snapshotId || selected.startTime !== video.startTime
|
|
4241
|
+
|| boundary + 1e-6 >= selected.endTime)) return false;
|
|
4242
|
+
bytes = selected?.bytes ?? pipe.bytes();
|
|
4243
|
+
} catch { return false; }
|
|
4244
|
+
if (!(bytes instanceof Uint8Array) || bytes.length <= video.bytes.length) return false;
|
|
4245
|
+
const serial = state.seekSerial;
|
|
4246
|
+
const continuation = p.continuation = { bytes, generation, decoder: null };
|
|
4247
|
+
p.attemptedGeneration = generation;
|
|
4248
|
+
const current = () => state.document === page.document && state.playing && !state.buffering
|
|
4249
|
+
&& state.seekSerial === serial && state.video === video && video.presentation === p
|
|
4250
|
+
&& p.continuation === continuation && pipe === (state.videoPipe ?? state.pipe)
|
|
4251
|
+
&& boundary + 1e-6 < video.endTime;
|
|
4252
|
+
// Keep a correctly opened, lazy page decoder for controls/layout fallback.
|
|
4253
|
+
// Only one replacement is staged; cancellation closes it before reuse.
|
|
4254
|
+
void (async () => {
|
|
4255
|
+
let decoder;
|
|
4256
|
+
try {
|
|
4257
|
+
decoder = await videoDecoder.decode(bytes, decodeOptions(state.videoMime || state.contentType, state.url));
|
|
4258
|
+
if (!current()) { decoder.close(); decoder = null; failVideoContinuation(state, p); return; }
|
|
4259
|
+
if (decoder.width !== video.decoder.width || decoder.height !== video.decoder.height || decoder.fps !== video.decoder.fps)
|
|
4260
|
+
throw new Error("video snapshot configuration changed");
|
|
4261
|
+
continuation.decoder = decoder;
|
|
4262
|
+
decoder = null;
|
|
4263
|
+
onVideo({ type: "video-extend", id: p.id, revision: p.revision, snapshotId: video.snapshotId,
|
|
4264
|
+
generation, bytes, boundary });
|
|
4265
|
+
} catch {
|
|
4266
|
+
decoder?.close();
|
|
4267
|
+
failVideoContinuation(state, p);
|
|
4268
|
+
}
|
|
4269
|
+
})();
|
|
4270
|
+
return true;
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4020
4273
|
function readPresentedFrame(state) {
|
|
4021
4274
|
const p = state.video?.presentation;
|
|
4022
4275
|
if (!p) return;
|
|
@@ -4032,10 +4285,28 @@ function buildPageRenderer({
|
|
|
4032
4285
|
if (!image) return;
|
|
4033
4286
|
state.resource.image?.delete();
|
|
4034
4287
|
Object.assign(state.resource, { image, bitmapWidth: width, bitmapHeight: height });
|
|
4288
|
+
// Decoder formats may carry alpha. Retain only this coherent frame's
|
|
4289
|
+
// owned copy; stopping/replacing the presentation releases it.
|
|
4290
|
+
let opaque = true;
|
|
4291
|
+
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] !== 255) { opaque = false; break; }
|
|
4292
|
+
p.frame = {pixels,width,height,opaque};
|
|
4035
4293
|
p.serial = serial;
|
|
4036
4294
|
}
|
|
4037
4295
|
|
|
4038
|
-
function
|
|
4296
|
+
function blackVideoPlan(region) {
|
|
4297
|
+
if (!onVideo || embedded) return null;
|
|
4298
|
+
const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
|
|
4299
|
+
if (items.length !== 1) return null;
|
|
4300
|
+
const item = items[0], resource = page.displayList.images[item.image];
|
|
4301
|
+
const state = [...page.media.values()].find(state => state.resource === resource);
|
|
4302
|
+
const frame = state?.video?.presentation?.frame;
|
|
4303
|
+
if (!frame?.opaque || !state.playing || state.video.done || state.buffering || state.presentationFailed
|
|
4304
|
+
|| state.document !== page.document || !state.element.isConnected || item.controls || item.fixed) return null;
|
|
4305
|
+
if (!videoLayerGeometry(item,region,page.deviceScaleFactor).replayable) return null;
|
|
4306
|
+
return {resource,frame};
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
function makeVideoLayer(canvas, surface, region, scale, width, height, pixels, blackResource = null) {
|
|
4039
4310
|
if (!onVideo) return null;
|
|
4040
4311
|
const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
|
|
4041
4312
|
const item = items.length === 1 ? items[0] : null;
|
|
@@ -4050,37 +4321,31 @@ function buildPageRenderer({
|
|
|
4050
4321
|
if (!video.presentation) {
|
|
4051
4322
|
const id = nextVideoPresentation++;
|
|
4052
4323
|
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
4053
|
-
video.presentation = { id, shared, serial: 0 };
|
|
4324
|
+
video.presentation = { id, shared, serial: 0, revision: 0 };
|
|
4054
4325
|
onVideo({ type: "video-open", id, shared, bytes: video.bytes,
|
|
4326
|
+
snapshotId: video.snapshotId, generation: video.openedGeneration,
|
|
4055
4327
|
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
4056
4328
|
startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
|
|
4057
4329
|
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
4058
4330
|
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
4059
4331
|
}
|
|
4060
|
-
const rect =
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
};
|
|
4078
|
-
const base = raster(false), mask = raster(true);
|
|
4079
|
-
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
4080
|
-
return { id: video.presentation.id, base, mask, rect: {
|
|
4081
|
-
x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
|
|
4082
|
-
width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
|
|
4083
|
-
} };
|
|
4332
|
+
const {rect} = videoLayerGeometry(item,region,scale);
|
|
4333
|
+
// Reuse the ordinary raster outside the video. Only this region can
|
|
4334
|
+
// differ between the black/white passes, including subtitles and overlays.
|
|
4335
|
+
const bounds = videoMaskBounds(item, region, scale, width, height);
|
|
4336
|
+
let cullPadding = 2;
|
|
4337
|
+
for (const entry of page.displayList.items) if (entry.transform) {
|
|
4338
|
+
// Display-list bounds are in CSS space; transformed device rounding may
|
|
4339
|
+
// reach beyond them. Unknown transforms keep conservative full culling.
|
|
4340
|
+
const {a, d} = entry.transform;
|
|
4341
|
+
cullPadding = Math.max(cullPadding, Math.abs(a) + 1, Math.abs(d) + 1);
|
|
4342
|
+
}
|
|
4343
|
+
const {base, mask} = rasterVideoMask({kit:CanvasKit, canvas, surface, width, height, pixels,
|
|
4344
|
+
bounds, region, scale, cullPadding, blackAlreadyRasterized:blackResource === resource, background:canvasColor(CanvasKit, page.backgroundColor),
|
|
4345
|
+
paint:(white, dirtyView) => paintDisplayList(canvas, page.displayList, region, scale, {resource, white}, dirtyView),
|
|
4346
|
+
hints:() => drawHints(canvas, region),
|
|
4347
|
+
});
|
|
4348
|
+
return { id: video.presentation.id, base, mask, rect };
|
|
4084
4349
|
}
|
|
4085
4350
|
|
|
4086
4351
|
function isPipe(value) {
|
|
@@ -4103,10 +4368,12 @@ function buildPageRenderer({
|
|
|
4103
4368
|
state.loading ??= (async () => {
|
|
4104
4369
|
if (state.mediaSource) {
|
|
4105
4370
|
await state.mediaSource.waitForPipes();
|
|
4371
|
+
if (!currentMediaState(state)) return;
|
|
4106
4372
|
bindSourcePipes(state);
|
|
4107
4373
|
}
|
|
4108
4374
|
if (!state.pipe && !state.bytes && !state.mediaSource) {
|
|
4109
4375
|
const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
|
|
4376
|
+
if (!currentMediaState(state)) return;
|
|
4110
4377
|
if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
|
|
4111
4378
|
state.contentType = response.contentType ?? "";
|
|
4112
4379
|
if (isPipe(response.body)) {
|
|
@@ -4118,6 +4385,7 @@ function buildPageRenderer({
|
|
|
4118
4385
|
}
|
|
4119
4386
|
}
|
|
4120
4387
|
await ensureProbed(state);
|
|
4388
|
+
if (!currentMediaState(state)) return;
|
|
4121
4389
|
if (!state.mediaSource) watchProgressiveMetadata(state);
|
|
4122
4390
|
if (state.hasVideo && !state.video) await openVideoDecoder(state);
|
|
4123
4391
|
// A separate audio buffer often arrives as an init segment, or empty.
|
|
@@ -4155,27 +4423,85 @@ function buildPageRenderer({
|
|
|
4155
4423
|
return state.loading;
|
|
4156
4424
|
}
|
|
4157
4425
|
|
|
4426
|
+
// A probe belongs to one immutable snapshot, not merely one media element.
|
|
4427
|
+
// Read MSE's current pipes directly: removeSourceBuffer/changeType can happen
|
|
4428
|
+
// while the demuxer is awaiting its result, before another append notifies us.
|
|
4429
|
+
function mediaProbeInput(state, time = 0) {
|
|
4430
|
+
const pipes = state.mediaSource?.streamPipes?.();
|
|
4431
|
+
const pipe = pipes ? pipes.video : state.videoPipe ?? state.pipe;
|
|
4432
|
+
const selected = pipe?.snapshotAt?.(time);
|
|
4433
|
+
return { pipe, generation: pipe?.generation, id: selected?.id,
|
|
4434
|
+
start: selected?.startTime, end: selected?.endTime,
|
|
4435
|
+
mime: pipes?.videoMime ?? state.videoMime ?? state.contentType,
|
|
4436
|
+
bytes: selected?.bytes ?? (pipe ? pipe.bytes() : state.bytes) };
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
function sameMediaProbeInput(a, b) {
|
|
4440
|
+
return a && a.pipe === b.pipe && a.generation === b.generation
|
|
4441
|
+
&& a.id === b.id && a.start === b.start && a.end === b.end && a.mime === b.mime
|
|
4442
|
+
// Versioned pipe snapshots are immutable. An incomplete MP4 header can
|
|
4443
|
+
// be reassembled into a fresh array on each read without new input.
|
|
4444
|
+
&& (a.bytes === b.bytes || (a.pipe && Number.isSafeInteger(a.generation)
|
|
4445
|
+
&& a.bytes?.byteLength === b.bytes?.byteLength));
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
async function probeMediaInput(state, input) {
|
|
4449
|
+
if (sameMediaProbeInput(state.lastProbe?.input, input)) return state.lastProbe.result;
|
|
4450
|
+
const result = await videoDecoder.probe(input.bytes);
|
|
4451
|
+
// A failed probe remains retryable, including retries of identical bytes.
|
|
4452
|
+
state.lastProbe = { input, result };
|
|
4453
|
+
return result;
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
async function waitForProbeBytes(state, input, size) {
|
|
4457
|
+
let waiting = state.probeByteWait;
|
|
4458
|
+
if (!waiting || waiting.pipe !== input.pipe || waiting.size !== size) {
|
|
4459
|
+
waiting = { pipe: input.pipe, size, promise: null };
|
|
4460
|
+
waiting.promise = input.pipe.waitFor(size).finally(() => {
|
|
4461
|
+
if (state.probeByteWait === waiting) state.probeByteWait = null;
|
|
4462
|
+
});
|
|
4463
|
+
state.probeByteWait = waiting;
|
|
4464
|
+
}
|
|
4465
|
+
let wake;
|
|
4466
|
+
const changed = new Promise(resolve => { wake = resolve; state.wakeProbe = resolve; });
|
|
4467
|
+
state.probeWaitInput = input;
|
|
4468
|
+
try { await Promise.race([waiting.promise, changed]); }
|
|
4469
|
+
finally {
|
|
4470
|
+
if (state.wakeProbe === wake) { state.wakeProbe = null; state.probeWaitInput = null; }
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4158
4474
|
/** Probe until the growing body exposes a picture or sample rate, or ends. */
|
|
4159
|
-
|
|
4475
|
+
function ensureProbed(state) {
|
|
4160
4476
|
if (state.probed) return;
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4477
|
+
// loadMedia and the first append share this transaction. A second waiter
|
|
4478
|
+
// must not start another probe or wait forever for an unnecessary byte.
|
|
4479
|
+
return state.initialProbe ??= probeInitialMedia(state).finally(() => { state.initialProbe = null; });
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
async function probeInitialMedia(state) {
|
|
4483
|
+
let rejected = null;
|
|
4484
|
+
while (currentMediaState(state) && !state.error) {
|
|
4485
|
+
const input = mediaProbeInput(state), {pipe, bytes} = input;
|
|
4166
4486
|
if (pipe?.error) throw pipe.error;
|
|
4167
|
-
|
|
4487
|
+
if (pipe && !pipe.done && sameMediaProbeInput(rejected, input)) {
|
|
4488
|
+
await waitForProbeBytes(state, input, pipe.size + 1);
|
|
4489
|
+
continue;
|
|
4490
|
+
}
|
|
4168
4491
|
if (!bytes?.byteLength) {
|
|
4169
4492
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
4493
|
+
await waitForProbeBytes(state, input, pipe.size + 1);
|
|
4170
4494
|
continue;
|
|
4171
4495
|
}
|
|
4172
4496
|
try {
|
|
4173
|
-
const probe = await
|
|
4497
|
+
const probe = await probeMediaInput(state, input);
|
|
4498
|
+
if (!currentMediaState(state)) return;
|
|
4499
|
+
if (!sameMediaProbeInput(input, mediaProbeInput(state))) continue;
|
|
4174
4500
|
const videoReady = probe.video?.width > 0;
|
|
4175
4501
|
const audioReady = probe.audio?.sampleRate > 0;
|
|
4176
4502
|
if (!videoReady && !audioReady) {
|
|
4177
4503
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
|
|
4178
|
-
|
|
4504
|
+
rejected = input;
|
|
4179
4505
|
continue;
|
|
4180
4506
|
}
|
|
4181
4507
|
if (!state.durationOverride) state.duration = probe.duration ?? NaN;
|
|
@@ -4189,40 +4515,60 @@ function buildPageRenderer({
|
|
|
4189
4515
|
mediaEvent(state, "loadedmetadata");
|
|
4190
4516
|
return;
|
|
4191
4517
|
} catch (error) {
|
|
4192
|
-
|
|
4193
|
-
if (!
|
|
4194
|
-
|
|
4518
|
+
if (!currentMediaState(state)) return;
|
|
4519
|
+
if (!sameMediaProbeInput(input, mediaProbeInput(state))) continue;
|
|
4520
|
+
if (!pipe || pipe.done) throw error;
|
|
4521
|
+
rejected = input;
|
|
4195
4522
|
}
|
|
4196
4523
|
}
|
|
4197
4524
|
}
|
|
4198
4525
|
|
|
4199
4526
|
/** A later MSE append can extend the duration and the buffered range. */
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
state.
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
4213
|
-
state.duration = probe.duration;
|
|
4214
|
-
state.resource.duration = probe.duration;
|
|
4215
|
-
mediaEvent(state, "durationchange");
|
|
4527
|
+
function refreshProbe(state) {
|
|
4528
|
+
// Audio-only appends do not wake a waiter on unchanged video bytes.
|
|
4529
|
+
if (state.wakeProbe && !sameMediaProbeInput(state.probeWaitInput, mediaProbeInput(state))) state.wakeProbe();
|
|
4530
|
+
state.probePending = true;
|
|
4531
|
+
if (state.probing) return state.probing;
|
|
4532
|
+
state.probing = (async () => {
|
|
4533
|
+
do {
|
|
4534
|
+
state.probePending = false;
|
|
4535
|
+
if (!currentMediaState(state)) return;
|
|
4536
|
+
if (!state.probed) {
|
|
4537
|
+
try { await ensureProbed(state); } catch {}
|
|
4538
|
+
continue;
|
|
4216
4539
|
}
|
|
4217
|
-
const
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4540
|
+
const time = mediaTime(state), input = mediaProbeInput(state, time);
|
|
4541
|
+
if (!input.pipe?.size) continue;
|
|
4542
|
+
try {
|
|
4543
|
+
const probe = await probeMediaInput(state, input);
|
|
4544
|
+
if (!currentMediaState(state)) return;
|
|
4545
|
+
// Compare the same selection point. Continuous WebM snapshots trim
|
|
4546
|
+
// their prefix as playback advances, even without any new input.
|
|
4547
|
+
// A seek into another initialization epoch must still reject the
|
|
4548
|
+
// old result; reading only its id leaves lazy snapshot bytes alone.
|
|
4549
|
+
if (!sameMediaProbeInput(input, mediaProbeInput(state, time))
|
|
4550
|
+
|| (input.id !== undefined && input.pipe.snapshotAt(mediaTime(state)).id !== input.id)) {
|
|
4551
|
+
state.probePending = true;
|
|
4552
|
+
continue;
|
|
4553
|
+
}
|
|
4554
|
+
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
4555
|
+
state.duration = probe.duration;
|
|
4556
|
+
state.resource.duration = probe.duration;
|
|
4557
|
+
mediaEvent(state, "durationchange");
|
|
4558
|
+
}
|
|
4559
|
+
const end = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : Math.max(state.bufferedEnd, 0.001);
|
|
4560
|
+
state.bufferedEnd = end;
|
|
4561
|
+
state.mediaSource?.noteBuffered(end);
|
|
4562
|
+
if (probe.video?.width > 0 && state.showsPicture) state.hasVideo = true;
|
|
4563
|
+
if (probe.audio?.sampleRate > 0) state.hasAudio = true;
|
|
4564
|
+
} catch {}
|
|
4565
|
+
} while (state.probePending);
|
|
4566
|
+
})().finally(() => {
|
|
4567
|
+
state.probing = null;
|
|
4568
|
+
// A notification queued as this promise settles still needs one pass.
|
|
4569
|
+
if (state.probePending && currentMediaState(state)) void refreshProbe(state);
|
|
4570
|
+
});
|
|
4571
|
+
return state.probing;
|
|
4226
4572
|
}
|
|
4227
4573
|
|
|
4228
4574
|
/** Refresh duration as a progressive response grows, especially MP3. */
|
|
@@ -4418,6 +4764,7 @@ function buildPageRenderer({
|
|
|
4418
4764
|
playbackEvent(state, "play", mediaEvent);
|
|
4419
4765
|
try {
|
|
4420
4766
|
await loadMedia(state);
|
|
4767
|
+
if (!currentMediaState(state)) return;
|
|
4421
4768
|
if (state.ended) {
|
|
4422
4769
|
state.ended = false;
|
|
4423
4770
|
await positionMedia(state, 0);
|
|
@@ -4428,7 +4775,7 @@ function buildPageRenderer({
|
|
|
4428
4775
|
}
|
|
4429
4776
|
state.needsPosition = false;
|
|
4430
4777
|
} catch (error) {
|
|
4431
|
-
if (state
|
|
4778
|
+
if (currentMediaState(state)) failMedia(state, error, state.pipe?.error ? 2 : 4);
|
|
4432
4779
|
return;
|
|
4433
4780
|
}
|
|
4434
4781
|
if (!state.playing || state.document !== page.document) return;
|
|
@@ -4464,7 +4811,7 @@ function buildPageRenderer({
|
|
|
4464
4811
|
if (state.refilling) await state.refilling.catch(() => {});
|
|
4465
4812
|
if (state.seekSerial !== serial) return;
|
|
4466
4813
|
await loadMedia(state);
|
|
4467
|
-
if (state.seekSerial !== serial) return;
|
|
4814
|
+
if (state.seekSerial !== serial || !currentMediaState(state)) return;
|
|
4468
4815
|
const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
|
|
4469
4816
|
state.ended = false;
|
|
4470
4817
|
await positionMedia(state, target);
|
|
@@ -4500,7 +4847,7 @@ function buildPageRenderer({
|
|
|
4500
4847
|
if (!state.video || state.video.done || seconds === 0 || attempt > 0
|
|
4501
4848
|
|| stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
|
|
4502
4849
|
try {
|
|
4503
|
-
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime);
|
|
4850
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, seconds, state.video.startTime, state.video.bytes);
|
|
4504
4851
|
state.video.hideUntil = seconds;
|
|
4505
4852
|
state.video.done = false;
|
|
4506
4853
|
return;
|
|
@@ -4644,7 +4991,7 @@ function buildPageRenderer({
|
|
|
4644
4991
|
const video = state.video;
|
|
4645
4992
|
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
4646
4993
|
try {
|
|
4647
|
-
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
|
|
4994
|
+
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime, video.bytes);
|
|
4648
4995
|
video.hideUntil = now;
|
|
4649
4996
|
video.needsLocalPosition = false;
|
|
4650
4997
|
} catch (error) { failMedia(state, error, 3); return; }
|
|
@@ -4756,7 +5103,7 @@ function buildPageRenderer({
|
|
|
4756
5103
|
function openAudioStream(state) {
|
|
4757
5104
|
const sound = state.sound;
|
|
4758
5105
|
sound.finishing = false;
|
|
4759
|
-
const name = new URL(state.url).pathname.split("/").pop() || "audio";
|
|
5106
|
+
const name = new URL(state.url ?? state.document.URL).pathname.split("/").pop() || "audio";
|
|
4760
5107
|
// Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
|
|
4761
5108
|
sound.streamStart = sound.position;
|
|
4762
5109
|
trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
|
|
@@ -4910,8 +5257,8 @@ function buildPageRenderer({
|
|
|
4910
5257
|
* binary search over seek targets finds where that keyframe starts (to a
|
|
4911
5258
|
* frame). The frames from there to the clock are then decoded unshown.
|
|
4912
5259
|
*/
|
|
4913
|
-
function seekToKeyframe(decoder, seconds, startTime = 0) {
|
|
4914
|
-
return seekVideoKeyframe(decoder, seconds, startTime);
|
|
5260
|
+
function seekToKeyframe(decoder, seconds, startTime = 0, bytes = null) {
|
|
5261
|
+
return seekVideoKeyframe(decoder, seconds, startTime, bytes);
|
|
4915
5262
|
}
|
|
4916
5263
|
|
|
4917
5264
|
/**
|
|
@@ -4932,6 +5279,7 @@ function buildPageRenderer({
|
|
|
4932
5279
|
return state.refilling;
|
|
4933
5280
|
}
|
|
4934
5281
|
const serial = state.seekSerial;
|
|
5282
|
+
const resumePresentation = Boolean(state.video?.presentation);
|
|
4935
5283
|
state.base = mediaTime(state);
|
|
4936
5284
|
// The audio output stays open through a stall: it plays the PCM it holds
|
|
4937
5285
|
// and then runs dry, and playing resumes right after that PCM. Closing
|
|
@@ -4958,8 +5306,17 @@ function buildPageRenderer({
|
|
|
4958
5306
|
// An append may have arrived after this decoder's snapshot was
|
|
4959
5307
|
// opened but before it reached EOF. Consume that generation now;
|
|
4960
5308
|
// waiting for one more append creates a segment-sized pause.
|
|
5309
|
+
const atEpochEnd = stalledTrack && Number.isFinite(stalledTrack.endTime)
|
|
5310
|
+
&& (stalledTrack.frameTime !== undefined
|
|
5311
|
+
? stalledTrack.frameTime + 1e-6 >= stalledTrack.endTime
|
|
5312
|
+
: stalledTrack.position >= Math.round(stalledTrack.endTime * stalledTrack.decoder.sampleRate));
|
|
5313
|
+
const nextEpoch = atEpochEnd ? pipe?.snapshotAt?.(stalledTrack.endTime) : null;
|
|
5314
|
+
// Multiple epochs may already be present in one generation. Only a
|
|
5315
|
+
// known different snapshot id proves there is another configuration;
|
|
5316
|
+
// reaching a timestamp alone must not cause an EOF reopen loop.
|
|
4961
5317
|
const hasNewSnapshot = Boolean(stalledTrack && pipe
|
|
4962
|
-
&& pipe.generation !== stalledTrack.openedGeneration
|
|
5318
|
+
&& (pipe.generation !== stalledTrack.openedGeneration
|
|
5319
|
+
|| (nextEpoch?.id != null && nextEpoch.id !== stalledTrack.snapshotId)));
|
|
4963
5320
|
// Some demuxers report EOF at an fMP4/WebM fragment boundary even
|
|
4964
5321
|
// though the same snapshot contains later fragments. Reopen once at
|
|
4965
5322
|
// each position/generation; if it makes no progress, wait for append.
|
|
@@ -4990,26 +5347,35 @@ function buildPageRenderer({
|
|
|
4990
5347
|
+ `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
|
|
4991
5348
|
if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
|
|
4992
5349
|
// A video decoder still decoding is left alone: reopened, it restarts
|
|
4993
|
-
// at a keyframe.
|
|
4994
|
-
//
|
|
5350
|
+
// at a keyframe. A newer shared/download snapshot does not invalidate
|
|
5351
|
+
// an audio decoder which can still produce its queued samples. Leave
|
|
5352
|
+
// it alone until its own EOF or epoch boundary requests a refill;
|
|
5353
|
+
// reopening it here needlessly decodes its entire played prefix.
|
|
4995
5354
|
const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
|
|
4996
|
-
const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
|
|
4997
|
-
|| (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
|
|
5355
|
+
const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound);
|
|
4998
5356
|
if (refillVideo) {
|
|
4999
5357
|
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
5000
5358
|
// The picture must not go back to the keyframe the decoder restarts at.
|
|
5001
5359
|
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
5002
5360
|
await openVideoDecoder(state, resumeAt);
|
|
5003
5361
|
state.video.hideUntil = hideUntil;
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5362
|
+
if (resumePresentation) {
|
|
5363
|
+
// The independent decoder will locate its own current keyframe.
|
|
5364
|
+
// Keep the page decoder lazy; if layout/controls now prevent
|
|
5365
|
+
// presentation, mediaTick positions it before local decoding.
|
|
5366
|
+
state.video.needsLocalPosition = true;
|
|
5367
|
+
} else {
|
|
5368
|
+
try {
|
|
5369
|
+
state.video.frameTime = seekToKeyframe(state.video.decoder, resumeAt, state.video.startTime, state.video.bytes);
|
|
5370
|
+
} catch {
|
|
5371
|
+
state.video.frameTime = state.video.startTime;
|
|
5372
|
+
}
|
|
5008
5373
|
}
|
|
5009
5374
|
state.video.done = false;
|
|
5010
5375
|
}
|
|
5011
5376
|
if (refillAudio) {
|
|
5012
5377
|
let opened = false;
|
|
5378
|
+
const replacingAudio = Boolean(state.sound);
|
|
5013
5379
|
try {
|
|
5014
5380
|
let audioTime = held ? writtenEnd : time;
|
|
5015
5381
|
if (state.sound && Number.isFinite(state.sound.endTime)
|
|
@@ -5018,7 +5384,10 @@ function buildPageRenderer({
|
|
|
5018
5384
|
}
|
|
5019
5385
|
opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
|
|
5020
5386
|
} catch (error) {
|
|
5021
|
-
|
|
5387
|
+
// A still-growing undecodable prefix returns false above. A
|
|
5388
|
+
// genuine failure replacing active audio must stop this media;
|
|
5389
|
+
// keeping its exhausted decoder would retry EOF in a busy loop.
|
|
5390
|
+
if (replacingAudio || !state.video) throw error;
|
|
5022
5391
|
reportError(error);
|
|
5023
5392
|
}
|
|
5024
5393
|
// A held stream continues right after the PCM it was given.
|
|
@@ -5028,9 +5397,24 @@ function buildPageRenderer({
|
|
|
5028
5397
|
// Drop this job before startMedia so a new stall can refill.
|
|
5029
5398
|
if (state.refilling === job) state.refilling = null;
|
|
5030
5399
|
state.buffering = false;
|
|
5031
|
-
if (state.playing && state.seekSerial === serial && state.document === page.document)
|
|
5400
|
+
if (state.playing && state.seekSerial === serial && state.document === page.document) {
|
|
5401
|
+
if (resumePresentation && refillVideo) {
|
|
5402
|
+
// Rebind a fresh layer before the first media tick so a resumed
|
|
5403
|
+
// presentation does not also decode its prefix on the page thread.
|
|
5404
|
+
rasterized.list = null;
|
|
5405
|
+
page.resourcePaintDirty = true;
|
|
5406
|
+
page.needsPaint = true;
|
|
5407
|
+
refresh();
|
|
5408
|
+
}
|
|
5409
|
+
startMedia(state);
|
|
5410
|
+
}
|
|
5032
5411
|
} catch (error) {
|
|
5033
|
-
|
|
5412
|
+
// A seek or pause can cancel this asynchronous refill while its
|
|
5413
|
+
// decoder is still opening. Its later failure belongs to that old
|
|
5414
|
+
// operation, not to the playback which replaced it.
|
|
5415
|
+
if (state.document === page.document && state.seekSerial === serial && state.playing) {
|
|
5416
|
+
failMedia(state, error, 3);
|
|
5417
|
+
}
|
|
5034
5418
|
} finally {
|
|
5035
5419
|
if (state.refilling === job) {
|
|
5036
5420
|
state.refilling = null;
|
|
@@ -5069,6 +5453,8 @@ function buildPageRenderer({
|
|
|
5069
5453
|
}
|
|
5070
5454
|
|
|
5071
5455
|
function closeMedia(state) {
|
|
5456
|
+
state.wakeProbe?.();
|
|
5457
|
+
state.lastProbe = null;
|
|
5072
5458
|
state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
|
|
5073
5459
|
state.audioPositioning = null;
|
|
5074
5460
|
stopClock(state);
|
|
@@ -5083,7 +5469,11 @@ function buildPageRenderer({
|
|
|
5083
5469
|
}
|
|
5084
5470
|
|
|
5085
5471
|
function disposeVideos() {
|
|
5086
|
-
for (const state of page.media.values())
|
|
5472
|
+
for (const state of page.media.values()) {
|
|
5473
|
+
closeMedia(state);
|
|
5474
|
+
linkMediaSource(state.element, null);
|
|
5475
|
+
elementSourceObjects.delete(state.element);
|
|
5476
|
+
}
|
|
5087
5477
|
page.media.clear();
|
|
5088
5478
|
}
|
|
5089
5479
|
|
|
@@ -5168,7 +5558,7 @@ function buildPageRenderer({
|
|
|
5168
5558
|
page.imageElements.delete(element);
|
|
5169
5559
|
imageRequests.delete(element);
|
|
5170
5560
|
}
|
|
5171
|
-
for (const element of
|
|
5561
|
+
for (const element of resourceElements.get("images")) {
|
|
5172
5562
|
const src = element.getAttribute("src");
|
|
5173
5563
|
const credentials = imageCrossOrigin(element);
|
|
5174
5564
|
const key = imageRequestKey(src, credentials);
|
|
@@ -5246,26 +5636,6 @@ function canvasColor(CanvasKit, value) {
|
|
|
5246
5636
|
: CanvasKit.BLACK;
|
|
5247
5637
|
}
|
|
5248
5638
|
|
|
5249
|
-
/**
|
|
5250
|
-
* Applies a document.cookie assignment to the local copy until the
|
|
5251
|
-
* controller's jar (the authority) reports the next value.
|
|
5252
|
-
*/
|
|
5253
|
-
function mergeDocumentCookie(current, assignment) {
|
|
5254
|
-
const [pair, ...attributes] = assignment.split(";");
|
|
5255
|
-
const equals = pair.indexOf("=");
|
|
5256
|
-
if (equals < 1) return current;
|
|
5257
|
-
const name = pair.slice(0, equals).trim();
|
|
5258
|
-
const value = pair.slice(equals + 1).trim();
|
|
5259
|
-
const expired = attributes.some((attribute) => {
|
|
5260
|
-
const [key, argument = ""] = attribute.split("=").map((part) => part.trim().toLowerCase());
|
|
5261
|
-
return (key === "max-age" && Number(argument) <= 0)
|
|
5262
|
-
|| (key === "expires" && Date.parse(argument) <= Date.now());
|
|
5263
|
-
});
|
|
5264
|
-
const entries = current ? current.split("; ").filter((entry) => !entry.startsWith(`${name}=`)) : [];
|
|
5265
|
-
if (!expired) entries.push(`${name}=${value}`);
|
|
5266
|
-
return entries.join("; ");
|
|
5267
|
-
}
|
|
5268
|
-
|
|
5269
5639
|
/** Page values leave the renderer only as primitives or JSON data. */
|
|
5270
5640
|
function cloneableResult(value) {
|
|
5271
5641
|
if (value === null || ["string", "number", "boolean", "undefined"].includes(typeof value)) return value;
|