buninu-browser 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai-debug.log +5537 -0
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +6 -0
- package/lib/happy-dom/parser.js +5 -0
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +3 -0
- package/lib/layout/text-layout.js +76 -40
- package/lib/media/video-keyframe.js +29 -0
- package/lib/paint/display-list.js +20 -5
- package/lib/render-tree/index.js +2 -0
- package/lib/renderer/bindings.js +11 -2
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +8 -1
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +3 -1
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/page-realm.js +9 -50
- package/lib/renderer/page-renderer.js +206 -79
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/style/computed-style.js +24 -7
- package/package.json +1 -1
- package/test/compositor.test.js +38 -0
- package/test/computed-style.test.js +21 -0
- package/test/display-list.test.js +14 -0
- package/test/download.test.js +8 -0
- package/test/headless-shell.test.js +22 -0
- package/test/media-playback-events.test.js +22 -0
- package/test/page-scripts.test.js +159 -1
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/pulse-client.test.js +17 -2
- package/test/text-layout.test.js +62 -0
- package/test/video-presentation.test.js +67 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Decoded PCM kept in the controller is independent of the device queue.
|
|
2
|
+
// It can bridge renderer work without increasing pause/seek output latency.
|
|
3
|
+
export class PlaybackBuffer {
|
|
4
|
+
constructor(bytesPerSecond, frameBytes, leadSeconds) {
|
|
5
|
+
this.bytesPerSecond = bytesPerSecond;
|
|
6
|
+
this.frameBytes = frameBytes;
|
|
7
|
+
this.reserve = leadSeconds <= 0.15 ? 0 : this.align(bytesPerSecond * 2);
|
|
8
|
+
this.threshold = this.reserve ? this.align(bytesPerSecond * 0.4) : 0;
|
|
9
|
+
this.buffering = Boolean(this.reserve);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
align(bytes) {
|
|
13
|
+
return Math.max(0, Math.floor(bytes / this.frameBytes) * this.frameBytes);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ready(queued, inDevice, finishing) {
|
|
17
|
+
if (!this.reserve || finishing) return true;
|
|
18
|
+
if (queued < this.frameBytes && inDevice < this.frameBytes) this.buffering = true;
|
|
19
|
+
if (this.buffering && queued >= this.threshold) this.buffering = false;
|
|
20
|
+
return !this.buffering;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
request(queued, outstanding, inDevice, immediateNeed) {
|
|
24
|
+
// Requests stay bounded even when the producer or network stops replying.
|
|
25
|
+
return this.align(Math.max(immediateNeed, this.reserve - inDevice) - queued - outstanding);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* other channels carry a stream's audio. Audio is sent as s16le.
|
|
14
14
|
*/
|
|
15
15
|
import { connect } from "node:net";
|
|
16
|
+
import { PlaybackBuffer } from "./playback-buffer.js";
|
|
16
17
|
|
|
17
18
|
export { floatToS16 } from "./pcm.js";
|
|
18
19
|
|
|
@@ -332,9 +333,9 @@ export class PulseClient {
|
|
|
332
333
|
* to LEAD_SECONDS ahead of the playback clock, whatever the server asks for.
|
|
333
334
|
* Some servers ask for more at once and queue it themselves (jspulse's
|
|
334
335
|
* OpenSL ES backend requests again as soon as it receives), and then a pause
|
|
335
|
-
* would leave everything already sent still playing.
|
|
336
|
-
*
|
|
337
|
-
*
|
|
336
|
+
* would leave everything already sent still playing. The producer prepares a
|
|
337
|
+
* separate bounded PCM reserve here so renderer work cannot immediately starve
|
|
338
|
+
* the output. That reserve is discarded on close, not sent to the device.
|
|
338
339
|
*/
|
|
339
340
|
const LEAD_SECONDS = 0.5;
|
|
340
341
|
const PACE_MS = 50;
|
|
@@ -359,6 +360,7 @@ class PlaybackStream {
|
|
|
359
360
|
#onTimingError;
|
|
360
361
|
#timingPending = false;
|
|
361
362
|
#playedBytes = 0;
|
|
363
|
+
#producerBuffer;
|
|
362
364
|
killed = false;
|
|
363
365
|
|
|
364
366
|
constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError) {
|
|
@@ -371,6 +373,7 @@ class PlaybackStream {
|
|
|
371
373
|
this.#bytesPerSecond = rate * this.#frameBytes;
|
|
372
374
|
this.#onRequest = onRequest;
|
|
373
375
|
this.#leadSeconds = leadSeconds;
|
|
376
|
+
this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
|
|
374
377
|
this.#onTiming = onTiming;
|
|
375
378
|
this.#onTimingError = onTimingError;
|
|
376
379
|
this.#timer = setInterval(() => { this.#pace(); void this.updateTiming(); }, PACE_MS);
|
|
@@ -388,7 +391,8 @@ class PlaybackStream {
|
|
|
388
391
|
const played = Math.min(end, Math.max(0, Math.min(end, timing.readIndex) - timing.sinkSeconds * this.#bytesPerSecond));
|
|
389
392
|
this.#playedBytes = Math.max(this.#playedBytes, this.#frameAligned(played));
|
|
390
393
|
this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
|
|
391
|
-
sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing
|
|
394
|
+
sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing,
|
|
395
|
+
bufferedSeconds: this.#queuedBytes / this.#bytesPerSecond });
|
|
392
396
|
this.#pace();
|
|
393
397
|
} catch (error) {
|
|
394
398
|
if (!this.killed) this.#onTimingError?.(error);
|
|
@@ -436,7 +440,11 @@ class PlaybackStream {
|
|
|
436
440
|
// (a few at a time), so small packets leave them little to play from.
|
|
437
441
|
const minimum = this.#frameAligned(this.#bytesPerSecond * MIN_PACKET_SECONDS);
|
|
438
442
|
const maximum = this.#frameAligned(this.#bytesPerSecond * MAX_PACKET_SECONDS);
|
|
439
|
-
|
|
443
|
+
const inDevice = this.#onTiming ? Math.max(0, this.#offset - this.#playedBytes)
|
|
444
|
+
: this.#started === null ? 0 : Math.max(0, this.#offset - (performance.now() - this.#started) / 1000 * this.#bytesPerSecond);
|
|
445
|
+
const ready = this.#producerBuffer.ready(this.#queuedBytes, inDevice, this.finishing);
|
|
446
|
+
const previousOffset = this.#offset;
|
|
447
|
+
while (ready && this.#credit > 0 && this.#queuedBytes > 0 && this.#offset < allowed) {
|
|
440
448
|
const room = this.#frameAligned(Math.min(this.#credit, allowed - this.#offset, maximum, this.#queuedBytes));
|
|
441
449
|
if (room < this.#frameBytes || (room < minimum && !this.finishing && this.#queuedBytes < minimum)) break;
|
|
442
450
|
const packet = new Uint8Array(room);
|
|
@@ -454,9 +462,11 @@ class PlaybackStream {
|
|
|
454
462
|
this.#credit -= room;
|
|
455
463
|
this.#queuedBytes -= room;
|
|
456
464
|
}
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
465
|
+
// Predecode independently of server credits; only transmission uses the
|
|
466
|
+
// small device budget above. Keep outstanding requests in that budget too.
|
|
467
|
+
const immediateNeed = Math.min(this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000 - this.#offset);
|
|
468
|
+
const sentNow = inDevice + this.#offset - previousOffset;
|
|
469
|
+
const need = this.#producerBuffer.request(this.#queuedBytes, this.#outstanding, sentNow, immediateNeed);
|
|
460
470
|
if (need >= this.#bytesPerSecond * MIN_REQUEST_SECONDS && !this.finishing) {
|
|
461
471
|
this.#outstanding += need;
|
|
462
472
|
this.#onRequest(need);
|
package/lib/cdp-server.js
CHANGED
|
@@ -686,6 +686,10 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
686
686
|
return {};
|
|
687
687
|
case "Input.dispatchMouseEvent": {
|
|
688
688
|
const target = getSessionTarget(state, sessionId);
|
|
689
|
+
if (params.type === "mouseMoved") {
|
|
690
|
+
await ctx.move?.(params.x, params.y);
|
|
691
|
+
return {};
|
|
692
|
+
}
|
|
689
693
|
if (params.type === "mouseWheel") {
|
|
690
694
|
await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0, { x: params.x, y: params.y });
|
|
691
695
|
return {};
|
|
@@ -719,6 +723,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
719
723
|
text: params.text ?? "",
|
|
720
724
|
code: params.code ?? "",
|
|
721
725
|
repeat: !!params.autoRepeat,
|
|
726
|
+
windowsVirtualKeyCode: params.windowsVirtualKeyCode,
|
|
722
727
|
});
|
|
723
728
|
target.cdpKeyDown = signature;
|
|
724
729
|
}
|
|
@@ -729,6 +734,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
729
734
|
text: params.text ?? "",
|
|
730
735
|
code: params.code ?? "",
|
|
731
736
|
repeat: !!params.autoRepeat,
|
|
737
|
+
windowsVirtualKeyCode: params.windowsVirtualKeyCode,
|
|
732
738
|
});
|
|
733
739
|
}
|
|
734
740
|
} else if (params.type === "keyUp") {
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
|
|
2
2
|
import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
|
|
3
|
+
import "./selector-pseudo-adapter.js";
|
|
3
4
|
|
|
4
5
|
const IMPLICIT_HEAD_ELEMENTS = new Set([
|
|
5
6
|
"BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
|
|
@@ -20,6 +21,10 @@ export function parseHTMLDocument(html, url = "about:blank") {
|
|
|
20
21
|
disableCSSFileLoading: true,
|
|
21
22
|
disableIframePageLoading: true,
|
|
22
23
|
enableImageFileLoading: false,
|
|
24
|
+
// This DOM is only the tree builder. Link activation must not change
|
|
25
|
+
// its URL before our controller commits a navigation (downloads never
|
|
26
|
+
// commit). Otherwise later #links resolve against the download URL.
|
|
27
|
+
navigation: { disableFallbackToSetURL: true },
|
|
23
28
|
},
|
|
24
29
|
});
|
|
25
30
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
|
|
2
|
+
|
|
3
|
+
// Happy DOM's :empty checks only element children. CSS also excludes elements
|
|
4
|
+
// with nonempty text nodes (including whitespace); comments do not count.
|
|
5
|
+
const original = SelectorItem.prototype.matchPseudoItem;
|
|
6
|
+
SelectorItem.prototype.matchPseudoItem = function (scope, element, siblings, pseudo, ignoreErrors) {
|
|
7
|
+
if (pseudo.name === "empty") {
|
|
8
|
+
for (const child of element.childNodes) {
|
|
9
|
+
if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
|
|
10
|
+
}
|
|
11
|
+
return { priorityWeight: 10 };
|
|
12
|
+
}
|
|
13
|
+
return original.call(this, scope, element, siblings, pseudo, ignoreErrors);
|
|
14
|
+
};
|
package/lib/headless-shell.js
CHANGED
|
@@ -455,6 +455,9 @@ export async function createBrowser({
|
|
|
455
455
|
lifecycleState() {
|
|
456
456
|
return [...reachedLifecycle(committed.host, committed.generation)];
|
|
457
457
|
},
|
|
458
|
+
move(x, y) {
|
|
459
|
+
return call("move", x / pageScale(), y / pageScale());
|
|
460
|
+
},
|
|
458
461
|
async click(x, y) {
|
|
459
462
|
const clicked = await call("click", x / pageScale(), y / pageScale());
|
|
460
463
|
const href = typeof clicked === "string" ? clicked : clicked?.url;
|
|
@@ -23,6 +23,14 @@ const ZERO_STRUT = Object.freeze({ positive: 0, negative: 0 });
|
|
|
23
23
|
const DEFAULT_ASCENT = 0.93;
|
|
24
24
|
const DEFAULT_CONTENT_HEIGHT = 1.17;
|
|
25
25
|
|
|
26
|
+
// CSS 2 §8.4: all four percentage paddings use the containing block's width.
|
|
27
|
+
// Cyclic percentages contribute zero during intrinsic sizing.
|
|
28
|
+
function paddingBox(style, width = 0) {
|
|
29
|
+
const padding = style?.padding ?? ZERO_BOX;
|
|
30
|
+
return padding.every(Number.isFinite) ? padding
|
|
31
|
+
: padding.map(value => Math.max(0, numberOrZero(resolveLength(value, width))));
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
const ROOT_LAYER = Object.freeze({ z: 0, order: 0, context: true, parent: null });
|
|
27
35
|
|
|
28
36
|
export function layoutText(tree, options = {}) {
|
|
@@ -325,7 +333,7 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
|
|
|
325
333
|
const style = box.style;
|
|
326
334
|
// Its containing block, for percentages in relative offsets.
|
|
327
335
|
const containing = ctx.containing;
|
|
328
|
-
const key = blockCacheKey(width, absorbTop, forcedHeight,
|
|
336
|
+
const key = blockCacheKey(width, absorbTop, forcedHeight, containing);
|
|
329
337
|
const cached = cachedBlockResult(ctx, box, key, y, x);
|
|
330
338
|
if (cached) return cached;
|
|
331
339
|
const boxStart = ctx.boxes.length;
|
|
@@ -511,31 +519,37 @@ function layoutAbsolutes(ctx, box, record) {
|
|
|
511
519
|
for (const absolute of box.absolutes) {
|
|
512
520
|
const style = absolute.style;
|
|
513
521
|
const containing = style?.position === "fixed" ? { ...icb, height: icb.height || block.height } : block;
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
522
|
+
const previousContaining = ctx.containing;
|
|
523
|
+
ctx.containing = containing;
|
|
524
|
+
try {
|
|
525
|
+
const [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
|
|
526
|
+
.map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
|
|
527
|
+
const margin = style?.margin ?? ZERO_BOX;
|
|
528
|
+
const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
|
|
529
|
+
const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
|
|
530
|
+
let width;
|
|
531
|
+
if (specifiedWidth == null && left != null && right != null) {
|
|
532
|
+
width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
|
|
533
|
+
} else {
|
|
534
|
+
width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
|
|
535
|
+
}
|
|
536
|
+
const staticPosition = absolute.staticPosition ?? containing;
|
|
537
|
+
const x = left != null ? containing.x + left + marginLeft
|
|
538
|
+
: right != null ? containing.x + containing.width - right - marginRight - width
|
|
539
|
+
: staticPosition.x + marginLeft;
|
|
540
|
+
const specifiedHeight = numberOrNull(resolveLength(style?.height, containing.height));
|
|
541
|
+
const forcedHeight = specifiedHeight == null && top != null && bottom != null
|
|
542
|
+
? Math.max(0, containing.height - top - bottom - marginTop - marginBottom)
|
|
543
|
+
: null;
|
|
544
|
+
let height = forcedHeight;
|
|
545
|
+
if (height == null && top == null && bottom != null) height = dryLayout(ctx, absolute, width).height;
|
|
546
|
+
const y = top != null ? containing.y + top + marginTop
|
|
547
|
+
: bottom != null ? containing.y + containing.height - bottom - marginBottom - height
|
|
548
|
+
: staticPosition.y + marginTop;
|
|
549
|
+
layoutBlock(ctx, absolute, x, width, y, false, forcedHeight);
|
|
550
|
+
} finally {
|
|
551
|
+
ctx.containing = previousContaining;
|
|
524
552
|
}
|
|
525
|
-
const staticPosition = absolute.staticPosition ?? containing;
|
|
526
|
-
const x = left != null ? containing.x + left + marginLeft
|
|
527
|
-
: right != null ? containing.x + containing.width - right - marginRight - width
|
|
528
|
-
: staticPosition.x + marginLeft;
|
|
529
|
-
const specifiedHeight = numberOrNull(resolveLength(style?.height, containing.height));
|
|
530
|
-
const forcedHeight = specifiedHeight == null && top != null && bottom != null
|
|
531
|
-
? Math.max(0, containing.height - top - bottom - marginTop - marginBottom)
|
|
532
|
-
: null;
|
|
533
|
-
let height = forcedHeight;
|
|
534
|
-
if (height == null && top == null && bottom != null) height = dryLayout(ctx, absolute, width).height;
|
|
535
|
-
const y = top != null ? containing.y + top + marginTop
|
|
536
|
-
: bottom != null ? containing.y + containing.height - bottom - marginBottom - height
|
|
537
|
-
: staticPosition.y + marginTop;
|
|
538
|
-
layoutBlock(ctx, absolute, x, width, y, false, forcedHeight);
|
|
539
553
|
}
|
|
540
554
|
}
|
|
541
555
|
|
|
@@ -593,9 +607,9 @@ function shiftedFragment(fragment, dx, dy) {
|
|
|
593
607
|
}
|
|
594
608
|
|
|
595
609
|
function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
|
|
596
|
-
const
|
|
610
|
+
const padding = paddingBox(box.style, ctx.containing?.width ?? width);
|
|
611
|
+
const style = box.style && padding !== box.style.padding ? { ...box.style, padding } : box.style;
|
|
597
612
|
const border = borderWidths(style);
|
|
598
|
-
const padding = style?.padding ?? ZERO_BOX;
|
|
599
613
|
const record = { nodeId: box.node.id, x, y, width, height: 0, style };
|
|
600
614
|
ctx.boxes.push(record);
|
|
601
615
|
if (box.replaced) {
|
|
@@ -718,8 +732,13 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
|
|
|
718
732
|
// Tables with width: auto are sized to their contents (CSS 2 §17.5.2).
|
|
719
733
|
const placed = resolveHorizontal(ctx, child, contentX, contentWidth, Boolean(child.table));
|
|
720
734
|
const result = layoutBlock(ctx, child, placed.x, placed.width, childY, collapsesThroughTop(child));
|
|
721
|
-
|
|
722
|
-
|
|
735
|
+
if (result.height === 0 && selfCollapsing(child)) {
|
|
736
|
+
// Empty blocks do not separate adjoining margins (CSS 2 §8.3.1).
|
|
737
|
+
pending = combineStruts(combined, bottomStrut(child));
|
|
738
|
+
} else {
|
|
739
|
+
cursor = childY + result.height;
|
|
740
|
+
pending = bottomStrut(child);
|
|
741
|
+
}
|
|
723
742
|
baseline = result.baseline ?? baseline;
|
|
724
743
|
});
|
|
725
744
|
contentBottom = collapsesThroughBottom(box) ? cursor : cursor + strutValue(pending ?? ZERO_STRUT);
|
|
@@ -912,7 +931,7 @@ function flexItem(ctx, box, containerWidth) {
|
|
|
912
931
|
// Sizes below are content-box sizes, as in Blink's FlexItem
|
|
913
932
|
// (base_content_size, hypothetical_content_size); `extra` is padding+border.
|
|
914
933
|
const style = box.style;
|
|
915
|
-
const padding = style
|
|
934
|
+
const padding = paddingBox(style, containerWidth);
|
|
916
935
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
917
936
|
const contentBox = (value) => Math.max(0, style?.boxSizing === "border-box" ? value - extra : value);
|
|
918
937
|
const margin = style?.margin ?? ZERO_BOX;
|
|
@@ -1503,7 +1522,7 @@ function minimumContribution(box, minContent, available) {
|
|
|
1503
1522
|
const margins = numberOrZero(margin[1]) + numberOrZero(margin[3]);
|
|
1504
1523
|
const specified = numberOrNull(resolveLength(style?.minWidth, available));
|
|
1505
1524
|
if (specified != null) {
|
|
1506
|
-
const padding = style
|
|
1525
|
+
const padding = paddingBox(style, available);
|
|
1507
1526
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
1508
1527
|
return (style.boxSizing === "border-box" ? Math.max(specified, extra) : specified + extra) + margins;
|
|
1509
1528
|
}
|
|
@@ -1693,7 +1712,7 @@ function dryLayout(ctx, box, width) {
|
|
|
1693
1712
|
/** Resolves the used border-box width and horizontal margins of a block-level box. */
|
|
1694
1713
|
function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
|
|
1695
1714
|
const style = box.style;
|
|
1696
|
-
const padding = style
|
|
1715
|
+
const padding = paddingBox(style, percentageBasis);
|
|
1697
1716
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
1698
1717
|
const borderBox = (value) => style?.boxSizing === "border-box" ? value : value + extra;
|
|
1699
1718
|
const margin = style?.margin ?? ZERO_BOX;
|
|
@@ -1729,7 +1748,7 @@ function maxContentWidth(ctx, box) {
|
|
|
1729
1748
|
const cached = ctx.maxContent.get(cacheKey);
|
|
1730
1749
|
if (cached != null) return cached;
|
|
1731
1750
|
const style = box.style;
|
|
1732
|
-
const padding = style
|
|
1751
|
+
const padding = paddingBox(style);
|
|
1733
1752
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
1734
1753
|
let width;
|
|
1735
1754
|
const specified = numberOrNull(style?.width);
|
|
@@ -1772,7 +1791,7 @@ function minContentWidth(ctx, box) {
|
|
|
1772
1791
|
const cached = ctx.minContent.get(cacheKey);
|
|
1773
1792
|
if (cached != null) return cached;
|
|
1774
1793
|
const style = box.style;
|
|
1775
|
-
const padding = style
|
|
1794
|
+
const padding = paddingBox(style);
|
|
1776
1795
|
const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
|
|
1777
1796
|
const specified = numberOrNull(style?.width);
|
|
1778
1797
|
const outer = (child) => {
|
|
@@ -1821,6 +1840,15 @@ function collapsesThroughTop(box) {
|
|
|
1821
1840
|
&& inFlowChildren(box)[0]?.kind === "block";
|
|
1822
1841
|
}
|
|
1823
1842
|
|
|
1843
|
+
function selfCollapsing(box) {
|
|
1844
|
+
return !box.bfc && box.kind === "block" && !box.replaced
|
|
1845
|
+
&& borderWidths(box.style).every(value => value === 0)
|
|
1846
|
+
&& (box.style?.padding ?? ZERO_BOX).every(value => value === 0)
|
|
1847
|
+
&& (box.style?.height === "auto" || box.style?.height === 0)
|
|
1848
|
+
&& (numberOrNull(box.style?.minHeight) ?? 0) <= 0
|
|
1849
|
+
&& inFlowChildren(box).every(selfCollapsing);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1824
1852
|
function collapsesThroughBottom(box) {
|
|
1825
1853
|
return !box.bfc && box.kind === "block" && !box.replaced
|
|
1826
1854
|
&& borderWidths(box.style)[2] === 0 && (box.style?.padding ?? ZERO_BOX)[2] === 0
|
|
@@ -1874,6 +1902,14 @@ function layoutInline(ctx, ifc, x, width, y) {
|
|
|
1874
1902
|
// Percentage sizes of inline images.
|
|
1875
1903
|
atomFor.containing = ctx.containing;
|
|
1876
1904
|
const lines = breakLines(ctx, paragraph, width, atomFor);
|
|
1905
|
+
// Legacy line clamping applies to a vertical -webkit-box, not ordinary blocks.
|
|
1906
|
+
const clamp = ifc.style?.webkitBoxOrient === "vertical"
|
|
1907
|
+
&& ["-webkit-box", "-webkit-inline-box"].includes(ifc.style?.display)
|
|
1908
|
+
? ifc.style.webkitLineClamp : null;
|
|
1909
|
+
if (clamp && lines.length > clamp) {
|
|
1910
|
+
lines.length = clamp;
|
|
1911
|
+
lines[clamp - 1] = { ...lines[clamp - 1], clamped: true };
|
|
1912
|
+
}
|
|
1877
1913
|
let cursor = y;
|
|
1878
1914
|
let baseline = null;
|
|
1879
1915
|
for (const line of lines) {
|
|
@@ -2048,7 +2084,7 @@ function mayBreakWithinWords(paragraph, piece) {
|
|
|
2048
2084
|
* room for "…". Mutates `segments`; returns the ellipsis to append, or null.
|
|
2049
2085
|
*/
|
|
2050
2086
|
function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
|
|
2051
|
-
if (style?.textOverflow !== "ellipsis" || style.overflowX !== "clip" || line.width <= width + 0.5) return null;
|
|
2087
|
+
if (!line.clamped && (style?.textOverflow !== "ellipsis" || style.overflowX !== "clip" || line.width <= width + 0.5)) return null;
|
|
2052
2088
|
const lastText = [...segments].reverse().find((segment) => segment.run.kind === "text");
|
|
2053
2089
|
if (!lastText) return null;
|
|
2054
2090
|
const text = "\u2026";
|
|
@@ -2088,7 +2124,7 @@ function truncateForEllipsis(ctx, style, paragraph, segments, line, width) {
|
|
|
2088
2124
|
function inlineImageMargins(node) {
|
|
2089
2125
|
const margin = node.style?.margin ?? ZERO_BOX;
|
|
2090
2126
|
const border = borderWidths(node.style);
|
|
2091
|
-
const padding = node.style
|
|
2127
|
+
const padding = paddingBox(node.style);
|
|
2092
2128
|
return {
|
|
2093
2129
|
left: numberOrZero(margin[3]) + border[3] + padding[3],
|
|
2094
2130
|
right: numberOrZero(margin[1]) + border[1] + padding[1],
|
|
@@ -2099,7 +2135,7 @@ function inlineImageMargins(node) {
|
|
|
2099
2135
|
function inlineImageVerticalInsets(node) {
|
|
2100
2136
|
const margin = node.style?.margin ?? ZERO_BOX;
|
|
2101
2137
|
const border = borderWidths(node.style);
|
|
2102
|
-
const padding = node.style
|
|
2138
|
+
const padding = paddingBox(node.style);
|
|
2103
2139
|
return {
|
|
2104
2140
|
top: numberOrZero(margin[0]) + border[0] + padding[0],
|
|
2105
2141
|
bottom: numberOrZero(margin[2]) + border[2] + padding[2],
|
|
@@ -2128,7 +2164,7 @@ function inlineImageTop(style, height, metrics) {
|
|
|
2128
2164
|
|
|
2129
2165
|
function inlineInsets(style) {
|
|
2130
2166
|
const margin = style?.margin ?? ZERO_BOX;
|
|
2131
|
-
const padding = style
|
|
2167
|
+
const padding = paddingBox(style);
|
|
2132
2168
|
const border = borderWidths(style);
|
|
2133
2169
|
return {
|
|
2134
2170
|
left: numberOrZero(margin[3]) + border[3] + padding[3],
|
|
@@ -2320,7 +2356,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
|
|
|
2320
2356
|
// Its background and borders, around the content box.
|
|
2321
2357
|
if (isDecorated(run.node.style)) {
|
|
2322
2358
|
const border = borderWidths(run.node.style);
|
|
2323
|
-
const padding = run.node.style.
|
|
2359
|
+
const padding = paddingBox(run.node.style, ctx.containing?.width);
|
|
2324
2360
|
ctx.boxes.push({
|
|
2325
2361
|
nodeId: run.nodeId,
|
|
2326
2362
|
x: imageX - padding[3] - border[3],
|
|
@@ -2396,7 +2432,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
|
|
|
2396
2432
|
for (const [node, edge] of edges) {
|
|
2397
2433
|
const nodeStyle = paragraph.inlineState.get(node)?.style;
|
|
2398
2434
|
if (!isDecorated(nodeStyle) || edge.first == null) continue;
|
|
2399
|
-
const padding = nodeStyle.
|
|
2435
|
+
const padding = paddingBox(nodeStyle, ctx.containing?.width);
|
|
2400
2436
|
const border = borderWidths(nodeStyle);
|
|
2401
2437
|
const metrics = textMetrics(ctx, nodeStyle);
|
|
2402
2438
|
const left = edge.left ?? edge.first;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// wasmpeg seeks to a keyframe without returning its timestamp. Locate it by
|
|
2
|
+
// comparing tiny decoded probes, then rewind before delivering full frames.
|
|
3
|
+
export function seekVideoKeyframe(decoder, seconds) {
|
|
4
|
+
const seek = time => decoder.seek(Math.max(1, time * 1000));
|
|
5
|
+
const frame = 1 / (decoder.fps || 30);
|
|
6
|
+
if (!(seconds >= frame)) { seek(0); return 0; }
|
|
7
|
+
const probe = time => {
|
|
8
|
+
seek(time);
|
|
9
|
+
const pixels = decoder.nextFrame(32, 18);
|
|
10
|
+
if (!pixels) return null;
|
|
11
|
+
let hash = 0x811c9dc5;
|
|
12
|
+
for (let i = 0; i < pixels.length; i += 3) hash = Math.imul(hash ^ pixels[i], 0x01000193);
|
|
13
|
+
return hash >>> 0;
|
|
14
|
+
};
|
|
15
|
+
const target = probe(seconds);
|
|
16
|
+
let keyframe = seconds;
|
|
17
|
+
if (target !== null) {
|
|
18
|
+
let earlier = Math.max(0, seconds - 10);
|
|
19
|
+
if (earlier > 0 && probe(earlier) !== target) {
|
|
20
|
+
while (keyframe - earlier > frame) {
|
|
21
|
+
const middle = (earlier + keyframe) / 2;
|
|
22
|
+
if (probe(middle) === target) keyframe = middle;
|
|
23
|
+
else earlier = middle;
|
|
24
|
+
}
|
|
25
|
+
} else keyframe = earlier;
|
|
26
|
+
}
|
|
27
|
+
seek(keyframe);
|
|
28
|
+
return keyframe;
|
|
29
|
+
}
|
|
@@ -215,7 +215,8 @@ export function buildDisplayList(layout, tree = null, resources = null, backgrou
|
|
|
215
215
|
if (run.video || run.audio) {
|
|
216
216
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
217
217
|
images.push(currentResource);
|
|
218
|
-
|
|
218
|
+
const controls = Boolean(tree?.nodesById.get(run.nodeId)?.domNode?.hasAttribute("controls"));
|
|
219
|
+
items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect, controls });
|
|
219
220
|
return;
|
|
220
221
|
}
|
|
221
222
|
if (!currentResource?.image) return;
|
|
@@ -402,7 +403,14 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
402
403
|
if (ownContent && escaping !== "fixed") {
|
|
403
404
|
const positioned = style.position && style.position !== "static";
|
|
404
405
|
if (!escaping || positioned) {
|
|
405
|
-
|
|
406
|
+
const clip = overflowClip(node);
|
|
407
|
+
const radii = clip ? cornerRadii(style, boxes.get(node.id)) : null;
|
|
408
|
+
if (radii) {
|
|
409
|
+
const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
|
|
410
|
+
radii.x = Math.max(0, radii.x - Math.max(left, right));
|
|
411
|
+
radii.y = Math.max(0, radii.y - Math.max(top, bottom));
|
|
412
|
+
}
|
|
413
|
+
clips.push([node, clip, radii]);
|
|
406
414
|
if (isScroller(style)) {
|
|
407
415
|
if (!scrolledBy.size) outOfFlow = positionedOut;
|
|
408
416
|
scrolledBy.add(node);
|
|
@@ -429,14 +437,20 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
429
437
|
transform = transform ? composeTransforms(transform, scrolled) : scrolled;
|
|
430
438
|
}
|
|
431
439
|
}
|
|
432
|
-
|
|
440
|
+
const roundedClips = [];
|
|
441
|
+
for (const [node, clip, radii] of clips) {
|
|
433
442
|
const matrix = transforms.get(node);
|
|
434
|
-
|
|
443
|
+
const rect = clip && matrix ? transformRect(matrix, clip) : clip;
|
|
444
|
+
clipRect = intersectRects(clipRect, rect);
|
|
445
|
+
if (rect && radii && (radii.x > 0 || radii.y > 0)) {
|
|
446
|
+
roundedClips.push({ rect, radii: matrix
|
|
447
|
+
? { x: radii.x * Math.abs(matrix.a), y: radii.y * Math.abs(matrix.d) } : radii });
|
|
448
|
+
}
|
|
435
449
|
}
|
|
436
450
|
// The owner's own box moves with every scroll container above it, not with its own.
|
|
437
451
|
transform = kind === "text" ? transform : transforms.get(owner) ?? null;
|
|
438
452
|
const scrollers = scrolledBy.size ? [...scrolledBy].map((node) => node.id) : null;
|
|
439
|
-
result = opacity <= 0 ? null : { opacity, clipRect, fixed, transform, scrollers, outOfFlow };
|
|
453
|
+
result = opacity <= 0 ? null : { opacity, clipRect, roundedClips, fixed, transform, scrollers, outOfFlow };
|
|
440
454
|
} else {
|
|
441
455
|
result = null;
|
|
442
456
|
}
|
|
@@ -503,6 +517,7 @@ function applyEffect(items, start, effect) {
|
|
|
503
517
|
item.clipRect = effect.clipRect;
|
|
504
518
|
item.bounds = intersectRects(item.bounds, effect.clipRect);
|
|
505
519
|
}
|
|
520
|
+
if (effect.roundedClips?.length) item.roundedClips = effect.roundedClips;
|
|
506
521
|
}
|
|
507
522
|
}
|
|
508
523
|
|
package/lib/render-tree/index.js
CHANGED
|
@@ -304,6 +304,8 @@ function sameSvg(left, right) {
|
|
|
304
304
|
|
|
305
305
|
function boxType(style, tagName = null) {
|
|
306
306
|
const display = style?.display;
|
|
307
|
+
if (display === "-webkit-box") return "block";
|
|
308
|
+
if (display === "-webkit-inline-box") return "inline-block";
|
|
307
309
|
if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
|
|
308
310
|
if (["table", "inline-table", "table-row-group", "table-header-group", "table-footer-group", "table-row",
|
|
309
311
|
"table-cell", "table-caption", "table-column", "table-column-group"].includes(display)) return display;
|
package/lib/renderer/bindings.js
CHANGED
|
@@ -16,6 +16,7 @@ const REFLECTED_PROPERTIES = [
|
|
|
16
16
|
"value", "checked", "disabled", "type", "name", "href", "src", "alt", "title",
|
|
17
17
|
"placeholder", "selected", "readOnly", "required", "min", "max", "step", "rel", "target",
|
|
18
18
|
"htmlFor", "lang", "dir", "tabIndex", "hidden", "defaultValue", "action", "method",
|
|
19
|
+
"controls", "autoplay", "loop", "playsInline", "preload", "poster",
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
/** Per-document state shared by that document's wrappers. */
|
|
@@ -782,6 +783,11 @@ class ElementBinding extends ParentBinding {
|
|
|
782
783
|
return this.#style;
|
|
783
784
|
}
|
|
784
785
|
|
|
786
|
+
// ElementCSSInlineStyle is [PutForwards=cssText], not assignment-readonly.
|
|
787
|
+
set style(value) {
|
|
788
|
+
this.style.cssText = value;
|
|
789
|
+
}
|
|
790
|
+
|
|
785
791
|
get dataset() {
|
|
786
792
|
this.#dataset ??= datasetBinding(nodeOf(this), realmOf(this));
|
|
787
793
|
return this.#dataset;
|
|
@@ -1767,6 +1773,9 @@ class EventBinding {
|
|
|
1767
1773
|
return this.#event.code;
|
|
1768
1774
|
}
|
|
1769
1775
|
|
|
1776
|
+
get keyCode() { return this.#event.keyCode ?? 0; }
|
|
1777
|
+
get charCode() { return this.#event.charCode ?? 0; }
|
|
1778
|
+
|
|
1770
1779
|
get clientX() {
|
|
1771
1780
|
return this.#event.clientX;
|
|
1772
1781
|
}
|
|
@@ -2515,7 +2524,7 @@ const EVENT_INTERFACES = {
|
|
|
2515
2524
|
MouseEvent: ["UIEvent", MOUSE_MEMBERS],
|
|
2516
2525
|
PointerEvent: ["MouseEvent", ["pointerType"]],
|
|
2517
2526
|
WheelEvent: ["MouseEvent", ["deltaX", "deltaY", "deltaMode"]],
|
|
2518
|
-
KeyboardEvent: ["UIEvent", ["key", "code", "repeat", ...MODIFIERS]],
|
|
2527
|
+
KeyboardEvent: ["UIEvent", ["key", "code", "keyCode", "charCode", "repeat", ...MODIFIERS]],
|
|
2519
2528
|
FocusEvent: ["UIEvent", ["relatedTarget"]],
|
|
2520
2529
|
InputEvent: ["UIEvent", ["data", "inputType"]],
|
|
2521
2530
|
MessageEvent: ["Event", ["data", "origin", "lastEventId", "source", "ports"]],
|
|
@@ -2531,7 +2540,7 @@ const CREATE_EVENT_TYPES = new Map([["event", "Event"], ["events", "Event"], ["h
|
|
|
2531
2540
|
const UI_EVENT_TYPES = ["UIEvent", "MouseEvent", "PointerEvent", "WheelEvent", "KeyboardEvent", "FocusEvent", "InputEvent"];
|
|
2532
2541
|
const UI_EVENT_FIELDS = [
|
|
2533
2542
|
...["detail", "clientX", "clientY", "screenX", "screenY", "button", "buttons", "deltaX", "deltaY", "deltaZ", "deltaMode",
|
|
2534
|
-
"pointerId", "width", "height", "pressure", "location"].map((key) => [key, Number]),
|
|
2543
|
+
"pointerId", "width", "height", "pressure", "location", "keyCode", "which", "charCode"].map((key) => [key, Number]),
|
|
2535
2544
|
...["key", "code", "data", "inputType", "pointerType"].map((key) => [key, String]),
|
|
2536
2545
|
...["ctrlKey", "altKey", "shiftKey", "metaKey", "repeat", "isComposing", "isPrimary"].map((key) => [key, Boolean]),
|
|
2537
2546
|
];
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
25
25
|
import { cropViewport, encodeImage } from "../paint/frame.js";
|
|
26
|
+
import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
|
|
27
|
+
import { VideoPresentation, compositeVideo } from "./video-presentation.js";
|
|
26
28
|
|
|
27
29
|
const CHANNEL_FD = 3;
|
|
28
30
|
const MAX_LINE_BYTES = 1024 * 1024;
|
|
@@ -32,6 +34,10 @@ let tile = null;
|
|
|
32
34
|
let scrollEpoch = -1;
|
|
33
35
|
let frameSerial = 0;
|
|
34
36
|
let cached = { key: "", data: "" };
|
|
37
|
+
const video = new VideoPresentation(wasmpeg, () => {
|
|
38
|
+
frameSerial++;
|
|
39
|
+
send({ type: "frame" });
|
|
40
|
+
}, report => postMessage({ type: "video-report", ...report }));
|
|
35
41
|
|
|
36
42
|
const channel = Bun.file(CHANNEL_FD).writer();
|
|
37
43
|
|
|
@@ -41,6 +47,9 @@ function send(message) {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
self.onmessage = ({ data }) => {
|
|
50
|
+
if (data?.type === "video-open") { void video.open(data); return; }
|
|
51
|
+
if (data?.type === "video-stop") { video.stop(data.id); frameSerial++; return; }
|
|
52
|
+
if (data?.type === "video-bind") { video.bind(data.id, data.audioId, data.audioStart, data.time); return; }
|
|
44
53
|
if (data?.type !== "frame") return;
|
|
45
54
|
tile = data.tile;
|
|
46
55
|
frameSerial++;
|
|
@@ -68,7 +77,8 @@ const methods = {
|
|
|
68
77
|
if (!tile) return "";
|
|
69
78
|
const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}`;
|
|
70
79
|
if (cached.key !== key) {
|
|
71
|
-
const
|
|
80
|
+
const composited = tile.videoLayer?.id === video.config?.id ? compositeVideo(tile, video.frame) : tile;
|
|
81
|
+
const { pixels, width, height } = cropViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height);
|
|
72
82
|
const bytes = await encodeImage(pixels, width, height, { format, quality });
|
|
73
83
|
cached = { key, data: Buffer.from(bytes).toString("base64") };
|
|
74
84
|
}
|
|
@@ -93,6 +103,10 @@ async function handleLine(line) {
|
|
|
93
103
|
} catch {
|
|
94
104
|
return;
|
|
95
105
|
}
|
|
106
|
+
if (request?.type === "audio-timing" && Number.isInteger(request.audioId)) {
|
|
107
|
+
video.timing(request.audioId, request.seconds);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
96
110
|
if (!Number.isInteger(request?.id) || !Object.hasOwn(methods, request.method)) return;
|
|
97
111
|
try {
|
|
98
112
|
const params = Array.isArray(request.params) ? request.params : [];
|
|
@@ -120,4 +134,5 @@ async function handleLine(line) {
|
|
|
120
134
|
// renderer installs seccomp, which forbids opening files.
|
|
121
135
|
await encodeImage(new Uint8Array(4), 1, 1);
|
|
122
136
|
await encodeImage(new Uint8Array(4), 1, 1, { format: "jpeg" });
|
|
137
|
+
await wasmpeg.load();
|
|
123
138
|
postMessage({ type: "ready" });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Bridge attribute reactions from the host DOM into the page's realm.
|
|
2
|
+
* Capture the callback and observed names at registration, as browsers do.
|
|
3
|
+
*/
|
|
4
|
+
export function installAttributeReactions(Adapter, constructor, realm, callPage) {
|
|
5
|
+
const callback = constructor.prototype.attributeChangedCallback;
|
|
6
|
+
if (typeof callback !== "function") return;
|
|
7
|
+
const observed = constructor.observedAttributes;
|
|
8
|
+
Object.defineProperty(Adapter, "observedAttributes", {
|
|
9
|
+
value: observed === undefined ? [] : Array.from(observed, String),
|
|
10
|
+
});
|
|
11
|
+
Object.defineProperty(Adapter.prototype, "attributeChangedCallback", {
|
|
12
|
+
value(name, oldValue, newValue, namespace = null) {
|
|
13
|
+
callPage(callback, realm.wrap(this), [name, oldValue, newValue, namespace]);
|
|
14
|
+
},
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
});
|
|
18
|
+
}
|