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
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { ebmlHeader } from './webm-stream.js';
|
|
2
|
+
|
|
3
|
+
const CLUSTER = 0x1f43b675;
|
|
4
|
+
const MAX_RECORDS = 250_000;
|
|
5
|
+
const OPUS_RATE = 48_000;
|
|
6
|
+
const join = parts => parts.length === 1 ? parts[0] : new Uint8Array(Bun.concatArrayBuffers(parts));
|
|
7
|
+
|
|
8
|
+
// Decode-time snapshots for a single Opus track. A PCM-only decoder cannot
|
|
9
|
+
// represent holes in a WebM timeline: concatenating 0..2s and 6..8s produces
|
|
10
|
+
// four seconds of samples, not eight. Keep each contiguous run independently
|
|
11
|
+
// decodable, without changing any packet or inventing samples for the hole.
|
|
12
|
+
export function createWebMAudioSnapshots() {
|
|
13
|
+
const headers = [], records = [];
|
|
14
|
+
let scale = 1_000_000, track = null, valid = true, runs = null, serial = 0, ordered = true;
|
|
15
|
+
let preroll = 1, cached = null, initHeaders = null;
|
|
16
|
+
const uint = (bytes, from, size) => {
|
|
17
|
+
if (size < 1 || size > 6 || from + size > bytes.length) throw Error('invalid integer');
|
|
18
|
+
let value = 0;
|
|
19
|
+
for (let i = 0; i < size; i++) value = value * 256 + bytes[from + i];
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
const children = (bytes, from, end) => {
|
|
23
|
+
const result = [];
|
|
24
|
+
for (let at = from; at < end;) {
|
|
25
|
+
if (result.length >= 65_536) throw Error('too many elements');
|
|
26
|
+
const head = ebmlHeader(bytes, at);
|
|
27
|
+
if (!head || !Number.isFinite(head.size) || head.size > end - at - head.length) throw Error('incomplete element');
|
|
28
|
+
const data = at + head.length;
|
|
29
|
+
result.push({ id: head.id, start: at, data, size: head.size, end: data + head.size });
|
|
30
|
+
at = data + head.size;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
const payload = bytes => {
|
|
35
|
+
const head = ebmlHeader(bytes);
|
|
36
|
+
if (!head || head.size !== bytes.length - head.length) throw Error('incomplete element');
|
|
37
|
+
return children(bytes, head.length, bytes.length);
|
|
38
|
+
};
|
|
39
|
+
const opusSamples = (bytes, at, end) => {
|
|
40
|
+
if (at >= end) throw Error('empty packet');
|
|
41
|
+
const toc = bytes[at], config = toc >> 3;
|
|
42
|
+
const frame = config >= 16 ? 2.5 * 2 ** (config & 3)
|
|
43
|
+
: config >= 12 ? 10 * 2 ** (config & 1) : [10, 20, 40, 60][config & 3];
|
|
44
|
+
const code = toc & 3;
|
|
45
|
+
if (code === 3 && at + 1 >= end) throw Error('incomplete packet');
|
|
46
|
+
const count = code === 0 ? 1 : code === 3 ? bytes[at + 1] & 63 : 2;
|
|
47
|
+
const samples = frame * count * 48;
|
|
48
|
+
if (!count || samples > 5760) throw Error('invalid Opus duration');
|
|
49
|
+
return samples;
|
|
50
|
+
};
|
|
51
|
+
const blockRange = (bytes, block, origin) => {
|
|
52
|
+
const from = block.data;
|
|
53
|
+
if (!bytes[from]) throw Error('invalid track');
|
|
54
|
+
let length = 1, marker = 0x80;
|
|
55
|
+
while (!(bytes[from] & marker)) { length++; marker >>= 1; }
|
|
56
|
+
if (length > 6 || from + length + 3 > block.end) throw Error('invalid block');
|
|
57
|
+
let number = bytes[from] & (marker - 1);
|
|
58
|
+
for (let i = 1; i < length; i++) number = number * 256 + bytes[from + i];
|
|
59
|
+
if (number !== track) throw Error('another track');
|
|
60
|
+
const at = from + length;
|
|
61
|
+
// Lacing requires a separate packet table; leave that format on the
|
|
62
|
+
// ordinary decoder path until its timing can be proved as well.
|
|
63
|
+
if (bytes[at + 2] & 6) throw Error('laced block');
|
|
64
|
+
const relative = new DataView(bytes.buffer, bytes.byteOffset + at, 2).getInt16(0);
|
|
65
|
+
const start = (origin + relative) * scale / 1e9;
|
|
66
|
+
if (!Number.isFinite(start) || start < 0) throw Error('invalid timestamp');
|
|
67
|
+
const samples = opusSamples(bytes, at + 3, block.end);
|
|
68
|
+
return { start, end: start + samples / OPUS_RATE, samples };
|
|
69
|
+
};
|
|
70
|
+
const record = (element) => {
|
|
71
|
+
const { bytes, cluster } = element;
|
|
72
|
+
const items = cluster ? children(bytes, 0, bytes.length) : payload(bytes);
|
|
73
|
+
if (!cluster) {
|
|
74
|
+
// A finite Cluster can contain many seconds of Opus. Index its complete
|
|
75
|
+
// packets just like a live Cluster, so reopening keeps the requested
|
|
76
|
+
// preroll instead of decoding everything from the Cluster's beginning.
|
|
77
|
+
// Only the size field changes; timestamps and encoded Blocks stay intact.
|
|
78
|
+
const head = ebmlHeader(bytes), header = bytes.slice(0, head.length);
|
|
79
|
+
const width = head.length - 4; // Cluster ID is always four bytes.
|
|
80
|
+
header[4] = (1 << (9 - width)) - 1;
|
|
81
|
+
header.fill(0xff, 5);
|
|
82
|
+
const prefix = [header], blocks = [];
|
|
83
|
+
let time = null;
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
if (item.id === 0xa3 || item.id === 0xa0) {
|
|
86
|
+
blocks.push(bytes.subarray(item.start, item.end));
|
|
87
|
+
} else if (!blocks.length && (item.id === 0xec || (item.id === 0xe7 && time == null))) {
|
|
88
|
+
if (item.id === 0xe7) time = uint(bytes, item.data, item.size);
|
|
89
|
+
prefix.push(bytes.subarray(item.start, item.end));
|
|
90
|
+
} else {
|
|
91
|
+
// CRC/Position/PrevSize describe the original complete Cluster;
|
|
92
|
+
// encrypted or unknown metadata cannot safely survive this cut.
|
|
93
|
+
throw Error('unsupported finite cluster metadata');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (time == null) throw Error('missing cluster time');
|
|
97
|
+
// This prefix cannot grow, unlike a live Cluster's mutable metadata.
|
|
98
|
+
// Share it across packet records instead of copying metadata N times.
|
|
99
|
+
Object.freeze(prefix);
|
|
100
|
+
const token = {};
|
|
101
|
+
for (const block of blocks) record({ bytes: block, cluster: { token, time, prefix, sharedPrefix: true } });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let origin = cluster?.time;
|
|
105
|
+
if (origin == null) {
|
|
106
|
+
const time = items.find(item => item.id === 0xe7);
|
|
107
|
+
if (!time) throw Error('missing cluster time');
|
|
108
|
+
origin = uint(bytes, time.data, time.size);
|
|
109
|
+
}
|
|
110
|
+
let start = null, end = null, samples = 0, padded = false;
|
|
111
|
+
for (const item of items) {
|
|
112
|
+
let block = item, padding = 0;
|
|
113
|
+
if (item.id === 0xa0) {
|
|
114
|
+
const group = children(bytes, item.data, item.end);
|
|
115
|
+
block = group.find(child => child.id === 0xa1);
|
|
116
|
+
const discard = group.find(child => child.id === 0x75a2);
|
|
117
|
+
if (discard) {
|
|
118
|
+
if (bytes[discard.data] & 0x80) throw Error('negative discard padding');
|
|
119
|
+
padding = uint(bytes, discard.data, discard.size) / 1e9;
|
|
120
|
+
}
|
|
121
|
+
} else if (item.id !== 0xa3) continue;
|
|
122
|
+
if (!block) throw Error('missing block');
|
|
123
|
+
const range = blockRange(bytes, block, origin);
|
|
124
|
+
range.end -= padding;
|
|
125
|
+
if (range.end <= range.start) throw Error('invalid padding');
|
|
126
|
+
if (end != null && Math.abs(range.start - end) > scale / 1e9 + 1e-6) throw Error('discontinuous cluster');
|
|
127
|
+
start ??= range.start;
|
|
128
|
+
end = range.end;
|
|
129
|
+
samples += range.samples;
|
|
130
|
+
padded ||= padding > 0;
|
|
131
|
+
}
|
|
132
|
+
if (start == null) return;
|
|
133
|
+
if (records.length >= MAX_RECORDS) throw Error('too many blocks');
|
|
134
|
+
if ((cluster?.prefix.length ?? 0) > 65_536) throw Error('too much cluster metadata');
|
|
135
|
+
initHeaders ??= headers.slice();
|
|
136
|
+
const inOrder = !records.length || start >= records.at(-1).start;
|
|
137
|
+
if (!inOrder) ordered = false;
|
|
138
|
+
const entry = { id: ++serial, start, end, samples, padded, bytes, token: cluster?.token,
|
|
139
|
+
prefix: cluster && (cluster.sharedPrefix ? cluster.prefix : cluster.prefix.slice()) };
|
|
140
|
+
records.push(entry);
|
|
141
|
+
// Ordered appends update only the final run, even for live Clusters which
|
|
142
|
+
// emit one record per packet. An out-of-order append rebuilds once on use.
|
|
143
|
+
if (runs && inOrder && start >= runs.at(-1).end - scale / 1e9 - 1e-6) addEntry(runs, entry);
|
|
144
|
+
else runs = null;
|
|
145
|
+
cached = null;
|
|
146
|
+
};
|
|
147
|
+
const addEntry = (result, entry) => {
|
|
148
|
+
let run = result.at(-1);
|
|
149
|
+
const tolerance = scale / 1e9 + 1e-6;
|
|
150
|
+
if (run && entry.start < run.end - tolerance) throw Error('overlapping clusters');
|
|
151
|
+
if (!run || entry.start > run.end + tolerance) {
|
|
152
|
+
run = { id: entry.id, startTime: entry.start, end: entry.end,
|
|
153
|
+
startSample: Math.round(entry.start * OPUS_RATE), headers: initHeaders, entries: [], samples: 0, exact: true };
|
|
154
|
+
result.push(run);
|
|
155
|
+
}
|
|
156
|
+
// Packet sample counts are exact; millisecond Block timestamps are not.
|
|
157
|
+
// Reopening reapplies Opus pre-skip, cancelling the initial decoder's
|
|
158
|
+
// pre-skip, so the new origin advances by encoded samples, not rounded PTS.
|
|
159
|
+
const timestamp = run.startTime + run.samples / OPUS_RATE;
|
|
160
|
+
// Round the initial cursor once, as openAudioDecoder does, then add integer
|
|
161
|
+
// samples. Rounding (fractional origin + elapsed time) can move a sample.
|
|
162
|
+
entry.origin = (run.startSample + run.samples) / OPUS_RATE;
|
|
163
|
+
entry.seekable = run.exact && Math.abs(entry.start - timestamp) <= tolerance;
|
|
164
|
+
run.samples += entry.samples;
|
|
165
|
+
run.exact &&= entry.seekable && !entry.padded && Number.isSafeInteger(run.samples);
|
|
166
|
+
run.entries.push(entry);
|
|
167
|
+
run.end = entry.end;
|
|
168
|
+
};
|
|
169
|
+
const buildRuns = () => {
|
|
170
|
+
const sorted = ordered ? records : records.slice().sort((a, b) => a.start - b.start);
|
|
171
|
+
const result = [];
|
|
172
|
+
for (const entry of sorted) addEntry(result, entry);
|
|
173
|
+
return result;
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
// Called only after SourceBuffer's existing retry/deduplication checks.
|
|
177
|
+
append(element) {
|
|
178
|
+
if (!valid) return;
|
|
179
|
+
try {
|
|
180
|
+
const { id, bytes } = element;
|
|
181
|
+
if (id === CLUSTER) {
|
|
182
|
+
if (track == null) throw Error('missing supported track');
|
|
183
|
+
record(element);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (records.length) {
|
|
187
|
+
// Metadata at EOF does not belong in a run; a new initialization
|
|
188
|
+
// needs a separate codec epoch, which this small index declines.
|
|
189
|
+
if ([0x1a45dfa3, 0x18538067, 0x1549a966, 0x1654ae6b].includes(id)) throw Error('new initialization');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (id === 0x1549a966) {
|
|
193
|
+
const time = payload(bytes).find(item => item.id === 0x2ad7b1);
|
|
194
|
+
if (time) scale = uint(bytes, time.data, time.size);
|
|
195
|
+
if (!(scale > 0)) throw Error('invalid timestamp scale');
|
|
196
|
+
} else if (id === 0x1654ae6b) {
|
|
197
|
+
const tracks = payload(bytes).filter(item => item.id === 0xae);
|
|
198
|
+
if (tracks.length !== 1 || track != null) throw Error('multiple tracks');
|
|
199
|
+
const fields = children(bytes, tracks[0].data, tracks[0].end);
|
|
200
|
+
const field = id => fields.find(item => item.id === id);
|
|
201
|
+
if (field(0x6d80)) throw Error('encoded or encrypted track');
|
|
202
|
+
const number = field(0xd7), type = field(0x83), codec = field(0x86), priv = field(0x63a2), delay = field(0x56aa);
|
|
203
|
+
if (!number || !type || !codec || !priv || !delay || uint(bytes, type.data, type.size) !== 2
|
|
204
|
+
|| new TextDecoder().decode(bytes.subarray(codec.data, codec.end)) !== 'A_OPUS'
|
|
205
|
+
|| priv.size < 19 || new TextDecoder().decode(bytes.subarray(priv.data, priv.data + 8)) !== 'OpusHead') throw Error('unsupported audio track');
|
|
206
|
+
const skip = bytes[priv.data + 10] | (bytes[priv.data + 11] << 8);
|
|
207
|
+
// Opening a new run reapplies Opus pre-skip. CodecDelay must match
|
|
208
|
+
// that skip, making the first decoded sample's origin its original
|
|
209
|
+
// Block timestamp; later cuts advance by exact packet sample counts.
|
|
210
|
+
if (Math.abs(uint(bytes, delay.data, delay.size) - skip * 1e9 / 48000) > 1) throw Error('inconsistent codec delay');
|
|
211
|
+
const seek = field(0x56bb);
|
|
212
|
+
// Matroska's Opus SeekPreRoll recommends 80ms. Keep at least one
|
|
213
|
+
// second to restore decoder state before returning audible PCM.
|
|
214
|
+
if (seek) preroll = Math.max(preroll, uint(bytes, seek.data, seek.size) / 1e9);
|
|
215
|
+
track = uint(bytes, number.data, number.size);
|
|
216
|
+
if (!(track > 0)) throw Error('invalid track');
|
|
217
|
+
}
|
|
218
|
+
if (headers.length >= 65_536) throw Error('too many headers');
|
|
219
|
+
headers.push(bytes);
|
|
220
|
+
} catch {
|
|
221
|
+
valid = false;
|
|
222
|
+
headers.length = records.length = 0;
|
|
223
|
+
runs = cached = initHeaders = null;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
at(seconds, fallback) {
|
|
227
|
+
const ordinary = () => ({ id: 0, startTime: 0, endTime: Infinity, bytes: fallback() });
|
|
228
|
+
if (!valid || !records.length) return ordinary();
|
|
229
|
+
try {
|
|
230
|
+
runs ??= buildRuns();
|
|
231
|
+
let low = 0, high = runs.length;
|
|
232
|
+
while (low + 1 < high) {
|
|
233
|
+
const mid = (low + high) >>> 1;
|
|
234
|
+
if (runs[mid].startTime <= seconds + 1e-6) low = mid;
|
|
235
|
+
else high = mid;
|
|
236
|
+
}
|
|
237
|
+
const selected = runs[low], endTime = runs[low + 1]?.startTime ?? Infinity;
|
|
238
|
+
const entries = selected.entries, count = entries.length;
|
|
239
|
+
let first = 0;
|
|
240
|
+
high = count;
|
|
241
|
+
const latest = seconds - preroll;
|
|
242
|
+
while (first + 1 < high) {
|
|
243
|
+
const mid = (first + high) >>> 1;
|
|
244
|
+
if (entries[mid].origin <= latest) first = mid;
|
|
245
|
+
else high = mid;
|
|
246
|
+
}
|
|
247
|
+
if (!entries[first].seekable) first = 0;
|
|
248
|
+
if (!first && runs.length === 1 && ordered) {
|
|
249
|
+
// Keep the initial full-file path, pre-skip and trailing metadata.
|
|
250
|
+
return { id: selected.id, startTime: selected.startTime, endTime, bytes: fallback() };
|
|
251
|
+
}
|
|
252
|
+
if (cached?.run === selected && cached.first === first && cached.count === count && cached.endTime === endTime) return cached.snapshot;
|
|
253
|
+
let bytes;
|
|
254
|
+
const snapshot = { id: selected.id, startTime: first ? entries[first].origin : selected.startTime, endTime,
|
|
255
|
+
get bytes() {
|
|
256
|
+
if (!bytes) {
|
|
257
|
+
const parts = selected.headers.slice();
|
|
258
|
+
let token = null;
|
|
259
|
+
for (let i = first; i < count; i++) {
|
|
260
|
+
const entry = entries[i];
|
|
261
|
+
if (entry.token && entry.token !== token) for (const part of entry.prefix) parts.push(part);
|
|
262
|
+
parts.push(entry.bytes);
|
|
263
|
+
token = entry.token;
|
|
264
|
+
}
|
|
265
|
+
bytes = join(parts);
|
|
266
|
+
}
|
|
267
|
+
return bytes;
|
|
268
|
+
} };
|
|
269
|
+
// Metadata notifications only read id/endTime; do not copy the audio
|
|
270
|
+
// there. The getter's fixed record count keeps its snapshot immutable
|
|
271
|
+
// if more packets append before decoding. Cache only the latest view.
|
|
272
|
+
cached = { run: selected, first, count, endTime, snapshot };
|
|
273
|
+
return snapshot;
|
|
274
|
+
} catch {
|
|
275
|
+
valid = false;
|
|
276
|
+
headers.length = records.length = 0;
|
|
277
|
+
runs = cached = initHeaders = null;
|
|
278
|
+
return ordinary();
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -16,10 +16,10 @@ export class CookieJar {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Stores Set-Cookie header values received for `url`. `fromScript` rejects HttpOnly (document.cookie). */
|
|
19
|
-
store(url, setCookies, { fromScript = false } = {}) {
|
|
19
|
+
store(url, setCookies, { fromScript = false, receivedAt = this.#now() } = {}) {
|
|
20
20
|
const target = new URL(url);
|
|
21
21
|
for (const header of setCookies) {
|
|
22
|
-
const cookie = parseSetCookie(header, target,
|
|
22
|
+
const cookie = parseSetCookie(header, target, receivedAt);
|
|
23
23
|
if (!cookie) continue;
|
|
24
24
|
if (fromScript && cookie.httpOnly) continue;
|
|
25
25
|
if (cookie.secure && target.protocol !== "https:" && !isLocalhost(target.hostname)) continue;
|
|
@@ -69,6 +69,22 @@ export class CookieJar {
|
|
|
69
69
|
return this.header(url, { includeHttpOnly: false });
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Non-HttpOnly records a renderer of this origin may mirror, including all paths. */
|
|
73
|
+
scriptSnapshot(url) {
|
|
74
|
+
const target = new URL(url), now = this.#now();
|
|
75
|
+
return this.#cookies.filter(cookie => !cookie.httpOnly && cookie.expires > now
|
|
76
|
+
&& domainMatches(cookie, target.hostname)
|
|
77
|
+
&& (!cookie.secure || target.protocol === "https:" || isLocalhost(target.hostname)))
|
|
78
|
+
.map(cookie => ({ ...cookie }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Replace a renderer's script-only mirror with a trusted controller snapshot. */
|
|
82
|
+
replaceScriptSnapshot(cookies) {
|
|
83
|
+
const now = this.#now();
|
|
84
|
+
this.#cookies = cookies.filter(cookie => !cookie.httpOnly && cookie.expires > now)
|
|
85
|
+
.map(cookie => ({ ...cookie, httpOnly: false }));
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
get size() {
|
|
73
89
|
return this.#cookies.length;
|
|
74
90
|
}
|
|
@@ -405,7 +405,8 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
405
405
|
if (!style) continue;
|
|
406
406
|
if (style.position === "fixed") fixed = true;
|
|
407
407
|
opacity *= style.opacity ?? 1;
|
|
408
|
-
|
|
408
|
+
const shape = shapeClip(node);
|
|
409
|
+
if (shape) clips.push([node, shape]);
|
|
409
410
|
const ownContent = kind === "text" || node !== owner;
|
|
410
411
|
if (ownContent && escaping !== "fixed") {
|
|
411
412
|
const positioned = style.position && style.position !== "static";
|
|
@@ -415,7 +416,7 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
415
416
|
if (radii) {
|
|
416
417
|
radii = insetRadii(radii, style.borderWidths ?? [0, 0, 0, 0]);
|
|
417
418
|
}
|
|
418
|
-
clips.push([node, clip, radii]);
|
|
419
|
+
if (clip) clips.push([node, clip, radii]);
|
|
419
420
|
if (isScroller(style) && boxes.has(node.id)) {
|
|
420
421
|
if (!scrolledBy.size) outOfFlow = positionedOut;
|
|
421
422
|
scrolledBy.add(node);
|
|
@@ -435,7 +436,7 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
|
|
|
435
436
|
const node = chain[index];
|
|
436
437
|
const own = ownTransform(node);
|
|
437
438
|
if (own) transform = transform ? composeTransforms(transform, own) : own;
|
|
438
|
-
transforms.set(node, transform);
|
|
439
|
+
if (transform) transforms.set(node, transform);
|
|
439
440
|
const offset = scrolledBy.has(node) ? scrollOffsetOf?.(node.domNode) : null;
|
|
440
441
|
if (offset && (offset.x || offset.y)) {
|
|
441
442
|
const scrolled = { a: 1, d: 1, e: 0 - offset.x, f: 0 - offset.y };
|
package/lib/paint/frame.js
CHANGED
|
@@ -117,11 +117,18 @@ export function backgroundBytes(color) {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
/** Raw screenshot payload, owned independently of a recyclable raster tile. */
|
|
120
|
-
export function rawFrame(pixels, width, height, format) {
|
|
121
|
-
if (!['rgba', 'bgra'].includes(format) || width * height > 16777216) throw Error('Invalid raw screenshot');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
120
|
+
export function rawFrame(pixels, width, height, format, { takeOwnership = false, inputFormat = "rgba" } = {}) {
|
|
121
|
+
if (!['rgba', 'bgra'].includes(format) || !['rgba', 'bgra'].includes(inputFormat) || width * height > 16777216) throw Error('Invalid raw screenshot');
|
|
122
|
+
// Only private composition/crop snapshots may be consumed in place. The
|
|
123
|
+
// default keeps raw captures independent of immutable/recyclable tiles.
|
|
124
|
+
const data = takeOwnership && pixels.buffer instanceof ArrayBuffer && pixels.byteOffset % 4 === 0 ? pixels : new Uint8Array(pixels);
|
|
125
|
+
if (format !== inputFormat) {
|
|
126
|
+
// Swap R/B as packed pixels, preserving G and alpha (Bun targets are little-endian).
|
|
127
|
+
const words = new Uint32Array(data.buffer, data.byteOffset, Math.floor(data.byteLength / 4));
|
|
128
|
+
for (let i = 0; i < words.length; i++) {
|
|
129
|
+
const p = words[i];
|
|
130
|
+
words[i] = (p & 0xff00ff00) | ((p & 0xff) << 16) | ((p >>> 16) & 0xff);
|
|
131
|
+
}
|
|
125
132
|
}
|
|
126
133
|
return {format, width, height, pixels:data};
|
|
127
134
|
}
|
package/lib/render-tree/index.js
CHANGED
|
@@ -315,6 +315,9 @@ function sameNodeProperties(previous, type, properties) {
|
|
|
315
315
|
for (const [key, value] of Object.entries(properties)) {
|
|
316
316
|
const old = previous[key];
|
|
317
317
|
if (old === value) continue;
|
|
318
|
+
// Extraction makes fresh immutable SVG values even when their paint is
|
|
319
|
+
// unchanged. Retain the node so its ancestors can reuse layout results.
|
|
320
|
+
if (key === "svg" && Bun.deepEquals(old, value, true)) continue;
|
|
318
321
|
if (key !== "children" || old.length !== value.length) return false;
|
|
319
322
|
for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
|
|
320
323
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { drainMicrotasks } from "bun:jsc";
|
|
2
|
+
import { createTimers } from "./timers.js";
|
|
3
|
+
|
|
4
|
+
// Older Bun checkpoints also dispatched queued event-loop tasks. Although
|
|
5
|
+
// 1.4.3 stable fixed this, Bun.version hides the suffix of unsafe 1.4.3
|
|
6
|
+
// canaries. Require the later 1.4.4 branch and keep unknown channels on timers.
|
|
7
|
+
export function supportsFrameCheckpoint(version = Bun.version) {
|
|
8
|
+
const match = typeof version === "string"
|
|
9
|
+
&& /^((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:-canary(?:\.(?:0|[1-9]\d*))?)?$/.exec(version);
|
|
10
|
+
return !!match && Bun.semver.order(match[1], "1.4.4") >= 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One callback collection per document, with a checkpoint between callbacks. */
|
|
14
|
+
export function createAnimationFrames({ invoke, now, checkpoint = drainMicrotasks,
|
|
15
|
+
frameTimers = null }) {
|
|
16
|
+
let nextWakeId = 1;
|
|
17
|
+
// A private timer collection keeps frame handles separate from Window timers.
|
|
18
|
+
// timerTask=false also prevents a frame inheriting a timer's nesting clamp.
|
|
19
|
+
const timers = frameTimers ?? createTimers({ invoke: callback => callback(), nextId: () => nextWakeId++ });
|
|
20
|
+
const callbacks = new Map();
|
|
21
|
+
let nextHandle = 0, wake = null, running = false, disposed = false;
|
|
22
|
+
|
|
23
|
+
function arm(delay) {
|
|
24
|
+
if (!disposed && wake === null && !running && callbacks.size) {
|
|
25
|
+
wake = timers.schedule(deliver, delay, false, false);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function deliver() {
|
|
29
|
+
wake = null;
|
|
30
|
+
if (disposed) return;
|
|
31
|
+
running = true;
|
|
32
|
+
const timestamp = now(), handles = [...callbacks.keys()];
|
|
33
|
+
try {
|
|
34
|
+
for (const handle of handles) {
|
|
35
|
+
if (disposed) break;
|
|
36
|
+
const callback = callbacks.get(handle);
|
|
37
|
+
if (callback === undefined) continue;
|
|
38
|
+
callbacks.delete(handle);
|
|
39
|
+
// invoke reports page exceptions. Drain the complete promise/microtask
|
|
40
|
+
// queue here, without yielding unrelated timers or I/O between callbacks.
|
|
41
|
+
try { invoke(callback, timestamp); }
|
|
42
|
+
finally { checkpoint(); }
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
running = false;
|
|
46
|
+
// Callbacks registered during delivery, including its microtasks, belong
|
|
47
|
+
// to the next frame. Long callbacks do not add another fixed 16 ms delay.
|
|
48
|
+
arm(Math.max(0, Math.ceil(16 - (now() - timestamp))));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
request(callback) {
|
|
53
|
+
if (typeof callback !== "function") throw new TypeError("requestAnimationFrame callback must be a function");
|
|
54
|
+
do { nextHandle = (nextHandle + 1) >>> 0; } while (callbacks.has(nextHandle));
|
|
55
|
+
const handle = nextHandle;
|
|
56
|
+
if (!disposed) {
|
|
57
|
+
callbacks.set(handle, callback);
|
|
58
|
+
arm(16);
|
|
59
|
+
}
|
|
60
|
+
return handle;
|
|
61
|
+
},
|
|
62
|
+
cancel(handle) {
|
|
63
|
+
callbacks.delete(+handle >>> 0);
|
|
64
|
+
if (!callbacks.size && wake !== null) {
|
|
65
|
+
timers.cancel(wake);
|
|
66
|
+
wake = null;
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
dispose() {
|
|
70
|
+
disposed = true;
|
|
71
|
+
callbacks.clear();
|
|
72
|
+
timers.dispose();
|
|
73
|
+
wake = null;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/lib/renderer/bindings.js
CHANGED
|
@@ -777,6 +777,7 @@ class ElementBinding extends ParentBinding {
|
|
|
777
777
|
load() {
|
|
778
778
|
if (!isMedia(this)) return;
|
|
779
779
|
realmOf(this).hooks.pauseMedia?.(nodeOf(this));
|
|
780
|
+
realmOf(this).hooks.reselectMedia?.(nodeOf(this));
|
|
780
781
|
void realmOf(this).hooks.seekMedia?.(nodeOf(this), 0);
|
|
781
782
|
}
|
|
782
783
|
|
|
@@ -918,6 +919,7 @@ class ElementBinding extends ParentBinding {
|
|
|
918
919
|
if (resetsCanvas) resetCanvas(node);
|
|
919
920
|
touch(this);
|
|
920
921
|
}
|
|
922
|
+
if (attribute.toLowerCase() === "src" && isMedia(this)) realmOf(this).hooks.attachMediaSrc?.(node, node.src);
|
|
921
923
|
}
|
|
922
924
|
|
|
923
925
|
removeAttribute(name) {
|
|
@@ -12,7 +12,7 @@ export function encodePacket(message, pixels = null) {
|
|
|
12
12
|
const json = Buffer.from(JSON.stringify(message));
|
|
13
13
|
const size = pixels?.byteLength ?? 0;
|
|
14
14
|
if (json.length > MAX_JSON || size > MAX_PIXELS || json.length + size > MAX_QUEUE) throw Error('Compositor packet too large');
|
|
15
|
-
const packet = Buffer.
|
|
15
|
+
const packet = Buffer.allocUnsafeSlow(8 + json.length + size);
|
|
16
16
|
packet.writeUInt32LE(json.length, 0); packet.writeUInt32LE(size, 4);
|
|
17
17
|
packet.set(json, 8); if (size) packet.set(pixels, 8 + json.length);
|
|
18
18
|
return packet;
|
|
@@ -34,7 +34,7 @@ export class PacketReader {
|
|
|
34
34
|
const binaryLength = this.header.readUInt32LE(4);
|
|
35
35
|
const total = this.jsonLength + binaryLength;
|
|
36
36
|
if (!this.jsonLength || this.jsonLength > MAX_JSON || binaryLength > MAX_PIXELS || total > this.limit) throw Error('Invalid compositor packet size');
|
|
37
|
-
this.packet = Buffer.
|
|
37
|
+
this.packet = Buffer.allocUnsafeSlow(total);
|
|
38
38
|
} else {
|
|
39
39
|
const packet = this.packet; this.packet = null;
|
|
40
40
|
const message = JSON.parse(packet.subarray(0, this.jsonLength).toString());
|
|
@@ -45,21 +45,106 @@ export class PacketReader {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Serialize complete packets, including their two writes, so control messages
|
|
49
|
+
// cannot land between an owned frame's header and pixels. Do not cork these
|
|
50
|
+
// writes: Bun's node:net _writev joins chunks with Buffer.concat.
|
|
51
|
+
export class PacketWriter {
|
|
52
|
+
constructor(socket, limit = MAX_QUEUE) {
|
|
53
|
+
this.socket = socket; this.limit = limit; this.queue = []; this.bytes = 0;
|
|
54
|
+
this.current = null; this.waiting = false; this.pumping = false; this.closed = false;
|
|
55
|
+
socket.on('error', () => this.close());
|
|
56
|
+
socket.once('close', () => {
|
|
57
|
+
this.close();
|
|
58
|
+
// A destroyed socket can still have a pending native write until its
|
|
59
|
+
// callback/close event. Only now is its last borrowed buffer disposable.
|
|
60
|
+
this.release(this.current); this.current = null;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
release(entry) {
|
|
64
|
+
if (!entry || entry.released) return;
|
|
65
|
+
entry.released = true;
|
|
66
|
+
if (entry.queued) this.bytes -= entry.size;
|
|
67
|
+
for (const buffer of entry.buffers) buffer.transfer(0);
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
if (this.closed) return;
|
|
71
|
+
this.closed = true;
|
|
72
|
+
for (const entry of this.queue) this.release(entry);
|
|
73
|
+
this.queue.length = 0;
|
|
74
|
+
this.socket.destroy();
|
|
75
|
+
}
|
|
76
|
+
pump() {
|
|
77
|
+
if (this.pumping || this.waiting || this.closed) return;
|
|
78
|
+
this.pumping = true;
|
|
79
|
+
try {
|
|
80
|
+
while (!this.waiting && !this.closed) {
|
|
81
|
+
this.current ??= this.queue.shift();
|
|
82
|
+
const entry = this.current;
|
|
83
|
+
if (!entry) break;
|
|
84
|
+
if (entry.index === entry.parts.length) {
|
|
85
|
+
this.release(entry); this.current = null; continue;
|
|
86
|
+
}
|
|
87
|
+
this.waiting = true;
|
|
88
|
+
try {
|
|
89
|
+
this.socket.write(entry.parts[entry.index++], error => {
|
|
90
|
+
if (entry !== this.current || entry.released) return;
|
|
91
|
+
this.waiting = false;
|
|
92
|
+
if (error || this.closed || this.socket.destroyed) {
|
|
93
|
+
this.close(); this.release(entry); this.current = null;
|
|
94
|
+
} else this.pump();
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Some header bytes may already be on the wire. Closing is required
|
|
98
|
+
// even when write throws synchronously, or the next packet is corrupt.
|
|
99
|
+
this.close();
|
|
100
|
+
return error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally { this.pumping = false; }
|
|
104
|
+
}
|
|
105
|
+
send(message, pixels, {takeOwnership = false} = {}) {
|
|
106
|
+
const entry = {parts:[], buffers:[], index:0, size:0, queued:false, released:false};
|
|
107
|
+
try {
|
|
108
|
+
if (takeOwnership) {
|
|
109
|
+
if (!(pixels instanceof Uint8Array) || !(pixels.buffer instanceof ArrayBuffer))
|
|
110
|
+
throw TypeError('Owned compositor pixels must have a private ArrayBuffer');
|
|
111
|
+
// The caller gives up this entire buffer, including on a rejected send.
|
|
112
|
+
entry.buffers.push(pixels.buffer);
|
|
113
|
+
}
|
|
114
|
+
if (this.closed || this.socket.destroyed) throw Error('Compositor channel closed');
|
|
115
|
+
const packet = encodePacket(message, takeOwnership ? null : pixels);
|
|
116
|
+
entry.buffers.push(packet.buffer);
|
|
117
|
+
entry.parts.push(packet);
|
|
118
|
+
if (takeOwnership) {
|
|
119
|
+
const size = pixels.byteLength;
|
|
120
|
+
if (size > MAX_PIXELS || packet.length - 8 + size > MAX_QUEUE) throw Error('Compositor packet too large');
|
|
121
|
+
packet.writeUInt32LE(size, 4);
|
|
122
|
+
if (size) entry.parts.push(pixels);
|
|
123
|
+
}
|
|
124
|
+
entry.size = entry.parts.reduce((size, part) => size + part.byteLength, 0);
|
|
125
|
+
if (this.bytes + entry.size > this.limit) {
|
|
126
|
+
this.close(); throw Error('Compositor channel backpressure limit');
|
|
127
|
+
}
|
|
128
|
+
entry.queued = true; this.bytes += entry.size; this.queue.push(entry);
|
|
129
|
+
const error = this.pump();
|
|
130
|
+
if (error) throw error;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!entry.queued) this.release(entry);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
48
138
|
function attach(socket, receive, closed, limit) {
|
|
49
139
|
socket.setNoDelay(true);
|
|
50
140
|
const reader = new PacketReader(receive, limit);
|
|
141
|
+
const writer = new PacketWriter(socket);
|
|
51
142
|
socket.on('data', chunk => { try { reader.push(chunk); } catch { socket.destroy(); } });
|
|
52
|
-
socket.on('error', () => {});
|
|
53
143
|
socket.once('close', closed);
|
|
54
144
|
return {
|
|
55
145
|
reader,
|
|
56
|
-
send(message, pixels) {
|
|
57
|
-
|
|
58
|
-
const packet = encodePacket(message, pixels);
|
|
59
|
-
if (socket.writableLength + packet.length > MAX_QUEUE) { socket.destroy(); throw Error('Compositor channel backpressure limit'); }
|
|
60
|
-
socket.write(packet);
|
|
61
|
-
},
|
|
62
|
-
close() { socket.destroy(); },
|
|
146
|
+
send(message, pixels, options) { writer.send(message, pixels, options); },
|
|
147
|
+
close() { writer.close(); },
|
|
63
148
|
};
|
|
64
149
|
}
|
|
65
150
|
|