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.
Files changed (39) hide show
  1. package/ai-debug.log +5537 -0
  2. package/lib/audio/playback-buffer.js +27 -0
  3. package/lib/audio/pulse-client.js +18 -8
  4. package/lib/cdp-server.js +6 -0
  5. package/lib/happy-dom/parser.js +5 -0
  6. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  7. package/lib/headless-shell.js +3 -0
  8. package/lib/layout/text-layout.js +76 -40
  9. package/lib/media/video-keyframe.js +29 -0
  10. package/lib/paint/display-list.js +20 -5
  11. package/lib/render-tree/index.js +2 -0
  12. package/lib/renderer/bindings.js +11 -2
  13. package/lib/renderer/compositor.js +16 -1
  14. package/lib/renderer/custom-element-reactions.js +18 -0
  15. package/lib/renderer/history.js +42 -0
  16. package/lib/renderer/host.js +8 -1
  17. package/lib/renderer/image-loading.js +27 -0
  18. package/lib/renderer/keyboard-input.js +12 -0
  19. package/lib/renderer/main.js +3 -1
  20. package/lib/renderer/media-playback-events.js +13 -0
  21. package/lib/renderer/page-realm.js +9 -50
  22. package/lib/renderer/page-renderer.js +206 -79
  23. package/lib/renderer/piano-input.js +22 -0
  24. package/lib/renderer/pointer-move.js +29 -0
  25. package/lib/renderer/video-presentation.js +126 -0
  26. package/lib/style/computed-style.js +24 -7
  27. package/package.json +1 -1
  28. package/test/compositor.test.js +38 -0
  29. package/test/computed-style.test.js +21 -0
  30. package/test/display-list.test.js +14 -0
  31. package/test/download.test.js +8 -0
  32. package/test/headless-shell.test.js +22 -0
  33. package/test/media-playback-events.test.js +22 -0
  34. package/test/page-scripts.test.js +159 -1
  35. package/test/piano-input.test.js +44 -0
  36. package/test/playback-buffer.test.js +31 -0
  37. package/test/pulse-client.test.js +17 -2
  38. package/test/text-layout.test.js +62 -0
  39. 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. `onRequest(bytes)` asks
336
- * the producer for about that much more; audio it has not been asked for is
337
- * never requested twice.
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
- while (this.#credit > 0 && this.#queuedBytes > 0 && this.#offset < allowed) {
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
- // Ask for what the clock will allow next that is neither queued nor already asked for.
458
- const need = this.#frameAligned(Math.min(this.#offset + this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000)
459
- - this.#offset - this.#queuedBytes - this.#outstanding);
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") {
@@ -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
+ };
@@ -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, style?.position === "relative" ? containing : null);
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 [top, right, bottom, left] = (style?.inset ?? ["auto", "auto", "auto", "auto"])
515
- .map((value, index) => value === "auto" ? null : resolveLength(value, index % 2 ? containing.width : containing.height));
516
- const margin = style?.margin ?? ZERO_BOX;
517
- const [marginTop, marginRight, marginBottom, marginLeft] = margin.map(numberOrZero);
518
- const specifiedWidth = numberOrNull(resolveLength(style?.width, containing.width));
519
- let width;
520
- if (specifiedWidth == null && left != null && right != null) {
521
- width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
522
- } else {
523
- width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
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 style = box.style;
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
- cursor = childY + result.height;
722
- pending = bottomStrut(child);
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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?.padding ?? ZERO_BOX;
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.padding ?? ZERO_BOX;
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.padding ?? ZERO_BOX;
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
- items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
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
- clips.push([node, overflowClip(node)]);
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
- for (const [node, clip] of clips) {
440
+ const roundedClips = [];
441
+ for (const [node, clip, radii] of clips) {
433
442
  const matrix = transforms.get(node);
434
- clipRect = intersectRects(clipRect, clip && matrix ? transformRect(matrix, clip) : clip);
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
 
@@ -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;
@@ -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 { pixels, width, height } = cropViewport(tile, viewport.x, viewport.y, viewport.width, viewport.height);
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
+ }