buninu-browser 0.5.0 → 0.6.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 +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +6 -1
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/layout/text-layout.js +4 -2
- 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 +107 -3
- 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 +577 -201
- 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/compare-node-iterator.js +42 -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/fractional-text-scale.js +38 -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/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -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/fractional-text-scale.test.js +6 -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/inert-link.test.js +37 -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/node-iterator.test.js +81 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +151 -1
- 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/text-layout.test.js +15 -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
- package/test/window-resize.test.js +29 -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;
|
|
@@ -438,6 +462,8 @@ function buildPageRenderer({
|
|
|
438
462
|
[...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
|
|
439
463
|
}
|
|
440
464
|
|
|
465
|
+
// Keep fractional CSS sizes through measurement and painting. Rounding each
|
|
466
|
+
// scale independently can widen painted glyphs beyond the measured line.
|
|
441
467
|
function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
|
|
442
468
|
const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
|
|
443
469
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
@@ -460,7 +486,7 @@ function buildPageRenderer({
|
|
|
460
486
|
textStyle: {
|
|
461
487
|
color,
|
|
462
488
|
fontFamilies: families,
|
|
463
|
-
fontSize: Math.max(1,
|
|
489
|
+
fontSize: Math.max(1, fontSize) * scale,
|
|
464
490
|
fontStyle: { weight, slant, width },
|
|
465
491
|
// Roboto is a variable font; static fonts ignore the axis and match by weight.
|
|
466
492
|
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
|
|
@@ -475,7 +501,7 @@ function buildPageRenderer({
|
|
|
475
501
|
const pushTextStyle = foregroundPaint
|
|
476
502
|
? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
|
|
477
503
|
const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
|
|
478
|
-
fontSize: Math.max(1,
|
|
504
|
+
fontSize: Math.max(1, fontSize) * scale, fontStyle: { weight, slant, width },
|
|
479
505
|
fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
|
|
480
506
|
function append(text, fonts) {
|
|
481
507
|
let emojiStyle;
|
|
@@ -518,7 +544,7 @@ function buildPageRenderer({
|
|
|
518
544
|
return paragraph;
|
|
519
545
|
}
|
|
520
546
|
|
|
521
|
-
// Text widths depend only on the font (families,
|
|
547
|
+
// Text widths depend only on the font (families, fractional size, weight),
|
|
522
548
|
// not on the element, so they are cached per font across elements,
|
|
523
549
|
// relayouts and documents. The cache is dropped when it grows too large.
|
|
524
550
|
const MAX_MEASURED_TEXTS = 200_000;
|
|
@@ -557,7 +583,7 @@ function buildPageRenderer({
|
|
|
557
583
|
const weight = computedStyle?.fontWeight ?? 400;
|
|
558
584
|
const fontStyle = computedStyle?.fontStyle ?? "normal";
|
|
559
585
|
const stretch = computedStyle?.fontStretch ?? 100;
|
|
560
|
-
const key = `${Math.max(1,
|
|
586
|
+
const key = `${Math.max(1, computedStyle?.fontSize ?? 16)}/${weight}/${fontStyle}/${stretch}/${
|
|
561
587
|
resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
|
|
562
588
|
if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
|
|
563
589
|
return key;
|
|
@@ -759,6 +785,10 @@ function buildPageRenderer({
|
|
|
759
785
|
const scale = page.deviceScaleFactor;
|
|
760
786
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
761
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);
|
|
762
792
|
const damage = canvasDamage(region);
|
|
763
793
|
if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
|
|
764
794
|
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
@@ -779,7 +809,7 @@ function buildPageRenderer({
|
|
|
779
809
|
// Clear and replay all layers inside the damage, including backgrounds
|
|
780
810
|
// and foreground occluders. Transparent canvas pixels must not leave trails.
|
|
781
811
|
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
782
|
-
paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
|
|
812
|
+
paintDisplayList(canvas, page.displayList, region, scale, blackPlan ? {resource:blackPlan.resource,white:false} : null, damage ? {
|
|
783
813
|
x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
|
|
784
814
|
} : region);
|
|
785
815
|
drawHints(canvas, region);
|
|
@@ -789,7 +819,26 @@ function buildPageRenderer({
|
|
|
789
819
|
let pixels;
|
|
790
820
|
try { pixels = rasterReadback.read(image, pixelWidth, pixelHeight); }
|
|
791
821
|
finally { image.delete(); }
|
|
792
|
-
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
|
+
}
|
|
793
842
|
page.needsPaint = false;
|
|
794
843
|
rasterRegion = {...region};
|
|
795
844
|
rasterized = {
|
|
@@ -843,22 +892,31 @@ function buildPageRenderer({
|
|
|
843
892
|
}
|
|
844
893
|
|
|
845
894
|
/** After a paint or a compositor scroll, rasterize around the viewport once this thread is idle. */
|
|
846
|
-
function schedulePrepaint() {
|
|
895
|
+
function schedulePrepaint(delay = 30) {
|
|
847
896
|
if (disposedRenderer || !onFrame || embedded) return;
|
|
848
897
|
hostClearTimeout(prepaintTimer);
|
|
849
898
|
prepaintTimer = hostSetTimeout(() => {
|
|
850
899
|
prepaintTimer = null;
|
|
851
|
-
// A pending relayout does not stop this: the current layout, even if
|
|
852
|
-
// slightly stale, is better than background where the user scrolled.
|
|
853
900
|
const width = Math.max(1, Math.floor(page.width));
|
|
854
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
|
+
}
|
|
855
913
|
// Still covered with a quarter viewport to spare in both directions
|
|
856
914
|
// (as far as the page goes).
|
|
857
915
|
const top = Math.max(0, viewport.y - height / 4);
|
|
858
916
|
const bottom = Math.max(viewport.y + height, Math.min(viewport.contentHeight, viewport.y + height * 1.25));
|
|
859
917
|
if (tileCovers(lastTile, { x: viewport.x, y: top, width, height: bottom - top }) && sameRasterInput()) return;
|
|
860
918
|
publish(rasterTile(prepaintRegion()));
|
|
861
|
-
},
|
|
919
|
+
}, delay);
|
|
862
920
|
}
|
|
863
921
|
|
|
864
922
|
/** A rendering opportunity for DOM changes nobody has asked to see yet. */
|
|
@@ -917,7 +975,10 @@ function buildPageRenderer({
|
|
|
917
975
|
if (rounded) canvas.drawRRect(rounded, paint);
|
|
918
976
|
else canvas.drawRect(deviceRect(rect), paint);
|
|
919
977
|
};
|
|
920
|
-
|
|
978
|
+
const paintItems = videoOverride && dirtyView !== view
|
|
979
|
+
? videoMaskItems(displayList, view, dirtyView, viewport)
|
|
980
|
+
: visibleItems(displayList, dirtyView, viewport);
|
|
981
|
+
for (const item of paintItems) {
|
|
921
982
|
const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
|
|
922
983
|
try {
|
|
923
984
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
@@ -1474,6 +1535,14 @@ function buildPageRenderer({
|
|
|
1474
1535
|
const records = styleChanges.records;
|
|
1475
1536
|
if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
|
|
1476
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
|
+
|
|
1477
1546
|
// qml handle begin: QML visual attributes invalidate retained rendering
|
|
1478
1547
|
if (hasQmlDomImages(page.document) && qmlTextureVisibilityChanged(records)) {
|
|
1479
1548
|
page.resourcePaintDirty = true;
|
|
@@ -1647,6 +1716,7 @@ function buildPageRenderer({
|
|
|
1647
1716
|
|
|
1648
1717
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
1649
1718
|
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
1719
|
+
resourceElements?.dispose();
|
|
1650
1720
|
resetInputCaret();
|
|
1651
1721
|
rasterReadback.dispose();
|
|
1652
1722
|
clearPaintParagraphs();
|
|
@@ -1665,6 +1735,10 @@ function buildPageRenderer({
|
|
|
1665
1735
|
page.window = parsed.window;
|
|
1666
1736
|
pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
|
|
1667
1737
|
page.document = parsed.document;
|
|
1738
|
+
resourceElements = createResourceElements(parsed.document);
|
|
1739
|
+
computedSheets = null;
|
|
1740
|
+
computedEnvironment = null;
|
|
1741
|
+
styleLayoutPending = false;
|
|
1668
1742
|
observeDocumentCanvases(parsed.document, (node) => {
|
|
1669
1743
|
if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
|
|
1670
1744
|
page.resourcePaintDirty = true;
|
|
@@ -1695,7 +1769,7 @@ function buildPageRenderer({
|
|
|
1695
1769
|
}
|
|
1696
1770
|
|
|
1697
1771
|
/** Style, render tree, layout and display list of the current document. */
|
|
1698
|
-
function styleAndLayout() {
|
|
1772
|
+
function styleAndLayout(styleOnly = false) {
|
|
1699
1773
|
const started = hostPerformance.now();
|
|
1700
1774
|
const ms = (from, to) => Math.round(to - from);
|
|
1701
1775
|
const sheets = currentStyleSheets();
|
|
@@ -1711,30 +1785,47 @@ function buildPageRenderer({
|
|
|
1711
1785
|
if (key !== fontDefinitionKey) {
|
|
1712
1786
|
fontDefinitionKey = key;
|
|
1713
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;
|
|
1714
1792
|
page.resourceLayoutDirty = true;
|
|
1715
1793
|
}
|
|
1716
1794
|
}
|
|
1717
1795
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1718
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
|
+
}
|
|
1719
1803
|
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
1720
1804
|
// not actually change DOM, state, resources, or styles. Keep the retained
|
|
1721
1805
|
// layout/display list in that case instead of laying the whole page out.
|
|
1722
|
-
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1806
|
+
if (!styleOnly && !styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1723
1807
|
&& changes.roots.size === 0 && changes.self.size === 0) {
|
|
1724
1808
|
lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
|
|
1725
|
-
return { style: Math.round(taken - started), layout: 0, skipped: true };
|
|
1809
|
+
return { style: Math.round(taken - started), layout: 0, skipped: true, geometryUnchanged: true };
|
|
1726
1810
|
}
|
|
1727
1811
|
styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
|
|
1812
|
+
computedSheets = sheets;
|
|
1813
|
+
computedEnvironment = environmentKey;
|
|
1728
1814
|
const computed = hostPerformance.now();
|
|
1729
1815
|
if (!hasState || hasStateStale) {
|
|
1730
1816
|
recordHasState(selectorTraitsFor(sheets));
|
|
1731
1817
|
hasStateStale = false;
|
|
1732
1818
|
}
|
|
1733
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
|
+
}
|
|
1734
1825
|
// Attribute mutations used by application bookkeeping often match no
|
|
1735
1826
|
// different rule and do not alter render-tree content. Once style has
|
|
1736
1827
|
// proved that no element changed, retain layout and paint unchanged.
|
|
1737
|
-
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1828
|
+
if (!styleLayoutPending && page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1738
1829
|
&& !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
|
|
1739
1830
|
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
|
|
1740
1831
|
return { style: Math.round(recorded - started), layout: 0, skipped: true };
|
|
@@ -1753,12 +1844,27 @@ function buildPageRenderer({
|
|
|
1753
1844
|
ignoreResources: true,
|
|
1754
1845
|
// A zero-element incremental pass retained every computed style.
|
|
1755
1846
|
// Control placeholders may still wrap that style in a fresh object.
|
|
1756
|
-
ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
|
|
1847
|
+
ignoreStyles: !styleLayoutPending && styleEngine.incremental && styleEngine.restyled === 0,
|
|
1757
1848
|
})) {
|
|
1849
|
+
styleLayoutPending = false;
|
|
1758
1850
|
lastStyleParts += ", unchanged layout input";
|
|
1759
1851
|
return { style: Math.round(styled - started), layout: 0, skipped: true };
|
|
1760
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;
|
|
1761
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
|
+
}
|
|
1762
1868
|
page.layout = layoutEngine.layout(page.renderTree, {
|
|
1763
1869
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1764
1870
|
});
|
|
@@ -1803,7 +1909,6 @@ function buildPageRenderer({
|
|
|
1803
1909
|
function rebuildRenderTree(reason = "update") {
|
|
1804
1910
|
const started = hostPerformance.now();
|
|
1805
1911
|
page.dirty = false;
|
|
1806
|
-
page.layoutVersion++;
|
|
1807
1912
|
page.title = page.document?.title ?? page.title;
|
|
1808
1913
|
let staleImages = [];
|
|
1809
1914
|
if (page.document) {
|
|
@@ -1811,6 +1916,11 @@ function buildPageRenderer({
|
|
|
1811
1916
|
staleImages = queueImageResources();
|
|
1812
1917
|
}
|
|
1813
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++;
|
|
1814
1924
|
if (staleImages.length) {
|
|
1815
1925
|
const stale = new Set(staleImages);
|
|
1816
1926
|
page.decodedImages = page.decodedImages.filter(image => !stale.has(image));
|
|
@@ -1857,7 +1967,7 @@ function buildPageRenderer({
|
|
|
1857
1967
|
}
|
|
1858
1968
|
|
|
1859
1969
|
async function runLoad({
|
|
1860
|
-
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 = "",
|
|
1861
1971
|
}, generation, commit) {
|
|
1862
1972
|
const current = () => generation === loadGeneration;
|
|
1863
1973
|
const finish = () => {
|
|
@@ -1876,7 +1986,7 @@ function buildPageRenderer({
|
|
|
1876
1986
|
// qml handle end
|
|
1877
1987
|
|
|
1878
1988
|
page.url = url;
|
|
1879
|
-
|
|
1989
|
+
if (!documentCookies.accept(cookieState)) documentCookies.seed(cookieUrl(), cookie, !embedded);
|
|
1880
1990
|
if (error) {
|
|
1881
1991
|
replaceDocument("", url);
|
|
1882
1992
|
page.title = "Network error";
|
|
@@ -2404,10 +2514,12 @@ function buildPageRenderer({
|
|
|
2404
2514
|
return { ...resource, originClean };
|
|
2405
2515
|
},
|
|
2406
2516
|
canvasFont(element, value) {
|
|
2517
|
+
if (page.dirty) styleAndLayout(true);
|
|
2407
2518
|
return parseCanvasFont(value, styleEngine.get(element)?.fontSize ?? 16,
|
|
2408
2519
|
styleEngine.get(page.document.documentElement)?.fontSize ?? 16);
|
|
2409
2520
|
},
|
|
2410
2521
|
canvasText(element, request) {
|
|
2522
|
+
if (request.settings.direction === "inherit" && page.dirty) styleAndLayout(true);
|
|
2411
2523
|
const style = { ...request.settings.parsedFont,
|
|
2412
2524
|
direction: request.settings.direction === "inherit" ? styleEngine.get(element)?.direction ?? "ltr" : request.settings.direction };
|
|
2413
2525
|
documentFonts.update(fontDefinitions, [{ text: String(request.args[0]), style }]);
|
|
@@ -2418,6 +2530,7 @@ function buildPageRenderer({
|
|
|
2418
2530
|
playMedia,
|
|
2419
2531
|
pauseMedia,
|
|
2420
2532
|
seekMedia,
|
|
2533
|
+
reselectMedia,
|
|
2421
2534
|
attachMediaSrc,
|
|
2422
2535
|
mediaReadyState,
|
|
2423
2536
|
mediaNetworkState,
|
|
@@ -2474,11 +2587,14 @@ function buildPageRenderer({
|
|
|
2474
2587
|
if (page.dirty) rebuildRenderTree("script scrolled");
|
|
2475
2588
|
revealElement(element, options);
|
|
2476
2589
|
},
|
|
2477
|
-
computedStyle(element) {
|
|
2590
|
+
computedStyle(element, needsLayout = true) {
|
|
2478
2591
|
if (!element) return null;
|
|
2479
|
-
if (page.dirty)
|
|
2592
|
+
if (page.dirty) {
|
|
2593
|
+
if (needsLayout) rebuildRenderTree("getComputedStyle");
|
|
2594
|
+
else styleAndLayout(true);
|
|
2595
|
+
}
|
|
2480
2596
|
const style = styleEngine.get(element);
|
|
2481
|
-
if (style?.padding.some(value => typeof value === "object")) {
|
|
2597
|
+
if (needsLayout && style?.padding.some(value => typeof value === "object")) {
|
|
2482
2598
|
const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
|
|
2483
2599
|
if (box) return { ...style, padding: box.style.padding };
|
|
2484
2600
|
}
|
|
@@ -2488,9 +2604,9 @@ function buildPageRenderer({
|
|
|
2488
2604
|
if (page.dirty) rebuildRenderTree("intersection observer");
|
|
2489
2605
|
return page.layoutVersion;
|
|
2490
2606
|
},
|
|
2491
|
-
customProperties(element) {
|
|
2607
|
+
customProperties(element, updateStyle = true) {
|
|
2492
2608
|
if (!element) return null;
|
|
2493
|
-
if (page.dirty)
|
|
2609
|
+
if (updateStyle && page.dirty) styleAndLayout(true);
|
|
2494
2610
|
return styleEngine.customPropertiesOf(element);
|
|
2495
2611
|
},
|
|
2496
2612
|
navigate: (url) => navigateTo(String(url)),
|
|
@@ -2525,18 +2641,18 @@ function buildPageRenderer({
|
|
|
2525
2641
|
userAgent: () => page.userAgent,
|
|
2526
2642
|
initScripts: () => page.initScripts ?? [],
|
|
2527
2643
|
rewriteScript,
|
|
2528
|
-
documentCookie: () =>
|
|
2644
|
+
documentCookie: () => documentCookies.get(cookieUrl()),
|
|
2529
2645
|
setDocumentCookie(value) {
|
|
2530
|
-
|
|
2531
|
-
setCookie(String(value));
|
|
2646
|
+
documentCookies.set(cookieUrl(), value);
|
|
2532
2647
|
},
|
|
2533
2648
|
clipboardRead,
|
|
2534
2649
|
clipboardWrite,
|
|
2535
2650
|
webSockets,
|
|
2536
2651
|
pageFetch(request) {
|
|
2652
|
+
const generation = loadGeneration;
|
|
2537
2653
|
const networkPending = pageFetch(request);
|
|
2538
2654
|
const result = networkPending.then((response) => {
|
|
2539
|
-
if (
|
|
2655
|
+
if (generation === loadGeneration) acceptCookies(response);
|
|
2540
2656
|
return response;
|
|
2541
2657
|
});
|
|
2542
2658
|
result.abort = (reason) => networkPending.abort?.(reason);
|
|
@@ -2596,6 +2712,9 @@ function buildPageRenderer({
|
|
|
2596
2712
|
|
|
2597
2713
|
/** The border box of an element's layout box in viewport coordinates. */
|
|
2598
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;
|
|
2599
2718
|
let fixed = false;
|
|
2600
2719
|
for (let ancestor = element; ancestor && ancestor.nodeType === 1; ancestor = ancestor.parentElement) {
|
|
2601
2720
|
if (styleEngine.get(ancestor)?.position === "fixed") { fixed = true; break; }
|
|
@@ -2604,12 +2723,8 @@ function buildPageRenderer({
|
|
|
2604
2723
|
const shift = scrollShift(element);
|
|
2605
2724
|
const scrollX = (fixed ? 0 : viewport.x) + shift.x;
|
|
2606
2725
|
const scrollY = (fixed ? 0 : viewport.y) + shift.y;
|
|
2607
|
-
const box = elementBox(page.renderTree, page.layout, element)?.box;
|
|
2608
2726
|
if (box) return { left: box.x - scrollX, top: box.y - scrollY, width: box.width, height: box.height };
|
|
2609
|
-
|
|
2610
|
-
return content
|
|
2611
|
-
? { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height }
|
|
2612
|
-
: null;
|
|
2727
|
+
return { left: content.left - scrollX, top: content.top - scrollY, width: content.width, height: content.height };
|
|
2613
2728
|
}
|
|
2614
2729
|
|
|
2615
2730
|
/**
|
|
@@ -2748,6 +2863,7 @@ function buildPageRenderer({
|
|
|
2748
2863
|
|
|
2749
2864
|
const api = {
|
|
2750
2865
|
loadDocument,
|
|
2866
|
+
updateCookies: state => documentCookies.accept(state),
|
|
2751
2867
|
/** Enables page scripts once the sandbox is ready: "ses", "host", or "off". */
|
|
2752
2868
|
setScripting(mode) {
|
|
2753
2869
|
page.scripting = ["ses", "host"].includes(mode) ? mode : "off";
|
|
@@ -2786,6 +2902,10 @@ function buildPageRenderer({
|
|
|
2786
2902
|
? deviceScaleFactor
|
|
2787
2903
|
: 1;
|
|
2788
2904
|
viewport.resize(width, height);
|
|
2905
|
+
// Viewport dimensions are layout input even when DOM/stylesheets did
|
|
2906
|
+
// not mutate (including CSS resizes that keep the same pixel size by
|
|
2907
|
+
// changing DPR). Bypass retained-layout shortcuts in that case.
|
|
2908
|
+
if (resized) page.resourceLayoutDirty = true;
|
|
2789
2909
|
rebuildRenderTree();
|
|
2790
2910
|
// Listeners must observe the new viewport and layout. A repeated metrics
|
|
2791
2911
|
// request (or DPR-only change) does not resize the CSS viewport.
|
|
@@ -2953,14 +3073,31 @@ function buildPageRenderer({
|
|
|
2953
3073
|
void placeVideo(state, mediaTime(state)).then(() => {
|
|
2954
3074
|
if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
|
|
2955
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);
|
|
2956
3094
|
} else if (Number.isFinite(report.end)) {
|
|
2957
|
-
state.video.
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
state.
|
|
2963
|
-
}
|
|
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);
|
|
2964
3101
|
} else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
|
|
2965
3102
|
},
|
|
2966
3103
|
/** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
|
|
@@ -3249,6 +3386,8 @@ function buildPageRenderer({
|
|
|
3249
3386
|
if (disposedRenderer) return;
|
|
3250
3387
|
resetInputCaret();
|
|
3251
3388
|
disposedRenderer = true;
|
|
3389
|
+
resourceElements?.dispose();
|
|
3390
|
+
resourceElements = null;
|
|
3252
3391
|
loadGeneration++;
|
|
3253
3392
|
if (page.document) loadImageResource.cancelDocument(page.document);
|
|
3254
3393
|
hostClearTimeout(prepaintTimer);
|
|
@@ -3293,7 +3432,9 @@ function buildPageRenderer({
|
|
|
3293
3432
|
*/
|
|
3294
3433
|
function collectStyleSheets(document, documentUrl, linkSheets, pending = null) {
|
|
3295
3434
|
const sheets = [];
|
|
3296
|
-
|
|
3435
|
+
const elements = document === page.document && resourceElements
|
|
3436
|
+
? resourceElements.get("sheets") : document.querySelectorAll("style, link[rel~='stylesheet']");
|
|
3437
|
+
for (const element of elements) {
|
|
3297
3438
|
if (!isCssStyleSheet(element)) continue;
|
|
3298
3439
|
const applies = styleSheetMatches(element);
|
|
3299
3440
|
if (element.tagName === "STYLE") {
|
|
@@ -3370,7 +3511,7 @@ function buildPageRenderer({
|
|
|
3370
3511
|
/** Brings the frames in line with the document's iframes: their documents and sizes. */
|
|
3371
3512
|
function syncFrames() {
|
|
3372
3513
|
const document = page.document;
|
|
3373
|
-
const elements = document ?
|
|
3514
|
+
const elements = document ? resourceElements.get("frames") : [];
|
|
3374
3515
|
if (!elements.length && !frames.size) return;
|
|
3375
3516
|
const boxes = frameBoxes();
|
|
3376
3517
|
for (const [element, frame] of frames) {
|
|
@@ -3427,6 +3568,8 @@ function buildPageRenderer({
|
|
|
3427
3568
|
clipboardWriteInput,
|
|
3428
3569
|
webSockets,
|
|
3429
3570
|
setCookie,
|
|
3571
|
+
documentCookies,
|
|
3572
|
+
inheritedCookieUrl: cookieUrl,
|
|
3430
3573
|
consoleMessage,
|
|
3431
3574
|
onFrame: (update) => showFrame(frame, update),
|
|
3432
3575
|
onLifecycle: (name, generation) => frameLifecycle(frame, name, generation),
|
|
@@ -3497,7 +3640,8 @@ function buildPageRenderer({
|
|
|
3497
3640
|
contentType: response.contentType || "text/html",
|
|
3498
3641
|
status: response.status,
|
|
3499
3642
|
statusText: response.statusText ?? "",
|
|
3500
|
-
cookie:
|
|
3643
|
+
cookie: documentCookies.get(target.href),
|
|
3644
|
+
cookieState: response.cookieState,
|
|
3501
3645
|
};
|
|
3502
3646
|
} catch (error) {
|
|
3503
3647
|
trace?.(`${page.url}: iframe ${target.href} not shown: ${error?.message ?? error}`);
|
|
@@ -3609,10 +3753,10 @@ function buildPageRenderer({
|
|
|
3609
3753
|
* Null for an element that is not one.
|
|
3610
3754
|
*/
|
|
3611
3755
|
function scrollerGeometry(element) {
|
|
3612
|
-
const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
|
|
3613
|
-
if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
|
|
3614
3756
|
const found = layoutBoxOf(element);
|
|
3615
3757
|
if (!found) return null;
|
|
3758
|
+
const style = element?.nodeType === 1 ? styleEngine.get(element) : null;
|
|
3759
|
+
if (!style?.overflowScrollX && !style?.overflowScrollY) return null;
|
|
3616
3760
|
const { node, box } = found;
|
|
3617
3761
|
const [borderTop, borderRight, borderBottom, borderLeft] = style.borderWidths ?? [0, 0, 0, 0];
|
|
3618
3762
|
const padding = style.padding ?? [0, 0, 0, 0];
|
|
@@ -3781,7 +3925,9 @@ function buildPageRenderer({
|
|
|
3781
3925
|
|
|
3782
3926
|
/** Every <video> and <audio> gets its (painted) resource: size, frame, playing, time. */
|
|
3783
3927
|
function initializeVideos(document, resources) {
|
|
3784
|
-
|
|
3928
|
+
const elements = document === page.document && resourceElements
|
|
3929
|
+
? resourceElements.get("media") : document.querySelectorAll("video, audio");
|
|
3930
|
+
for (const element of elements) {
|
|
3785
3931
|
if (resources.get(element)) continue;
|
|
3786
3932
|
const audioOnly = element.tagName === "AUDIO";
|
|
3787
3933
|
resources.set(element, { width: audioOnly ? 300 : Number(element.getAttribute("width")) || 300,
|
|
@@ -3847,6 +3993,45 @@ function buildPageRenderer({
|
|
|
3847
3993
|
return null;
|
|
3848
3994
|
}
|
|
3849
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
|
+
|
|
3850
4035
|
/** A resource record for a media element created after the document was parsed. */
|
|
3851
4036
|
function ensureMediaResource(element) {
|
|
3852
4037
|
if (!element || (element.tagName !== "VIDEO" && element.tagName !== "AUDIO")) return null;
|
|
@@ -3865,24 +4050,17 @@ function buildPageRenderer({
|
|
|
3865
4050
|
|
|
3866
4051
|
/** src or srcObject points at a MediaSource: attach it and queue sourceopen. */
|
|
3867
4052
|
function attachMediaSrc(element, value) {
|
|
3868
|
-
if (!element ||
|
|
3869
|
-
|
|
3870
|
-
if (
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
}
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
if (source.readyState === "closed") {
|
|
3877
|
-
try {
|
|
3878
|
-
source.attach(typeof value === "string" ? value : source._objectURL);
|
|
3879
|
-
} catch {
|
|
3880
|
-
return;
|
|
3881
|
-
}
|
|
3882
|
-
}
|
|
3883
|
-
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));
|
|
3884
4061
|
if (!state) return;
|
|
3885
|
-
state.mediaSource
|
|
4062
|
+
const source = state.mediaSource;
|
|
4063
|
+
if (!source) return;
|
|
3886
4064
|
if (Number.isFinite(source.duration)) {
|
|
3887
4065
|
state.durationOverride = true;
|
|
3888
4066
|
state.duration = source.duration;
|
|
@@ -3892,11 +4070,8 @@ function buildPageRenderer({
|
|
|
3892
4070
|
}
|
|
3893
4071
|
|
|
3894
4072
|
function mediaAppended(source) {
|
|
3895
|
-
const
|
|
3896
|
-
if (!element) return;
|
|
3897
|
-
const state = page.media.get(element) ?? mediaStateFor(element);
|
|
4073
|
+
const state = attachedMediaState(source);
|
|
3898
4074
|
if (!state) return;
|
|
3899
|
-
state.mediaSource = source;
|
|
3900
4075
|
bindSourcePipes(state);
|
|
3901
4076
|
// A later initialization segment can replace already-buffered video.
|
|
3902
4077
|
// Shorten the active presentation at its epoch boundary as soon as that
|
|
@@ -3920,8 +4095,7 @@ function buildPageRenderer({
|
|
|
3920
4095
|
}
|
|
3921
4096
|
|
|
3922
4097
|
function mediaDuration(source, seconds) {
|
|
3923
|
-
const
|
|
3924
|
-
const state = element && page.media.get(element);
|
|
4098
|
+
const state = attachedMediaState(source);
|
|
3925
4099
|
if (!state || !Number.isFinite(seconds)) return;
|
|
3926
4100
|
state.durationOverride = true;
|
|
3927
4101
|
state.duration = seconds;
|
|
@@ -3930,28 +4104,27 @@ function buildPageRenderer({
|
|
|
3930
4104
|
}
|
|
3931
4105
|
|
|
3932
4106
|
/** The element's playback state, created (not loaded) on first use. */
|
|
3933
|
-
function mediaStateFor(element) {
|
|
4107
|
+
function mediaStateFor(element, reselect = false) {
|
|
4108
|
+
if (!element || element.ownerDocument !== page.document) return null;
|
|
3934
4109
|
const resource = ensureMediaResource(element);
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
if (!source && attributeUrl) source = resolveMediaSource(attributeUrl);
|
|
3938
|
-
const url = attributeUrl ?? source?._objectURL ?? null;
|
|
3939
|
-
if (!videoDecoder || !resource || (!url && !source)) return null;
|
|
3940
|
-
if (source && source.readyState === "closed") {
|
|
3941
|
-
try {
|
|
3942
|
-
source.attach(url);
|
|
3943
|
-
elementSources.set(element, source);
|
|
3944
|
-
sourceElements.set(source, element);
|
|
3945
|
-
} catch {}
|
|
3946
|
-
}
|
|
4110
|
+
const { input, source, url } = selectedMediaSource(element, reselect);
|
|
4111
|
+
linkMediaSource(element, source);
|
|
3947
4112
|
let state = page.media.get(element);
|
|
3948
|
-
if (state && state.
|
|
4113
|
+
if (state && (state.mediaSource !== source || (!source && (state.input !== input || reselect)))) {
|
|
3949
4114
|
closeMedia(state);
|
|
3950
4115
|
page.media.delete(element);
|
|
3951
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 {}
|
|
3952
4124
|
}
|
|
4125
|
+
if (!videoDecoder || !resource || (!url && !source)) return null;
|
|
3953
4126
|
if (!state) {
|
|
3954
|
-
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,
|
|
3955
4128
|
loading: null, showsPicture: element.tagName === "VIDEO", mediaSource: source,
|
|
3956
4129
|
video: null, sound: null, playing: false, ended: false,
|
|
3957
4130
|
duration: Number.isFinite(source?.duration) ? source.duration : NaN, base: 0, clockStart: null,
|
|
@@ -3963,12 +4136,17 @@ function buildPageRenderer({
|
|
|
3963
4136
|
state.durationOverride = true;
|
|
3964
4137
|
resource.duration = source.duration;
|
|
3965
4138
|
}
|
|
3966
|
-
} else
|
|
3967
|
-
state.
|
|
4139
|
+
} else {
|
|
4140
|
+
state.input = input;
|
|
4141
|
+
state.url = url;
|
|
3968
4142
|
}
|
|
3969
4143
|
return state;
|
|
3970
4144
|
}
|
|
3971
4145
|
|
|
4146
|
+
function reselectMedia(element) {
|
|
4147
|
+
return mediaStateFor(element, true);
|
|
4148
|
+
}
|
|
4149
|
+
|
|
3972
4150
|
/** Current position in seconds. */
|
|
3973
4151
|
function mediaTime(state) {
|
|
3974
4152
|
const sound = state.sound;
|
|
@@ -3994,10 +4172,21 @@ function buildPageRenderer({
|
|
|
3994
4172
|
|
|
3995
4173
|
/** The control bar and frame redraw without a relayout. */
|
|
3996
4174
|
function repaintMedia() {
|
|
3997
|
-
//
|
|
3998
|
-
//
|
|
3999
|
-
|
|
4000
|
-
|
|
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
|
+
}
|
|
4001
4190
|
rasterized.list = null;
|
|
4002
4191
|
render();
|
|
4003
4192
|
}
|
|
@@ -4006,11 +4195,81 @@ function buildPageRenderer({
|
|
|
4006
4195
|
const presentation = state.video?.presentation;
|
|
4007
4196
|
if (!presentation) return;
|
|
4008
4197
|
readPresentedFrame(state);
|
|
4198
|
+
presentation.continuation?.decoder?.close();
|
|
4199
|
+
if (presentation.continuation) presentation.continuation.decoder = null;
|
|
4200
|
+
presentation.continuation = null;
|
|
4009
4201
|
onVideo?.({ type: "video-stop", id: presentation.id });
|
|
4010
4202
|
state.video.presentation = null;
|
|
4011
4203
|
state.video.needsLocalPosition = true;
|
|
4012
4204
|
}
|
|
4013
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
|
+
|
|
4014
4273
|
function readPresentedFrame(state) {
|
|
4015
4274
|
const p = state.video?.presentation;
|
|
4016
4275
|
if (!p) return;
|
|
@@ -4026,10 +4285,28 @@ function buildPageRenderer({
|
|
|
4026
4285
|
if (!image) return;
|
|
4027
4286
|
state.resource.image?.delete();
|
|
4028
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};
|
|
4029
4293
|
p.serial = serial;
|
|
4030
4294
|
}
|
|
4031
4295
|
|
|
4032
|
-
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) {
|
|
4033
4310
|
if (!onVideo) return null;
|
|
4034
4311
|
const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
|
|
4035
4312
|
const item = items.length === 1 ? items[0] : null;
|
|
@@ -4044,37 +4321,31 @@ function buildPageRenderer({
|
|
|
4044
4321
|
if (!video.presentation) {
|
|
4045
4322
|
const id = nextVideoPresentation++;
|
|
4046
4323
|
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
4047
|
-
video.presentation = { id, shared, serial: 0 };
|
|
4324
|
+
video.presentation = { id, shared, serial: 0, revision: 0 };
|
|
4048
4325
|
onVideo({ type: "video-open", id, shared, bytes: video.bytes,
|
|
4326
|
+
snapshotId: video.snapshotId, generation: video.openedGeneration,
|
|
4049
4327
|
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
4050
4328
|
startTime: video.startTime ?? 0, endTime: video.endTime ?? Infinity,
|
|
4051
4329
|
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
4052
4330
|
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
4053
4331
|
}
|
|
4054
|
-
const rect =
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
};
|
|
4072
|
-
const base = raster(false), mask = raster(true);
|
|
4073
|
-
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
4074
|
-
return { id: video.presentation.id, base, mask, rect: {
|
|
4075
|
-
x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
|
|
4076
|
-
width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
|
|
4077
|
-
} };
|
|
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 };
|
|
4078
4349
|
}
|
|
4079
4350
|
|
|
4080
4351
|
function isPipe(value) {
|
|
@@ -4097,10 +4368,12 @@ function buildPageRenderer({
|
|
|
4097
4368
|
state.loading ??= (async () => {
|
|
4098
4369
|
if (state.mediaSource) {
|
|
4099
4370
|
await state.mediaSource.waitForPipes();
|
|
4371
|
+
if (!currentMediaState(state)) return;
|
|
4100
4372
|
bindSourcePipes(state);
|
|
4101
4373
|
}
|
|
4102
4374
|
if (!state.pipe && !state.bytes && !state.mediaSource) {
|
|
4103
4375
|
const response = await fetchResource(state.url, state.showsPicture ? "video" : "audio");
|
|
4376
|
+
if (!currentMediaState(state)) return;
|
|
4104
4377
|
if (!response.ok) throw new Error(`media ${state.url} failed with HTTP ${response.status}`);
|
|
4105
4378
|
state.contentType = response.contentType ?? "";
|
|
4106
4379
|
if (isPipe(response.body)) {
|
|
@@ -4112,6 +4385,7 @@ function buildPageRenderer({
|
|
|
4112
4385
|
}
|
|
4113
4386
|
}
|
|
4114
4387
|
await ensureProbed(state);
|
|
4388
|
+
if (!currentMediaState(state)) return;
|
|
4115
4389
|
if (!state.mediaSource) watchProgressiveMetadata(state);
|
|
4116
4390
|
if (state.hasVideo && !state.video) await openVideoDecoder(state);
|
|
4117
4391
|
// A separate audio buffer often arrives as an init segment, or empty.
|
|
@@ -4149,27 +4423,85 @@ function buildPageRenderer({
|
|
|
4149
4423
|
return state.loading;
|
|
4150
4424
|
}
|
|
4151
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
|
+
|
|
4152
4474
|
/** Probe until the growing body exposes a picture or sample rate, or ends. */
|
|
4153
|
-
|
|
4475
|
+
function ensureProbed(state) {
|
|
4154
4476
|
if (state.probed) return;
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
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;
|
|
4160
4486
|
if (pipe?.error) throw pipe.error;
|
|
4161
|
-
|
|
4487
|
+
if (pipe && !pipe.done && sameMediaProbeInput(rejected, input)) {
|
|
4488
|
+
await waitForProbeBytes(state, input, pipe.size + 1);
|
|
4489
|
+
continue;
|
|
4490
|
+
}
|
|
4162
4491
|
if (!bytes?.byteLength) {
|
|
4163
4492
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: empty response`);
|
|
4493
|
+
await waitForProbeBytes(state, input, pipe.size + 1);
|
|
4164
4494
|
continue;
|
|
4165
4495
|
}
|
|
4166
4496
|
try {
|
|
4167
|
-
const probe = await
|
|
4497
|
+
const probe = await probeMediaInput(state, input);
|
|
4498
|
+
if (!currentMediaState(state)) return;
|
|
4499
|
+
if (!sameMediaProbeInput(input, mediaProbeInput(state))) continue;
|
|
4168
4500
|
const videoReady = probe.video?.width > 0;
|
|
4169
4501
|
const audioReady = probe.audio?.sampleRate > 0;
|
|
4170
4502
|
if (!videoReady && !audioReady) {
|
|
4171
4503
|
if (!pipe || pipe.done) throw new Error(`media ${state.url}: no supported tracks`);
|
|
4172
|
-
|
|
4504
|
+
rejected = input;
|
|
4173
4505
|
continue;
|
|
4174
4506
|
}
|
|
4175
4507
|
if (!state.durationOverride) state.duration = probe.duration ?? NaN;
|
|
@@ -4183,40 +4515,60 @@ function buildPageRenderer({
|
|
|
4183
4515
|
mediaEvent(state, "loadedmetadata");
|
|
4184
4516
|
return;
|
|
4185
4517
|
} catch (error) {
|
|
4186
|
-
|
|
4187
|
-
if (!
|
|
4188
|
-
|
|
4518
|
+
if (!currentMediaState(state)) return;
|
|
4519
|
+
if (!sameMediaProbeInput(input, mediaProbeInput(state))) continue;
|
|
4520
|
+
if (!pipe || pipe.done) throw error;
|
|
4521
|
+
rejected = input;
|
|
4189
4522
|
}
|
|
4190
4523
|
}
|
|
4191
4524
|
}
|
|
4192
4525
|
|
|
4193
4526
|
/** A later MSE append can extend the duration and the buffered range. */
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
state.
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
if (!state.durationOverride && probe.duration != null && probe.duration !== state.duration) {
|
|
4207
|
-
state.duration = probe.duration;
|
|
4208
|
-
state.resource.duration = probe.duration;
|
|
4209
|
-
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;
|
|
4210
4539
|
}
|
|
4211
|
-
const
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
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;
|
|
4220
4572
|
}
|
|
4221
4573
|
|
|
4222
4574
|
/** Refresh duration as a progressive response grows, especially MP3. */
|
|
@@ -4412,6 +4764,7 @@ function buildPageRenderer({
|
|
|
4412
4764
|
playbackEvent(state, "play", mediaEvent);
|
|
4413
4765
|
try {
|
|
4414
4766
|
await loadMedia(state);
|
|
4767
|
+
if (!currentMediaState(state)) return;
|
|
4415
4768
|
if (state.ended) {
|
|
4416
4769
|
state.ended = false;
|
|
4417
4770
|
await positionMedia(state, 0);
|
|
@@ -4422,7 +4775,7 @@ function buildPageRenderer({
|
|
|
4422
4775
|
}
|
|
4423
4776
|
state.needsPosition = false;
|
|
4424
4777
|
} catch (error) {
|
|
4425
|
-
if (state
|
|
4778
|
+
if (currentMediaState(state)) failMedia(state, error, state.pipe?.error ? 2 : 4);
|
|
4426
4779
|
return;
|
|
4427
4780
|
}
|
|
4428
4781
|
if (!state.playing || state.document !== page.document) return;
|
|
@@ -4458,7 +4811,7 @@ function buildPageRenderer({
|
|
|
4458
4811
|
if (state.refilling) await state.refilling.catch(() => {});
|
|
4459
4812
|
if (state.seekSerial !== serial) return;
|
|
4460
4813
|
await loadMedia(state);
|
|
4461
|
-
if (state.seekSerial !== serial) return;
|
|
4814
|
+
if (state.seekSerial !== serial || !currentMediaState(state)) return;
|
|
4462
4815
|
const target = Math.max(0, Math.min(Number.isFinite(state.duration) ? state.duration : Infinity, Number(seconds) || 0));
|
|
4463
4816
|
state.ended = false;
|
|
4464
4817
|
await positionMedia(state, target);
|
|
@@ -4494,7 +4847,7 @@ function buildPageRenderer({
|
|
|
4494
4847
|
if (!state.video || state.video.done || seconds === 0 || attempt > 0
|
|
4495
4848
|
|| stale || (selected && selected.id !== state.video.snapshotId)) await openVideoDecoder(state, seconds);
|
|
4496
4849
|
try {
|
|
4497
|
-
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);
|
|
4498
4851
|
state.video.hideUntil = seconds;
|
|
4499
4852
|
state.video.done = false;
|
|
4500
4853
|
return;
|
|
@@ -4638,7 +4991,7 @@ function buildPageRenderer({
|
|
|
4638
4991
|
const video = state.video;
|
|
4639
4992
|
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
4640
4993
|
try {
|
|
4641
|
-
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime);
|
|
4994
|
+
video.frameTime = seekToKeyframe(video.decoder, now, video.startTime, video.bytes);
|
|
4642
4995
|
video.hideUntil = now;
|
|
4643
4996
|
video.needsLocalPosition = false;
|
|
4644
4997
|
} catch (error) { failMedia(state, error, 3); return; }
|
|
@@ -4750,7 +5103,7 @@ function buildPageRenderer({
|
|
|
4750
5103
|
function openAudioStream(state) {
|
|
4751
5104
|
const sound = state.sound;
|
|
4752
5105
|
sound.finishing = false;
|
|
4753
|
-
const name = new URL(state.url).pathname.split("/").pop() || "audio";
|
|
5106
|
+
const name = new URL(state.url ?? state.document.URL).pathname.split("/").pop() || "audio";
|
|
4754
5107
|
// Where this stream's PCM starts, to tell where its output ends when it closes (see stopClock).
|
|
4755
5108
|
sound.streamStart = sound.position;
|
|
4756
5109
|
trace?.(`audio stream opened at ${(sound.position / sound.decoder.sampleRate).toFixed(3)}s`);
|
|
@@ -4904,8 +5257,8 @@ function buildPageRenderer({
|
|
|
4904
5257
|
* binary search over seek targets finds where that keyframe starts (to a
|
|
4905
5258
|
* frame). The frames from there to the clock are then decoded unshown.
|
|
4906
5259
|
*/
|
|
4907
|
-
function seekToKeyframe(decoder, seconds, startTime = 0) {
|
|
4908
|
-
return seekVideoKeyframe(decoder, seconds, startTime);
|
|
5260
|
+
function seekToKeyframe(decoder, seconds, startTime = 0, bytes = null) {
|
|
5261
|
+
return seekVideoKeyframe(decoder, seconds, startTime, bytes);
|
|
4909
5262
|
}
|
|
4910
5263
|
|
|
4911
5264
|
/**
|
|
@@ -4926,6 +5279,7 @@ function buildPageRenderer({
|
|
|
4926
5279
|
return state.refilling;
|
|
4927
5280
|
}
|
|
4928
5281
|
const serial = state.seekSerial;
|
|
5282
|
+
const resumePresentation = Boolean(state.video?.presentation);
|
|
4929
5283
|
state.base = mediaTime(state);
|
|
4930
5284
|
// The audio output stays open through a stall: it plays the PCM it holds
|
|
4931
5285
|
// and then runs dry, and playing resumes right after that PCM. Closing
|
|
@@ -4952,8 +5306,17 @@ function buildPageRenderer({
|
|
|
4952
5306
|
// An append may have arrived after this decoder's snapshot was
|
|
4953
5307
|
// opened but before it reached EOF. Consume that generation now;
|
|
4954
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.
|
|
4955
5317
|
const hasNewSnapshot = Boolean(stalledTrack && pipe
|
|
4956
|
-
&& pipe.generation !== stalledTrack.openedGeneration
|
|
5318
|
+
&& (pipe.generation !== stalledTrack.openedGeneration
|
|
5319
|
+
|| (nextEpoch?.id != null && nextEpoch.id !== stalledTrack.snapshotId)));
|
|
4957
5320
|
// Some demuxers report EOF at an fMP4/WebM fragment boundary even
|
|
4958
5321
|
// though the same snapshot contains later fragments. Reopen once at
|
|
4959
5322
|
// each position/generation; if it makes no progress, wait for append.
|
|
@@ -4984,26 +5347,35 @@ function buildPageRenderer({
|
|
|
4984
5347
|
+ `${held ? `, audio held to ${writtenEnd.toFixed(3)}s` : ""} after ${((hostPerformance.now() - stall.at) / 1000).toFixed(2)}s`);
|
|
4985
5348
|
if (stalledTrack && pipe) state.lastRefill = { pipe, generation: pipe.generation, time };
|
|
4986
5349
|
// A video decoder still decoding is left alone: reopened, it restarts
|
|
4987
|
-
// at a keyframe.
|
|
4988
|
-
//
|
|
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.
|
|
4989
5354
|
const refillVideo = state.hasVideo && (!stalledTrack || stalledTrack === state.video || !state.video);
|
|
4990
|
-
const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound
|
|
4991
|
-
|| (state.sound.pipe && state.sound.pipe.generation !== state.sound.openedGeneration));
|
|
5355
|
+
const refillAudio = state.hasAudio && audio && (!stalledTrack || stalledTrack === state.sound || !state.sound);
|
|
4992
5356
|
if (refillVideo) {
|
|
4993
5357
|
const resumeAt = stalledTrack === state.video && state.video ? Math.min(time, state.video.frameTime) : time;
|
|
4994
5358
|
// The picture must not go back to the keyframe the decoder restarts at.
|
|
4995
5359
|
const hideUntil = Math.max(state.video?.shownTime ?? 0, state.video?.hideUntil ?? 0);
|
|
4996
5360
|
await openVideoDecoder(state, resumeAt);
|
|
4997
5361
|
state.video.hideUntil = hideUntil;
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
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
|
+
}
|
|
5002
5373
|
}
|
|
5003
5374
|
state.video.done = false;
|
|
5004
5375
|
}
|
|
5005
5376
|
if (refillAudio) {
|
|
5006
5377
|
let opened = false;
|
|
5378
|
+
const replacingAudio = Boolean(state.sound);
|
|
5007
5379
|
try {
|
|
5008
5380
|
let audioTime = held ? writtenEnd : time;
|
|
5009
5381
|
if (state.sound && Number.isFinite(state.sound.endTime)
|
|
@@ -5012,7 +5384,10 @@ function buildPageRenderer({
|
|
|
5012
5384
|
}
|
|
5013
5385
|
opened = await openAudioDecoder(state, { keepStream: Boolean(held), time: audioTime });
|
|
5014
5386
|
} catch (error) {
|
|
5015
|
-
|
|
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;
|
|
5016
5391
|
reportError(error);
|
|
5017
5392
|
}
|
|
5018
5393
|
// A held stream continues right after the PCM it was given.
|
|
@@ -5022,9 +5397,24 @@ function buildPageRenderer({
|
|
|
5022
5397
|
// Drop this job before startMedia so a new stall can refill.
|
|
5023
5398
|
if (state.refilling === job) state.refilling = null;
|
|
5024
5399
|
state.buffering = false;
|
|
5025
|
-
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
|
+
}
|
|
5026
5411
|
} catch (error) {
|
|
5027
|
-
|
|
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
|
+
}
|
|
5028
5418
|
} finally {
|
|
5029
5419
|
if (state.refilling === job) {
|
|
5030
5420
|
state.refilling = null;
|
|
@@ -5063,6 +5453,8 @@ function buildPageRenderer({
|
|
|
5063
5453
|
}
|
|
5064
5454
|
|
|
5065
5455
|
function closeMedia(state) {
|
|
5456
|
+
state.wakeProbe?.();
|
|
5457
|
+
state.lastProbe = null;
|
|
5066
5458
|
state.audioOpenSerial = (state.audioOpenSerial ?? 0) + 1;
|
|
5067
5459
|
state.audioPositioning = null;
|
|
5068
5460
|
stopClock(state);
|
|
@@ -5077,7 +5469,11 @@ function buildPageRenderer({
|
|
|
5077
5469
|
}
|
|
5078
5470
|
|
|
5079
5471
|
function disposeVideos() {
|
|
5080
|
-
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
|
+
}
|
|
5081
5477
|
page.media.clear();
|
|
5082
5478
|
}
|
|
5083
5479
|
|
|
@@ -5162,7 +5558,7 @@ function buildPageRenderer({
|
|
|
5162
5558
|
page.imageElements.delete(element);
|
|
5163
5559
|
imageRequests.delete(element);
|
|
5164
5560
|
}
|
|
5165
|
-
for (const element of
|
|
5561
|
+
for (const element of resourceElements.get("images")) {
|
|
5166
5562
|
const src = element.getAttribute("src");
|
|
5167
5563
|
const credentials = imageCrossOrigin(element);
|
|
5168
5564
|
const key = imageRequestKey(src, credentials);
|
|
@@ -5240,26 +5636,6 @@ function canvasColor(CanvasKit, value) {
|
|
|
5240
5636
|
: CanvasKit.BLACK;
|
|
5241
5637
|
}
|
|
5242
5638
|
|
|
5243
|
-
/**
|
|
5244
|
-
* Applies a document.cookie assignment to the local copy until the
|
|
5245
|
-
* controller's jar (the authority) reports the next value.
|
|
5246
|
-
*/
|
|
5247
|
-
function mergeDocumentCookie(current, assignment) {
|
|
5248
|
-
const [pair, ...attributes] = assignment.split(";");
|
|
5249
|
-
const equals = pair.indexOf("=");
|
|
5250
|
-
if (equals < 1) return current;
|
|
5251
|
-
const name = pair.slice(0, equals).trim();
|
|
5252
|
-
const value = pair.slice(equals + 1).trim();
|
|
5253
|
-
const expired = attributes.some((attribute) => {
|
|
5254
|
-
const [key, argument = ""] = attribute.split("=").map((part) => part.trim().toLowerCase());
|
|
5255
|
-
return (key === "max-age" && Number(argument) <= 0)
|
|
5256
|
-
|| (key === "expires" && Date.parse(argument) <= Date.now());
|
|
5257
|
-
});
|
|
5258
|
-
const entries = current ? current.split("; ").filter((entry) => !entry.startsWith(`${name}=`)) : [];
|
|
5259
|
-
if (!expired) entries.push(`${name}=${value}`);
|
|
5260
|
-
return entries.join("; ");
|
|
5261
|
-
}
|
|
5262
|
-
|
|
5263
5639
|
/** Page values leave the renderer only as primitives or JSON data. */
|
|
5264
5640
|
function cloneableResult(value) {
|
|
5265
5641
|
if (value === null || ["string", "number", "boolean", "undefined"].includes(typeof value)) return value;
|