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.
- package/README.md +13 -6
- package/index.js +10 -6
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +94 -46
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/parser.js +2 -0
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +2 -0
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +567 -197
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/cdp-viewers.test.js +43 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/index.test.js +1 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +35 -0
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
package/lib/headless-shell.js
CHANGED
|
@@ -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 =
|
|
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)
|
|
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
|
-
|
|
236
|
-
spare
|
|
237
|
-
|
|
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()`
|
|
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
|
-
|
|
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;
|