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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "bun:test";
|
|
2
|
+
import { cropViewport, rawFrame } from "../lib/paint/frame.js";
|
|
2
3
|
import { VideoPresentation, compositeVideo } from "../lib/renderer/video-presentation.js";
|
|
4
|
+
import wasmpeg from "../vendor/wasmpeg/src/js/index.js";
|
|
3
5
|
|
|
4
6
|
test("video predecode is bounded and waits for measured audio, skipping stale frames", async () => {
|
|
5
7
|
let frames = 0, closed = 0;
|
|
@@ -39,6 +41,92 @@ test("a late decoder open cannot replace a newer playback generation", async ()
|
|
|
39
41
|
expect(video.config).toBeNull();
|
|
40
42
|
});
|
|
41
43
|
|
|
44
|
+
test("queued timing after a synchronous decoder open is read before seeking", async () => {
|
|
45
|
+
let at = 0;
|
|
46
|
+
const seeks = [];
|
|
47
|
+
const video = new VideoPresentation({ decode: () => {
|
|
48
|
+
// Like a worker timing message queued while WASM is opening: this task
|
|
49
|
+
// runs after decode's await continuation, before the resumed seek.
|
|
50
|
+
setTimeout(() => video.timing(14, 3), 0);
|
|
51
|
+
return { width: 2, height: 2, fps: 10,
|
|
52
|
+
seek(ms) { seeks.push(ms); at = Math.floor(ms / 1000); },
|
|
53
|
+
nextFrame(w, h) { const pixels = new Uint8Array(w * h * 4).fill(Math.floor(at) + 1); at += 0.1; return pixels; },
|
|
54
|
+
close() {} };
|
|
55
|
+
} }, () => {}, () => {});
|
|
56
|
+
try {
|
|
57
|
+
video.timing(14, 0);
|
|
58
|
+
await video.open({ id: 8, time: 10, startTime: 10, endTime: 20,
|
|
59
|
+
hasAudio: true, audioId: 14, audioStart: 10 });
|
|
60
|
+
expect(seeks[0]).toBe(13000);
|
|
61
|
+
expect(video.resumeTime).toBe(13);
|
|
62
|
+
expect(video.frame.time).toBeCloseTo(13);
|
|
63
|
+
} finally { video.stop(); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("stopping during the post-open task closes the decoder without seeking or publishing", async () => {
|
|
67
|
+
let closed = 0, sought = 0, decoded = 0, changed = 0;
|
|
68
|
+
const reports = [];
|
|
69
|
+
const video = new VideoPresentation({ decode: () => {
|
|
70
|
+
setTimeout(() => video.stop(), 0);
|
|
71
|
+
return { width: 2, height: 2, fps: 10,
|
|
72
|
+
seek() { sought++; }, nextFrame() { decoded++; return new Uint8Array(16); }, close() { closed++; } };
|
|
73
|
+
} }, () => changed++, report => reports.push(report));
|
|
74
|
+
try {
|
|
75
|
+
await video.open({ id: 9, time: 10, startTime: 0, hasAudio: true });
|
|
76
|
+
expect(closed).toBe(1);
|
|
77
|
+
expect(sought).toBe(0);
|
|
78
|
+
expect(decoded).toBe(0);
|
|
79
|
+
expect(changed).toBe(0);
|
|
80
|
+
expect(reports).toEqual([]);
|
|
81
|
+
expect(video.config).toBeNull();
|
|
82
|
+
expect(video.decoder).toBeNull();
|
|
83
|
+
} finally { video.stop(); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a delayed snapshot open seeks against audio progress without resetting its clock", async () => {
|
|
87
|
+
let finish, at = 0;
|
|
88
|
+
const seeks = [], reports = [];
|
|
89
|
+
const video = new VideoPresentation({ decode: () => new Promise(resolve => { finish = resolve; }) },
|
|
90
|
+
() => {}, report => reports.push(report));
|
|
91
|
+
try {
|
|
92
|
+
video.timing(12, 0);
|
|
93
|
+
const opening = video.open({ id: 6, time: 10, startTime: 10, endTime: 20,
|
|
94
|
+
hasAudio: true, audioId: 12, audioStart: 10 });
|
|
95
|
+
video.timing(12, 4);
|
|
96
|
+
finish({ width: 2, height: 2, fps: 10,
|
|
97
|
+
seek(ms) { seeks.push(ms); at = Math.floor(ms / 1000); },
|
|
98
|
+
nextFrame(w, h) { const pixels = new Uint8Array(w * h * 4).fill(Math.floor(at) + 1); at += 0.1; return pixels; },
|
|
99
|
+
close() {} });
|
|
100
|
+
await opening;
|
|
101
|
+
expect(seeks[0]).toBe(14000);
|
|
102
|
+
expect(video.resumeTime).toBe(14);
|
|
103
|
+
expect(video.config.time).toBe(10);
|
|
104
|
+
expect(video.time()).toBe(14);
|
|
105
|
+
expect(video.frame.time).toBeCloseTo(14);
|
|
106
|
+
expect(reports.filter(report => report.time !== undefined).every(report => report.time >= 13.9)).toBe(true);
|
|
107
|
+
} finally { video.stop(); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a delayed open that has passed its epoch reports the boundary without decoding obsolete frames", async () => {
|
|
111
|
+
let finish, decoded = 0, sought = 0;
|
|
112
|
+
const reports = [];
|
|
113
|
+
const video = new VideoPresentation({ decode: () => new Promise(resolve => { finish = resolve; }) },
|
|
114
|
+
() => {}, report => reports.push(report));
|
|
115
|
+
try {
|
|
116
|
+
video.timing(13, 0);
|
|
117
|
+
const opening = video.open({ id: 7, time: 10, startTime: 10, endTime: 12,
|
|
118
|
+
hasAudio: true, audioId: 13, audioStart: 10 });
|
|
119
|
+
video.timing(13, 3);
|
|
120
|
+
finish({ width: 2, height: 2, fps: 10,
|
|
121
|
+
seek() { sought++; }, nextFrame() { decoded++; return new Uint8Array(16); }, close() {} });
|
|
122
|
+
await opening;
|
|
123
|
+
expect(video.resumeTime).toBe(12);
|
|
124
|
+
expect(sought).toBe(0);
|
|
125
|
+
expect(decoded).toBe(0);
|
|
126
|
+
expect(reports).toEqual([{ id: 7, end: 12 }]);
|
|
127
|
+
} finally { video.stop(); }
|
|
128
|
+
});
|
|
129
|
+
|
|
42
130
|
test("reopening at a keyframe does not show the prefix before the resume position", async () => {
|
|
43
131
|
let at = 0;
|
|
44
132
|
const reports = [];
|
|
@@ -54,6 +142,80 @@ test("reopening at a keyframe does not show the prefix before the resume positio
|
|
|
54
142
|
} finally { video.stop(); }
|
|
55
143
|
});
|
|
56
144
|
|
|
145
|
+
test("discarded GOP frames use tiny output while the resume boundary keeps full-size pixels", async () => {
|
|
146
|
+
for (const time of [0.5, 0.51]) {
|
|
147
|
+
let at = 0;
|
|
148
|
+
const calls = [];
|
|
149
|
+
const video = new VideoPresentation({ decode: async () => ({ width: 8, height: 4, fps: 4,
|
|
150
|
+
seek() { at = 0; calls.length = 0; },
|
|
151
|
+
nextFrame(w, h) { calls.push([w, h]); return new Uint8Array(w * h * 4).fill(++at); },
|
|
152
|
+
close() {} }) }, () => {}, () => {});
|
|
153
|
+
try {
|
|
154
|
+
video.timing(20, time);
|
|
155
|
+
await video.open({ id: 10, time, hasAudio: true, audioId: 20, audioStart: 0 });
|
|
156
|
+
// The frame ending exactly at 0.5 is discarded, but the frame spanning
|
|
157
|
+
// 0.5–0.75 must keep its full image for both an exact and fractional seek.
|
|
158
|
+
expect(calls.slice(0, 2)).toEqual([[1, 1], [1, 1]]);
|
|
159
|
+
expect(calls.slice(2).length).toBeGreaterThan(0);
|
|
160
|
+
expect(calls.slice(2).every(([w, h]) => w === 8 && h === 4)).toBe(true);
|
|
161
|
+
expect(video.frame.time).toBe(0.5);
|
|
162
|
+
expect(video.frame.pixels[0]).toBe(3);
|
|
163
|
+
for (const frame of [video.frame, ...video.queue]) {
|
|
164
|
+
expect([frame.width, frame.height, frame.pixels.length]).toEqual([8, 4, 128]);
|
|
165
|
+
}
|
|
166
|
+
} finally { video.stop(); }
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("real interframe decoders preserve every displayed pixel after tiny GOP-prefix output", async () => {
|
|
171
|
+
await wasmpeg.load();
|
|
172
|
+
for (const [fixture, resume] of [["long-video.mp4", 15.125], ["mse-switch-large.mp4", 3.75]]) {
|
|
173
|
+
const bytes = new Uint8Array(await Bun.file(new URL(`./fixtures/${fixture}`, import.meta.url)).arrayBuffer());
|
|
174
|
+
const run = async fullPrefix => {
|
|
175
|
+
let smallFrames = 0;
|
|
176
|
+
const reports = [];
|
|
177
|
+
const engine = { decode: async () => {
|
|
178
|
+
const decoder = await wasmpeg.decode(bytes);
|
|
179
|
+
return { ...decoder, nextFrame(w, h) {
|
|
180
|
+
if (w === 1 && h === 1) {
|
|
181
|
+
smallFrames++;
|
|
182
|
+
// Reference: retain the old behavior, producing normal-sized
|
|
183
|
+
// RGBA even for frames which the presentation discards.
|
|
184
|
+
if (fullPrefix) return decoder.nextFrame(decoder.width, decoder.height);
|
|
185
|
+
}
|
|
186
|
+
return decoder.nextFrame(w, h);
|
|
187
|
+
} };
|
|
188
|
+
} };
|
|
189
|
+
const video = new VideoPresentation(engine, () => {}, report => reports.push(report));
|
|
190
|
+
const result = [];
|
|
191
|
+
const pump = () => {
|
|
192
|
+
for (let steps = 0; steps < 100 && !video.eof && video.nextTime <= video.time() + 0.75
|
|
193
|
+
&& video.queue.length < video.limit; steps++) {
|
|
194
|
+
clearTimeout(video.timer);
|
|
195
|
+
video.step();
|
|
196
|
+
}
|
|
197
|
+
clearTimeout(video.timer);
|
|
198
|
+
expect(video.frame).not.toBeNull();
|
|
199
|
+
for (const frame of [video.frame, ...video.queue]) {
|
|
200
|
+
expect(frame.pixels.length).toBe(frame.width * frame.height * 4);
|
|
201
|
+
}
|
|
202
|
+
result.push([video.frame, ...video.queue]);
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
video.timing(21, resume);
|
|
206
|
+
await video.open({ id: 11, time: resume, hasAudio: true, audioId: 21, audioStart: 0 });
|
|
207
|
+
pump();
|
|
208
|
+
video.timing(21, resume + 0.5);
|
|
209
|
+
pump();
|
|
210
|
+
expect(smallFrames).toBeGreaterThan(0);
|
|
211
|
+
expect(reports.some(report => report.error)).toBe(false);
|
|
212
|
+
return result;
|
|
213
|
+
} finally { video.stop(); }
|
|
214
|
+
};
|
|
215
|
+
expect(await run(false)).toEqual(await run(true));
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
57
219
|
test("a presentation keeps absolute epoch times and ends before replaced frames", async () => {
|
|
58
220
|
let at = 0;
|
|
59
221
|
const seeks = [], reports = [];
|
|
@@ -87,13 +249,16 @@ test("a short epoch snapshot reports its actual EOF so playback can request more
|
|
|
87
249
|
let at = 0;
|
|
88
250
|
const reports = [];
|
|
89
251
|
const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
252
|
+
seek() { at = 0; },
|
|
90
253
|
nextFrame(w, h) { return ++at <= 2 ? new Uint8Array(w * h * 4).fill(at) : null; }, close() {} }) },
|
|
91
254
|
() => {}, report => reports.push(report));
|
|
92
255
|
try {
|
|
93
256
|
video.timing(10, 0.25);
|
|
94
257
|
await video.open({ id: 4, time: 40, startTime: 40, endTime: 45,
|
|
95
258
|
hasAudio: true, audioId: 10, audioStart: 40 });
|
|
96
|
-
|
|
259
|
+
// Audio already passed both frames: request the next snapshot without
|
|
260
|
+
// flashing this obsolete prefix over the previously displayed picture.
|
|
261
|
+
expect(video.frame).toBeNull();
|
|
97
262
|
expect(reports.find(report => report.end !== undefined)?.end).toBeCloseTo(40.2);
|
|
98
263
|
} finally { video.stop(); }
|
|
99
264
|
});
|
|
@@ -134,3 +299,76 @@ test("video layer preserves opaque controls, translucent overlays and clipped pi
|
|
|
134
299
|
]);
|
|
135
300
|
expect(compositeVideo(tile, null)).toBe(tile);
|
|
136
301
|
});
|
|
302
|
+
|
|
303
|
+
test("cached video masks match scalar composition across fractional, clipped and resized layers", () => {
|
|
304
|
+
const base = Uint8Array.from({length: 13 * 9 * 4}, (_, i) => i * 29 % 256);
|
|
305
|
+
const mask = Uint8Array.from(base, (_, i) => i % 7 === 0 ? 127 : i % 3 ? 255 : 0);
|
|
306
|
+
for (let i=0;i<base.length;i+=4) if (i%12===0) {
|
|
307
|
+
base[i]=base[i+1]=base[i+2]=0;
|
|
308
|
+
mask[i]=mask[i+1]=mask[i+2]=255;
|
|
309
|
+
}
|
|
310
|
+
for (const rect of [{x:-2.3,y:1.2,width:12.4,height:5.8},{x:3.2,y:-2.7,width:15,height:13}]) {
|
|
311
|
+
const tile={pixelWidth:13,pixelHeight:9,pixels:base,videoLayer:{base,mask,rect}};
|
|
312
|
+
for (const [w,h] of [[5,3],[5,3],[7,4]]) {
|
|
313
|
+
const frame={width:w,height:h,pixels:Uint8Array.from({length:w*h*4},(_,i)=>i*71%256)};
|
|
314
|
+
const expected=new Uint8Array(base);
|
|
315
|
+
for(let py=Math.max(0,Math.floor(rect.y));py<Math.min(9,Math.ceil(rect.y+rect.height));py++) {
|
|
316
|
+
const sy=Math.max(0,Math.min(h-1,Math.floor((py-rect.y)/rect.height*h)));
|
|
317
|
+
for(let px=Math.max(0,Math.floor(rect.x));px<Math.min(13,Math.ceil(rect.x+rect.width));px++) {
|
|
318
|
+
const sx=Math.max(0,Math.min(w-1,Math.floor((px-rect.x)/rect.width*w)));
|
|
319
|
+
const d=(py*13+px)*4,s=(sy*w+sx)*4;
|
|
320
|
+
for(let c=0;c<3;c++) expected[d+c]=Math.min(255,base[d+c]+Math.round(frame.pixels[s+c]*mask[d+c]/255));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
expect(compositeVideo(tile,frame).pixels).toEqual(expected);
|
|
324
|
+
const bgra = new Uint8Array(expected);
|
|
325
|
+
for (let i=0;i<bgra.length;i+=4) [bgra[i],bgra[i+2]]=[bgra[i+2],bgra[i]];
|
|
326
|
+
expect(compositeVideo(tile,frame,"bgra").pixels).toEqual(bgra);
|
|
327
|
+
expect(compositeVideo(tile,frame).pixels).toEqual(expected);
|
|
328
|
+
expect(tile.pixels).toBe(base);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("BGRA composition preserves the page background when clipping outside its tile", () => {
|
|
334
|
+
const base=Uint8Array.of(0,0,0,255),mask=Uint8Array.of(255,255,255,0);
|
|
335
|
+
const tile={x:0,y:0,width:1,height:1,scale:1,pixelWidth:1,pixelHeight:1,pixels:base,
|
|
336
|
+
background:[12,34,56,255],videoLayer:{base,mask,rect:{x:0,y:0,width:1,height:1}}};
|
|
337
|
+
const frame={width:1,height:1,pixels:Uint8Array.of(20,40,60,255)};
|
|
338
|
+
const rgba=cropViewport(compositeVideo(tile,frame),-1,-1,3,3);
|
|
339
|
+
const bgra=cropViewport(compositeVideo(tile,frame,"bgra"),-1,-1,3,3);
|
|
340
|
+
expect(bgra.pixels).toEqual(rawFrame(rgba.pixels,3,3,"bgra").pixels);
|
|
341
|
+
expect(tile.background).toEqual([12,34,56,255]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("upscaled opaque rows keep masks, alpha and fresh frame pixels in both channel orders", () => {
|
|
345
|
+
const width = 19, height = 11, rect = {x:-1.25,y:0.6,width:22.5,height:10.2};
|
|
346
|
+
const base = new Uint8Array(width*height*4), mask = new Uint8Array(base.length);
|
|
347
|
+
for (let i=0;i<width*height;i++) {
|
|
348
|
+
// Full opaque rows and interrupted spans, plus transparent and blended pixels.
|
|
349
|
+
const kind = i < width*3 ? 0 : i%4, d=i*4;
|
|
350
|
+
base[d+3] = kind === 1 ? 93 : 255;
|
|
351
|
+
if (kind === 2) base.set([20,30,40,200],d);
|
|
352
|
+
if (kind !== 3) mask.set(kind === 2 ? [127,80,190,0] : [255,255,255,0],d);
|
|
353
|
+
}
|
|
354
|
+
const tile={pixelWidth:width,pixelHeight:height,pixels:base,videoLayer:{base,mask,rect}};
|
|
355
|
+
for (const offset of [0,1]) for (const tick of [0,17]) {
|
|
356
|
+
const pixels=new Uint8Array(new ArrayBuffer(5*3*4+offset),offset);
|
|
357
|
+
for(let i=0;i<pixels.length;i++) pixels[i]=(i*71+tick)%256;
|
|
358
|
+
const frame={width:5,height:3,pixels};
|
|
359
|
+
const expected=new Uint8Array(base);
|
|
360
|
+
for(let py=Math.max(0,Math.floor(rect.y));py<Math.min(height,Math.ceil(rect.y+rect.height));py++) {
|
|
361
|
+
const sy=Math.max(0,Math.min(2,Math.floor((py-rect.y)/rect.height*3)));
|
|
362
|
+
for(let px=Math.max(0,Math.floor(rect.x));px<Math.min(width,Math.ceil(rect.x+rect.width));px++) {
|
|
363
|
+
const sx=Math.max(0,Math.min(4,Math.floor((px-rect.x)/rect.width*5)));
|
|
364
|
+
const d=(py*width+px)*4,s=(sy*5+sx)*4;
|
|
365
|
+
for(let c=0;c<3;c++) expected[d+c]=Math.min(255,base[d+c]+Math.round(pixels[s+c]*mask[d+c]/255));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
expect(compositeVideo(tile,frame).pixels).toEqual(expected);
|
|
369
|
+
const bgra=new Uint8Array(expected);
|
|
370
|
+
for(let i=0;i<bgra.length;i+=4) [bgra[i],bgra[i+2]]=[bgra[i+2],bgra[i]];
|
|
371
|
+
expect(compositeVideo(tile,frame,"bgra").pixels).toEqual(bgra);
|
|
372
|
+
expect(tile.pixels).toBe(base);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { VideoPresentation } from "../lib/renderer/video-presentation.js";
|
|
3
|
+
|
|
4
|
+
function queuedVideo(changed = () => {}) {
|
|
5
|
+
const reports = [];
|
|
6
|
+
let decoded = 0;
|
|
7
|
+
const video = new VideoPresentation({}, changed, report => reports.push(report));
|
|
8
|
+
Object.assign(video, {
|
|
9
|
+
config: { id: 10, audioId: 7, audioStart: 10, time: 10, hasAudio: true },
|
|
10
|
+
decoder: {
|
|
11
|
+
fps: 10,
|
|
12
|
+
nextFrame() { decoded++; throw new Error("timing must not decode"); },
|
|
13
|
+
close() {},
|
|
14
|
+
},
|
|
15
|
+
queue: [10.1, 10.2, 10.3].map((time, i) => ({
|
|
16
|
+
time, width: 2, height: 2, pixels: new Uint8Array(16).fill(i + 1),
|
|
17
|
+
})),
|
|
18
|
+
nextTime: 10.4,
|
|
19
|
+
frame: null,
|
|
20
|
+
eof: false,
|
|
21
|
+
reportedEnd: false,
|
|
22
|
+
});
|
|
23
|
+
return { video, reports, decoded: () => decoded };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("active measured audio presents due frames before another decode turn", () => {
|
|
27
|
+
let changed = 0;
|
|
28
|
+
const { video, reports, decoded } = queuedVideo(() => changed++);
|
|
29
|
+
const timer = video.timer = setTimeout(() => {}, 100_000);
|
|
30
|
+
try {
|
|
31
|
+
for (const [id, time] of [[8, 0.5], [7, NaN], [7, -1], [7, 0.09]])
|
|
32
|
+
video.timing(id, time);
|
|
33
|
+
expect(video.frame).toBeNull();
|
|
34
|
+
video.timing(7, 0.1);
|
|
35
|
+
expect(video.frame.time).toBe(10.1);
|
|
36
|
+
video.timing(7, 0.1);
|
|
37
|
+
video.timing(7, 0.05);
|
|
38
|
+
expect(changed).toBe(1);
|
|
39
|
+
video.timing(7, 0.3);
|
|
40
|
+
expect(video.frame.time).toBe(10.3);
|
|
41
|
+
expect(changed).toBe(2);
|
|
42
|
+
expect(reports).toEqual([{ id: 10, time: 10.1 }, { id: 10, time: 10.3 }]);
|
|
43
|
+
expect(decoded()).toBe(0);
|
|
44
|
+
expect(video.timer).toBe(timer);
|
|
45
|
+
|
|
46
|
+
video.config.audioId = null;
|
|
47
|
+
video.queue.push({ time: 11, width: 2, height: 2, pixels: new Uint8Array(16) });
|
|
48
|
+
video.timing(null, 99);
|
|
49
|
+
expect(changed).toBe(2);
|
|
50
|
+
video.stop();
|
|
51
|
+
video.timing(7, 99);
|
|
52
|
+
expect(changed).toBe(2);
|
|
53
|
+
expect(video.frame).toBeNull();
|
|
54
|
+
} finally { video.stop(); }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("timing and decode turns report EOF once at the measured end boundary", () => {
|
|
58
|
+
const { video, reports, decoded } = queuedVideo();
|
|
59
|
+
try {
|
|
60
|
+
video.eof = true;
|
|
61
|
+
video.timing(7, 0.3);
|
|
62
|
+
expect(reports).toEqual([{ id: 10, time: 10.3 }]);
|
|
63
|
+
video.timing(7, 0.399);
|
|
64
|
+
expect(reports).toHaveLength(1);
|
|
65
|
+
video.timing(7, 0.4);
|
|
66
|
+
video.timing(7, 1);
|
|
67
|
+
video.step();
|
|
68
|
+
expect(reports).toEqual([{ id: 10, time: 10.3 }, { id: 10, end: 10.4 }]);
|
|
69
|
+
expect(decoded()).toBe(0);
|
|
70
|
+
} finally { video.stop(); }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("timing publication copies shared pixels before transferring their buffer", () => {
|
|
74
|
+
const received = [];
|
|
75
|
+
const { video, reports } = queuedVideo(() => {
|
|
76
|
+
expect([...new Uint8Array(video.config.shared, 16, 16)]).toEqual([...video.frame.pixels]);
|
|
77
|
+
received.push(structuredClone(video.frame, { transfer: [video.frame.pixels.buffer] }));
|
|
78
|
+
expect(video.frame.pixels.byteLength).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
video.config.shared = new SharedArrayBuffer(32);
|
|
81
|
+
try {
|
|
82
|
+
video.timing(7, 0.1);
|
|
83
|
+
video.timing(7, 0.2);
|
|
84
|
+
expect(received.map(frame => frame.time)).toEqual([10.1, 10.2]);
|
|
85
|
+
expect(received.map(frame => frame.pixels[0])).toEqual([1, 2]);
|
|
86
|
+
expect(reports).toEqual([{ id: 10, time: 10.1 }, { id: 10, time: 10.2 }]);
|
|
87
|
+
expect([...new Int32Array(video.config.shared, 0, 4)]).toEqual([4, 2, 2, 0]);
|
|
88
|
+
} finally { video.stop(); }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("timing while a decoder opens stores the clock without publishing a stopped generation", async () => {
|
|
92
|
+
let finish, changed = 0, closed = 0;
|
|
93
|
+
const video = new VideoPresentation({ decode: () => new Promise(resolve => { finish = resolve; }) },
|
|
94
|
+
() => changed++, () => {});
|
|
95
|
+
const opening = video.open({ id: 11, audioId: 7, audioStart: 10, time: 10, hasAudio: true });
|
|
96
|
+
try {
|
|
97
|
+
video.timing(7, 2);
|
|
98
|
+
expect(video.clocks.get(7)).toBe(2);
|
|
99
|
+
expect(changed).toBe(0);
|
|
100
|
+
video.stop();
|
|
101
|
+
finish({ close() { closed++; } });
|
|
102
|
+
await opening;
|
|
103
|
+
expect(closed).toBe(1);
|
|
104
|
+
expect(changed).toBe(0);
|
|
105
|
+
} finally { video.stop(); }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("timing publication errors are not mistaken for decoder EOF", () => {
|
|
109
|
+
const { video } = queuedVideo(() => { throw new Error("publish failed"); });
|
|
110
|
+
try {
|
|
111
|
+
expect(() => video.timing(7, 0.1)).toThrow("publish failed");
|
|
112
|
+
expect(video.eof).toBe(false);
|
|
113
|
+
expect(video.reportedEnd).toBe(false);
|
|
114
|
+
} finally { video.stop(); }
|
|
115
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
test("boxless geometry avoids lazy styles while CSSOM, scrolling and visible geometry remain current", async () => {
|
|
4
|
+
const child = Bun.spawn([process.execPath, new URL("./fixtures/viewport-bounds.js", import.meta.url).pathname],
|
|
5
|
+
{ stdout: "pipe", stderr: "pipe" });
|
|
6
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited,
|
|
7
|
+
new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
8
|
+
expect(stderr).toBe("");
|
|
9
|
+
expect(exit).toBe(0);
|
|
10
|
+
const result = JSON.parse(stdout);
|
|
11
|
+
expect(result.passed).toBe(true);
|
|
12
|
+
expect(result.checks).toBeGreaterThan(20);
|
|
13
|
+
}, 30_000);
|