buninu-browser 0.5.1 → 0.6.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 +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- 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/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/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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { seekVideoKeyframe } from "../media/video-keyframe.js";
|
|
2
|
+
import { captureViewport } from "../paint/frame.js";
|
|
2
3
|
|
|
3
4
|
// Runs in the compositor Worker, never in the page's JavaScript turn. Both
|
|
4
5
|
// queue duration and decoded pixel memory are bounded. No audio extrapolation.
|
|
@@ -15,15 +16,27 @@ export class VideoPresentation {
|
|
|
15
16
|
this.stop();
|
|
16
17
|
const epoch = this.epoch;
|
|
17
18
|
this.config = config;
|
|
19
|
+
this.revision = 0;
|
|
20
|
+
this.exhaustedReported = false;
|
|
21
|
+
this.extension = null;
|
|
22
|
+
this.continuationFloor = null;
|
|
18
23
|
config.started = performance.now();
|
|
19
24
|
try {
|
|
20
25
|
const decoder = await this.engine.decode(config.bytes, config.options);
|
|
26
|
+
// WASM opens synchronously: audio timing messages may still be queued
|
|
27
|
+
// behind it. Yield a task before choosing the resumed frame position.
|
|
28
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
21
29
|
if (epoch !== this.epoch) { decoder.close(); return; }
|
|
22
30
|
this.decoder = decoder;
|
|
23
31
|
const startTime = config.startTime ?? 0;
|
|
24
32
|
const endTime = config.endTime ?? Infinity;
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
// Opening a growing snapshot can take time while audio keeps playing.
|
|
34
|
+
// Seek against its latest measured clock, not the stale open request.
|
|
35
|
+
// Keep the original clock anchor: changing config.time would count the
|
|
36
|
+
// opening delay twice for a video whose clock advances without audio.
|
|
37
|
+
this.resumeTime = Math.min(endTime, Math.max(config.time, this.time()));
|
|
38
|
+
this.nextTime = this.resumeTime >= endTime ? endTime
|
|
39
|
+
: this.resumeTime > startTime ? seekVideoKeyframe(decoder, this.resumeTime, startTime, config.bytes) : startTime;
|
|
27
40
|
this.width = Math.max(1, Math.min(480, decoder.width));
|
|
28
41
|
this.height = Math.max(1, Math.round(this.width * decoder.height / Math.max(1, decoder.width)));
|
|
29
42
|
if (this.height > 480) {
|
|
@@ -39,6 +52,7 @@ export class VideoPresentation {
|
|
|
39
52
|
stop(id) {
|
|
40
53
|
if (id !== undefined && this.config?.id !== id) return;
|
|
41
54
|
this.epoch++;
|
|
55
|
+
this.extension = null;
|
|
42
56
|
clearTimeout(this.timer);
|
|
43
57
|
this.decoder?.close();
|
|
44
58
|
this.decoder = null;
|
|
@@ -53,6 +67,8 @@ export class VideoPresentation {
|
|
|
53
67
|
this.clocks.set(id, seconds);
|
|
54
68
|
// Audio stream identifiers increase monotonically within this renderer.
|
|
55
69
|
if (this.clocks.size > 32) this.clocks.delete(this.clocks.keys().next().value);
|
|
70
|
+
if (this.config?.audioId != null && (this.decoder || this.extension || this.eof) && this.config.audioId === id)
|
|
71
|
+
this.present(this.time());
|
|
56
72
|
}
|
|
57
73
|
bind(id, audioId, audioStart, time, endTime) {
|
|
58
74
|
if (this.config?.id !== id) return;
|
|
@@ -66,6 +82,69 @@ export class VideoPresentation {
|
|
|
66
82
|
this.queue = this.queue.filter(frame => frame.time + 1e-6 < endTime);
|
|
67
83
|
}
|
|
68
84
|
}
|
|
85
|
+
// Only an exhausted snapshot of this same source/configuration can grow.
|
|
86
|
+
// Preserve its queued frames and measured clock while replacing the decoder;
|
|
87
|
+
// stop/open still owns seeks, pauses, navigation and configuration changes.
|
|
88
|
+
async extend(request) {
|
|
89
|
+
const current = this.config;
|
|
90
|
+
if (!current || request.id !== current.id || request.revision !== this.revision || this.extension) return false;
|
|
91
|
+
const reject = error => {
|
|
92
|
+
this.report({ id: current.id, revision: this.revision, continuationError: String(error) });
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
if (!this.eof || !Number.isSafeInteger(current.generation) || request.snapshotId !== current.snapshotId
|
|
96
|
+
|| !Number.isSafeInteger(request.generation) || request.generation <= current.generation
|
|
97
|
+
|| !(request.bytes instanceof Uint8Array) || request.bytes.length <= current.bytes.length
|
|
98
|
+
|| !Number.isFinite(request.boundary) || Math.abs(request.boundary - this.nextTime) > 1e-6)
|
|
99
|
+
return reject("stale video snapshot continuation");
|
|
100
|
+
const epoch = this.epoch, boundary = this.nextTime;
|
|
101
|
+
const expected = { width: this.decoder?.width, height: this.decoder?.height, fps: this.decoder?.fps };
|
|
102
|
+
const extension = this.extension = request;
|
|
103
|
+
this.decoder?.close();
|
|
104
|
+
this.decoder = null;
|
|
105
|
+
let decoder;
|
|
106
|
+
try {
|
|
107
|
+
decoder = await this.engine.decode(request.bytes, current.options);
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
109
|
+
if (epoch !== this.epoch || this.config !== current || this.extension !== extension) {
|
|
110
|
+
decoder.close();
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (decoder.width !== expected.width || decoder.height !== expected.height || decoder.fps !== expected.fps)
|
|
114
|
+
throw new Error("video snapshot configuration changed");
|
|
115
|
+
if (boundary + 1e-6 >= (current.endTime ?? Infinity))
|
|
116
|
+
throw new Error("video continuation crossed its configuration boundary");
|
|
117
|
+
const start = current.startTime ?? 0;
|
|
118
|
+
this.nextTime = boundary > start ? seekVideoKeyframe(decoder, boundary, start, request.bytes) : start;
|
|
119
|
+
this.decoder = decoder;
|
|
120
|
+
decoder = null;
|
|
121
|
+
this.resumeTime = boundary;
|
|
122
|
+
// Old queued frames own every timestamp before the decoded boundary.
|
|
123
|
+
// A containing GOP frame must not be queued twice after seeking back.
|
|
124
|
+
this.continuationFloor = boundary;
|
|
125
|
+
current.bytes = request.bytes;
|
|
126
|
+
current.generation = request.generation;
|
|
127
|
+
this.revision++;
|
|
128
|
+
this.eof = false;
|
|
129
|
+
this.reportedEnd = false;
|
|
130
|
+
this.exhaustedReported = false;
|
|
131
|
+
this.extension = null;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
decoder?.close();
|
|
134
|
+
if (epoch === this.epoch && this.config === current && this.extension === extension) {
|
|
135
|
+
this.extension = null;
|
|
136
|
+
this.nextTime = Math.min(boundary, current.endTime ?? Infinity);
|
|
137
|
+
reject(error);
|
|
138
|
+
this.present(this.time());
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
// Commit the new revision before any of its time/EOF reports arrive.
|
|
143
|
+
this.report({ id: current.id, revision: this.revision, continued: request.generation });
|
|
144
|
+
clearTimeout(this.timer);
|
|
145
|
+
this.step();
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
69
148
|
time() {
|
|
70
149
|
const c = this.config;
|
|
71
150
|
if (c.audioId != null) return c.audioStart + (this.clocks.get(c.audioId) ?? 0);
|
|
@@ -73,21 +152,55 @@ export class VideoPresentation {
|
|
|
73
152
|
if (c.hasAudio) return c.time;
|
|
74
153
|
return c.time + (performance.now() - c.started) / 1000;
|
|
75
154
|
}
|
|
155
|
+
// An already decoded frame can become due between decode turns. Publish it
|
|
156
|
+
// when measured audio advances, without another decode or an estimated clock.
|
|
157
|
+
// The queue is consumed synchronously by both callers, so a frame is sent once.
|
|
158
|
+
present(time) {
|
|
159
|
+
let latest = null;
|
|
160
|
+
while (this.queue.length && this.queue[0].time <= time + 1e-6) latest = this.queue.shift();
|
|
161
|
+
if (latest) {
|
|
162
|
+
this.frame = latest;
|
|
163
|
+
if (this.config.shared) {
|
|
164
|
+
const meta = new Int32Array(this.config.shared, 0, 4);
|
|
165
|
+
Atomics.add(meta, 0, 1);
|
|
166
|
+
new Uint8Array(this.config.shared, 16, latest.pixels.length).set(latest.pixels);
|
|
167
|
+
Atomics.store(meta, 1, latest.width);
|
|
168
|
+
Atomics.store(meta, 2, latest.height);
|
|
169
|
+
Atomics.add(meta, 0, 1);
|
|
170
|
+
}
|
|
171
|
+
this.changed();
|
|
172
|
+
this.report({ id: this.config.id, time: latest.time,
|
|
173
|
+
...(Number.isSafeInteger(this.config.generation) ? { revision: this.revision } : {}) });
|
|
174
|
+
}
|
|
175
|
+
if (this.eof && !this.extension && !this.queue.length && time >= this.nextTime && !this.reportedEnd) {
|
|
176
|
+
this.reportedEnd = true;
|
|
177
|
+
this.report({ id: this.config.id, end: this.nextTime,
|
|
178
|
+
...(Number.isSafeInteger(this.config.generation) ? { revision: this.revision } : {}) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
76
181
|
step() {
|
|
77
|
-
if (!this.
|
|
182
|
+
if (!this.config) return;
|
|
183
|
+
if (!this.decoder) {
|
|
184
|
+
this.present(this.time());
|
|
185
|
+
if (this.extension || (this.eof && !this.reportedEnd)) this.timer = setTimeout(() => this.step(), 10);
|
|
186
|
+
else this.timer = null;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
78
189
|
const time = this.time();
|
|
79
190
|
const endTime = this.config.endTime ?? Infinity;
|
|
80
191
|
const deadline = performance.now() + 8;
|
|
81
192
|
try {
|
|
82
193
|
while (!this.eof && this.queue.length < this.limit && this.nextTime <= time + 0.75
|
|
83
194
|
&& this.nextTime + 1e-6 < endTime) {
|
|
84
|
-
const
|
|
195
|
+
const duration = 1 / (this.decoder.fps || 30);
|
|
196
|
+
const keep = this.nextTime + duration > Math.max(this.config.time, this.resumeTime)
|
|
197
|
+
&& (this.continuationFloor == null || this.nextTime + 1e-6 >= this.continuationFloor);
|
|
198
|
+
// Preserve codec dependencies while discarding a reopened GOP's prefix.
|
|
199
|
+
// Only frames that can enter the queue need full-size RGBA conversion.
|
|
200
|
+
const pixels = this.decoder.nextFrame(keep ? this.width : 1, keep ? this.height : 1);
|
|
85
201
|
if (!pixels) { this.eof = true; break; }
|
|
86
|
-
|
|
87
|
-
this.nextTime +=
|
|
88
|
-
// A reopened decoder starts at an earlier keyframe. Decode through
|
|
89
|
-
// that prefix without flashing it over the last displayed picture.
|
|
90
|
-
if (frame.time + 1 / (this.decoder.fps || 30) > this.config.time) this.queue.push(frame);
|
|
202
|
+
if (keep) this.queue.push({ pixels, time: this.nextTime, width: this.width, height: this.height });
|
|
203
|
+
this.nextTime += duration;
|
|
91
204
|
if (performance.now() >= deadline) break;
|
|
92
205
|
}
|
|
93
206
|
} catch {
|
|
@@ -101,45 +214,197 @@ export class VideoPresentation {
|
|
|
101
214
|
this.eof = true;
|
|
102
215
|
this.nextTime = endTime;
|
|
103
216
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.frame = latest;
|
|
108
|
-
if (this.config.shared) {
|
|
109
|
-
const meta = new Int32Array(this.config.shared, 0, 4);
|
|
110
|
-
Atomics.add(meta, 0, 1);
|
|
111
|
-
new Uint8Array(this.config.shared, 16, latest.pixels.length).set(latest.pixels);
|
|
112
|
-
Atomics.store(meta, 1, latest.width);
|
|
113
|
-
Atomics.store(meta, 2, latest.height);
|
|
114
|
-
Atomics.add(meta, 0, 1);
|
|
115
|
-
}
|
|
116
|
-
this.changed();
|
|
117
|
-
this.report({ id: this.config.id, time: latest.time });
|
|
217
|
+
if (this.eof && !this.exhaustedReported && Number.isSafeInteger(this.config.generation)) {
|
|
218
|
+
this.exhaustedReported = true;
|
|
219
|
+
this.report({ id: this.config.id, revision: this.revision, exhaustedAt: this.nextTime });
|
|
118
220
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
this.
|
|
221
|
+
this.present(time);
|
|
222
|
+
if (this.eof && this.reportedEnd && !this.extension) { this.timer = null; return; }
|
|
223
|
+
// Yield to pending captures/audio messages, but do not add another 10ms
|
|
224
|
+
// per decoded frame while playback is behind the measured audio clock.
|
|
225
|
+
const catchingUp = !this.eof && this.queue.length < this.limit && this.nextTime <= this.time();
|
|
226
|
+
this.timer = setTimeout(() => this.step(), catchingUp ? 0 : 10);
|
|
124
227
|
}
|
|
125
228
|
}
|
|
126
229
|
|
|
127
230
|
// Black/white raster passes capture the video's contribution after clipping,
|
|
128
231
|
// opacity, subtitles and controls. This preserves foreground pixels instead of
|
|
129
232
|
// pasting a rectangular video over the page's UI.
|
|
130
|
-
|
|
233
|
+
// Layers are immutable raster results. Keep sampling geometry and mask runs
|
|
234
|
+
// only as long as their layer lives; a decoded frame never enters this cache.
|
|
235
|
+
const videoPlans = new WeakMap();
|
|
236
|
+
const bgraBases = new WeakMap();
|
|
237
|
+
function videoPlan(tile, frame, region = null) {
|
|
131
238
|
const layer = tile.videoLayer;
|
|
132
|
-
if (!layer || !frame) return tile;
|
|
133
|
-
const pixels = new Uint8Array(layer.base);
|
|
134
239
|
const { x, y, width, height } = layer.rect;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
240
|
+
const crop = region ?? { x: 0, y: 0, width: tile.pixelWidth, height: tile.pixelHeight };
|
|
241
|
+
const key = [tile.pixelWidth, tile.pixelHeight, frame.width, frame.height, x, y, width, height,
|
|
242
|
+
crop.x, crop.y, crop.width, crop.height].join(",");
|
|
243
|
+
const previous = videoPlans.get(layer);
|
|
244
|
+
if (previous?.key === key) return previous;
|
|
245
|
+
const left = Math.max(0, Math.floor(x), crop.x), right = Math.min(tile.pixelWidth, Math.ceil(x + width), crop.x + crop.width);
|
|
246
|
+
const top = Math.max(0, Math.floor(y), crop.y), bottom = Math.min(tile.pixelHeight, Math.ceil(y + height), crop.y + crop.height);
|
|
247
|
+
const xs = new Int32Array(Math.max(0, right - left));
|
|
248
|
+
for (let px = left; px < right; px++)
|
|
249
|
+
xs[px - left] = Math.max(0, Math.min(frame.width - 1, Math.floor((px - x) / width * frame.width)));
|
|
250
|
+
const rows = [];
|
|
251
|
+
for (let py = top; py < bottom; py++) {
|
|
252
|
+
const source = Math.max(0, Math.min(frame.height - 1, Math.floor((py - y) / height * frame.height))) * frame.width;
|
|
253
|
+
const maskTarget = py * tile.pixelWidth + left;
|
|
254
|
+
const target = (py - crop.y) * crop.width + left - crop.x;
|
|
255
|
+
const runs = [];
|
|
256
|
+
let kind = -1, begin = 0;
|
|
257
|
+
for (let i = 0; i < xs.length; i++) {
|
|
258
|
+
const d = (maskTarget + i) * 4, m = layer.mask, b = layer.base;
|
|
259
|
+
const next = !(m[d] || m[d + 1] || m[d + 2]) ? 0
|
|
260
|
+
: m[d] === 255 && m[d + 1] === 255 && m[d + 2] === 255
|
|
261
|
+
&& !(b[d] || b[d + 1] || b[d + 2]) ? (b[d + 3] === 255 ? 3 : 1) : 2;
|
|
262
|
+
if (next !== kind) {
|
|
263
|
+
if (kind > 0) runs.push(begin, i, kind);
|
|
264
|
+
begin = i; kind = next;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (kind > 0) runs.push(begin, xs.length, kind);
|
|
268
|
+
rows.push({ source, target, maskTarget, runs });
|
|
269
|
+
}
|
|
270
|
+
const repeatedRows = rows.some((row, i) => i > 0 && row.source === rows[i - 1].source);
|
|
271
|
+
const plan = { key, xs, rows, repeatedRows };
|
|
272
|
+
videoPlans.set(layer, plan);
|
|
273
|
+
return plan;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function videoBase(layer, bgra) {
|
|
277
|
+
let base = layer.base;
|
|
278
|
+
if (bgra) {
|
|
279
|
+
base = bgraBases.get(layer);
|
|
280
|
+
if (!base) {
|
|
281
|
+
base = new Uint8Array(layer.base);
|
|
282
|
+
const words = new Uint32Array(base.buffer);
|
|
283
|
+
for (let i = 0; i < words.length; i++) {
|
|
284
|
+
const p = words[i];
|
|
285
|
+
words[i] = (p & 0xff00ff00) | ((p & 0xff) << 16) | ((p >>> 16) & 0xff);
|
|
286
|
+
}
|
|
287
|
+
bgraBases.set(layer, base);
|
|
142
288
|
}
|
|
143
289
|
}
|
|
144
|
-
return
|
|
290
|
+
return base;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function videoBackground(tile, bgra) {
|
|
294
|
+
return bgra && tile.background
|
|
295
|
+
? [tile.background[2], tile.background[1], tile.background[0], tile.background[3]] : tile.background;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Only fully opaque video runs replace all four bytes. Copy their complement
|
|
299
|
+
// from the immutable base; translucent overlays and preserved alpha still need
|
|
300
|
+
// the original pixels. Keep these source views on the existing geometry plan,
|
|
301
|
+
// so a new crop/format replaces the cache instead of retaining old captures.
|
|
302
|
+
function copyStaticVideoBase(base, plan) {
|
|
303
|
+
let copies = plan.baseCopies;
|
|
304
|
+
if (!copies || copies.buffer !== base.buffer || copies.offset !== base.byteOffset || copies.length !== base.length) {
|
|
305
|
+
const spans = [];
|
|
306
|
+
let copied = 0;
|
|
307
|
+
for (const { target, runs } of plan.rows) for (let r = 0; r < runs.length; r += 3) {
|
|
308
|
+
if (runs[r + 2] !== 3) continue;
|
|
309
|
+
const from = (target + runs[r]) * 4, to = (target + runs[r + 1]) * 4;
|
|
310
|
+
if (from > copied) spans.push([copied, base.subarray(copied, from)]);
|
|
311
|
+
copied = to;
|
|
312
|
+
}
|
|
313
|
+
if (copied < base.length) spans.push([copied, base.subarray(copied)]);
|
|
314
|
+
copies = plan.baseCopies = { buffer: base.buffer, offset: base.byteOffset, length: base.length, spans };
|
|
315
|
+
}
|
|
316
|
+
const pixels = Bun.allocUnsafe(base.length);
|
|
317
|
+
// Captures are transferred/detached after delivery. Never borrow a pool or
|
|
318
|
+
// expose a nonzero offset to the packed-pixel compositor.
|
|
319
|
+
if (!(pixels.buffer instanceof ArrayBuffer) || pixels.byteOffset !== 0 || pixels.buffer.byteLength !== base.length)
|
|
320
|
+
return new Uint8Array(base);
|
|
321
|
+
for (const [offset, span] of copies.spans) pixels.set(span, offset);
|
|
322
|
+
return pixels;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function compositeVideoPixels(tile, frame, pixels, format, region = null) {
|
|
326
|
+
const layer = tile.videoLayer;
|
|
327
|
+
const bgra = format === "bgra";
|
|
328
|
+
const { xs, rows, repeatedRows } = videoPlan(tile, frame, region);
|
|
329
|
+
const targetWords = new Uint32Array(pixels.buffer, 0, Math.floor(pixels.byteLength / 4));
|
|
330
|
+
const sourceWords = frame.pixels.byteOffset % 4 === 0
|
|
331
|
+
? new Uint32Array(frame.pixels.buffer, frame.pixels.byteOffset, Math.floor(frame.pixels.byteLength / 4)) : null;
|
|
332
|
+
// Upscaling often repeats one decoded row several times. Expand it once,
|
|
333
|
+
// then copy opaque spans; keep only one scratch row for this capture.
|
|
334
|
+
let expanded = null, expandedSource = -1;
|
|
335
|
+
// Decode output and raster tiles are RGBA. Preserve the raster's alpha:
|
|
336
|
+
// the black/white mask already accounts for video opacity and overlays.
|
|
337
|
+
for (const { source, target, maskTarget, runs } of rows) {
|
|
338
|
+
for (let r = 0; r < runs.length; r += 3) {
|
|
339
|
+
const end = runs[r + 1];
|
|
340
|
+
const kind = runs[r + 2];
|
|
341
|
+
if (kind === 3 && sourceWords && repeatedRows) {
|
|
342
|
+
if (expandedSource !== source) {
|
|
343
|
+
expanded ??= new Uint32Array(xs.length);
|
|
344
|
+
for (let i = 0; i < xs.length; i++) {
|
|
345
|
+
const p = sourceWords[source + xs[i]];
|
|
346
|
+
expanded[i] = 0xff000000 | (bgra
|
|
347
|
+
? (p & 0x0000ff00) | ((p & 0xff) << 16) | ((p >>> 16) & 0xff)
|
|
348
|
+
: p & 0x00ffffff);
|
|
349
|
+
}
|
|
350
|
+
expandedSource = source;
|
|
351
|
+
}
|
|
352
|
+
targetWords.set(runs[r] === 0 && end === xs.length
|
|
353
|
+
? expanded : expanded.subarray(runs[r], end), target + runs[r]);
|
|
354
|
+
} else if ((kind === 1 || kind === 3) && sourceWords) {
|
|
355
|
+
for (let i = runs[r]; i < end; i++) {
|
|
356
|
+
const dst = target + i;
|
|
357
|
+
const p = sourceWords[source + xs[i]];
|
|
358
|
+
targetWords[dst] = (kind === 3 ? 0xff000000 : targetWords[dst] & 0xff000000) | (bgra
|
|
359
|
+
? (p & 0x0000ff00) | ((p & 0xff) << 16) | ((p >>> 16) & 0xff)
|
|
360
|
+
: p & 0x00ffffff);
|
|
361
|
+
}
|
|
362
|
+
} else if (kind === 1 || kind === 3) {
|
|
363
|
+
for (let i = runs[r]; i < end; i++) {
|
|
364
|
+
const dst = (target + i) * 4, src = (source + xs[i]) * 4;
|
|
365
|
+
pixels[dst] = frame.pixels[src + (bgra ? 2 : 0)];
|
|
366
|
+
pixels[dst + 1] = frame.pixels[src + 1];
|
|
367
|
+
pixels[dst + 2] = frame.pixels[src + (bgra ? 0 : 2)];
|
|
368
|
+
if (kind === 3) pixels[dst + 3] = 255;
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
for (let i = runs[r]; i < end; i++) {
|
|
372
|
+
const dst = (target + i) * 4, src = (source + xs[i]) * 4, mask = (maskTarget + i) * 4;
|
|
373
|
+
for (let c = 0; c < 3; c++) pixels[dst + (bgra ? 2 - c : c)] = Math.min(255,
|
|
374
|
+
layer.base[mask + c] + Math.round(frame.pixels[src + c] * layer.mask[mask + c] / 255));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function compositeVideo(tile, frame, format = "rgba") {
|
|
382
|
+
if (!tile.videoLayer || !frame) return tile;
|
|
383
|
+
const pixels = new Uint8Array(videoBase(tile.videoLayer, format === "bgra"));
|
|
384
|
+
compositeVideoPixels(tile, frame, pixels, format);
|
|
385
|
+
return { ...tile, pixels, pixelFormat: format, background: videoBackground(tile, format === "bgra") };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Cut the static base before composing video, avoiding offscreen prepaint
|
|
390
|
+
* pixels on each raw capture. Scaled clips keep the full-composite fallback.
|
|
391
|
+
* Pixel sampling stays in the original tile's coordinates: translating a
|
|
392
|
+
* fractional video rect before nearest-neighbour sampling changes its phase.
|
|
393
|
+
* The result exclusively owns its pixels; neither the tile nor its mask/base
|
|
394
|
+
* can be modified or detached when the caller consumes the capture.
|
|
395
|
+
*/
|
|
396
|
+
export function captureVideoViewport(tile, frame, x, y, width, height, clip, format = "rgba") {
|
|
397
|
+
if (!tile.videoLayer || !frame || (clip && clip.scale !== 1)) return null;
|
|
398
|
+
const base = videoBase(tile.videoLayer, format === "bgra");
|
|
399
|
+
const captured = captureViewport({ ...tile, pixels: base, background: videoBackground(tile, format === "bgra") },
|
|
400
|
+
x, y, width, height, clip);
|
|
401
|
+
const region = {
|
|
402
|
+
x: Math.round(((clip?.x ?? x) - tile.x) * tile.scale),
|
|
403
|
+
y: Math.round(((clip?.y ?? y) - tile.y) * tile.scale),
|
|
404
|
+
width: captured.width, height: captured.height,
|
|
405
|
+
};
|
|
406
|
+
const pixels = captured.pixels.buffer === base.buffer
|
|
407
|
+
? copyStaticVideoBase(captured.pixels, videoPlan(tile, frame, region)) : captured.pixels;
|
|
408
|
+
compositeVideoPixels(tile, frame, pixels, format, region);
|
|
409
|
+
return { ...captured, pixels, pixelFormat: format };
|
|
145
410
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Decoding must not block screenshot composition (or vice versa). Initialise
|
|
2
|
+
// WASM before the compositor announces readiness and seccomp is installed.
|
|
3
|
+
import wasmpeg from '../../vendor/wasmpeg/src/js/index.js';
|
|
4
|
+
import { VideoPresentation } from './video-presentation.js';
|
|
5
|
+
|
|
6
|
+
// The decoder can keep advancing while the compositor is busy. Bound pixels
|
|
7
|
+
// waiting across the Worker boundary to one transmitted and one latest frame.
|
|
8
|
+
// Keep an outstanding delivery across stop/open: its ACK must be received
|
|
9
|
+
// before another generation can send, and cannot acknowledge a newer frame.
|
|
10
|
+
let inFlight = null, pending = null, nextDelivery = 0;
|
|
11
|
+
function sendLatest() {
|
|
12
|
+
if (inFlight || !pending) return;
|
|
13
|
+
const {id, frame} = pending;
|
|
14
|
+
pending = null;
|
|
15
|
+
if (video.config?.id !== id) return;
|
|
16
|
+
const delivery = ++nextDelivery;
|
|
17
|
+
inFlight = {id, delivery};
|
|
18
|
+
try { postMessage({ type: 'frame', id, delivery, frame }, [frame.pixels.buffer]); }
|
|
19
|
+
catch (error) { inFlight = null; throw error; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const video = new VideoPresentation(wasmpeg, () => {
|
|
23
|
+
pending = {id:video.config.id, frame:video.frame};
|
|
24
|
+
sendLatest();
|
|
25
|
+
}, report => postMessage({ type: 'report', report }));
|
|
26
|
+
|
|
27
|
+
self.onmessage = ({data}) => {
|
|
28
|
+
switch (data.type) {
|
|
29
|
+
case 'open': pending = null; void video.open(data.config); break;
|
|
30
|
+
case 'extend': void video.extend(data); break;
|
|
31
|
+
case 'stop':
|
|
32
|
+
if (data.id === undefined || video.config?.id === data.id) pending = null;
|
|
33
|
+
video.stop(data.id);
|
|
34
|
+
break;
|
|
35
|
+
case 'frame-ack':
|
|
36
|
+
if (inFlight?.id === data.id && inFlight.delivery === data.delivery) {
|
|
37
|
+
inFlight = null;
|
|
38
|
+
sendLatest();
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
case 'bind': video.bind(data.id, data.audioId, data.audioStart, data.time, data.endTime); break;
|
|
42
|
+
case 'timing': video.timing(data.id, data.seconds); break;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await wasmpeg.load();
|
|
46
|
+
postMessage({ type: 'ready' });
|
package/lib/renderer/workers.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
referenceHelper,
|
|
16
16
|
} from "./script-rewrite.js";
|
|
17
17
|
import { createPorts } from "./messaging.js";
|
|
18
|
+
import { createTimers } from "./timers.js";
|
|
18
19
|
|
|
19
20
|
/** Constructor reads, then the classic-script rewrite, sharing one parse when the first changes nothing. */
|
|
20
21
|
function rewriteWorkerScript(source) {
|
|
@@ -44,7 +45,6 @@ function literalImports(source, base) {
|
|
|
44
45
|
return urls;
|
|
45
46
|
}
|
|
46
47
|
const hostSetTimeout = globalThis.setTimeout;
|
|
47
|
-
const hostClearTimeout = globalThis.clearTimeout;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* @param {{
|
|
@@ -107,7 +107,6 @@ export function createWorkers({
|
|
|
107
107
|
const worker = Object.create(Worker.prototype);
|
|
108
108
|
const outside = eventTarget(() => worker);
|
|
109
109
|
const inside = eventTarget(() => scope);
|
|
110
|
-
const timers = new Map();
|
|
111
110
|
let nextTimer = 1;
|
|
112
111
|
let terminated = false;
|
|
113
112
|
let started = false;
|
|
@@ -119,8 +118,7 @@ export function createWorkers({
|
|
|
119
118
|
terminate() {
|
|
120
119
|
terminated = true;
|
|
121
120
|
workerPorts.dispose();
|
|
122
|
-
|
|
123
|
-
timers.clear();
|
|
121
|
+
timers.dispose();
|
|
124
122
|
pending.length = 0;
|
|
125
123
|
live.delete(state);
|
|
126
124
|
},
|
|
@@ -140,21 +138,13 @@ export function createWorkers({
|
|
|
140
138
|
const workerCall = (fn, thisArg, args) => {
|
|
141
139
|
if (typeof fn === "function") Reflect.apply(fn, thisArg, args);
|
|
142
140
|
};
|
|
141
|
+
const timers = createTimers({ invoke: callback => workerTask(callback), nextId: () => nextTimer++ });
|
|
143
142
|
const schedule = (handler, delay, args, repeat) => {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (repeat) timers.set(id, hostSetTimeout(fire, Math.max(4, Number(delay) || 0)));
|
|
148
|
-
else timers.delete(id);
|
|
149
|
-
workerTask(() => typeof handler === "function" ? Reflect.apply(handler, scope, args) : evaluate(String(handler)));
|
|
150
|
-
};
|
|
151
|
-
timers.set(id, hostSetTimeout(fire, Math.max(0, Number(delay) || 0)));
|
|
152
|
-
return id;
|
|
153
|
-
};
|
|
154
|
-
const cancel = (id) => {
|
|
155
|
-
hostClearTimeout(timers.get(Number(id)));
|
|
156
|
-
timers.delete(Number(id));
|
|
143
|
+
const code = typeof handler === "function" ? null : String(handler);
|
|
144
|
+
return timers.schedule(() => typeof handler === "function"
|
|
145
|
+
? Reflect.apply(handler, scope, args) : evaluate(code), delay, repeat);
|
|
157
146
|
};
|
|
147
|
+
const cancel = id => timers.cancel(id);
|
|
158
148
|
const messageFor = (target, data, ports = []) => Object.freeze({
|
|
159
149
|
type: "message", data, origin: "", lastEventId: "", source: null, ports: Object.freeze([...ports]),
|
|
160
150
|
target, currentTarget: target, bubbles: false, cancelable: false, defaultPrevented: false,
|