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,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
- expect(video.frame.time).toBeCloseTo(40.1);
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);