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.
Files changed (133) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/mutation-observer.js +157 -0
  7. package/lib/happy-dom/parser.js +2 -0
  8. package/lib/headless-shell.js +49 -9
  9. package/lib/layout/repaint.js +89 -0
  10. package/lib/media/mp4-keyframes.js +144 -0
  11. package/lib/media/video-keyframe.js +29 -1
  12. package/lib/media/webm-snapshots.js +282 -0
  13. package/lib/network/cookie-jar.js +18 -2
  14. package/lib/paint/display-list.js +4 -3
  15. package/lib/paint/frame.js +12 -5
  16. package/lib/render-tree/index.js +3 -0
  17. package/lib/renderer/animation-frames.js +76 -0
  18. package/lib/renderer/bindings.js +2 -0
  19. package/lib/renderer/compositor-channel.js +95 -10
  20. package/lib/renderer/compositor.js +54 -18
  21. package/lib/renderer/document-cookies.js +87 -0
  22. package/lib/renderer/font-loading.js +60 -16
  23. package/lib/renderer/host.js +48 -19
  24. package/lib/renderer/idle-callbacks.js +109 -0
  25. package/lib/renderer/main.js +5 -1
  26. package/lib/renderer/media-source.js +4 -0
  27. package/lib/renderer/page-realm.js +159 -49
  28. package/lib/renderer/page-renderer.js +567 -197
  29. package/lib/renderer/resource-elements.js +88 -0
  30. package/lib/renderer/script-rewrite.js +18 -1
  31. package/lib/renderer/seccomp.js +6 -4
  32. package/lib/renderer/timers.js +71 -0
  33. package/lib/renderer/video-channel.js +94 -0
  34. package/lib/renderer/video-mask.js +115 -0
  35. package/lib/renderer/video-presentation.js +304 -39
  36. package/lib/renderer/video-worker.js +46 -0
  37. package/lib/renderer/workers.js +7 -17
  38. package/lib/style/computed-style.js +257 -61
  39. package/package.json +1 -1
  40. package/test/animation-frames.test.js +67 -0
  41. package/test/canvas-style-snapshot.test.js +18 -0
  42. package/test/cdp-stream.test.js +96 -3
  43. package/test/compositor-channel-ownership.test.js +194 -0
  44. package/test/compositor.test.js +27 -1
  45. package/test/computed-style-binding.test.js +49 -0
  46. package/test/computed-style-connectivity.test.js +13 -0
  47. package/test/computed-style.test.js +86 -0
  48. package/test/cookie-sync.test.js +82 -0
  49. package/test/document-base-cache.test.js +103 -0
  50. package/test/document-cookies.test.js +117 -0
  51. package/test/duplicate-style-keys.test.js +11 -0
  52. package/test/fixtures/animation-frames.js +56 -0
  53. package/test/fixtures/canvas-style-snapshot.js +81 -0
  54. package/test/fixtures/computed-style-connectivity.js +101 -0
  55. package/test/fixtures/computed-style-lazy.js +135 -0
  56. package/test/fixtures/duplicate-style-keys.js +97 -0
  57. package/test/fixtures/fake-renderer.js +12 -0
  58. package/test/fixtures/font-definition-invalidation.js +97 -0
  59. package/test/fixtures/font-metrics-invalidation.js +87 -0
  60. package/test/fixtures/full-style-identity.js +36 -0
  61. package/test/fixtures/hidden-style-engine.js +260 -0
  62. package/test/fixtures/hidden-style-reentry.js +232 -0
  63. package/test/fixtures/hidden-style-renderer.js +203 -0
  64. package/test/fixtures/layout-repaint.js +69 -0
  65. package/test/fixtures/media-presentation-continuation.js +141 -0
  66. package/test/fixtures/media-presentation-refill.js +113 -0
  67. package/test/fixtures/media-probe.js +148 -0
  68. package/test/fixtures/media-refill-cancel.js +79 -0
  69. package/test/fixtures/media-repaint.js +48 -0
  70. package/test/fixtures/media-resource-reselection.js +94 -0
  71. package/test/fixtures/media-source-attachment.js +136 -0
  72. package/test/fixtures/media-source-no-decoder.js +17 -0
  73. package/test/fixtures/media-track-refill.js +132 -0
  74. package/test/fixtures/media-webm-seek.js +51 -0
  75. package/test/fixtures/mutation-observer.js +201 -0
  76. package/test/fixtures/observer-invalidation.js +105 -0
  77. package/test/fixtures/prepaint-scheduling.js +73 -0
  78. package/test/fixtures/resource-discovery.js +32 -0
  79. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  80. package/test/fixtures/style-propagation.js +26 -0
  81. package/test/fixtures/svg-node-reuse.js +83 -0
  82. package/test/fixtures/video-black-replay.js +140 -0
  83. package/test/fixtures/video-controls-invalidation.js +71 -0
  84. package/test/fixtures/viewport-bounds.js +148 -0
  85. package/test/font-loading.test.js +77 -0
  86. package/test/font-metrics-invalidation.test.js +60 -0
  87. package/test/full-style-identity.test.js +191 -0
  88. package/test/hidden-style-lazy.test.js +31 -0
  89. package/test/hidden-style-reentry.test.js +20 -0
  90. package/test/idle-callback.test.js +89 -0
  91. package/test/idle-scheduler.test.js +148 -0
  92. package/test/inline-style-cascade.test.js +63 -0
  93. package/test/layout-repaint-renderer.test.js +9 -0
  94. package/test/layout-repaint.test.js +222 -0
  95. package/test/media-presentation-continuation.test.js +14 -0
  96. package/test/media-presentation-refill.test.js +8 -0
  97. package/test/media-probe.test.js +16 -0
  98. package/test/media-repaint.test.js +8 -0
  99. package/test/media-source-attachment.test.js +34 -0
  100. package/test/media-track-refill.test.js +26 -0
  101. package/test/media-webm-seek.test.js +11 -0
  102. package/test/mp4-keyframes.test.js +36 -0
  103. package/test/mutation-observer.test.js +91 -0
  104. package/test/observer-invalidation.test.js +12 -0
  105. package/test/page-scripts.test.js +35 -0
  106. package/test/page-timers.test.js +67 -0
  107. package/test/paint-frame.test.js +18 -1
  108. package/test/pcm.test.js +63 -0
  109. package/test/prepaint-scheduling.test.js +13 -0
  110. package/test/pulse-client.test.js +165 -0
  111. package/test/raw-capture.test.js +34 -5
  112. package/test/renderer-host.test.js +51 -0
  113. package/test/resource-discovery.test.js +12 -0
  114. package/test/resource-elements.test.js +234 -0
  115. package/test/root-self-style.test.js +104 -0
  116. package/test/script-rewrite.test.js +35 -0
  117. package/test/seccomp.test.js +45 -0
  118. package/test/spare-renderer-scheduling.test.js +8 -0
  119. package/test/style-propagation.test.js +225 -0
  120. package/test/style-sheets.test.js +19 -0
  121. package/test/svg.test.js +45 -0
  122. package/test/timers.test.js +58 -0
  123. package/test/video-black-replay.test.js +34 -0
  124. package/test/video-capture.test.js +170 -0
  125. package/test/video-channel.test.js +253 -0
  126. package/test/video-continuation.test.js +57 -0
  127. package/test/video-controls-invalidation.test.js +12 -0
  128. package/test/video-keyframe.test.js +104 -4
  129. package/test/video-mask.test.js +94 -0
  130. package/test/video-presentation.test.js +239 -1
  131. package/test/video-timing-presentation.test.js +115 -0
  132. package/test/viewport-bounds.test.js +13 -0
  133. 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
