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
|
@@ -29,6 +29,10 @@ import { parseHTMLDocument } from "../happy-dom/parser.js";
|
|
|
29
29
|
import { LayoutEngine } from "../layout/text-layout.js";
|
|
30
30
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
31
31
|
import { elementAtPoint, elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
32
|
+
import { beginImageLoad, finishImageLoad } from "./image-loading.js";
|
|
33
|
+
import { dispatchPointerMove } from "./pointer-move.js";
|
|
34
|
+
import { keyboardInput } from "./keyboard-input.js";
|
|
35
|
+
import { playbackEvent } from "./media-playback-events.js";
|
|
32
36
|
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
33
37
|
import { absolutizeStyleSheetUrls, matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
34
38
|
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
@@ -42,7 +46,9 @@ import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
|
42
46
|
import { containerFormat } from "./media-source.js";
|
|
43
47
|
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
44
48
|
import { advanceAudio } from "../media/position-audio.js";
|
|
45
|
-
import {
|
|
49
|
+
import { seekVideoKeyframe } from "../media/video-keyframe.js";
|
|
50
|
+
import { PianoMixer } from "../audio/piano.js";
|
|
51
|
+
import { handlePianoInput } from "./piano-input.js";
|
|
46
52
|
|
|
47
53
|
// Captured at load: in host mode page scripts share this global object.
|
|
48
54
|
const hostPerformance = globalThis.performance;
|
|
@@ -67,11 +73,6 @@ const AUDIO_HISTORY_SECONDS = 3;
|
|
|
67
73
|
const RESUME_BUFFER_SECONDS = 2;
|
|
68
74
|
const RESUME_BUFFER_MAX_WAIT_MS = 6000;
|
|
69
75
|
const RESUME_BUFFER_IDLE_MS = 400;
|
|
70
|
-
// Finding a seek's keyframe (see seekToKeyframe): how far back it may lie,
|
|
71
|
-
// and the size of the pictures compared.
|
|
72
|
-
const KEYFRAME_SEARCH_SECONDS = 10;
|
|
73
|
-
const KEYFRAME_PROBE_WIDTH = 32;
|
|
74
|
-
const KEYFRAME_PROBE_HEIGHT = 18;
|
|
75
76
|
// element → MediaSource, and the reverse. One renderer owns one page pipeline.
|
|
76
77
|
const elementSources = new WeakMap();
|
|
77
78
|
const sourceElements = new WeakMap();
|
|
@@ -219,6 +220,7 @@ function buildPageRenderer({
|
|
|
219
220
|
consoleMessage = null,
|
|
220
221
|
trace = null,
|
|
221
222
|
onFrame = null,
|
|
223
|
+
onVideo = null,
|
|
222
224
|
onLifecycle = null,
|
|
223
225
|
videoDecoder = null,
|
|
224
226
|
// Prepares a classic script on another thread: (source) => Promise<{ code, parsed, strict }>.
|
|
@@ -463,6 +465,7 @@ function buildPageRenderer({
|
|
|
463
465
|
// PaintController reusing cached display items
|
|
464
466
|
// (platform/graphics/paint/paint_controller.cc, BSD).
|
|
465
467
|
let rasterized = { list: null, overlay: "", background: "", scale: 0 };
|
|
468
|
+
let nextVideoPresentation = 1;
|
|
466
469
|
let rasterSurface = null;
|
|
467
470
|
let rasterSurfaceWidth = 0;
|
|
468
471
|
let rasterSurfaceHeight = 0;
|
|
@@ -489,6 +492,7 @@ function buildPageRenderer({
|
|
|
489
492
|
|
|
490
493
|
/** Rasterizes the viewport and hands it to the compositor. */
|
|
491
494
|
function render() {
|
|
495
|
+
if (onVideo) for (const state of page.media.values()) readPresentedFrame(state);
|
|
492
496
|
const width = Math.max(1, Math.floor(page.width));
|
|
493
497
|
const height = Math.max(1, Math.floor(page.height));
|
|
494
498
|
const region = { x: viewport.x, y: viewport.y, width, height };
|
|
@@ -508,7 +512,8 @@ function buildPageRenderer({
|
|
|
508
512
|
const pixelWidth = Math.max(1, Math.round(region.width * scale));
|
|
509
513
|
const pixelHeight = Math.max(1, Math.round(region.height * scale));
|
|
510
514
|
if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
|
|
511
|
-
|
|
515
|
+
// MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
|
|
516
|
+
rasterSurface?.dispose();
|
|
512
517
|
rasterSurface = CanvasKit.MakeSurface(pixelWidth, pixelHeight);
|
|
513
518
|
rasterSurfaceWidth = pixelWidth;
|
|
514
519
|
rasterSurfaceHeight = pixelHeight;
|
|
@@ -529,6 +534,7 @@ function buildPageRenderer({
|
|
|
529
534
|
colorSpace: CanvasKit.ColorSpace.SRGB,
|
|
530
535
|
});
|
|
531
536
|
image.delete();
|
|
537
|
+
const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
|
|
532
538
|
page.needsPaint = false;
|
|
533
539
|
rasterized = {
|
|
534
540
|
list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
|
|
@@ -544,6 +550,7 @@ function buildPageRenderer({
|
|
|
544
550
|
height: region.height,
|
|
545
551
|
scale,
|
|
546
552
|
background: backgroundBytes(page.backgroundColor),
|
|
553
|
+
videoLayer,
|
|
547
554
|
};
|
|
548
555
|
}
|
|
549
556
|
|
|
@@ -623,7 +630,7 @@ function buildPageRenderer({
|
|
|
623
630
|
/** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
|
|
624
631
|
const paintFailures = new Set();
|
|
625
632
|
|
|
626
|
-
function paintDisplayList(canvas, displayList, view, scale) {
|
|
633
|
+
function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
|
|
627
634
|
if (!displayList) return;
|
|
628
635
|
const pixel = (value) => Math.round(value * scale);
|
|
629
636
|
const deviceRect = (rect) => CanvasKit.XYWHRect(
|
|
@@ -654,6 +661,9 @@ function buildPageRenderer({
|
|
|
654
661
|
try {
|
|
655
662
|
if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
|
|
656
663
|
if (item.clipRect) canvas.clipRect(deviceRect(item.clipRect), CanvasKit.ClipOp.Intersect, true);
|
|
664
|
+
for (const clip of item.roundedClips ?? []) {
|
|
665
|
+
canvas.clipRRect(deviceRRect(clip.rect, clip.radii), CanvasKit.ClipOp.Intersect, true);
|
|
666
|
+
}
|
|
657
667
|
// A scale in document space, as a device-space matrix around the item's device coordinates.
|
|
658
668
|
if (item.transform) {
|
|
659
669
|
const { a, d, e, f } = item.transform;
|
|
@@ -790,7 +800,10 @@ function buildPageRenderer({
|
|
|
790
800
|
canvas.clipRect(rect, CanvasKit.ClipOp.Intersect, true);
|
|
791
801
|
paint.setColor(CanvasKit.Color(0, 0, 0, 1));
|
|
792
802
|
canvas.drawRect(rect, paint);
|
|
793
|
-
if (resource
|
|
803
|
+
if (videoOverride?.resource === resource) {
|
|
804
|
+
paint.setColor(CanvasKit.Color(videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, videoOverride.white ? 255 : 0, 1));
|
|
805
|
+
canvas.drawRect(rect, paint);
|
|
806
|
+
} else if (resource?.image) {
|
|
794
807
|
canvas.drawImageRect(resource.image,
|
|
795
808
|
CanvasKit.XYWHRect(0, 0, resource.bitmapWidth ?? resource.width, resource.bitmapHeight ?? resource.height),
|
|
796
809
|
rect, null, false);
|
|
@@ -798,7 +811,7 @@ function buildPageRenderer({
|
|
|
798
811
|
const centerX = rect[0] + (rect[2] - rect[0]) / 2;
|
|
799
812
|
const centerY = rect[1] + (rect[3] - rect[1]) / 2;
|
|
800
813
|
paint.setColor(CanvasKit.Color(0, 0, 0, 0.65));
|
|
801
|
-
if (!resource?.playing) {
|
|
814
|
+
if (item.controls && !resource?.playing) {
|
|
802
815
|
canvas.drawCircle(centerX, centerY, 22 * scale, paint);
|
|
803
816
|
paint.setColor(CanvasKit.Color(255, 255, 255, 1));
|
|
804
817
|
const path = CanvasKit.Path.MakeFromSVGString(
|
|
@@ -806,7 +819,7 @@ function buildPageRenderer({
|
|
|
806
819
|
if (path) { canvas.drawPath(path, paint); path.delete(); }
|
|
807
820
|
}
|
|
808
821
|
// The control bar (play/pause, time, seekable progress) once playing or known.
|
|
809
|
-
if (resource?.playing || Number.isFinite(resource?.duration)) {
|
|
822
|
+
if (item.controls && (resource?.playing || Number.isFinite(resource?.duration))) {
|
|
810
823
|
drawMediaBar(canvas, item.rect.x - view.x, item.rect.y + item.rect.height - MEDIA_BAR_HEIGHT - view.y,
|
|
811
824
|
item.rect.width, MEDIA_BAR_HEIGHT, resource, scale, true);
|
|
812
825
|
}
|
|
@@ -1652,6 +1665,7 @@ function buildPageRenderer({
|
|
|
1652
1665
|
}
|
|
1653
1666
|
page.resources.set(element, resource);
|
|
1654
1667
|
page.decodedImages.push(resource.image);
|
|
1668
|
+
finishImageLoad(element, src, resource, type => element.dispatchEvent(new page.window.Event(type)));
|
|
1655
1669
|
}
|
|
1656
1670
|
page.dirty = true;
|
|
1657
1671
|
page.resourceLayoutDirty = true;
|
|
@@ -1679,13 +1693,7 @@ function buildPageRenderer({
|
|
|
1679
1693
|
if (element.tagName === "INPUT" && !["text", "search", "url", "email", "tel", "password"].includes(element.type)) return;
|
|
1680
1694
|
// Mobile IMEs often send committed text without a usable keydown (key 229
|
|
1681
1695
|
// or Unidentified). Handle that before the normal beforeinput insertion.
|
|
1682
|
-
if (!backspace && element
|
|
1683
|
-
const note = pianoNote(text);
|
|
1684
|
-
if (note !== null) {
|
|
1685
|
-
playPiano(note);
|
|
1686
|
-
return;
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1696
|
+
if (!backspace && handlePianoInput(element, { text }, audio ? playPiano : null)) return;
|
|
1689
1697
|
const value = String(element.value ?? "");
|
|
1690
1698
|
let start = Number.isInteger(element.selectionStart) ? element.selectionStart : value.length;
|
|
1691
1699
|
const end = Number.isInteger(element.selectionEnd) ? element.selectionEnd : start;
|
|
@@ -1819,7 +1827,12 @@ function buildPageRenderer({
|
|
|
1819
1827
|
computedStyle(element) {
|
|
1820
1828
|
if (!element) return null;
|
|
1821
1829
|
if (page.dirty) rebuildRenderTree("getComputedStyle");
|
|
1822
|
-
|
|
1830
|
+
const style = styleEngine.get(element);
|
|
1831
|
+
if (style?.padding.some(value => typeof value === "object")) {
|
|
1832
|
+
const box = page.layout?.boxes.find(box => page.renderTree.nodesById.get(box.nodeId)?.domNode === element);
|
|
1833
|
+
if (box) return { ...style, padding: box.style.padding };
|
|
1834
|
+
}
|
|
1835
|
+
return style;
|
|
1823
1836
|
},
|
|
1824
1837
|
layoutVersion() {
|
|
1825
1838
|
if (page.dirty) rebuildRenderTree("intersection observer");
|
|
@@ -2215,19 +2228,15 @@ function buildPageRenderer({
|
|
|
2215
2228
|
if (focusedFrame?.renderer) return focusedFrame.renderer.press(key, options);
|
|
2216
2229
|
refresh();
|
|
2217
2230
|
const pianoInput = page.document?.activeElement;
|
|
2218
|
-
if (audio
|
|
2219
|
-
|
|
2220
|
-
if (note !== null && !options.repeat && !options.modifiers) {
|
|
2221
|
-
playPiano(note);
|
|
2222
|
-
return { x: viewport.x, y: viewport.y };
|
|
2223
|
-
}
|
|
2231
|
+
if (handlePianoInput(pianoInput, { ...options, key }, audio ? playPiano : null)) {
|
|
2232
|
+
return { x: viewport.x, y: viewport.y };
|
|
2224
2233
|
}
|
|
2225
2234
|
// Pages see keydown/keyup first; preventDefault() cancels the default scroll.
|
|
2226
2235
|
if (page.realm) {
|
|
2227
2236
|
const target = page.document.activeElement ?? page.document.body;
|
|
2228
2237
|
const modifiers = Number(options?.modifiers) || 0;
|
|
2229
2238
|
const init = {
|
|
2230
|
-
|
|
2239
|
+
...keyboardInput(key, options), repeat: Boolean(options?.repeat),
|
|
2231
2240
|
altKey: Boolean(modifiers & 1), ctrlKey: Boolean(modifiers & 2),
|
|
2232
2241
|
metaKey: Boolean(modifiers & 4), shiftKey: Boolean(modifiers & 8),
|
|
2233
2242
|
bubbles: true, cancelable: true,
|
|
@@ -2272,10 +2281,37 @@ function buildPageRenderer({
|
|
|
2272
2281
|
scrollOffset() {
|
|
2273
2282
|
return { x: viewport.x, y: viewport.y };
|
|
2274
2283
|
},
|
|
2284
|
+
videoReport(report) {
|
|
2285
|
+
const state = [...page.media.values()].find(state => state.video?.presentation?.id === report.id);
|
|
2286
|
+
if (!state || !state.playing) return;
|
|
2287
|
+
if (report.error) {
|
|
2288
|
+
state.presentationFailed = true;
|
|
2289
|
+
stopVideoPresentation(state);
|
|
2290
|
+
const serial = state.seekSerial;
|
|
2291
|
+
void placeVideo(state, mediaTime(state)).then(() => {
|
|
2292
|
+
if (state.playing && state.document === page.document && state.seekSerial === serial) startMedia(state);
|
|
2293
|
+
}).catch(reportError);
|
|
2294
|
+
} else if (Number.isFinite(report.end)) {
|
|
2295
|
+
state.video.frameTime = report.end;
|
|
2296
|
+
if (needsMore(state.video)) void refillMedia(state, state.video);
|
|
2297
|
+
else {
|
|
2298
|
+
readPresentedFrame(state);
|
|
2299
|
+
stopVideoPresentation(state);
|
|
2300
|
+
state.video.done = true;
|
|
2301
|
+
}
|
|
2302
|
+
} else if (Number.isFinite(report.time)) state.video.shownTime = report.time;
|
|
2303
|
+
},
|
|
2275
2304
|
/** The wheel at a viewport point: the delta the page itself still has to scroll (see wheel). */
|
|
2276
2305
|
wheel(x, y, deltaX, deltaY, options = undefined) {
|
|
2277
2306
|
return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
|
|
2278
2307
|
},
|
|
2308
|
+
/** Move the pointer in viewport coordinates, including boundary events. */
|
|
2309
|
+
move(x, y) {
|
|
2310
|
+
refresh();
|
|
2311
|
+
const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport);
|
|
2312
|
+
dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y));
|
|
2313
|
+
refresh();
|
|
2314
|
+
},
|
|
2279
2315
|
/**
|
|
2280
2316
|
* Clicks at a viewport point: the page receives a click event first, and
|
|
2281
2317
|
* unless it calls preventDefault() a link under the point is returned for
|
|
@@ -2351,7 +2387,7 @@ function buildPageRenderer({
|
|
|
2351
2387
|
refresh();
|
|
2352
2388
|
if (prevented) return null;
|
|
2353
2389
|
}
|
|
2354
|
-
if (target.tagName === "AUDIO" || target.tagName === "VIDEO") {
|
|
2390
|
+
if ((target.tagName === "AUDIO" || target.tagName === "VIDEO") && target.hasAttribute("controls")) {
|
|
2355
2391
|
clickMedia(target, x, y);
|
|
2356
2392
|
return null;
|
|
2357
2393
|
}
|
|
@@ -2446,7 +2482,7 @@ function buildPageRenderer({
|
|
|
2446
2482
|
page.realm?.dispose();
|
|
2447
2483
|
closePiano();
|
|
2448
2484
|
disposeVideos();
|
|
2449
|
-
rasterSurface?.
|
|
2485
|
+
rasterSurface?.dispose();
|
|
2450
2486
|
rasterSurface = null;
|
|
2451
2487
|
page.window?.happyDOM?.abort?.();
|
|
2452
2488
|
for (const image of page.decodedImages) image.delete();
|
|
@@ -2497,9 +2533,10 @@ function buildPageRenderer({
|
|
|
2497
2533
|
}, () => {
|
|
2498
2534
|
// A failed stylesheet must not prevent the document itself from loading.
|
|
2499
2535
|
}).then(() => {
|
|
2500
|
-
if (
|
|
2536
|
+
if (document !== page.document || linkSheets.get(element) !== entry) return;
|
|
2501
2537
|
page.dirty = true;
|
|
2502
2538
|
scheduleFrame();
|
|
2539
|
+
element.dispatchEvent(new page.window.Event(entry.text === null ? "error" : "load"));
|
|
2503
2540
|
});
|
|
2504
2541
|
pending?.push(request);
|
|
2505
2542
|
}
|
|
@@ -2522,20 +2559,23 @@ function buildPageRenderer({
|
|
|
2522
2559
|
await Promise.all([...document.querySelectorAll("img")].map(async (element) => {
|
|
2523
2560
|
const src = element.getAttribute("src");
|
|
2524
2561
|
if (!src) return;
|
|
2562
|
+
beginImageLoad(element, src);
|
|
2525
2563
|
try {
|
|
2526
2564
|
const response = await fetchResource(new URL(src, documentUrl).href, "image");
|
|
2527
|
-
if (!response.ok)
|
|
2565
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2528
2566
|
// Untrusted image bytes are decoded here, inside the renderer.
|
|
2529
2567
|
const bytes = response.body;
|
|
2530
2568
|
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
2531
2569
|
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
2532
|
-
if (!image)
|
|
2570
|
+
if (!image) throw new Error("Image decoding failed");
|
|
2533
2571
|
const resource = Object.freeze({ width, height, image, url: response.url });
|
|
2572
|
+
finishImageLoad(element, src, resource);
|
|
2534
2573
|
map.set(element, resource);
|
|
2535
2574
|
decoded.push(image);
|
|
2536
2575
|
entries.push({ element, src, resource });
|
|
2537
2576
|
} catch {
|
|
2538
2577
|
// Broken images keep their fallback replaced-element dimensions.
|
|
2578
|
+
finishImageLoad(element, src, null);
|
|
2539
2579
|
}
|
|
2540
2580
|
}));
|
|
2541
2581
|
return { map, decoded, entries };
|
|
@@ -3135,10 +3175,88 @@ function buildPageRenderer({
|
|
|
3135
3175
|
|
|
3136
3176
|
/** The control bar and frame redraw without a relayout. */
|
|
3137
3177
|
function repaintMedia() {
|
|
3178
|
+
// The compositor replaces this video's pixels independently. Page changes
|
|
3179
|
+
// still repaint normally; native controls get their periodic time update.
|
|
3180
|
+
if ([...page.media.values()].some(state => state.video?.presentation && state.playing)
|
|
3181
|
+
&& !page.dirty && !page.needsPaint) return;
|
|
3138
3182
|
rasterized.list = null;
|
|
3139
3183
|
render();
|
|
3140
3184
|
}
|
|
3141
3185
|
|
|
3186
|
+
function stopVideoPresentation(state) {
|
|
3187
|
+
const presentation = state.video?.presentation;
|
|
3188
|
+
if (!presentation) return;
|
|
3189
|
+
readPresentedFrame(state);
|
|
3190
|
+
onVideo?.({ type: "video-stop", id: presentation.id });
|
|
3191
|
+
state.video.presentation = null;
|
|
3192
|
+
state.video.needsLocalPosition = true;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
function readPresentedFrame(state) {
|
|
3196
|
+
const p = state.video?.presentation;
|
|
3197
|
+
if (!p) return;
|
|
3198
|
+
const meta = new Int32Array(p.shared, 0, 4);
|
|
3199
|
+
const serial = Atomics.load(meta, 0);
|
|
3200
|
+
if (serial % 2 || !serial || serial === p.serial) return;
|
|
3201
|
+
const width = Atomics.load(meta, 1), height = Atomics.load(meta, 2);
|
|
3202
|
+
if (width < 1 || height < 1 || width * height > 480 * 480) return;
|
|
3203
|
+
const pixels = new Uint8Array(p.shared, 16, width * height * 4).slice();
|
|
3204
|
+
if (serial !== Atomics.load(meta, 0)) return;
|
|
3205
|
+
const image = CanvasKit.MakeImage({ width, height, alphaType: CanvasKit.AlphaType.Unpremul,
|
|
3206
|
+
colorType: CanvasKit.ColorType.RGBA_8888, colorSpace: CanvasKit.ColorSpace.SRGB }, pixels, width * 4);
|
|
3207
|
+
if (!image) return;
|
|
3208
|
+
state.resource.image?.delete();
|
|
3209
|
+
Object.assign(state.resource, { image, bitmapWidth: width, bitmapHeight: height });
|
|
3210
|
+
p.serial = serial;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
function makeVideoLayer(canvas, surface, region, scale, width, height) {
|
|
3214
|
+
if (!onVideo) return null;
|
|
3215
|
+
const items = [...visibleItems(page.displayList, region, viewport)].filter(item => item.op === "drawVideo");
|
|
3216
|
+
const item = items.length === 1 ? items[0] : null;
|
|
3217
|
+
const resource = item && page.displayList.images[item.image];
|
|
3218
|
+
const state = [...page.media.values()].find(state => state.resource === resource);
|
|
3219
|
+
const eligible = state?.playing && state.video && !state.video.done && !state.buffering
|
|
3220
|
+
&& !state.presentationFailed && !item.controls && !item.fixed
|
|
3221
|
+
&& (!item.transform || (item.transform.a > 0 && item.transform.d > 0));
|
|
3222
|
+
for (const other of page.media.values()) if (!eligible || other !== state) stopVideoPresentation(other);
|
|
3223
|
+
if (!eligible) return null;
|
|
3224
|
+
const video = state.video;
|
|
3225
|
+
if (!video.presentation) {
|
|
3226
|
+
const id = nextVideoPresentation++;
|
|
3227
|
+
const shared = new SharedArrayBuffer(16 + 480 * 480 * 4);
|
|
3228
|
+
video.presentation = { id, shared, serial: 0 };
|
|
3229
|
+
onVideo({ type: "video-open", id, shared, bytes: snapshot(video.pipe, state.bytes),
|
|
3230
|
+
options: decodeOptions(state.videoMime || state.contentType, state.url), time: mediaTime(state),
|
|
3231
|
+
hasAudio: state.hasAudio, audioId: state.sound?.id ?? null,
|
|
3232
|
+
audioStart: state.sound ? state.sound.streamStart / state.sound.decoder.sampleRate : 0 });
|
|
3233
|
+
}
|
|
3234
|
+
const rect = { ...item.rect };
|
|
3235
|
+
if (item.transform) {
|
|
3236
|
+
rect.x = rect.x * item.transform.a + item.transform.e;
|
|
3237
|
+
rect.y = rect.y * item.transform.d + item.transform.f;
|
|
3238
|
+
rect.width *= item.transform.a;
|
|
3239
|
+
rect.height *= item.transform.d;
|
|
3240
|
+
}
|
|
3241
|
+
const raster = white => {
|
|
3242
|
+
canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
|
|
3243
|
+
paintDisplayList(canvas, page.displayList, region, scale, { resource, white });
|
|
3244
|
+
drawHints(canvas, region);
|
|
3245
|
+
surface.flush();
|
|
3246
|
+
const image = surface.makeImageSnapshot();
|
|
3247
|
+
const pixels = image.readPixels(0, 0, { width, height, colorType: CanvasKit.ColorType.RGBA_8888,
|
|
3248
|
+
alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB });
|
|
3249
|
+
image.delete();
|
|
3250
|
+
return pixels;
|
|
3251
|
+
};
|
|
3252
|
+
const base = raster(false), mask = raster(true);
|
|
3253
|
+
for (let i = 0; i < mask.length; i++) mask[i] = Math.max(0, mask[i] - base[i]);
|
|
3254
|
+
return { id: video.presentation.id, base, mask, rect: {
|
|
3255
|
+
x: Math.round((rect.x - region.x) * scale), y: Math.round((rect.y - region.y) * scale),
|
|
3256
|
+
width: Math.round(rect.width * scale), height: Math.round(rect.height * scale),
|
|
3257
|
+
} };
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3142
3260
|
function isPipe(value) {
|
|
3143
3261
|
return Boolean(value && typeof value.waitFor === "function" && typeof value.bytes === "function");
|
|
3144
3262
|
}
|
|
@@ -3318,6 +3436,7 @@ function buildPageRenderer({
|
|
|
3318
3436
|
}
|
|
3319
3437
|
|
|
3320
3438
|
async function openVideoDecoder(state) {
|
|
3439
|
+
stopVideoPresentation(state);
|
|
3321
3440
|
const pipe = state.videoPipe ?? state.pipe;
|
|
3322
3441
|
const generation = pipe?.generation ?? 0;
|
|
3323
3442
|
const bytes = snapshot(pipe, state.bytes);
|
|
@@ -3462,6 +3581,7 @@ function buildPageRenderer({
|
|
|
3462
3581
|
if (!state || state.playing) return;
|
|
3463
3582
|
state.error = null;
|
|
3464
3583
|
setMediaPlaying(state, true);
|
|
3584
|
+
playbackEvent(state, "play", mediaEvent);
|
|
3465
3585
|
try {
|
|
3466
3586
|
await loadMedia(state);
|
|
3467
3587
|
if (state.ended) {
|
|
@@ -3484,7 +3604,6 @@ function buildPageRenderer({
|
|
|
3484
3604
|
return;
|
|
3485
3605
|
}
|
|
3486
3606
|
startMedia(state);
|
|
3487
|
-
mediaEvent(state, "play");
|
|
3488
3607
|
}
|
|
3489
3608
|
|
|
3490
3609
|
function pauseMedia(element) {
|
|
@@ -3495,7 +3614,7 @@ function buildPageRenderer({
|
|
|
3495
3614
|
stopClock(state);
|
|
3496
3615
|
state.needsPosition = true;
|
|
3497
3616
|
setMediaPlaying(state, false);
|
|
3498
|
-
|
|
3617
|
+
playbackEvent(state, "pause", mediaEvent);
|
|
3499
3618
|
}
|
|
3500
3619
|
|
|
3501
3620
|
async function seekMedia(element, seconds) {
|
|
@@ -3598,6 +3717,8 @@ function buildPageRenderer({
|
|
|
3598
3717
|
|
|
3599
3718
|
/** Starts the clock, the audio stream and the frame/time ticks. */
|
|
3600
3719
|
function startMedia(state) {
|
|
3720
|
+
hostClearTimeout(state.timer);
|
|
3721
|
+
state.timer = null;
|
|
3601
3722
|
// An output stream held through a stall (see refillMedia) is already
|
|
3602
3723
|
// open: the clock goes on from where its audio is, and what it asked for
|
|
3603
3724
|
// meanwhile is sent now.
|
|
@@ -3642,6 +3763,7 @@ function buildPageRenderer({
|
|
|
3642
3763
|
}
|
|
3643
3764
|
|
|
3644
3765
|
function stopClock(state, { keepAudio = false } = {}) {
|
|
3766
|
+
stopVideoPresentation(state);
|
|
3645
3767
|
hostClearTimeout(state.timer);
|
|
3646
3768
|
state.timer = null;
|
|
3647
3769
|
state.clockStart = null;
|
|
@@ -3680,7 +3802,18 @@ function buildPageRenderer({
|
|
|
3680
3802
|
if (!state.playing || state.generation !== loadGeneration || state.document !== page.document) return;
|
|
3681
3803
|
const now = mediaTime(state);
|
|
3682
3804
|
const video = state.video;
|
|
3683
|
-
if (video && !video.
|
|
3805
|
+
if (video?.needsLocalPosition && !video.presentation && !video.done) {
|
|
3806
|
+
try {
|
|
3807
|
+
video.frameTime = seekToKeyframe(video.decoder, now);
|
|
3808
|
+
video.hideUntil = now;
|
|
3809
|
+
video.needsLocalPosition = false;
|
|
3810
|
+
} catch (error) { failMedia(state, error, 3); return; }
|
|
3811
|
+
}
|
|
3812
|
+
if (video?.presentation) {
|
|
3813
|
+
// Decode and present on the compositor thread. This tick only updates
|
|
3814
|
+
// DOM media state; it must not decode the same movie a second time.
|
|
3815
|
+
video.frameTime = now + 1 / (video.decoder.fps || 30);
|
|
3816
|
+
} else if (video && !video.done && video.frameTime <= now) {
|
|
3684
3817
|
// Late frames are decoded but only the newest is shown.
|
|
3685
3818
|
const decodeDeadline = hostPerformance.now() + 100;
|
|
3686
3819
|
while (video.frameTime <= mediaTime(state) && !video.done) {
|
|
@@ -3713,6 +3846,10 @@ function buildPageRenderer({
|
|
|
3713
3846
|
}
|
|
3714
3847
|
}
|
|
3715
3848
|
state.resource.time = now;
|
|
3849
|
+
if (!state.buffering && !state.awaitingAudio) {
|
|
3850
|
+
playbackEvent(state, "playing", mediaEvent);
|
|
3851
|
+
if (!state.playing) return;
|
|
3852
|
+
}
|
|
3716
3853
|
repaintMedia();
|
|
3717
3854
|
if (Math.floor(now * 4) !== state.lastTimeUpdate) {
|
|
3718
3855
|
state.lastTimeUpdate = Math.floor(now * 4);
|
|
@@ -3796,7 +3933,8 @@ function buildPageRenderer({
|
|
|
3796
3933
|
if (!state.lastSyncTrace || now - state.lastSyncTrace >= 1000) {
|
|
3797
3934
|
state.lastSyncTrace = now;
|
|
3798
3935
|
trace?.(`media sync: audio played ${sound.playedTime.toFixed(3)}s, video shown ${(state.video?.shownTime ?? 0).toFixed(3)}s,`
|
|
3799
|
-
+ ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`
|
|
3936
|
+
+ ` output queued ${Math.max(0, event.sentSeconds - event.seconds).toFixed(3)}s, sink ${Number(event.sinkSeconds ?? 0).toFixed(3)}s`
|
|
3937
|
+
+ `, PCM reserve ${Number(event.bufferedSeconds ?? 0).toFixed(3)}s`);
|
|
3800
3938
|
}
|
|
3801
3939
|
} else if (event.event === "timing-error") {
|
|
3802
3940
|
// Do not silently switch to a free-running clock on missing feedback.
|
|
@@ -3830,6 +3968,8 @@ function buildPageRenderer({
|
|
|
3830
3968
|
}
|
|
3831
3969
|
});
|
|
3832
3970
|
sound.id = id;
|
|
3971
|
+
if (state.video?.presentation) onVideo?.({ type: "video-bind", id: state.video.presentation.id,
|
|
3972
|
+
audioId: id, audioStart: sound.streamStart / sound.decoder.sampleRate, time: mediaTime(state) });
|
|
3833
3973
|
}
|
|
3834
3974
|
|
|
3835
3975
|
/** Sends about `bytes` of s16le audio (pending samples first, then decoding); at the end, lets it drain. */
|
|
@@ -3838,7 +3978,7 @@ function buildPageRenderer({
|
|
|
3838
3978
|
if (!state.playing || sound.id === null || sound.finishing) return;
|
|
3839
3979
|
// While buffering the output plays what it holds and then nothing, as
|
|
3840
3980
|
// the video waits; what it asks for is sent when playing resumes.
|
|
3841
|
-
if (state.buffering || state.audioPositioning) {
|
|
3981
|
+
if (state.buffering || state.refilling || state.audioPositioning) {
|
|
3842
3982
|
sound.owed = (sound.owed ?? 0) + bytes;
|
|
3843
3983
|
return;
|
|
3844
3984
|
}
|
|
@@ -3918,42 +4058,7 @@ function buildPageRenderer({
|
|
|
3918
4058
|
* frame). The frames from there to the clock are then decoded unshown.
|
|
3919
4059
|
*/
|
|
3920
4060
|
function seekToKeyframe(decoder, seconds) {
|
|
3921
|
-
|
|
3922
|
-
// first keyframe as well.
|
|
3923
|
-
const seek = (time) => decoder.seek(Math.max(1, time * 1000));
|
|
3924
|
-
const frame = 1 / (decoder.fps || 30);
|
|
3925
|
-
if (!(seconds >= frame)) {
|
|
3926
|
-
seek(0);
|
|
3927
|
-
return 0;
|
|
3928
|
-
}
|
|
3929
|
-
const probe = (time) => {
|
|
3930
|
-
seek(time);
|
|
3931
|
-
return pictureKey(decoder.nextFrame(KEYFRAME_PROBE_WIDTH, KEYFRAME_PROBE_HEIGHT));
|
|
3932
|
-
};
|
|
3933
|
-
const target = probe(seconds);
|
|
3934
|
-
let keyframe = seconds;
|
|
3935
|
-
if (target !== null) {
|
|
3936
|
-
let earlier = Math.max(0, seconds - KEYFRAME_SEARCH_SECONDS);
|
|
3937
|
-
if (earlier > 0 && probe(earlier) !== target) {
|
|
3938
|
-
// The keyframe lies in (earlier, keyframe]: narrow it to a frame.
|
|
3939
|
-
while (keyframe - earlier > frame) {
|
|
3940
|
-
const middle = (earlier + keyframe) / 2;
|
|
3941
|
-
if (probe(middle) === target) keyframe = middle;
|
|
3942
|
-
else earlier = middle;
|
|
3943
|
-
}
|
|
3944
|
-
} else {
|
|
3945
|
-
keyframe = earlier;
|
|
3946
|
-
}
|
|
3947
|
-
}
|
|
3948
|
-
seek(keyframe);
|
|
3949
|
-
return keyframe;
|
|
3950
|
-
}
|
|
3951
|
-
|
|
3952
|
-
function pictureKey(pixels) {
|
|
3953
|
-
if (!pixels) return null;
|
|
3954
|
-
let hash = 0x811c9dc5;
|
|
3955
|
-
for (let index = 0; index < pixels.length; index += 3) hash = Math.imul(hash ^ pixels[index], 0x01000193);
|
|
3956
|
-
return hash >>> 0;
|
|
4061
|
+
return seekVideoKeyframe(decoder, seconds);
|
|
3957
4062
|
}
|
|
3958
4063
|
|
|
3959
4064
|
/**
|
|
@@ -3963,17 +4068,33 @@ function buildPageRenderer({
|
|
|
3963
4068
|
* new snapshot. The reported output cursor remains the A/V master clock.
|
|
3964
4069
|
*/
|
|
3965
4070
|
function refillMedia(state, stalledTrack = null) {
|
|
3966
|
-
if (state.refilling)
|
|
4071
|
+
if (state.refilling) {
|
|
4072
|
+
// An audio prefetch may already be waiting for a later segment. If the
|
|
4073
|
+
// picture catches its own end meanwhile, it really must wait too.
|
|
4074
|
+
if (stalledTrack === state.video && !state.buffering) {
|
|
4075
|
+
state.buffering = true;
|
|
4076
|
+
stopClock(state, { keepAudio: true });
|
|
4077
|
+
if (state.playing) playbackEvent(state, "waiting", mediaEvent);
|
|
4078
|
+
}
|
|
4079
|
+
return state.refilling;
|
|
4080
|
+
}
|
|
3967
4081
|
const serial = state.seekSerial;
|
|
3968
|
-
state.buffering = true;
|
|
3969
4082
|
state.base = mediaTime(state);
|
|
3970
4083
|
// The audio output stays open through a stall: it plays the PCM it holds
|
|
3971
4084
|
// and then runs dry, and playing resumes right after that PCM. Closing
|
|
3972
4085
|
// and reopening it instead loses track of what was heard (a closed
|
|
3973
4086
|
// stream may still play out its device buffer, which then plays twice).
|
|
3974
4087
|
const holdAudio = Boolean(stalledTrack && state.sound && state.sound.id != null && !state.sound.finishing);
|
|
4088
|
+
// PCM prefetch reaches the end of a snapshot ahead of actual playback.
|
|
4089
|
+
// Keep presenting video against measured audio while the controller plays
|
|
4090
|
+
// its reserve; a decoder needing bytes is not itself an output underrun.
|
|
4091
|
+
const prefetch = holdAudio && stalledTrack === state.sound;
|
|
4092
|
+
state.buffering = !prefetch;
|
|
3975
4093
|
const stall = { at: hostPerformance.now(), base: state.base };
|
|
3976
|
-
|
|
4094
|
+
if (!prefetch) {
|
|
4095
|
+
stopClock(state, { keepAudio: holdAudio });
|
|
4096
|
+
if (state.playing) playbackEvent(state, "waiting", mediaEvent);
|
|
4097
|
+
}
|
|
3977
4098
|
// The first await lets `job` be assigned before the body looks at it,
|
|
3978
4099
|
// so a stall inside startMedia can begin a new refill.
|
|
3979
4100
|
const job = (async () => {
|
|
@@ -4078,7 +4199,7 @@ function buildPageRenderer({
|
|
|
4078
4199
|
state.ended = true;
|
|
4079
4200
|
state.base = Number.isFinite(state.duration) ? state.duration : state.base;
|
|
4080
4201
|
setMediaPlaying(state, false);
|
|
4081
|
-
|
|
4202
|
+
playbackEvent(state, "pause", mediaEvent);
|
|
4082
4203
|
mediaEvent(state, "ended");
|
|
4083
4204
|
}
|
|
4084
4205
|
|
|
@@ -4187,18 +4308,20 @@ function buildPageRenderer({
|
|
|
4187
4308
|
const oldImage = page.resources.get(element)?.image;
|
|
4188
4309
|
if (oldImage) staleImages.push(oldImage);
|
|
4189
4310
|
page.resources.delete(element);
|
|
4311
|
+
beginImageLoad(element, src);
|
|
4190
4312
|
fetchResource(url, "image").then(async (response) => {
|
|
4191
|
-
if (!response.ok)
|
|
4313
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
4192
4314
|
const bytes = response.body;
|
|
4193
4315
|
const { width, height } = await new Bun.Image(bytes).metadata();
|
|
4194
4316
|
const image = CanvasKit.MakeImageFromEncoded(bytes);
|
|
4195
|
-
if (!image)
|
|
4317
|
+
if (!image) throw new Error("Image decoding failed");
|
|
4196
4318
|
if (generation !== loadGeneration || document !== page.document || element.getAttribute("src") !== src) {
|
|
4197
4319
|
image.delete();
|
|
4198
4320
|
return;
|
|
4199
4321
|
}
|
|
4200
4322
|
page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
|
|
4201
4323
|
page.decodedImages.push(image);
|
|
4324
|
+
finishImageLoad(element, src, { width, height }, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4202
4325
|
const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
|
|
4203
4326
|
const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
|
|
4204
4327
|
.find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
|
|
@@ -4219,8 +4342,12 @@ function buildPageRenderer({
|
|
|
4219
4342
|
page.resourceLayoutDirty = true;
|
|
4220
4343
|
}
|
|
4221
4344
|
refresh();
|
|
4222
|
-
}).catch(() => {
|
|
4345
|
+
}).catch((error) => {
|
|
4223
4346
|
// A failed image retains its fallback replaced-element dimensions.
|
|
4347
|
+
trace?.(`image failed ${url}: ${error?.message ?? error}`);
|
|
4348
|
+
if (generation === loadGeneration && document === page.document) {
|
|
4349
|
+
finishImageLoad(element, src, null, type => element.dispatchEvent(new page.window.Event(type)));
|
|
4350
|
+
}
|
|
4224
4351
|
});
|
|
4225
4352
|
}
|
|
4226
4353
|
return staleImages;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { pianoNote } from "../audio/piano.js";
|
|
2
|
+
|
|
3
|
+
// Keep a real editable input so mobile keyboards can open, but consume piano
|
|
4
|
+
// input before text editing. Terminals and IMEs may commit several keys at once.
|
|
5
|
+
export function handlePianoInput(element, input, play) {
|
|
6
|
+
if (element?.getAttribute?.("id") !== "buninu-piano-input" || element.disabled || element.readOnly) return false;
|
|
7
|
+
// CDP key events also carry text (often "" on rawKeyDown). Only a text-only
|
|
8
|
+
// commit belongs here; otherwise use key/code, including Space and F1–F12.
|
|
9
|
+
if (input.key === undefined && input.text !== undefined) {
|
|
10
|
+
for (const key of String(input.text)) {
|
|
11
|
+
const note = pianoNote(key);
|
|
12
|
+
if (note !== null) play?.(note);
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const note = pianoNote(String(input.key), String(input.code ?? ""));
|
|
17
|
+
if (note === null) return false;
|
|
18
|
+
// Suppressing text insertion must not depend on whether a note is retriggered
|
|
19
|
+
// or an audio backend is available. Shift still selects a physical piano key.
|
|
20
|
+
if (!input.repeat && !(Number(input.modifiers) & 7)) play?.(note);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const previousTargets = new WeakMap();
|
|
2
|
+
|
|
3
|
+
/** Deliver pointer/mouse boundary events before movement, as a real pointer does. */
|
|
4
|
+
export function dispatchPointerMove(window, realm, target, x, y) {
|
|
5
|
+
const document = window.document;
|
|
6
|
+
const previous = previousTargets.get(document) ?? null;
|
|
7
|
+
previousTargets.set(document, target);
|
|
8
|
+
const ancestors = node => { const list = []; for (; node?.nodeType === 1; node = node.parentElement) list.push(node); return list; };
|
|
9
|
+
const before = ancestors(previous), after = ancestors(target);
|
|
10
|
+
const send = (node, type, relatedTarget, bubbles = true) => {
|
|
11
|
+
if (!node) return;
|
|
12
|
+
const pointer = type.startsWith('pointer');
|
|
13
|
+
const Constructor = pointer ? window.PointerEvent : window.MouseEvent;
|
|
14
|
+
const event = new Constructor(type, { clientX: x, clientY: y, relatedTarget, bubbles,
|
|
15
|
+
composed: bubbles, cancelable: bubbles, button: pointer ? -1 : 0, buttons: 0,
|
|
16
|
+
pointerId: 1, pointerType: 'mouse', isPrimary: true });
|
|
17
|
+
if (realm) realm.dispatch(node, event); else node.dispatchEvent(event);
|
|
18
|
+
};
|
|
19
|
+
if (previous !== target) {
|
|
20
|
+
for (const prefix of ['pointer', 'mouse']) {
|
|
21
|
+
send(previous, `${prefix}out`, target);
|
|
22
|
+
for (const node of before) if (!after.includes(node)) send(node, `${prefix}leave`, target, false);
|
|
23
|
+
send(target, `${prefix}over`, previous);
|
|
24
|
+
for (const node of after.toReversed()) if (!before.includes(node)) send(node, `${prefix}enter`, previous, false);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
send(target, 'pointermove', null);
|
|
28
|
+
send(target, 'mousemove', null);
|
|
29
|
+
}
|