buninu-browser 0.6.0 → 0.9.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/AGENTS.md +5 -0
- package/README.md +94 -4
- package/buninu-browser.js +1 -1
- package/frontends/wayland/README.md +35 -2
- package/frontends/wayland/client.js +8 -6
- package/frontends/wayland/clipboard.js +1 -1
- package/frontends/wayland/index.js +1 -1
- package/frontends/wayland/input.js +1 -1
- package/frontends/wayland/resource-io.js +6 -0
- package/frontends/wayland/transport-posix.js +120 -0
- package/frontends/wayland/transport-win32.js +9 -0
- package/frontends/wayland/transport.js +3 -120
- package/frontends/wayland/win32-resources.js +80 -0
- package/frontends/wayland/win32-wire.js +62 -0
- package/index.js +10 -6
- package/lib/audio/biquad.js +25 -0
- package/lib/audio/piano.js +6 -3
- package/lib/audio/playback-buffer.js +5 -5
- package/lib/audio/pulse-client.js +24 -5
- package/lib/audio/web-audio.js +570 -0
- package/lib/browser-dialog.js +116 -0
- package/lib/cdp-server.js +128 -72
- package/lib/file-picker.js +48 -0
- package/lib/headless-shell.js +34 -12
- package/lib/layout/text-layout.js +112 -19
- package/lib/media/mp4-frame-times.js +93 -0
- package/lib/paint/direct-canvas.ts +39 -0
- package/lib/paint/display-list.js +37 -12
- package/lib/paint/raster-readback.js +16 -7
- package/lib/renderer/animation-frames.js +10 -5
- package/lib/renderer/bindings.js +69 -16
- package/lib/renderer/canvas-2d.js +265 -15
- package/lib/renderer/canvas-round-rect.js +36 -0
- package/lib/renderer/css-animations.js +173 -0
- package/lib/renderer/dialog-client.js +30 -0
- package/lib/renderer/dialog-worker.js +35 -0
- package/lib/renderer/eval-environment.js +28 -0
- package/lib/renderer/file-input.js +49 -0
- package/lib/renderer/host.js +33 -4
- package/lib/renderer/html-drag.js +77 -0
- package/lib/renderer/image-loading.js +45 -2
- package/lib/renderer/main.js +4 -0
- package/lib/renderer/module-transform.js +5 -3
- package/lib/renderer/page-builtins.js +59 -0
- package/lib/renderer/page-realm.js +177 -40
- package/lib/renderer/page-renderer.js +397 -49
- package/lib/renderer/piano-input.js +3 -1
- package/lib/renderer/raster-surface.js +18 -0
- package/lib/renderer/script-rewrite.js +165 -9
- package/lib/renderer/video-presentation.js +5 -4
- package/lib/renderer/webgl/clip.js +24 -0
- package/lib/renderer/webgl/context.js +158 -0
- package/lib/renderer/webgl/cpu-fragment.js +211 -0
- package/lib/renderer/webgl/draw-cache.js +22 -0
- package/lib/renderer/webgl/pipeline.js +70 -0
- package/lib/renderer/webgl/shaders.js +58 -0
- package/lib/renderer/webgl/textures.js +43 -0
- package/lib/renderer/window-named-properties.js +46 -0
- package/lib/screenshot-cli.js +4 -7
- package/lib/style/animation-values.js +29 -0
- package/lib/style/computed-style.js +109 -13
- package/lib/style/transform.js +44 -0
- package/lib/window-size.js +10 -0
- package/package.json +1 -1
- package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
- package/patches/happy-dom/README.md +16 -0
- package/test/animation-frames.test.js +15 -0
- package/test/canvas-2d.test.js +252 -4
- package/test/canvas-frame-pacing.test.js +7 -0
- package/test/cdp-viewers.test.js +79 -0
- package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
- package/test/classic-games-patches/README.md +156 -0
- package/test/classic-games-scenarios.js +133 -0
- package/test/clipboard.test.js +16 -0
- package/test/computed-style.test.js +18 -0
- package/test/css-animation-lifecycle.test.js +28 -0
- package/test/css-animations.test.js +83 -0
- package/test/direct-canvas.test.js +39 -0
- package/test/dynamic-inline-handlers.test.js +25 -0
- package/test/eval-declarations.test.js +22 -0
- package/test/file-picker.test.js +26 -0
- package/test/fixtures/canvas-damage.js +23 -0
- package/test/fixtures/canvas-frame-pacing.js +35 -0
- package/test/fixtures/direct-canvas.js +96 -0
- package/test/fixtures/javascript-dialogs.html +8 -0
- package/test/fixtures/media-audio-lifetime.js +65 -0
- package/test/fixtures/renderer-memory.js +7 -5
- package/test/fixtures/video-black-replay.js +8 -6
- package/test/fixtures/video-slow-end.js +39 -0
- package/test/fixtures/web-audio-renderer.js +45 -0
- package/test/flex.test.js +26 -0
- package/test/grid.test.js +13 -0
- package/test/headless-shell.test.js +9 -0
- package/test/headless-window-size.test.js +28 -0
- package/test/html-rcdata.test.js +35 -0
- package/test/image-decode.test.js +18 -0
- package/test/index.test.js +1 -0
- package/test/javascript-dialog.test.js +83 -0
- package/test/manual-breakout-paddle.js +42 -0
- package/test/manual-breakout-static.js +35 -0
- package/test/manual-classic-games.js +41 -0
- package/test/manual-clumsy-bird-three-pipes.js +81 -0
- package/test/manual-clumsy-bird.js +52 -0
- package/test/manual-hextris.js +30 -0
- package/test/manual-numbered-music.js +24 -0
- package/test/manual-t-rex-runner.js +49 -0
- package/test/manual-threejs-scene.js +64 -0
- package/test/manual-webgl-benchmark.js +27 -0
- package/test/manual-webgl-cpu.html +26 -0
- package/test/media-audio-lifetime.test.js +8 -0
- package/test/mp4-frame-times.test.js +33 -0
- package/test/page-scripts.test.js +96 -1
- package/test/piano-input.test.js +15 -2
- package/test/piano.test.js +11 -2
- package/test/playback-buffer.test.js +11 -0
- package/test/pointer-drag.test.js +33 -0
- package/test/positioning.test.js +82 -0
- package/test/pulse-client.test.js +51 -2
- package/test/raster-readback.test.js +14 -0
- package/test/script-rewrite.test.js +61 -3
- package/test/video-slow-end.test.js +6 -0
- package/test/viewport-keyboard-overflow.test.js +25 -0
- package/test/wayland-win32-resources.test.js +47 -0
- package/test/wayland-win32-wire.test.js +59 -0
- package/test/web-audio-renderer.test.js +8 -0
- package/test/web-audio.test.js +264 -0
- package/test/webgl-clip.test.js +9 -0
- package/test/webgl-cpu-fragment.test.js +43 -0
- package/test/window-named-properties.test.js +41 -0
- package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
- package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
- package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
- package/vendor/land-renderer/LICENSE +21 -0
- package/vendor/land-renderer/README.md +14 -0
- package/vendor/land-renderer/direct-compositor.ts +443 -0
- package/vendor/land-renderer/frame.js +88 -0
- package/vendor/land-renderer/png-fast.js +85 -0
- package/vendor/land-renderer/raw-frames.js +66 -0
- package/vendor/land-renderer/render-types.ts +7 -0
- package/vendor/land-renderer/renderer.ts +134 -0
- package/vendor/land-renderer/snapshot.js +37 -0
- package/vendor/land-renderer/viewport.js +18 -0
- package/vendor/ses/dist/ses.mjs +1 -1
package/lib/headless-shell.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {BrowserDialog} from "./browser-dialog.js";
|
|
1
2
|
// qml handle begin: select a dedicated local application renderer
|
|
2
3
|
import {isQmlDocument} from './qml/format.js';
|
|
3
4
|
// Local QML uses a dedicated host renderer.
|
|
@@ -15,6 +16,7 @@ import {isQmlDocument} from './qml/format.js';
|
|
|
15
16
|
* always passes it) but ignored. `--dangerously-allow-host-js` relaxes
|
|
16
17
|
* the renderer sandbox for every page.
|
|
17
18
|
*/
|
|
19
|
+
import { parseWindowSize } from "./window-size.js";
|
|
18
20
|
import { CdpServer } from "./cdp-server.js";
|
|
19
21
|
import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
|
|
20
22
|
import { audioDemoDocument, displayDocument } from "./document-types.js";
|
|
@@ -31,6 +33,7 @@ export const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
|
|
|
31
33
|
|
|
32
34
|
export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
33
35
|
installLog(logFileFromEnv());
|
|
36
|
+
const initialViewport = parseWindowSize(args);
|
|
34
37
|
const allowHostJs = args.includes(DANGEROUSLY_ALLOW_HOST_JS);
|
|
35
38
|
if (args.includes("--no-sandbox")) {
|
|
36
39
|
console.error(`buninu-browser: --no-sandbox is ignored; use ${DANGEROUSLY_ALLOW_HOST_JS} to disable the page sandbox`);
|
|
@@ -44,7 +47,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
44
47
|
|
|
45
48
|
// The endpoint opens while the first renderer starts: a client's first
|
|
46
49
|
// navigation fetches its document meanwhile.
|
|
47
|
-
const browser = await createBrowser({ allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
|
|
50
|
+
const browser = await createBrowser({ initialViewport, allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
|
|
48
51
|
const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
|
|
49
52
|
const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
|
|
50
53
|
const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
|
|
@@ -85,14 +88,14 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
85
88
|
* anything that needs a renderer waits for it.
|
|
86
89
|
*
|
|
87
90
|
* @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
|
|
88
|
-
* waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean,
|
|
91
|
+
* waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean, initialViewport?: {width: number, height: number},
|
|
89
92
|
* clipboard?: {read: Function, write: Function} }} options
|
|
90
93
|
* WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
|
|
91
94
|
*/
|
|
92
95
|
export async function createBrowser({
|
|
93
96
|
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 0, mobile = false,
|
|
94
97
|
waitForRenderer = true, webkit = true,
|
|
95
|
-
clipboard = null,
|
|
98
|
+
clipboard = null, initialViewport = { width: 800, height: 600 },
|
|
96
99
|
} = {}) {
|
|
97
100
|
// Explicit presentation profiles fix the identity for requests and scripts.
|
|
98
101
|
const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
|
|
@@ -111,12 +114,18 @@ export async function createBrowser({
|
|
|
111
114
|
let rendererReady = null;
|
|
112
115
|
const documentReady = new WeakMap();
|
|
113
116
|
// Renderer-independent state replayed into every new renderer.
|
|
114
|
-
const viewport = {
|
|
117
|
+
const viewport = { ...initialViewport, deviceScaleFactor: 1 };
|
|
115
118
|
let userAgent = webkit ? initialUserAgent : null;
|
|
116
119
|
// Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
|
|
117
120
|
const initScripts = new Map();
|
|
118
121
|
let nextInitScript = 1;
|
|
119
122
|
// Listeners for frame and lifecycle notifications (the CDP server).
|
|
123
|
+
let dialogHandler = null;
|
|
124
|
+
const dialogs = new BrowserDialog({viewport:()=>({...viewport}),
|
|
125
|
+
background:()=>current?.composite('screenshot',{format:'png'}),
|
|
126
|
+
changed:()=>notify(frameListeners),
|
|
127
|
+
opened:request=>dialogHandler?.(request),
|
|
128
|
+
closed:result=>dialogHandler?.(null,result),clipboard});
|
|
120
129
|
const frameListeners = new Set();
|
|
121
130
|
const lifecycleListeners = new Set();
|
|
122
131
|
const downloadListeners = new Set();
|
|
@@ -173,6 +182,7 @@ export async function createBrowser({
|
|
|
173
182
|
// qml handle end
|
|
174
183
|
|
|
175
184
|
const host = new RendererHost({
|
|
185
|
+
onDialog: (request,signal) => host===current ? dialogs.open({...request,url:host.documentUrl},signal) : {accept:false},
|
|
176
186
|
// Native frontends supply one clipboard for page APIs and input defaults.
|
|
177
187
|
...(clipboard?{clipboardRead:()=>clipboard.read(),clipboardWrite:text=>clipboard.write(text)}:{}),
|
|
178
188
|
network,
|
|
@@ -327,6 +337,7 @@ export async function createBrowser({
|
|
|
327
337
|
* restarted renderer).
|
|
328
338
|
*/
|
|
329
339
|
async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
|
|
340
|
+
dialogs.close();
|
|
330
341
|
const started = performance.now();
|
|
331
342
|
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
332
343
|
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
@@ -428,6 +439,7 @@ export async function createBrowser({
|
|
|
428
439
|
await call("runJavaScriptURL", resolved);
|
|
429
440
|
return;
|
|
430
441
|
}
|
|
442
|
+
dialogs.close();
|
|
431
443
|
await rememberScroll();
|
|
432
444
|
let shown = resolved;
|
|
433
445
|
if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
@@ -458,6 +470,7 @@ export async function createBrowser({
|
|
|
458
470
|
|
|
459
471
|
async function traverseHistory(index) {
|
|
460
472
|
if (index < 0 || index >= history.length || index === historyIndex) return;
|
|
473
|
+
dialogs.close();
|
|
461
474
|
await rememberScroll();
|
|
462
475
|
historyIndex = index;
|
|
463
476
|
const entry = history[index];
|
|
@@ -466,6 +479,10 @@ export async function createBrowser({
|
|
|
466
479
|
}
|
|
467
480
|
|
|
468
481
|
const context = {
|
|
482
|
+
onDialog(handler) { dialogHandler = handler; },
|
|
483
|
+
get dialogState() { return dialogs.active?.request ?? null; },
|
|
484
|
+
handleDialog(result) { dialogs.finish(result); },
|
|
485
|
+
get viewport() { return { ...viewport }; },
|
|
469
486
|
get url() { return currentUrl; },
|
|
470
487
|
navigate,
|
|
471
488
|
get userAgent() { return userAgent ?? initialUserAgent; },
|
|
@@ -482,6 +499,7 @@ export async function createBrowser({
|
|
|
482
499
|
goForward: () => traverseHistory(historyIndex + 1),
|
|
483
500
|
resize(width, height, deviceScaleFactor = 1) {
|
|
484
501
|
Object.assign(viewport, { width, height, deviceScaleFactor });
|
|
502
|
+
if(dialogs.active){Promise.resolve(applyState("resize",resizeArguments)).catch(()=>{});return dialogs.resize(width,height,deviceScaleFactor);}
|
|
485
503
|
return applyState("resize", resizeArguments);
|
|
486
504
|
},
|
|
487
505
|
setUserAgent(value) {
|
|
@@ -501,13 +519,13 @@ export async function createBrowser({
|
|
|
501
519
|
if (!initScripts.delete(String(identifier))) return;
|
|
502
520
|
applyState("setInitScripts", () => [[...initScripts.values()]]);
|
|
503
521
|
},
|
|
504
|
-
screenshot: (options = {}) => composite("screenshot", {
|
|
522
|
+
screenshot: (options = {}) => (dialogs.active ? dialogs.invoke.bind(dialogs) : composite)("screenshot", {
|
|
505
523
|
format: ["jpeg", "webp", "rgba", "bgra"].includes(options.format) ? options.format : "png",
|
|
506
524
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
507
525
|
optimizeForSpeed: options.optimizeForSpeed === true,
|
|
508
|
-
clip: options.clip ? {...options.clip,x:options.clip.x/pageScale(),y:options.clip.y/pageScale(),width:options.clip.width/pageScale(),height:options.clip.height/pageScale()} : undefined,
|
|
526
|
+
clip: options.clip ? {...options.clip,x:options.clip.x/(dialogs.active?1:pageScale()),y:options.clip.y/(dialogs.active?1:pageScale()),width:options.clip.width/(dialogs.active?1:pageScale()),height:options.clip.height/(dialogs.active?1:pageScale())} : undefined,
|
|
509
527
|
}),
|
|
510
|
-
title: () => call("title"),
|
|
528
|
+
title: () => dialogs.active ? currentUrl : call("title"),
|
|
511
529
|
/**
|
|
512
530
|
* The document's response body as it arrived (CDP Page.getResourceContent),
|
|
513
531
|
* for the current document's URL with or without its fragment; else null.
|
|
@@ -530,6 +548,7 @@ export async function createBrowser({
|
|
|
530
548
|
* first; the page scrolls when nothing there takes it.
|
|
531
549
|
*/
|
|
532
550
|
async scroll(deltaX, deltaY, at = null) {
|
|
551
|
+
if(dialogs.active)return at?dialogs.invoke("wheel",at.x,at.y,deltaX,deltaY):dialogs.invoke("scroll",deltaX,deltaY);
|
|
533
552
|
const scale = pageScale();
|
|
534
553
|
let left = { x: deltaX / scale, y: deltaY / scale };
|
|
535
554
|
if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
|
|
@@ -538,9 +557,9 @@ export async function createBrowser({
|
|
|
538
557
|
}
|
|
539
558
|
return composite("scroll", left.x, left.y);
|
|
540
559
|
},
|
|
541
|
-
press: (key, options) => call("press", key, options),
|
|
542
|
-
type: (text) => call("type", text),
|
|
543
|
-
scrollOffset: () => composite("scrollOffset"),
|
|
560
|
+
press: (key, options) => dialogs.active ? dialogs.invoke("press",key,options) : call("press", key, options),
|
|
561
|
+
type: (text) => dialogs.active ? dialogs.invoke("type",text) : call("type", text),
|
|
562
|
+
scrollOffset: () => dialogs.active ? Promise.resolve({x:0,y:0}) : composite("scrollOffset"),
|
|
544
563
|
/** Calls `listener(frame?)` when the viewport changes, with its measured scroll offset when available. */
|
|
545
564
|
onFrame(listener) {
|
|
546
565
|
frameListeners.add(listener);
|
|
@@ -556,12 +575,14 @@ export async function createBrowser({
|
|
|
556
575
|
return [...reachedLifecycle(committed.host, committed.generation)];
|
|
557
576
|
},
|
|
558
577
|
move(x, y, options = {}) {
|
|
578
|
+
if(dialogs.active)return dialogs.invoke("move",x,y,options);
|
|
559
579
|
return call("move", x / pageScale(), y / pageScale(), options);
|
|
560
580
|
},
|
|
561
581
|
pointerButton(x, y, button, state, options = {}) {
|
|
562
582
|
return this.click(x, y, {...options, button:button===273?'right':button===274?'middle':'left', phase:state?'down':'up'});
|
|
563
583
|
},
|
|
564
584
|
async click(x, y, options = {}) {
|
|
585
|
+
if(dialogs.active){await dialogs.invoke("click",x,y,options);return null;}
|
|
565
586
|
const clicked = await call("click", x / pageScale(), y / pageScale(), options);
|
|
566
587
|
const href = typeof clicked === "string" ? clicked : clicked?.url;
|
|
567
588
|
if (typeof href !== "string") return null;
|
|
@@ -573,10 +594,10 @@ export async function createBrowser({
|
|
|
573
594
|
referrer: current.documentUrl });
|
|
574
595
|
return result === null ? null : { url: href, title: await call("title") };
|
|
575
596
|
},
|
|
576
|
-
evaluate: (expression) => call("evaluate", String(expression)),
|
|
597
|
+
evaluate: (expression) => dialogs.active ? dialogs.invoke("evaluate",String(expression)) : call("evaluate", String(expression)),
|
|
577
598
|
async cdp(method) {
|
|
578
599
|
if(method==='Page.getLayoutMetrics') {
|
|
579
|
-
const offset=await composite('scrollOffset');
|
|
600
|
+
const offset=dialogs.active?{x:0,y:0}:await composite('scrollOffset');
|
|
580
601
|
const visual={pageX:offset.x*pageScale(),pageY:offset.y*pageScale(),offsetX:0,offsetY:0,clientWidth:viewport.width,clientHeight:viewport.height,scale:1,zoom:1};
|
|
581
602
|
return {cssVisualViewport:visual,cssLayoutViewport:visual,visualViewport:visual,layoutViewport:visual};
|
|
582
603
|
}
|
|
@@ -612,6 +633,7 @@ export async function createBrowser({
|
|
|
612
633
|
},
|
|
613
634
|
close() {
|
|
614
635
|
closed = true;
|
|
636
|
+
dialogs.close();
|
|
615
637
|
cancelSparePreparation();
|
|
616
638
|
current?.close();
|
|
617
639
|
spare?.close();
|
|
@@ -179,7 +179,7 @@ function buildBox(node, flags, blockified = false) {
|
|
|
179
179
|
// by its display: body { display: grid } or flex is common.
|
|
180
180
|
const display = node.type === "root" ? style?.display : node.type;
|
|
181
181
|
const flex = ["flex", "inline-flex"].includes(display)
|
|
182
|
-
? style?.flexDirection
|
|
182
|
+
? style?.flexDirection?.startsWith("column") ? "column" : "row"
|
|
183
183
|
: null;
|
|
184
184
|
const grid = ["grid", "inline-grid"].includes(display);
|
|
185
185
|
const table = ["table", "inline-table"].includes(node.type);
|
|
@@ -358,6 +358,7 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
|
|
|
358
358
|
const context = Number.isInteger(style.zIndex);
|
|
359
359
|
ctx.layer = Object.freeze({
|
|
360
360
|
z: context ? style.zIndex : 0,
|
|
361
|
+
nodeId: box.node.id,
|
|
361
362
|
order: ctx.layerCount++,
|
|
362
363
|
context,
|
|
363
364
|
parent: ctx.stackingContext,
|
|
@@ -430,6 +431,7 @@ function cachedBlockResult(ctx, box, key, y, x) {
|
|
|
430
431
|
for (const layer of cached.layers) {
|
|
431
432
|
layers.push(Object.freeze({
|
|
432
433
|
z: layer.z,
|
|
434
|
+
nodeId: layer.nodeId,
|
|
433
435
|
order: ctx.layerCount++,
|
|
434
436
|
context: layer.context,
|
|
435
437
|
parent: layer.parent === -1 ? ctx.stackingContext : layers[layer.parent],
|
|
@@ -485,7 +487,7 @@ function cacheBlockResult(ctx, box, key, x, y, result, start) {
|
|
|
485
487
|
for (const layer of ordered) {
|
|
486
488
|
const parent = indices.get(layer.parent) ?? (layer.parent === start.context ? -1 : null);
|
|
487
489
|
if (parent === null) return;
|
|
488
|
-
layers.push({ z: layer.z, context: layer.context, parent });
|
|
490
|
+
layers.push({ z: layer.z, nodeId: layer.nodeId, context: layer.context, parent });
|
|
489
491
|
}
|
|
490
492
|
const indexOf = (layer) => layer === start.layer ? -1 : indices.get(layer) ?? null;
|
|
491
493
|
const boxLayers = boxes.map((record) => indexOf(record.layer));
|
|
@@ -538,7 +540,7 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
538
540
|
const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
|
|
539
541
|
.map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
|
|
540
542
|
const margin = style?.margin ?? ZERO_BOX;
|
|
541
|
-
|
|
543
|
+
let [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
|
|
542
544
|
const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
|
|
543
545
|
let width;
|
|
544
546
|
if (specifiedWidth == null && left != null && right != null) {
|
|
@@ -546,6 +548,19 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
546
548
|
} else {
|
|
547
549
|
width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
|
|
548
550
|
}
|
|
551
|
+
// CSS 2 §10.3.7: with both insets and width resolved, auto margins
|
|
552
|
+
// absorb the remaining space. Normal-flow margin resolution above is
|
|
553
|
+
// only used to size the box; its x is not the absolute position.
|
|
554
|
+
if (specifiedWidth != null && left != null && right != null) {
|
|
555
|
+
const remaining = containing.width-left-right-width-marginLeft-marginRight;
|
|
556
|
+
const autoLeft=margin[3]==="auto",autoRight=margin[1]==="auto";
|
|
557
|
+
if(autoLeft&&autoRight) {
|
|
558
|
+
if(remaining>=0)marginLeft=marginRight=remaining/2;
|
|
559
|
+
else if(style.direction==="rtl"){marginLeft=remaining;marginRight=0;}
|
|
560
|
+
else {marginLeft=0;marginRight=remaining;}
|
|
561
|
+
}else if(autoLeft)marginLeft=remaining;
|
|
562
|
+
else if(autoRight)marginRight=remaining;
|
|
563
|
+
}
|
|
549
564
|
const staticPosition = absolute.staticPosition ?? containing;
|
|
550
565
|
const x = left != null ? containing.x + left + marginLeft
|
|
551
566
|
: right != null ? containing.x + containing.width - right - marginRight - width
|
|
@@ -566,6 +581,14 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
566
581
|
}
|
|
567
582
|
}
|
|
568
583
|
|
|
584
|
+
/** CSS Sizing 4: a degenerate ratio behaves as auto; replaced boxes can prefer their natural ratio. */
|
|
585
|
+
function preferredAspectRatio(style, natural = null) {
|
|
586
|
+
const value = style?.aspectRatio;
|
|
587
|
+
if (!value || value.auto && natural != null) return natural;
|
|
588
|
+
const ratio = value.numerator / value.denominator;
|
|
589
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : natural;
|
|
590
|
+
}
|
|
591
|
+
|
|
569
592
|
/**
|
|
570
593
|
* justify-content in a column flex container (Flexbox §8.2): `free` is the
|
|
571
594
|
* height left over once the items are stacked, as with a min-height or height
|
|
@@ -602,12 +625,12 @@ function justifyColumn(ctx, items, justify = "normal", free) {
|
|
|
602
625
|
}
|
|
603
626
|
|
|
604
627
|
/** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
|
|
605
|
-
function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
|
|
606
|
-
for (let index = boxStart; index <
|
|
628
|
+
function shiftOutput(ctx, boxStart, fragmentStart, dx, dy, boxEnd=ctx.boxes.length, fragmentEnd=ctx.fragments.length) {
|
|
629
|
+
for (let index = boxStart; index < boxEnd; index++) {
|
|
607
630
|
const record = ctx.boxes[index];
|
|
608
631
|
ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
|
|
609
632
|
}
|
|
610
|
-
for (let index = fragmentStart; index <
|
|
633
|
+
for (let index = fragmentStart; index < fragmentEnd; index++) {
|
|
611
634
|
ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
|
|
612
635
|
}
|
|
613
636
|
}
|
|
@@ -674,14 +697,24 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
674
697
|
const containingHeight = ctx.containing?.height;
|
|
675
698
|
const resolveHeight = (value) => numberOrNull(resolveLength(value, containingHeight ?? NaN));
|
|
676
699
|
const specifiedHeight = resolveHeight(style?.height);
|
|
700
|
+
const ratio = preferredAspectRatio(style);
|
|
701
|
+
// The transferred height uses the box selected by box-sizing. It is
|
|
702
|
+
// definite for descendants (including percentage heights and grid tracks).
|
|
703
|
+
let ratioHeight = specifiedHeight == null && forcedHeight == null && ratio != null
|
|
704
|
+
? style.boxSizing === "border-box" ? toContentHeight(width / ratio) : contentWidth / ratio
|
|
705
|
+
: null;
|
|
677
706
|
const minHeight = resolveHeight(style?.minHeight);
|
|
678
707
|
const maxHeight = style?.maxHeight === "none" ? null : resolveHeight(style?.maxHeight);
|
|
708
|
+
if (ratioHeight != null) {
|
|
709
|
+
if (maxHeight != null) ratioHeight = Math.min(ratioHeight, toContentHeight(maxHeight));
|
|
710
|
+
if (minHeight != null) ratioHeight = Math.max(ratioHeight, toContentHeight(minHeight));
|
|
711
|
+
}
|
|
679
712
|
const previousContaining = ctx.containing;
|
|
680
713
|
ctx.containing = {
|
|
681
714
|
width: contentWidth,
|
|
682
715
|
height: forcedHeight != null
|
|
683
716
|
? Math.max(0, forcedHeight - border[0] - border[2] - padding[0] - padding[2])
|
|
684
|
-
: specifiedHeight != null ? toContentHeight(specifiedHeight) :
|
|
717
|
+
: specifiedHeight != null ? toContentHeight(specifiedHeight) : ratioHeight,
|
|
685
718
|
};
|
|
686
719
|
|
|
687
720
|
if (box.table) {
|
|
@@ -743,10 +776,25 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
743
776
|
ctx.containing = previousContaining;
|
|
744
777
|
let contentHeight = Math.max(0, contentBottom - contentTop);
|
|
745
778
|
if (specifiedHeight != null) contentHeight = toContentHeight(specifiedHeight);
|
|
779
|
+
else if (ratioHeight != null) contentHeight = style.minHeight === "auto" && style.overflowY === "visible"
|
|
780
|
+
? Math.max(contentHeight, ratioHeight) : ratioHeight;
|
|
746
781
|
if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
|
|
747
782
|
if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
|
|
748
783
|
if (forcedHeight != null) contentHeight = Math.max(0, forcedHeight - padding[0] - padding[2] - border[0] - border[2]);
|
|
749
|
-
if (columnItems?.length)
|
|
784
|
+
if (columnItems?.length) {
|
|
785
|
+
justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
|
|
786
|
+
if(style?.flexDirection === "column-reverse") {
|
|
787
|
+
for(const [index,item] of columnItems.entries()){
|
|
788
|
+
const record=ctx.boxes[item.boxStart];
|
|
789
|
+
const position=item.mainY+record.y-item.initialY;
|
|
790
|
+
const delta=2*contentTop+contentHeight-2*position-item.mainHeight;
|
|
791
|
+
if(index===0&&baseline!=null)baseline+=delta;
|
|
792
|
+
const next=columnItems[index+1];
|
|
793
|
+
shiftOutput(ctx,item.boxStart,item.fragmentStart,0,delta,
|
|
794
|
+
next?.boxStart??ctx.boxes.length,next?.fragmentStart??ctx.fragments.length);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
750
798
|
if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
|
|
751
799
|
record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
|
|
752
800
|
// Summary's disclosure marker participates in its inline content.
|
|
@@ -840,6 +888,8 @@ function roman(value) {
|
|
|
840
888
|
|
|
841
889
|
function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
|
|
842
890
|
const style = box.style;
|
|
891
|
+
const reverse=style?.flexDirection === "column-reverse";
|
|
892
|
+
const margins=child=>{const m=child.style?.margin??ZERO_BOX;return reverse?[m[2],m[1],m[0],m[3]]:m;};
|
|
843
893
|
const gap = style?.rowGap ?? style?.gap ?? 0;
|
|
844
894
|
const definiteHeight = ctx.containing?.height;
|
|
845
895
|
// Most column containers have auto height. Without a main-axis constraint
|
|
@@ -854,10 +904,11 @@ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
|
|
|
854
904
|
for (const [index, child] of box.children.entries()) {
|
|
855
905
|
if (index) cursor += gap;
|
|
856
906
|
const horizontal = columnHorizontal(ctx, child, style, x, width);
|
|
857
|
-
const margin = child
|
|
907
|
+
const margin = margins(child);
|
|
858
908
|
items.push({boxStart:ctx.boxes.length,fragmentStart:ctx.fragments.length,
|
|
859
909
|
autoTop:margin[0] === "auto",autoBottom:margin[2] === "auto"});
|
|
860
910
|
const result = layoutBlock(ctx, child, horizontal.x, horizontal.width, cursor + numberOrZero(margin[0]));
|
|
911
|
+
Object.assign(items.at(-1),{mainY:cursor+numberOrZero(margin[0]),mainHeight:result.height,initialY:ctx.boxes[items.at(-1).boxStart].y});
|
|
861
912
|
cursor += numberOrZero(margin[0]) + result.height + numberOrZero(margin[2]);
|
|
862
913
|
baseline ??= result.baseline;
|
|
863
914
|
}
|
|
@@ -866,7 +917,7 @@ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
|
|
|
866
917
|
const items = box.children.map(child => {
|
|
867
918
|
const childStyle = child.style;
|
|
868
919
|
const horizontal = columnHorizontal(ctx, child, style, x, width);
|
|
869
|
-
const margin =
|
|
920
|
+
const margin = margins(child);
|
|
870
921
|
const padding = paddingBox(childStyle, width);
|
|
871
922
|
const border = borderWidths(childStyle);
|
|
872
923
|
const extra = padding[0] + padding[2] + border[0] + border[2];
|
|
@@ -908,6 +959,7 @@ function layoutFlexColumn(ctx, box, x, width, y, minHeight, maxHeight) {
|
|
|
908
959
|
const forcedHeight = item.definite || Math.abs(targetHeight - item.naturalHeight) > 1e-9 ? targetHeight : null;
|
|
909
960
|
const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width,
|
|
910
961
|
cursor + item.marginLeft, false, forcedHeight);
|
|
962
|
+
Object.assign(item,{mainY:cursor+item.marginLeft,mainHeight:result.height,initialY:ctx.boxes[item.boxStart].y});
|
|
911
963
|
cursor += item.marginLeft + result.height + item.marginRight;
|
|
912
964
|
baseline ??= result.baseline;
|
|
913
965
|
}
|
|
@@ -932,7 +984,14 @@ function layoutFlexRow(ctx, box, x, width, y) {
|
|
|
932
984
|
const style = box.style;
|
|
933
985
|
const columnGap = style?.columnGap ?? style?.gap ?? 0;
|
|
934
986
|
const rowGap = style?.rowGap ?? style?.gap ?? 0;
|
|
935
|
-
const items = box.children.map((child) =>
|
|
987
|
+
const items = box.children.map((child) => {
|
|
988
|
+
const item=flexItem(ctx,child,width);
|
|
989
|
+
if(style?.flexDirection === "row-reverse") {
|
|
990
|
+
[item.marginLeft,item.marginRight]=[item.marginRight,item.marginLeft];
|
|
991
|
+
[item.autoLeft,item.autoRight]=[item.autoRight,item.autoLeft];
|
|
992
|
+
}
|
|
993
|
+
return item;
|
|
994
|
+
});
|
|
936
995
|
|
|
937
996
|
const lines = [];
|
|
938
997
|
let line = [];
|
|
@@ -1007,7 +1066,8 @@ function layoutFlexRow(ctx, box, x, width, y) {
|
|
|
1007
1066
|
} else if (alignment === "end") {
|
|
1008
1067
|
itemY += crossSize - outerHeight;
|
|
1009
1068
|
}
|
|
1010
|
-
const
|
|
1069
|
+
const itemX=style?.flexDirection === "row-reverse"?x+width-(mainX-x)-item.target:mainX;
|
|
1070
|
+
const result = layoutBlock(ctx, item.box, itemX, item.target, itemY, false, forcedHeight);
|
|
1011
1071
|
baseline ??= result.baseline;
|
|
1012
1072
|
mainX += item.target + item.marginRight + (item.autoRight ? autoShare : 0) + columnGap + between;
|
|
1013
1073
|
}
|
|
@@ -1147,7 +1207,7 @@ function layoutGrid(ctx, box, x, width, y) {
|
|
|
1147
1207
|
const measured = dryLayout(ctx, item.box, horizontal.width);
|
|
1148
1208
|
item.height = measured.height + numberOrZero(margin[0]) + numberOrZero(margin[2]);
|
|
1149
1209
|
}
|
|
1150
|
-
const rows = sizeGridRows(grid, rowGap);
|
|
1210
|
+
const rows = sizeGridRows(grid, rowGap, ctx.containing?.height, style?.alignContent);
|
|
1151
1211
|
const rowStarts = trackStarts(rows, [], rowGap, y);
|
|
1152
1212
|
|
|
1153
1213
|
let baseline = null;
|
|
@@ -1164,7 +1224,8 @@ function layoutGrid(ctx, box, x, width, y) {
|
|
|
1164
1224
|
const contentHeight = item.height - margin.top - margin.bottom;
|
|
1165
1225
|
let itemY = areaY + margin.top;
|
|
1166
1226
|
let forcedHeight = null;
|
|
1167
|
-
if (["normal", "stretch"].includes(alignment) && numberOrNull(itemStyle?.height) == null
|
|
1227
|
+
if (["normal", "stretch"].includes(alignment) && numberOrNull(itemStyle?.height) == null
|
|
1228
|
+
&& (alignment === "stretch" || preferredAspectRatio(itemStyle) == null)) {
|
|
1168
1229
|
forcedHeight = Math.max(contentHeight, areaHeight - margin.top - margin.bottom);
|
|
1169
1230
|
} else if (alignment === "center") {
|
|
1170
1231
|
itemY += (areaHeight - item.height) / 2;
|
|
@@ -1756,10 +1817,10 @@ function updateGrowth(track, value) {
|
|
|
1756
1817
|
}
|
|
1757
1818
|
|
|
1758
1819
|
/** Row sizing: explicit lengths are fixed; other rows fit their tallest item. */
|
|
1759
|
-
function sizeGridRows(grid, gap) {
|
|
1820
|
+
function sizeGridRows(grid, gap, available = null, alignment = "normal") {
|
|
1760
1821
|
const rows = grid.rowTracks.map((track) => {
|
|
1761
|
-
const fixed = numberOrNull(resolveBreadth(track.min,
|
|
1762
|
-
const fixedMax = numberOrNull(resolveBreadth(track.max,
|
|
1822
|
+
const fixed = numberOrNull(resolveBreadth(track.min, available));
|
|
1823
|
+
const fixedMax = numberOrNull(resolveBreadth(track.max, available));
|
|
1763
1824
|
return fixed != null && fixedMax != null ? { size: fixedMax, fixed: true } : { size: fixed ?? 0, fixed: false };
|
|
1764
1825
|
});
|
|
1765
1826
|
for (const item of grid.items.filter((candidate) => candidate.rowSpan === 1)) {
|
|
@@ -1772,6 +1833,34 @@ function sizeGridRows(grid, gap) {
|
|
|
1772
1833
|
const flexible = spanned.filter((row) => !row.fixed);
|
|
1773
1834
|
if (item.height > current && flexible.length) flexible.at(-1).size += item.height - current;
|
|
1774
1835
|
}
|
|
1836
|
+
// Fractional row tracks consume the definite block-axis space, just as
|
|
1837
|
+
// column fr tracks consume inline space. Empty grid items still stretch
|
|
1838
|
+
// into those tracks (e.g. a 2x2 game board sized by aspect-ratio).
|
|
1839
|
+
const flexible=grid.rowTracks.flatMap((track,index)=>track.max?.fr>0?[index]:[]);
|
|
1840
|
+
if(flexible.length){
|
|
1841
|
+
let fraction=0;
|
|
1842
|
+
if(available!=null){
|
|
1843
|
+
const leftover=available-gap*Math.max(0,rows.length-1)-rows.reduce((sum,row,index)=>sum+(flexible.includes(index)?0:row.size),0);
|
|
1844
|
+
const frozen=new Set();
|
|
1845
|
+
for(let guard=0;guard<=flexible.length;guard++){
|
|
1846
|
+
const active=flexible.filter(index=>!frozen.has(index));
|
|
1847
|
+
const factors=Math.max(1,active.reduce((sum,index)=>sum+grid.rowTracks[index].max.fr,0));
|
|
1848
|
+
const space=leftover-[...frozen].reduce((sum,index)=>sum+rows[index].size,0);
|
|
1849
|
+
fraction=Math.max(0,space/factors);
|
|
1850
|
+
const violators=active.filter(index=>rows[index].size>fraction*grid.rowTracks[index].max.fr);
|
|
1851
|
+
if(!violators.length)break;
|
|
1852
|
+
for(const index of violators)frozen.add(index);
|
|
1853
|
+
}
|
|
1854
|
+
}else fraction=Math.max(...flexible.map(index=>rows[index].size/Math.max(1,grid.rowTracks[index].max.fr)));
|
|
1855
|
+
for(const index of flexible)rows[index].size=Math.max(rows[index].size,fraction*grid.rowTracks[index].max.fr);
|
|
1856
|
+
}
|
|
1857
|
+
// Normal grid content alignment stretches auto tracks to fill a definite
|
|
1858
|
+
// height, so place-items:center centers inside the entire grid area.
|
|
1859
|
+
if (available != null && ["normal", "stretch", undefined].includes(alignment)) {
|
|
1860
|
+
const free = available - rows.reduce((sum, row) => sum + row.size, 0) - gap * Math.max(0, rows.length - 1);
|
|
1861
|
+
const stretchy = rows.filter((row, index) => grid.rowTracks[index].max === "auto");
|
|
1862
|
+
if (free > 0 && stretchy.length) for (const row of stretchy) row.size += free / stretchy.length;
|
|
1863
|
+
}
|
|
1775
1864
|
return rows.map((row) => row.size);
|
|
1776
1865
|
}
|
|
1777
1866
|
|
|
@@ -1838,6 +1927,10 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
|
|
|
1838
1927
|
let width;
|
|
1839
1928
|
if (box.replaced) width = imageDimensions(box.node, containingWidth).width + extra;
|
|
1840
1929
|
else if (specified != null) width = borderBox(specified);
|
|
1930
|
+
else if (preferredAspectRatio(style) != null && numberOrNull(resolveLength(style?.height, ctx.containing?.height ?? NaN)) != null) {
|
|
1931
|
+
const height = resolveLength(style.height, ctx.containing?.height ?? NaN);
|
|
1932
|
+
width = borderBox(height * preferredAspectRatio(style));
|
|
1933
|
+
}
|
|
1841
1934
|
else if (shrink) width = Math.min(maxContentWidth(ctx, box), available);
|
|
1842
1935
|
else width = available;
|
|
1843
1936
|
const maxWidth = numberOrNull(resolveLength(style?.maxWidth, percentageBasis));
|
|
@@ -2735,7 +2828,7 @@ function imageDimensions(node, referenceWidth, referenceHeight = null) {
|
|
|
2735
2828
|
const intrinsicWidth = (canvas ? node.widthAttribute : node.resource?.width) ?? node.widthAttribute ?? 300;
|
|
2736
2829
|
const intrinsicHeight = (canvas ? node.heightAttribute : node.resource?.height) ?? node.heightAttribute ?? 150;
|
|
2737
2830
|
const emptyCanvas = node.tagName === "CANVAS" && !(intrinsicWidth > 0 && intrinsicHeight > 0);
|
|
2738
|
-
const ratio = emptyCanvas ? null : intrinsicWidth / intrinsicHeight;
|
|
2831
|
+
const ratio = preferredAspectRatio(node.style, emptyCanvas ? null : intrinsicWidth / intrinsicHeight);
|
|
2739
2832
|
const cssWidth = node.style?.width;
|
|
2740
2833
|
const cssHeight = node.style?.height;
|
|
2741
2834
|
let width = cssWidth !== "auto" && cssWidth != null
|
|
@@ -2746,7 +2839,7 @@ function imageDimensions(node, referenceWidth, referenceHeight = null) {
|
|
|
2746
2839
|
: null;
|
|
2747
2840
|
if (!Number.isFinite(width)) width = null;
|
|
2748
2841
|
if (!Number.isFinite(height)) height = null;
|
|
2749
|
-
if (width == null && height == null) [width, height] = [intrinsicWidth, intrinsicHeight];
|
|
2842
|
+
if (width == null && height == null) [width, height] = [intrinsicWidth, ratio == null ? intrinsicHeight : intrinsicWidth / ratio];
|
|
2750
2843
|
else if (width == null) width = ratio == null ? intrinsicWidth : height * ratio;
|
|
2751
2844
|
else if (height == null) height = ratio == null ? intrinsicHeight : width / ratio;
|
|
2752
2845
|
if (node.style?.maxWidth !== "none" && node.style?.maxWidth != null) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Presentation timestamps for ordinary (nonfragmented) MP4 video. FFmpeg
|
|
2
|
+
// returns decoded pictures in presentation order; average FPS cannot time VFR.
|
|
3
|
+
// Unsupported tables/edits retain the existing decoder clock.
|
|
4
|
+
export function mp4FrameTimes(bytes) {
|
|
5
|
+
if (!(bytes instanceof Uint8Array)) return null;
|
|
6
|
+
try {
|
|
7
|
+
const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
|
|
8
|
+
let seen=0;
|
|
9
|
+
const u=(p,end)=>{if(p<0||p+4>end)throw Error('short field');return v.getUint32(p)};
|
|
10
|
+
const boxes=(start,end)=>{
|
|
11
|
+
const out=[];
|
|
12
|
+
for(let p=start;p<end;){
|
|
13
|
+
if(++seen>32768)throw Error('box limit');
|
|
14
|
+
let size=u(p,end),header=8;const type=u(p+4,end);
|
|
15
|
+
if(size===1){size=u(p+8,end)*4294967296+u(p+12,end);header=16}
|
|
16
|
+
if(size===0)size=end-p;
|
|
17
|
+
// Progressive files may end inside mdat; timing tables before it
|
|
18
|
+
// are already complete and do not depend on the arriving payload.
|
|
19
|
+
if(size>end-p && type===0x6d646174 && start===0)break;
|
|
20
|
+
if(!Number.isSafeInteger(size)||size<header||size>end-p)throw Error('bad box');
|
|
21
|
+
out.push({type,p:p+header,end:p+size});p+=size;
|
|
22
|
+
}return out;
|
|
23
|
+
};
|
|
24
|
+
const tag=s=>[...s].reduce((n,c)=>n*256+c.charCodeAt(0),0);
|
|
25
|
+
const find=(list,name)=>list.find(b=>b.type===tag(name));
|
|
26
|
+
const children=b=>boxes(b.p,b.end);
|
|
27
|
+
const top=boxes(0,bytes.length),moov=find(top,'moov');if(!moov)return null;
|
|
28
|
+
const movie=children(moov);if(find(movie,'mvex'))return null;
|
|
29
|
+
const tracks=movie.filter(b=>b.type===tag('trak'));
|
|
30
|
+
let result=null;
|
|
31
|
+
for(const trak of tracks){
|
|
32
|
+
const nodes=children(trak),mdia=find(nodes,'mdia');if(!mdia)continue;
|
|
33
|
+
const media=children(mdia),hdlr=find(media,'hdlr');
|
|
34
|
+
if(!hdlr||u(hdlr.p+8,hdlr.end)!==tag('vide'))continue;
|
|
35
|
+
if(result)return null;
|
|
36
|
+
const mdhd=find(media,'mdhd');if(!mdhd||bytes[mdhd.p]>1)return null;
|
|
37
|
+
const scale=u(mdhd.p+(bytes[mdhd.p]===1?20:12),mdhd.end);if(!scale)return null;
|
|
38
|
+
let offset=0;const edts=find(nodes,'edts');
|
|
39
|
+
if(edts){
|
|
40
|
+
const edit=find(children(edts),'elst');
|
|
41
|
+
if(!edit||bytes[edit.p]!==0||u(edit.p+4,edit.end)!==1||u(edit.p+16,edit.end)!==65536)return null;
|
|
42
|
+
offset=u(edit.p+12,edit.end);if(offset>=2147483648)return null;
|
|
43
|
+
}
|
|
44
|
+
const minf=find(media,'minf');if(!minf)return null;
|
|
45
|
+
const stbl=find(children(minf),'stbl');if(!stbl)return null;
|
|
46
|
+
const tables=children(stbl),stts=find(tables,'stts'),ctts=find(tables,'ctts');if(!stts||bytes[stts.p]!==0)return null;
|
|
47
|
+
const times=[],durations=[];let time=0;
|
|
48
|
+
const runs=u(stts.p+4,stts.end);if(runs>250000)return null;
|
|
49
|
+
for(let i=0;i<runs;i++){
|
|
50
|
+
const count=u(stts.p+8+i*8,stts.end),delta=u(stts.p+12+i*8,stts.end);
|
|
51
|
+
if(!delta||times.length+count>250000)return null;
|
|
52
|
+
for(let j=0;j<count;j++){times.push(time);durations.push(delta);time+=delta;if(!Number.isSafeInteger(time))return null;}
|
|
53
|
+
}
|
|
54
|
+
if(ctts){
|
|
55
|
+
if(bytes[ctts.p]>1)return null;let index=0;
|
|
56
|
+
const runs=u(ctts.p+4,ctts.end);if(runs>250000)return null;
|
|
57
|
+
for(let i=0;i<runs;i++){
|
|
58
|
+
const count=u(ctts.p+8+i*8,ctts.end);let delta=u(ctts.p+12+i*8,ctts.end);
|
|
59
|
+
if(bytes[ctts.p]===1)delta|=0;
|
|
60
|
+
if(index+count>times.length)return null;
|
|
61
|
+
for(let j=0;j<count;j++)times[index++]+=delta;
|
|
62
|
+
}if(index!==times.length)return null;
|
|
63
|
+
}
|
|
64
|
+
const keys=[];const stss=find(tables,'stss');
|
|
65
|
+
if(stss){const count=u(stss.p+4,stss.end);if(count>times.length)return null;
|
|
66
|
+
for(let i=0;i<count;i++){const n=u(stss.p+8+i*4,stss.end);if(!n||n>times.length)return null;keys.push((times[n-1]-offset)/scale)}
|
|
67
|
+
}else for(const t of times)keys.push((t-offset)/scale);
|
|
68
|
+
const frames=Float64Array.from(times,t=>(t-offset)/scale);frames.sort();keys.sort((a,b)=>a-b);
|
|
69
|
+
if(!frames.length||frames[0]<-1e-6)return null;
|
|
70
|
+
let end=0;for(let i=0;i<times.length;i++)end=Math.max(end,(times[i]+durations[i]-offset)/scale);
|
|
71
|
+
result={frames,keys,end};
|
|
72
|
+
}return result;
|
|
73
|
+
}catch{return null}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function timeVideoDecoder(decoder,bytes) {
|
|
77
|
+
const timeline=mp4FrameTimes(bytes);if(!timeline)return decoder;
|
|
78
|
+
let index=0;
|
|
79
|
+
return {
|
|
80
|
+
width:decoder.width,height:decoder.height,fps:decoder.fps,
|
|
81
|
+
get frameTime(){return timeline.frames[Math.max(0,index-1)]},
|
|
82
|
+
get nextFrameTime(){return timeline.frames[index]??timeline.end},
|
|
83
|
+
nextFrame(...args){const pixels=decoder.nextFrame(...args);if(pixels)index++;return pixels},
|
|
84
|
+
seek(ms){
|
|
85
|
+
decoder.seek(ms);const target=ms/1000;let key=timeline.keys[0]??0;
|
|
86
|
+
for(const t of timeline.keys){if(t>target+1e-6)break;key=t}
|
|
87
|
+
let low=0,high=timeline.frames.length;
|
|
88
|
+
while(low<high){const mid=(low+high)>>>1;if(timeline.frames[mid]<key-1e-6)low=mid+1;else high=mid}
|
|
89
|
+
index=low;
|
|
90
|
+
},
|
|
91
|
+
close(){decoder.close()},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Browser adapter only. All pixel composition and sampling use Land unchanged.
|
|
2
|
+
import {createDirectComposer, type DirectSurface} from '../../vendor/land-renderer/direct-compositor.ts';
|
|
3
|
+
export interface CanvasLayer {
|
|
4
|
+
pixels: Uint8Array; width: number; height: number;
|
|
5
|
+
x: number; y: number; drawWidth: number; drawHeight: number;
|
|
6
|
+
}
|
|
7
|
+
export class CanvasComposer {
|
|
8
|
+
private surfaces: DirectSurface[] = [];
|
|
9
|
+
private positions = new WeakMap<DirectSurface,{x:number;y:number}>();
|
|
10
|
+
private buffers = new WeakMap<Uint8Array,NonNullable<DirectSurface["pixels"]>>();
|
|
11
|
+
private base: {key:string;surface:DirectSurface} | null = null;
|
|
12
|
+
private readonly direct = createDirectComposer({input:{x:0,y:0,pointer:false},wm:{
|
|
13
|
+
renderSurfaces:()=>this.surfaces,origin:surface=>this.positions.get(surface)!,
|
|
14
|
+
}});
|
|
15
|
+
private surface(layer: CanvasLayer): DirectSurface {
|
|
16
|
+
let pixels=this.buffers.get(layer.pixels);
|
|
17
|
+
if(!pixels||pixels.width!==layer.width||pixels.height!==layer.height){pixels={bytes:layer.pixels,width:layer.width,height:layer.height,stride:layer.width*4,format:0};this.buffers.set(layer.pixels,pixels);}
|
|
18
|
+
const surface:DirectSurface={pixels,
|
|
19
|
+
scale:1,transform:0,viewportDestination:[layer.drawWidth,layer.drawHeight]};
|
|
20
|
+
this.positions.set(surface,{x:layer.x,y:layer.y});
|
|
21
|
+
return surface;
|
|
22
|
+
}
|
|
23
|
+
compose(width:number,height:number,background:readonly number[],layers:CanvasLayer[]):Uint8Array {
|
|
24
|
+
const key=[width,height,...background].join('/');
|
|
25
|
+
if(this.base?.key!==key){
|
|
26
|
+
const pixels=new Uint8Array(width*height*4);
|
|
27
|
+
// Browser RGBA and Land BGRA use identical alpha and blend operations.
|
|
28
|
+
// Supplying an opaque RGBA base also hides Land's default desktop color.
|
|
29
|
+
const words=new Uint32Array(pixels.buffer);
|
|
30
|
+
const color=new Uint8Array([background[0]!,background[1]!,background[2]!,255]);
|
|
31
|
+
words.fill(new Uint32Array(color.buffer)[0]!);
|
|
32
|
+
this.base={key,surface:this.surface({pixels,width,height,x:0,y:0,drawWidth:width,drawHeight:height})};
|
|
33
|
+
}
|
|
34
|
+
this.surfaces=[this.base.surface,...layers.map(layer=>this.surface(layer))];
|
|
35
|
+
const out=this.direct.compose(width,height);
|
|
36
|
+
return new Uint8Array(out.buffer,out.byteOffset,out.byteLength);
|
|
37
|
+
}
|
|
38
|
+
close():void {this.direct.close();this.surfaces=[];this.base=null;this.positions=new WeakMap();this.buffers=new WeakMap();}
|
|
39
|
+
}
|