- this.nextTime = config.time >= endTime ? endTime
26
- : config.time > startTime ? seekVideoKeyframe(decoder, config.time, startTime) : startTime;
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.decoder || !this.config) return;
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 pixels = this.decoder.nextFrame(this.width, this.height);
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
- const frame = { pixels, time: this.nextTime, width: this.width, height: this.height };
87
- this.nextTime += 1 / (this.decoder.fps || 30);
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
- let latest = null;
105
- while (this.queue.length && this.queue[0].time <= time + 1e-6) latest = this.queue.shift();
106
- if (latest) {
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
- if (this.eof && !this.queue.length && time >= this.nextTime && !this.reportedEnd) {
120
- this.reportedEnd = true;
121
- this.report({ id: this.config.id, end: this.nextTime });
122
- }
123
- this.timer = setTimeout(() => this.step(), 10);
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
- export function compositeVideo(tile, frame) {
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
- for (let py = Math.max(0, Math.floor(y)); py < Math.min(tile.pixelHeight, Math.ceil(y + height)); py++) {
136
- const sy = Math.max(0, Math.min(frame.height - 1, Math.floor((py - y) / height * frame.height)));
137
- for (let px = Math.max(0, Math.floor(x)); px < Math.min(tile.pixelWidth, Math.ceil(x + width)); px++) {
138
- const sx = Math.max(0, Math.min(frame.width - 1, Math.floor((px - x) / width * frame.width)));
139
- const dst = (py * tile.pixelWidth + px) * 4, src = (sy * frame.width + sx) * 4;
140
- for (let c = 0; c < 3; c++) pixels[dst + c] = Math.min(255,
141
- layer.base[dst + c] + Math.round(frame.pixels[src + c] * layer.mask[dst + c] / 255));
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 { ...tile, pixels };
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' });
@@ -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
- for (const handle of timers.values()) hostClearTimeout(handle);
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 id = nextTimer++;
145
- const fire = () => {
146
- if (!timers.has(id)) return;
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,