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
@@ -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 { pianoNote, PianoMixer } from "../audio/piano.js";
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
- rasterSurface?.delete();
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?.image) {
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.getAttribute("id") === "buninu-piano-input" && audio && text.length === 1) {
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
- return styleEngine.get(element);
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 && pianoInput?.getAttribute?.("id") === "buninu-piano-input") {
2219
- const note = pianoNote(String(key), String(options.code ?? ""));
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
- key: String(key), code: String(options?.code ?? ""), repeat: Boolean(options?.repeat),
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?.delete();
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 (entry.text === null || document !== page.document || linkSheets.get(element) !== entry) return;
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) return;
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) return;
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
- mediaEvent(state, "pause");
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.done && video.frameTime <= now) {
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
- // wasmpeg fails a seek to 0 once frames were decoded; 1 ms reaches the
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) return 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
- stopClock(state, { keepAudio: holdAudio });
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
- mediaEvent(state, "pause");
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) return;
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) return;
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
+ }