buninu-browser 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -90,7 +90,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
90
90
  * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
91
91
  */
92
92
  export async function createBrowser({
93
- allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
93
+ allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 0, mobile = false,
94
94
  waitForRenderer = true, webkit = true,
95
95
  clipboard = null,
96
96
  } = {}) {
@@ -105,6 +105,7 @@ export async function createBrowser({
105
105
  let current = null;
106
106
  let currentKey = null;
107
107
  let spare = null;
108
+ let sparePreparation = null;
108
109
  let closed = false;
109
110
  // Settles when the first renderer shows about:blank (assigned below).
110
111
  let rendererReady = null;
@@ -195,8 +196,8 @@ export async function createBrowser({
195
196
  if (host !== current) return;
196
197
  void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
197
198
  },
198
- onFrame: () => {
199
- if (host === current) notify(frameListeners);
199
+ onFrame: (frame) => {
200
+ if (host === current) notify(frameListeners, frame);
200
201
  },
201
202
  onLifecycle: (name, generation) => {
202
203
  let generations = lifecycleSeen.get(host);
@@ -207,7 +208,11 @@ export async function createBrowser({
207
208
  generations.set(generation, names);
208
209
  if (names.has(name)) return;
209
210
  names.add(name);
210
- if (committed.host === host && committed.generation === generation) notify(lifecycleListeners, name);
211
+ if (committed.host === host && committed.generation === generation) {
212
+ if (name === "load" && sparePreparation?.host === host && sparePreparation.generation === generation)
213
+ armSparePreparation(sparePreparation, 1_000);
214
+ notify(lifecycleListeners, name);
215
+ }
211
216
  },
212
217
  });
213
218
  return host;
@@ -232,12 +237,45 @@ export async function createBrowser({
232
237
 
233
238
  function prepareSpare() {
234
239
  if (!spareRenderer || closed || spare) return;
235
- spare = createHost();
236
- spare.start().catch(() => {
237
- spare = null;
240
+ const preparing = createHost();
241
+ spare = preparing;
242
+ preparing.start().catch(() => {
243
+ // An acquired spare may fail after its successor has been scheduled.
244
+ // Only this host owns its slot; never clear a newer spare's reference.
245
+ if (spare === preparing) {
246
+ spare = null;
247
+ preparing.close();
248
+ }
238
249
  });
239
250
  }
240
251
 
252
+ function cancelSparePreparation() {
253
+ if (sparePreparation) clearTimeout(sparePreparation.timer);
254
+ sparePreparation = null;
255
+ }
256
+
257
+ function armSparePreparation(request, delay) {
258
+ clearTimeout(request.timer);
259
+ request.timer = setTimeout(() => {
260
+ if (sparePreparation !== request) return;
261
+ sparePreparation = null;
262
+ if (current === request.host && committed.host === request.host
263
+ && committed.generation === request.generation) prepareSpare();
264
+ }, delay);
265
+ request.timer.unref?.();
266
+ }
267
+
268
+ function scheduleSparePreparation(host, generation) {
269
+ cancelSparePreparation();
270
+ if (!spareRenderer || closed || spare || host !== current) return;
271
+ const request = { host, generation, timer: null };
272
+ sparePreparation = request;
273
+ // Delay after load, rather than competing with the new page's initial
274
+ // scripts/layout. This is not CPU-idle detection. A bounded fallback
275
+ // still warms a spare for pages whose load event never arrives.
276
+ armSparePreparation(request, reachedLifecycle(host, generation).has("load") ? 1_000 : 30_000);
277
+ }
278
+
241
279
  /** The renderer for a URL's origin, swapping processes on an origin change. */
242
280
  async function rendererFor(url) {
243
281
  let key = originKey(url);
@@ -264,7 +302,6 @@ export async function createBrowser({
264
302
  current = next;
265
303
  currentKey = key;
266
304
  previous?.close();
267
- prepareSpare();
268
305
  return current;
269
306
  }
270
307
 
@@ -345,12 +382,14 @@ export async function createBrowser({
345
382
  selectRenderer = url => rendererFor(url, { qmlHost });
346
383
  // qml handle end
347
384
 
385
+ cancelSparePreparation();
348
386
  const host = invoke ? current : await selectRenderer(document.url);
349
387
  let generation;
350
388
  try {
351
389
  generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
352
390
  } finally { documentReady.get(host)?.resolve(); }
353
391
  committed = { host, generation };
392
+ scheduleSparePreparation(host, generation);
354
393
  for (const name of reachedLifecycle(host, generation)) notify(lifecycleListeners, name);
355
394
  writeLog("navigation", `${document.url}: first paint in ${Math.round(performance.now() - fetched)}ms`);
356
395
  }
@@ -502,7 +541,7 @@ export async function createBrowser({
502
541
  press: (key, options) => call("press", key, options),
503
542
  type: (text) => call("type", text),
504
543
  scrollOffset: () => composite("scrollOffset"),
505
- /** Calls `listener()` whenever what the viewport shows changes; returns an unsubscribe function. */
544
+ /** Calls `listener(frame?)` when the viewport changes, with its measured scroll offset when available. */
506
545
  onFrame(listener) {
507
546
  frameListeners.add(listener);
508
547
  return () => frameListeners.delete(listener);
@@ -573,6 +612,7 @@ export async function createBrowser({
573
612
  },
574
613
  close() {
575
614
  closed = true;
615
+ cancelSparePreparation();
576
616
  current?.close();
577
617
  spare?.close();
578
618
  current = null;
@@ -0,0 +1,89 @@
1
+ import { sameLayoutInput } from "../render-tree/index.js";
2
+
3
+ // These properties change raster output, not box/text metrics. Inline
4
+ // decoration boxes still need the guard below. Keep this list small.
5
+ const PAINT_PROPERTIES = new Set([
6
+ "opacity", "backgroundColor",
7
+ "backgroundSize", "backgroundPosition", "backgroundRepeat", "backgroundOrigin", "backgroundClip",
8
+ "transformOrigin",
9
+ ]);
10
+
11
+ /** Retain immutable geometry when paint changes preserve layout records. */
12
+ export function repaintLayout(layout, previous, next) {
13
+ if (!sameLayoutInput(previous.root, next.root, { ignoreStyles: true })) return null;
14
+ const changes = new Map();
15
+ const styles = new Map();
16
+ for (const [id, node] of next.nodesById) {
17
+ const before = previous.nodesById.get(id)?.style, after = node.style;
18
+ // Anonymous fragments have no node ID and must be restyled by their old
19
+ // style identity. If that identity split into different new styles, only
20
+ // layout can determine which fragment belongs to which new owner.
21
+ if (styles.has(before) && !Bun.deepEquals(styles.get(before), after)) return null;
22
+ if (before === after) {
23
+ styles.set(before, before);
24
+ continue;
25
+ }
26
+ if (!before || !after) return null;
27
+ const paint = {};
28
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
29
+ if (Bun.deepEquals(before[key], after[key])) continue;
30
+ if (key === "transform") {
31
+ // Translation is currently baked into layout coordinates. Only scale
32
+ // changes within an existing transform can reuse those coordinates.
33
+ const a = before[key], b = after[key];
34
+ if (!a || !b || !Bun.deepEquals(a.x, b.x) || !Bun.deepEquals(a.y, b.y)
35
+ || Object.keys(a).some(k => !["x", "y", "scaleX", "scaleY"].includes(k))
36
+ || Object.keys(b).some(k => !["x", "y", "scaleX", "scaleY"].includes(k))) return null;
37
+ } else if (!PAINT_PROPERTIES.has(key)) return null;
38
+ paint[key] = after[key];
39
+ }
40
+ if (Object.keys(paint).length) {
41
+ // Layout only emits inline/replaced decoration boxes when a background
42
+ // or border is present. Adding/removing one requires those boxes to be
43
+ // regenerated, even though the text/image metrics have not changed.
44
+ if ((node.type === "inline" || node.type === "image")
45
+ && hasDecoration(before) !== hasDecoration(after)) return null;
46
+ changes.set(id, paint);
47
+ styles.set(before, after);
48
+ } else styles.set(before, before);
49
+ }
50
+ const restyle = record => {
51
+ const paint = changes.get(record.nodeId);
52
+ let style = styles.get(record.style);
53
+ // Some layout records contain resolved/adjusted styles (e.g. tables).
54
+ // Preserve all of those metrics instead of replacing the complete style.
55
+ if (!style && paint && record.style) {
56
+ style = Object.freeze({ ...record.style, ...paint });
57
+ styles.set(record.style, style);
58
+ }
59
+ // Boxes, runs and anonymous line fragments can share one computed style.
60
+ // Keep that identity: background-clip:text finds its gradient by style.
61
+ return style && style !== record.style ? Object.freeze({ ...record, style }) : record;
62
+ };
63
+ return Object.freeze({ ...layout, generation: next.generation,
64
+ boxes: mapChanged(layout.boxes, restyle),
65
+ fragments: mapChanged(layout.fragments, fragment => {
66
+ const updated = restyle(fragment);
67
+ const runs = fragment.runs && mapChanged(fragment.runs, restyle);
68
+ return runs === fragment.runs ? updated : Object.freeze({ ...updated, runs });
69
+ }),
70
+ });
71
+ }
72
+
73
+ // Keep in sync with the inline decoration predicate in text-layout.js.
74
+ function hasDecoration(style) {
75
+ return Boolean((style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)")
76
+ || style.backgroundImage || style.borderWidths?.some(width => width > 0));
77
+ }
78
+
79
+ /** Copy immutable arrays only when a record actually changed. */
80
+ function mapChanged(records, update) {
81
+ let changed = null;
82
+ for (let index = 0; index < records.length; index++) {
83
+ const record = records[index], next = update(record);
84
+ if (next === record) continue;
85
+ changed ??= records.slice();
86
+ changed[index] = next;
87
+ }
88
+ return changed ? Object.freeze(changed) : records;
89
+ }
@@ -0,0 +1,144 @@
1
+ // Fragmented MP4 sync-sample decode times are seek candidates, not decoded
2
+ // presentation timestamps. The caller must verify them against its decoder.
3
+ // Unsupported layouts return null and retain the ordinary seek search.
4
+ const cache = new WeakMap();
5
+ const MAX_BOXES = 32768, MAX_SAMPLES = 250000, MAX_KEYS = 16384;
6
+
7
+ export function mp4KeyframeBefore(bytes, seconds) {
8
+ if (!(bytes instanceof Uint8Array) || !Number.isFinite(seconds)) return null;
9
+ let index = cache.get(bytes);
10
+ if (index === undefined) {
11
+ try { index = inspect(bytes); } catch { index = null; }
12
+ cache.set(bytes, index);
13
+ }
14
+ if (!index?.length) return null;
15
+ let low = 0, high = index.length;
16
+ while (low < high) {
17
+ const mid = (low + high) >>> 1;
18
+ if (index[mid] <= seconds) low = mid + 1;
19
+ else high = mid;
20
+ }
21
+ return low ? index[low - 1] : null;
22
+ }
23
+
24
+ function inspect(bytes) {
25
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
26
+ let boxesSeen = 0, samplesSeen = 0;
27
+ const u32 = (at, end) => {
28
+ if (at < 0 || at + 4 > end) throw Error('short MP4 field');
29
+ return view.getUint32(at);
30
+ };
31
+ const u64 = (at, end) => {
32
+ const value = u32(at, end) * 0x100000000 + u32(at + 4, end);
33
+ if (!Number.isSafeInteger(value)) throw Error('large MP4 timestamp');
34
+ return value;
35
+ };
36
+ const boxes = (from, end) => {
37
+ const result = [];
38
+ for (let at = from; at < end;) {
39
+ if (++boxesSeen > MAX_BOXES) throw Error('too many MP4 boxes');
40
+ let size = u32(at, end), header = 8;
41
+ const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
42
+ if (at + header > end) throw Error('short MP4 box');
43
+ if (size === 1) { size = u64(at + 8, end); header = 16; }
44
+ else if (size === 0) size = end - at;
45
+ if (size < header || size > end - at) throw Error('invalid MP4 box size');
46
+ result.push({ type, data: at + header, end: at + size });
47
+ at += size;
48
+ }
49
+ return result;
50
+ };
51
+ const children = box => boxes(box.data, box.end);
52
+ const one = (list, type) => {
53
+ const matches = list.filter(box => box.type === type);
54
+ if (matches.length !== 1) throw Error(`missing/duplicate ${type}`);
55
+ return matches[0];
56
+ };
57
+ const version = box => {
58
+ u32(box.data, box.end);
59
+ if (bytes[box.data] > 1) throw Error('unknown MP4 version');
60
+ return bytes[box.data];
61
+ };
62
+ const flags = box => u32(box.data, box.end) & 0xffffff;
63
+ const top = boxes(0, bytes.length), movie = children(one(top, 'moov'));
64
+ const defaults = new Map();
65
+ for (const box of children(one(movie, 'mvex'))) if (box.type === 'trex') {
66
+ if (version(box) !== 0 || defaults.size >= 32) return null;
67
+ const id = u32(box.data + 4, box.end);
68
+ if (defaults.has(id) || u32(box.data + 8, box.end) !== 1) return null;
69
+ defaults.set(id, { duration: u32(box.data + 12, box.end), flags: u32(box.data + 20, box.end) });
70
+ }
71
+ let track = null;
72
+ for (const box of movie) if (box.type === 'trak') {
73
+ const nodes = children(box), media = children(one(nodes, 'mdia'));
74
+ const handler = one(media, 'hdlr');
75
+ if (u32(handler.data + 8, handler.end) !== 0x76696465) continue; // vide
76
+ if (track) return null;
77
+ const edits = nodes.filter(box => box.type === 'edts');
78
+ if (edits.length > 1) return null;
79
+ if (edits.length) {
80
+ const edit = one(children(edits[0]), 'elst'), wide = version(edit) === 1;
81
+ if (u32(edit.data + 4, edit.end) !== 1) return null;
82
+ const mediaTimeAt = edit.data + (wide ? 16 : 12), rateAt = edit.data + (wide ? 24 : 16);
83
+ if (u32(mediaTimeAt, edit.end) & 0x80000000 || u32(rateAt, edit.end) !== 0x00010000) return null;
84
+ // Only a single forward unit-rate edit is considered. Its offset is
85
+ // deliberately not presented as a decoder timestamp: seek boundary
86
+ // verification must still succeed before this candidate can be used.
87
+ }
88
+ const header = one(nodes, 'tkhd'), timing = one(media, 'mdhd');
89
+ const id = u32(header.data + (version(header) ? 20 : 12), header.end);
90
+ const timescale = u32(timing.data + (version(timing) ? 20 : 12), timing.end);
91
+ if (!timescale || !defaults.has(id)) return null;
92
+ track = { id, timescale, ...defaults.get(id) };
93
+ }
94
+ if (!track) return null;
95
+ const keys = [];
96
+ let lastDecodeTime = -1;
97
+ for (const moof of top) if (moof.type === 'moof') {
98
+ for (const traf of children(moof)) if (traf.type === 'traf') {
99
+ const nodes = children(traf), header = one(nodes, 'tfhd');
100
+ if (u32(header.data + 4, header.end) !== track.id) continue;
101
+ if (version(header) !== 0 || nodes.some(box => box.type === 'sbgp' || box.type === 'sgpd')) return null;
102
+ const hf = flags(header);
103
+ if (hf & ~0x02003b) return null;
104
+ let cursor = header.data + 8, duration = track.duration, sampleFlags = track.flags;
105
+ if (hf & 1) cursor += 8;
106
+ if (hf & 2) { if (u32(cursor, header.end) !== 1) return null; cursor += 4; }
107
+ if (hf & 8) { duration = u32(cursor, header.end); cursor += 4; }
108
+ if (hf & 16) cursor += 4;
109
+ if (hf & 32) { sampleFlags = u32(cursor, header.end); cursor += 4; }
110
+ if (cursor > header.end) return null;
111
+ const timing = one(nodes, 'tfdt');
112
+ let time = version(timing) ? u64(timing.data + 4, timing.end) : u32(timing.data + 4, timing.end);
113
+ for (const run of nodes) if (run.type === 'trun') {
114
+ version(run);
115
+ const rf = flags(run), count = u32(run.data + 4, run.end);
116
+ if (rf & ~0xf05 || (rf & 4) && (rf & 0x400)) return null;
117
+ samplesSeen += count;
118
+ if (samplesSeen > MAX_SAMPLES) return null;
119
+ cursor = run.data + 8;
120
+ if (rf & 1) cursor += 4;
121
+ let firstFlags = sampleFlags;
122
+ if (rf & 4) { firstFlags = u32(cursor, run.end); cursor += 4; }
123
+ for (let i = 0; i < count; i++) {
124
+ let step = duration, sf = i === 0 ? firstFlags : sampleFlags;
125
+ if (rf & 0x100) { step = u32(cursor, run.end); cursor += 4; }
126
+ if (rf & 0x200) cursor += 4;
127
+ if (rf & 0x400) { sf = u32(cursor, run.end); cursor += 4; }
128
+ if (rf & 0x800) cursor += 4; // composition offset does not change decode time
129
+ if (cursor > run.end || !step || time < lastDecodeTime) return null;
130
+ const depends = (sf >>> 24) & 3;
131
+ if (depends === 3) return null;
132
+ if (!(sf & 0x10000) && depends !== 1) {
133
+ if (keys.length >= MAX_KEYS) return null;
134
+ keys.push(time / track.timescale);
135
+ }
136
+ lastDecodeTime = time;
137
+ time += step;
138
+ if (!Number.isSafeInteger(time)) return null;
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return keys;
144
+ }
@@ -1,20 +1,48 @@
1
1
  // wasmpeg seeks to a keyframe without returning its timestamp. Locate it by
2
2
  // comparing tiny decoded probes, then rewind before delivering full frames.
3
- export function seekVideoKeyframe(decoder, seconds, startTime = 0) {
3
+ import { mp4KeyframeBefore } from './mp4-keyframes.js';
4
+
5
+ export function seekVideoKeyframe(decoder, seconds, startTime = 0, bytes = null) {
4
6
  const start = Math.max(0, startTime);
5
7
  seconds = Math.max(start, seconds);
6
8
  const seek = time => decoder.seek(Math.max(1, time * 1000));
7
9
  const frame = 1 / (decoder.fps || 30);
8
10
  if (!(seconds >= start + frame)) { seek(start); return start; }
11
+ let lastPixels = null;
9
12
  const probe = time => {
10
13
  seek(time);
11
14
  const pixels = decoder.nextFrame(32, 18);
15
+ lastPixels = pixels;
12
16
  if (!pixels) return null;
13
17
  let hash = 0x811c9dc5;
14
18
  for (let i = 0; i < pixels.length; i += 3) hash = Math.imul(hash ^ pixels[i], 0x01000193);
15
19
  return hash >>> 0;
16
20
  };
17
21
  const target = probe(seconds);
22
+ const candidate = bytes && mp4KeyframeBefore(bytes, seconds);
23
+ if (target !== null && candidate !== null && candidate >= start && frame >= 0.001) {
24
+ // A container index narrows the search; it cannot establish what a
25
+ // decoder actually returned. Require the same complete tiny picture at
26
+ // the candidate and a different one just before its seek boundary.
27
+ // This preserves the existing decode-time convention, not guessed PTS.
28
+ const targetPixels = lastPixels.slice();
29
+ const samePicture = () => lastPixels?.length === targetPixels.length
30
+ && targetPixels.every((byte, i) => byte === lastPixels[i]);
31
+ // The decoder's seek API rounds to integer milliseconds; do not round
32
+ // a fractional keyframe time down into the preceding GOP.
33
+ const candidateSeek = Math.ceil(candidate * 1000) / 1000;
34
+ try {
35
+ probe(candidateSeek);
36
+ if (samePicture()) {
37
+ const atStart = candidate === start;
38
+ if (!atStart) probe(Math.max(start, candidate - frame));
39
+ if (atStart || (lastPixels && !samePicture())) { seek(candidateSeek); return candidate; }
40
+ }
41
+ } catch {
42
+ // An optional index hint must not turn a working decoder search into
43
+ // an open failure. Errors from the original search still propagate.
44
+ }
45
+ }
18
46
  let keyframe = seconds;
19
47
  if (target !== null) {
20
48
  let distance = 10;