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
@@ -0,0 +1,253 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
3
+ import {tmpdir} from 'node:os';
4
+ import {join} from 'node:path';
5
+ import {VideoChannel,startVideoChannel} from '../lib/renderer/video-channel.js';
6
+
7
+ test('video channel rejects late frames and reports from stopped/replaced presentations',()=>{
8
+ let listener,changed=0;const reports=[],sent=[];
9
+ const channel=new VideoChannel({addEventListener(type,f){if(type==='message')listener=f;},postMessage(m){sent.push(m);}},()=>changed++,r=>reports.push(r));
10
+ channel.open({id:1});channel.open({id:2});
11
+ listener({data:{type:'frame',id:1,delivery:1,frame:{time:1}}});
12
+ listener({data:{type:'report',report:{id:1,end:2}}});
13
+ expect(changed).toBe(0);expect(reports).toHaveLength(0);
14
+ listener({data:{type:'frame',id:2,delivery:2,frame:{time:3}}});
15
+ expect(channel.frame.time).toBe(3);expect(changed).toBe(1);
16
+ channel.stop(1);expect(channel.config.id).toBe(2);
17
+ channel.stop(2);listener({data:{type:'frame',id:2,delivery:3,frame:{time:4}}});
18
+ expect(channel.frame).toBeNull();expect(changed).toBe(1);
19
+ expect(sent.filter(m=>m.type==='frame-ack')).toEqual([
20
+ {type:'frame-ack',id:1,delivery:1},
21
+ {type:'frame-ack',id:2,delivery:2},
22
+ {type:'frame-ack',id:2,delivery:3},
23
+ ]);
24
+ });
25
+
26
+ test('frame notification failures still ACK and a failed transport reports fallback without more sends',()=>{
27
+ let listener,failed=false,terminated=0;const sent=[],reports=[];
28
+ const worker={addEventListener(type,f){if(type==='message')listener=f;},postMessage(m){
29
+ if(failed)throw Error('worker unavailable');
30
+ sent.push(m);
31
+ },terminate(){terminated++;}};
32
+ const channel=new VideoChannel(worker,()=>{throw Error('presentation callback');},r=>reports.push(r));
33
+ channel.open({id:7});
34
+ expect(()=>listener({data:{type:'frame',id:7,delivery:1,frame:{time:0}}})).toThrow('presentation callback');
35
+ expect(sent.at(-1)).toEqual({type:'frame-ack',id:7,delivery:1});
36
+ failed=true;channel.timing(9,.5);
37
+ expect(reports).toEqual([{id:7,error:'worker unavailable'}]);
38
+ expect(channel.frame).toBeNull();expect(channel.config).toBeNull();
39
+ channel.open({id:8});channel.bind(8,9,0,0);channel.stop();channel.timing(9,1);
40
+ listener({data:{type:'frame',id:8,delivery:2,frame:{time:1}}});
41
+ expect(reports.at(-1)).toEqual({id:8,error:'worker unavailable'});
42
+ expect(channel.frame).toBeNull();expect(terminated).toBe(1);
43
+ expect(sent).toHaveLength(2);
44
+ });
45
+
46
+ test('worker failure is latched before terminate synchronously emits close',()=>{
47
+ const listeners=new Map(),reports=[];let terminated=0;
48
+ const worker={addEventListener(type,listener){listeners.set(type,listener);},postMessage(){},
49
+ terminate(){terminated++;listeners.get('close')({code:0});}};
50
+ const channel=new VideoChannel(worker,()=>{},report=>reports.push(report));
51
+ channel.open({id:9});
52
+ listeners.get('error')(new Error(''));
53
+ listeners.get('close')({code:0});
54
+ listeners.get('error')(new Error('later'));
55
+ expect(reports).toEqual([{id:9,error:'Video worker failed'}]);
56
+ expect(terminated).toBe(1);
57
+ expect(channel.config).toBeNull();expect(channel.frame).toBeNull();
58
+ });
59
+
60
+ // Use the real Bun Worker lifecycle with a tiny file, avoiding decoder/WASM
61
+ // startup and page realms' scoped Blob URL hooks. Restore the constructor
62
+ // before the first async continuation; each caller removes its own directory.
63
+ function startLifecycleWorker(source,changed,report) {
64
+ const Original=globalThis.Worker;
65
+ const directory=mkdtempSync(join(tmpdir(),'buninu-video-lifecycle-'));
66
+ const path=join(directory,'worker.js');
67
+ let worker,starting;
68
+ const dispose=()=>{worker?.terminate();rmSync(directory,{recursive:true,force:true});};
69
+ try {
70
+ writeFileSync(path,source);
71
+ globalThis.Worker=class extends Original {constructor(){super(path);worker=this;}};
72
+ try {starting=startVideoChannel(changed,report);}
73
+ finally {globalThis.Worker=Original;}
74
+ return {worker,starting,dispose};
75
+ } catch(error) {dispose();throw error;}
76
+ }
77
+
78
+ test('closing before ready rejects promptly without a startup timeout',async()=>{
79
+ const reports=[];
80
+ const fixture=startLifecycleWorker('process.exit(7)',()=>{},report=>reports.push(report));
81
+ let timer;
82
+ try {
83
+ const result=await Promise.race([
84
+ fixture.starting.then(()=>null,error=>error.message),
85
+ new Promise(resolve=>{timer=setTimeout(()=>resolve('startup was not rejected'),2000);}),
86
+ ]);
87
+ expect(result).toBe('Video worker closed (7)');
88
+ expect(reports).toEqual([]);
89
+ }finally{clearTimeout(timer);fixture.dispose();}
90
+ },5000);
91
+
92
+ test('closing after ready clears stale pixels and reports fallback exactly once',async()=>{
93
+ const reports=[];let changed=0;
94
+ const fixture=startLifecycleWorker(`self.onmessage=({data})=>{
95
+ if(data.type==='open'){
96
+ postMessage({type:'frame',id:data.config.id,delivery:1,
97
+ frame:{time:0,width:1,height:1,pixels:new Uint8Array([1,2,3,255])}});
98
+ setTimeout(()=>process.exit(0),5);
99
+ }
100
+ };postMessage({type:'ready'});`,()=>changed++,report=>reports.push(report));
101
+ try {
102
+ const channel=await fixture.starting;
103
+ channel.open({id:10});
104
+ for(let i=0;i<100&&!reports.length;i++)await Bun.sleep(10);
105
+ expect(changed).toBe(1);
106
+ expect(reports).toEqual([{id:10,error:'Video worker closed (0)'}]);
107
+ expect(channel.config).toBeNull();expect(channel.frame).toBeNull();
108
+ channel.timing(1,1);channel.fail(new Error('later'));fixture.worker.terminate();
109
+ await Bun.sleep(10);
110
+ expect(reports).toHaveLength(1);
111
+ }finally{fixture.dispose();}
112
+ },5000);
113
+
114
+ test('independent decoder transfers live pixels and retains measured-audio gating',async()=>{
115
+ const reports=[];const channel=await startVideoChannel(()=>{},r=>reports.push(r));
116
+ try {
117
+ const bytes=await Bun.file(new URL('./fixtures/long-video.mp4',import.meta.url)).bytes();
118
+ channel.timing(4,0);
119
+ channel.open({id:10,bytes,time:0,hasAudio:true,audioId:4,audioStart:0});
120
+ for(let i=0;i<100&&!channel.frame;i++)await Bun.sleep(20);
121
+ expect(channel.frame?.pixels.byteLength).toBeGreaterThan(0);
122
+ expect(channel.frame.time).toBe(0);
123
+ const first=channel.frame;
124
+ await Bun.sleep(100);expect(channel.frame).toBe(first);
125
+ channel.timing(4,.5);
126
+ for(let i=0;i<100&&(channel.frame?.time??0)<.4;i++)await Bun.sleep(20);
127
+ expect(channel.frame.time).toBeGreaterThanOrEqual(.4);
128
+ expect(channel.frame.pixels.byteLength).toBe(channel.frame.width*channel.frame.height*4);
129
+ expect(first.pixels.byteLength).toBe(first.width*first.height*4);
130
+ expect(reports.some(r=>r.error)).toBe(false);
131
+ }finally{channel.stop();channel.worker.terminate();}
132
+ },15000);
133
+
134
+ test('decoder sends only its latest pending frame and validates ACKs across stop and reopen',async()=>{
135
+ const worker=new Worker(new URL('../lib/renderer/video-worker.js',import.meta.url).href);
136
+ const frames=[],reports=[];let ready=false,error=null;
137
+ worker.addEventListener('message',({data})=>{
138
+ if(data.type==='ready')ready=true;
139
+ else if(data.type==='frame')frames.push(data);
140
+ else if(data.type==='report')reports.push(data.report);
141
+ });
142
+ worker.addEventListener('error',event=>{error=event;});
143
+ async function until(predicate){
144
+ for(let i=0;i<200&&!predicate()&&!error;i++)await Bun.sleep(10);
145
+ if(error)throw error;
146
+ expect(predicate()).toBe(true);
147
+ }
148
+ const ack=frame=>worker.postMessage({type:'frame-ack',id:frame.id,delivery:frame.delivery});
149
+ try {
150
+ await until(()=>ready);
151
+ const bytes=await Bun.file(new URL('./fixtures/long-video.mp4',import.meta.url)).bytes();
152
+ const open=(id,audioId)=>{
153
+ worker.postMessage({type:'timing',id:audioId,seconds:0});
154
+ worker.postMessage({type:'open',config:{id,bytes,time:0,hasAudio:true,audioId,audioStart:0}});
155
+ };
156
+ open(1,11);
157
+ await until(()=>frames.length===1);
158
+ worker.postMessage({type:'timing',id:11,seconds:1});
159
+ await until(()=>reports.some(r=>r.id===1&&r.time>=1));
160
+ expect(frames).toHaveLength(1);
161
+ ack({...frames[0],id:99});ack({...frames[0],delivery:99});
162
+ await Bun.sleep(30);expect(frames).toHaveLength(1);
163
+ ack(frames[0]);await until(()=>frames.length===2);
164
+ expect(frames[1].frame.time).toBeGreaterThanOrEqual(1);
165
+ // Reopening retains the outstanding delivery, while discarding pending
166
+ // pixels from the previous generation. Its valid ACK unlocks the new one.
167
+ open(2,12);
168
+ await until(()=>reports.some(r=>r.id===2&&r.time===0));
169
+ ack(frames[0]);await Bun.sleep(30);expect(frames).toHaveLength(2);
170
+ ack(frames[1]);await until(()=>frames.length===3);
171
+ expect(frames[2].id).toBe(2);expect(frames[2].frame.time).toBe(0);
172
+ worker.postMessage({type:'timing',id:12,seconds:1});
173
+ await until(()=>reports.some(r=>r.id===2&&r.time>=1));
174
+ worker.postMessage({type:'stop',id:2});open(3,13);
175
+ await until(()=>reports.some(r=>r.id===3&&r.time===0));
176
+ ack(frames[1]);await Bun.sleep(30);expect(frames).toHaveLength(3);
177
+ ack(frames[2]);await until(()=>frames.length===4);
178
+ expect(frames[3].id).toBe(3);expect(frames[3].frame.time).toBe(0);
179
+ expect(frames[3].delivery).toBeGreaterThan(frames[2].delivery);
180
+ expect(reports.some(r=>r.error)).toBe(false);
181
+ for(const {frame}of frames)expect(frame.pixels.byteLength).toBe(frame.width*frame.height*4);
182
+ }finally{worker.terminate();}
183
+ },15000);
184
+
185
+ test('same-presentation extension preserves the last frame and filters replaced sessions', () => {
186
+ const sent = [];
187
+ const channel = new VideoChannel({ addEventListener() {}, postMessage(message) { sent.push(message); } }, () => {}, () => {});
188
+ channel.open({ id: 7 });
189
+ const frame = channel.frame = { time: 2 };
190
+ channel.extend({ id: 6, revision: 0, generation: 2 });
191
+ expect(sent).toHaveLength(1);
192
+ channel.extend({ id: 7, revision: 0, generation: 2 });
193
+ expect(sent.at(-1)).toEqual({ type: 'extend', id: 7, revision: 0, generation: 2 });
194
+ expect(channel.frame).toBe(frame);
195
+ channel.stop();
196
+ });
197
+
198
+ test('real fMP4 snapshot extension preserves transfer ownership and sequential pixels across EOF', async () => {
199
+ const { default: wasmpeg } = await import('../vendor/wasmpeg/src/js/index.js');
200
+ await wasmpeg.load();
201
+ const bytes = await Bun.file(new URL('./fixtures/mse-switch-large.mp4', import.meta.url)).bytes();
202
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
203
+ let end = 0;
204
+ while (end < bytes.length) {
205
+ const size = view.getUint32(end);
206
+ const type = String.fromCharCode(...bytes.subarray(end + 4, end + 8));
207
+ end += size;
208
+ if (type === 'mdat') break;
209
+ }
210
+ const oracle = await wasmpeg.decode(bytes);
211
+ const expected = [];
212
+ try {
213
+ let pixels;
214
+ while ((pixels = oracle.nextFrame(oracle.width, oracle.height))) expected.push(Bun.hash(pixels));
215
+ } finally { oracle.close(); }
216
+ const reports = [], frames = [];
217
+ const channel = await startVideoChannel(() => {
218
+ const { time, pixels } = channel.frame;
219
+ frames.push({ time, pixels, hash: Bun.hash(pixels) });
220
+ }, report => reports.push(report));
221
+ const until = async predicate => {
222
+ for (let i = 0; i < 200 && !predicate(); i++) await Bun.sleep(10);
223
+ expect(predicate()).toBe(true);
224
+ };
225
+ try {
226
+ channel.timing(9, 0);
227
+ channel.open({ id: 30, bytes: bytes.slice(0, end), snapshotId: 'video-epoch', generation: 1,
228
+ time: 0, hasAudio: true, audioId: 9, audioStart: 0 });
229
+ await until(() => frames.length > 0);
230
+ channel.timing(9, 1.3);
231
+ await until(() => reports.some(report => Number.isFinite(report.exhaustedAt)));
232
+ const exhausted = reports.find(report => Number.isFinite(report.exhaustedAt));
233
+ expect(exhausted.exhaustedAt).toBeCloseTo(2, 6);
234
+ const previous = channel.frame;
235
+ channel.extend({ id: 30, revision: 0, generation: 2, snapshotId: 'video-epoch',
236
+ bytes, boundary: exhausted.exhaustedAt });
237
+ expect(channel.frame).toBe(previous);
238
+ await until(() => reports.some(report => report.continued === 2));
239
+ for (let i = 14; i <= 60; i++) {
240
+ channel.timing(9, i / 10);
241
+ await Bun.sleep(15);
242
+ }
243
+ await until(() => reports.some(report => report.end >= 5.9));
244
+ expect(reports.some(report => report.error || report.continuationError)).toBe(false);
245
+ expect(reports.filter(report => report.continued).map(report => report.revision)).toEqual([1]);
246
+ expect(new Set(frames.map(frame => Math.round(frame.time * 10))).size).toBe(frames.length);
247
+ expect(frames.some(frame => Math.abs(frame.time - 2) < 1e-6)).toBe(true);
248
+ for (const frame of frames) {
249
+ expect(frame.pixels.byteLength).toBe(128 * 96 * 4);
250
+ expect(frame.hash).toBe(expected[Math.round(frame.time * 10)]);
251
+ }
252
+ } finally { channel.stop(); channel.worker.terminate(); }
253
+ }, 15000);
@@ -0,0 +1,57 @@
1
+ import {test,expect} from 'bun:test';
2
+ import {VideoPresentation} from '../lib/renderer/video-presentation.js';
3
+ const sleep=Bun.sleep;
4
+ const wait=async fn=>{for(let i=0;i<100;i++){if(fn())return;await sleep(2);}throw Error('timeout');};
5
+ function fixture({hold=false,changedFormat=false}={}){
6
+ const reports=[],shown=[],decoders=[];let release,decodeCalls=0;
7
+ const engine={async decode(bytes){
8
+ const d={width:4,height:changedFormat&&bytes[0]>1?4:3,fps:10,at:0,calls:0,closed:0,
9
+ seek(ms){this.at=Math.floor(ms/1000)*10;},
10
+ nextFrame(w,h){this.calls++;if(this.at>=bytes[0]*10)return null;return new Uint8Array(w*h*4).fill(++this.at);},
11
+ close(){this.closed++;}};
12
+ decoders.push(d);decodeCalls++;if(hold&&bytes[0]>1)await new Promise(r=>release=r);return d;
13
+ }};
14
+ const v=new VideoPresentation(engine,()=>{shown.push({time:v.frame.time,value:v.frame.pixels[0]});v.frame.pixels.buffer.transfer(0);},r=>reports.push(r));
15
+ const config={id:3,bytes:new Uint8Array([1]),options:{},time:0,audioId:4,audioStart:0,hasAudio:true,snapshotId:'a',generation:1,shared:new SharedArrayBuffer(16+480*480*4)};
16
+ const start=async()=>{v.timing(4,0);await v.open(config);v.timing(4,.4);await wait(()=>reports.some(x=>x.exhaustedAt));};
17
+ const request=()=>({id:3,revision:0,snapshotId:'a',generation:2,boundary:1,bytes:new Uint8Array([2,0])});
18
+ return {v,reports,shown,decoders,start,request,release:()=>release(),calls:()=>decodeCalls};
19
+ }
20
+ test('continuation retains queued pixels, transfers once, and resumes at decoded boundary',async()=>{
21
+ const f=fixture({hold:true});try{
22
+ await f.start();const exhausted=f.reports.find(x=>x.exhaustedAt);
23
+ expect(exhausted.exhaustedAt).toBeCloseTo(1,9);expect(f.v.queue.length).toBeGreaterThan(0);
24
+ const pending=f.v.extend(f.request());await wait(()=>f.calls()===2);
25
+ const before=f.shown.length;f.v.timing(4,.7);expect(f.shown.length).toBe(before+1);expect(f.shown.at(-1).time).toBeCloseTo(.7,9);
26
+ expect(f.v.queue.length).toBeGreaterThan(0);f.release();expect(await pending).toBe(true);
27
+ for(let i=8;i<=21;i++){f.v.timing(4,i/10);await sleep(12);expect(f.v.queue.length).toBeLessThanOrEqual(f.v.limit);}
28
+ expect(new Set(f.shown.map(x=>x.value)).size).toBe(f.shown.length);
29
+ expect(f.shown.every((x,i,a)=>i===0||x.time>a[i-1].time)).toBe(true);
30
+ expect(f.shown.some(x=>x.value===11)).toBe(true);
31
+ expect(f.v.config.audioId).toBe(4);expect(f.v.config.generation).toBe(2);
32
+ expect(f.reports.filter(x=>x.end!==undefined)).toHaveLength(1);
33
+ const calls=f.decoders.at(-1).calls;await sleep(35);expect(f.v.timer).toBeNull();expect(f.decoders.at(-1).calls).toBe(calls);
34
+ }finally{f.v.stop();}
35
+ });
36
+ test('old id/revision/generation/configuration epoch requests cannot replace decoder',async()=>{
37
+ const f=fixture();try{await f.start();for(const change of [{id:9},{revision:8},{generation:1},{snapshotId:'other'}])expect(await f.v.extend({...f.request(),...change})).toBe(false);expect(f.calls()).toBe(1);}finally{f.v.stop();}
38
+ });
39
+ test('pause or seek cancels a decoder opening without resurrecting old frames',async()=>{
40
+ for(const openNew of [false,true]){const f=fixture({hold:true});try{
41
+ await f.start();const pending=f.v.extend(f.request());await wait(()=>f.calls()===2);const staged=f.decoders[1];
42
+ f.v.stop();if(openNew)await f.v.open({id:7,bytes:new Uint8Array([1]),options:{},time:0,audioId:8,audioStart:0,snapshotId:'b',generation:1});
43
+ const before=f.shown.length;f.release();expect(await pending).toBe(false);expect(staged.closed).toBe(1);expect(f.shown.length).toBe(before);expect(f.v.config?.id??null).toBe(openNew?7:null);
44
+ }finally{f.v.stop();}}
45
+ });
46
+ test('one in-progress continuation is bounded and changed codec geometry rejects safely',async()=>{
47
+ const f=fixture({hold:true,changedFormat:true});try{await f.start();const pending=f.v.extend(f.request());await wait(()=>f.calls()===2);expect(await f.v.extend({...f.request(),generation:3})).toBe(false);expect(f.calls()).toBe(2);f.release();expect(await pending).toBe(false);expect(f.reports.some(x=>x.continuationError)).toBe(true);expect(f.v.config.generation).toBe(1);f.v.timing(4,1.1);await sleep(15);expect(f.reports.filter(x=>x.end!==undefined)).toHaveLength(1);}finally{f.v.stop();}
48
+ });
49
+ test('EOF without new bytes is reported once and does not poll/decode forever',async()=>{
50
+ const f=fixture();try{await f.start();f.v.timing(4,1.1);await sleep(15);expect(f.reports.filter(x=>x.exhaustedAt!==undefined)).toHaveLength(1);expect(f.reports.filter(x=>x.end!==undefined)).toHaveLength(1);const count=f.decoders[0].calls;await sleep(35);expect(f.decoders[0].calls).toBe(count);expect(f.v.timer).toBeNull();}finally{f.v.stop();}
51
+ });
52
+ test('updated track boundary cancels pending continuation and never shows next epoch pixels',async()=>{
53
+ const f=fixture({hold:true});try{await f.start();const pending=f.v.extend(f.request());await wait(()=>f.calls()===2);f.v.bind(3,undefined,undefined,undefined,.8);f.release();expect(await pending).toBe(false);f.v.timing(4,1.1);await sleep(15);expect(f.shown.every(x=>x.time<.8)).toBe(true);expect(f.reports.filter(x=>x.end!==undefined).map(x=>x.end)).toEqual([.8]);expect(f.v.config.generation).toBe(1);}finally{f.v.stop();}
54
+ });
55
+ test('clock binding updates survive continuation without resetting or extrapolating audio time',async()=>{
56
+ const f=fixture({hold:true});try{await f.start();const pending=f.v.extend(f.request());await wait(()=>f.calls()===2);f.v.bind(3,8,.5);const before=f.shown.length;f.v.timing(4,9);expect(f.shown.length).toBe(before);f.v.timing(8,.2);expect(f.v.time()).toBe(.7);f.release();expect(await pending).toBe(true);expect(f.v.config.audioId).toBe(8);expect(f.v.config.audioStart).toBe(.5);expect(f.v.time()).toBe(.7);expect(f.v.config.time).toBe(0);}finally{f.v.stop();}
57
+ });
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ test("video controls mutations refresh paint while retaining unchanged geometry", async () => {
4
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/video-controls-invalidation.js", import.meta.url).pathname], {
5
+ stdout: "pipe", stderr: "pipe",
6
+ });
7
+ const [out, error, code] = await Promise.all([
8
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
9
+ ]);
10
+ expect({ code, error }).toEqual({ code: 0, error: "" });
11
+ expect(out).toContain("video controls paint invalidation passed");
12
+ }, 15000);
@@ -1,6 +1,28 @@
1
1
  import { expect, test } from 'bun:test';
2
2
  import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
3
3
  import { seekVideoKeyframe } from '../lib/media/video-keyframe.js';
4
+ import { mp4KeyframeBefore } from '../lib/media/mp4-keyframes.js';
5
+
6
+ function withEditList(bytes, rate = 0x00010000) {
7
+ const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
8
+ const find=(from,end,type)=>{
9
+ for(let at=from;at+8<=end;at+=view.getUint32(at)){
10
+ const size=view.getUint32(at);
11
+ if(size<8||at+size>end)break;
12
+ if(String.fromCharCode(...bytes.subarray(at+4,at+8))===type)return {at,end:at+size};
13
+ }
14
+ throw Error(`missing ${type}`);
15
+ };
16
+ const moov=find(0,bytes.length,'moov'),trak=find(moov.at+8,moov.end,'trak');
17
+ const edit=new Uint8Array(36),ev=new DataView(edit.buffer);
18
+ ev.setUint32(0,36);edit.set([101,100,116,115],4); // edts
19
+ ev.setUint32(8,28);edit.set([101,108,115,116],12); // elst
20
+ ev.setUint32(20,1);ev.setUint32(24,6000);ev.setInt32(28,2048);ev.setUint32(32,rate);
21
+ const out=new Uint8Array(bytes.length+edit.length),ov=new DataView(out.buffer);
22
+ out.set(bytes.subarray(0,trak.end));out.set(edit,trak.end);out.set(bytes.subarray(trak.end),trak.end+edit.length);
23
+ ov.setUint32(moov.at,view.getUint32(moov.at)+edit.length);ov.setUint32(trak.at,view.getUint32(trak.at)+edit.length);
24
+ return out;
25
+ }
4
26
 
5
27
  test('seeking within a GOP longer than ten seconds preserves the decoded frame timestamp', async () => {
6
28
  await wasmpeg.load();
@@ -52,10 +74,88 @@ test('a later MP4 initialization keeps absolute seek times, including seeks back
52
74
  // The fixture's keyframes are 2s apart. After moving tfdt by six seconds,
53
75
  // seek(8s) must return frame 20, not frame 0 or an offset-relative frame.
54
76
  for (const [target, frame, expectedTime] of [[6, 0, 6], [7.9, 0, 6], [8, 20, 8], [11, 40, 10], [6.2, 0, 6], [5, 0, 6]]) {
55
- const timestamp = seekVideoKeyframe(decoder, target, 6);
56
- expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(hashes[frame]);
57
- expect(timestamp).toBeGreaterThanOrEqual(expectedTime);
58
- expect(timestamp).toBeLessThan(expectedTime + 1 / decoder.fps);
77
+ for(const index of [null,bytes]){
78
+ const timestamp = seekVideoKeyframe(decoder, target, 6,index);
79
+ expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(hashes[frame]);
80
+ expect(timestamp).toBeGreaterThanOrEqual(expectedTime);
81
+ expect(timestamp).toBeLessThan(expectedTime + 1 / decoder.fps);
82
+ }
59
83
  }
60
84
  } finally { decoder.close(); }
61
85
  });
86
+
87
+ test('verified fMP4 candidates reduce probes and preserve subsequent full-size frames',async()=>{
88
+ await wasmpeg.load();
89
+ for(const name of ['mse-switch-small.mp4','mse-switch-large.mp4']){
90
+ const bytes=new Uint8Array(await Bun.file(new URL(`./fixtures/${name}`,import.meta.url)).arrayBuffer());
91
+ const decoder=await wasmpeg.decode(bytes,{format:'mp4'});
92
+ let probes=0;
93
+ const counted={fps:decoder.fps,seek:time=>decoder.seek(time),nextFrame:(...args)=>{probes++;return decoder.nextFrame(...args);}};
94
+ try{
95
+ for(const [target,keyframe] of [[1.9,0],[2,2],[3.99,2],[4,4],[5.8,4]]){
96
+ probes=0;const baselineTime=seekVideoKeyframe(counted,target),baselineProbes=probes;
97
+ const expected=[];
98
+ for(let i=0;i<8;i++)expected.push(decoder.nextFrame());
99
+ probes=0;const indexedTime=seekVideoKeyframe(counted,target,0,bytes);
100
+ expect(indexedTime).toBe(keyframe);
101
+ expect(Math.abs(indexedTime-baselineTime)).toBeLessThan(1/decoder.fps);
102
+ expect(probes).toBeLessThanOrEqual(3);
103
+ if(keyframe)expect(probes).toBeLessThan(baselineProbes);
104
+ else expect(probes).toBeLessThanOrEqual(baselineProbes);
105
+ for(const pixels of expected)expect(decoder.nextFrame()).toEqual(pixels);
106
+ }
107
+ }finally{decoder.close();}
108
+ }
109
+ });
110
+
111
+ test('a mismatched candidate or indistinguishable pictures retain the original seek search',async()=>{
112
+ const bytes=new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4',import.meta.url)).arrayBuffer());
113
+ for(const staticPicture of [false,true]){
114
+ let position=0;
115
+ const decoder={fps:10,seek(ms){position=Math.floor(ms/3000)*3;},nextFrame(){return new Uint8Array([staticPicture?1:position,9,7,255]);}};
116
+ const baseline=seekVideoKeyframe(decoder,3.9);
117
+ const indexed=seekVideoKeyframe(decoder,3.9,0,bytes);
118
+ expect(indexed).toBe(baseline);
119
+ expect(position).toBe(staticPicture?0:3);
120
+ }
121
+ });
122
+
123
+ test('failed optional candidate probes fall back, while original search errors still propagate',async()=>{
124
+ const bytes=new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4',import.meta.url)).arrayBuffer());
125
+ const makeDecoder=(failAt,method)=>{
126
+ let time=0;
127
+ return {fps:10,seek(ms){time=ms;if(method==='seek'&&ms===failAt)throw Error('seek failure');},
128
+ nextFrame(){if(method==='nextFrame'&&time===failAt)throw Error('decode failure');return new Uint8Array([Math.floor(time/2000),9,7,255]);}};
129
+ };
130
+ const baseline=seekVideoKeyframe(makeDecoder(-1,'seek'),3.9);
131
+ for(const method of ['seek','nextFrame']){
132
+ expect(seekVideoKeyframe(makeDecoder(2000,method),3.9,0,bytes)).toBe(baseline);
133
+ expect(()=>seekVideoKeyframe(makeDecoder(3900,method),3.9,0,bytes)).toThrow();
134
+ expect(()=>seekVideoKeyframe(makeDecoder(1,method),3.9,0,null)).toThrow();
135
+ }
136
+ });
137
+
138
+ test('an unreadable preceding probe cannot verify a candidate boundary',async()=>{
139
+ const bytes=new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4',import.meta.url)).arrayBuffer());
140
+ let time=0;
141
+ const decoder={fps:10,seek(ms){time=ms;},nextFrame(){return time===1900?null:new Uint8Array([Math.floor(time/2000),9,7,255]);}};
142
+ const baseline=seekVideoKeyframe(decoder,3.9);
143
+ expect(baseline).toBeGreaterThan(2);
144
+ expect(seekVideoKeyframe(decoder,3.9,0,bytes)).toBe(baseline);
145
+ });
146
+
147
+ test('a simple edit list is only a verified seek candidate, while rate-changing edits decline',async()=>{
148
+ await wasmpeg.load();
149
+ const original=new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4',import.meta.url)).arrayBuffer());
150
+ expect(mp4KeyframeBefore(withEditList(original,0x00020000),3.5)).toBeNull();
151
+ const bytes=withEditList(original),decoder=await wasmpeg.decode(bytes,{format:'mp4'});
152
+ try{
153
+ for(const target of [2.8,4.7]){
154
+ const baseline=seekVideoKeyframe(decoder,target),expected=[];
155
+ for(let i=0;i<8;i++)expected.push(decoder.nextFrame());
156
+ const indexed=seekVideoKeyframe(decoder,target,0,bytes);
157
+ expect(Math.abs(indexed-baseline)).toBeLessThan(1/decoder.fps);
158
+ for(const pixels of expected)expect(decoder.nextFrame()).toEqual(pixels);
159
+ }
160
+ }finally{decoder.close();}
161
+ });
@@ -0,0 +1,94 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {loadGraphics} from '../lib/renderer/page-renderer.js';
3
+ import {visibleItems} from '../lib/paint/display-list.js';
4
+ import {rasterVideoMask,videoMaskBounds,videoMaskItems} from '../lib/renderer/video-mask.js';
5
+ const {CanvasKit:kit,builtinTypefaces}=await loadGraphics();
6
+ const width=160,height=120;
7
+ const info={width,height,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB};
8
+
9
+ function scene({scale=1,rect={x:15.2,y:22.4,width:57.3,height:37.2},transform,opacity=1,clip=false,rounded=false,transparent=false,shadow=false}) {
10
+ const region={x:7.3,y:11.7,width:width/scale,height:height/scale};
11
+ const surface=kit.MakeSurface(width,height),canvas=surface.getCanvas();
12
+ const background=kit.Color(31,47,71,transparent?0.5:1);
13
+ const video={rect,transform,opacity,bounds:{...rect}};
14
+ if(transform)video.bounds={x:rect.x*transform.a+transform.e,y:rect.y*transform.d+transform.f,width:rect.width*transform.a,height:rect.height*transform.d};
15
+ const overlays=[
16
+ {rect:{x:18,y:29,width:26,height:12},color:kit.Color(240,33,80,0.5)},
17
+ {rect:{x:16,y:39,width:44,height:8},color:kit.Color(250,250,250,1),text:true},
18
+ {rect:{x:114,y:81,width:22,height:23},color:kit.Color(17,225,82,1)},
19
+ ].map(item=>({...item,bounds:item.rect}));
20
+ const shadowItem={op:'drawShadow',rect:{x:10,y:30,width:30,height:60},bounds:{x:-20,y:0,width:90,height:120},blur:30};
21
+ const items=[video,...(shadow?[shadowItem]:[]),...overlays];
22
+ let drawn=0;
23
+ function device(rect){return kit.XYWHRect(Math.round((rect.x-region.x)*scale),Math.round((rect.y-region.y)*scale),Math.round(rect.width*scale),Math.round(rect.height*scale));}
24
+ function paint(white,view=region){
25
+ for(const item of videoMaskItems({items},region,view)){
26
+ drawn++;
27
+ const saved=canvas.save();
28
+ const p=new kit.Paint();p.setAntiAlias(true);
29
+ if(item===video){
30
+ if(clip)canvas.clipRect(device({x:22.3,y:25.1,width:25.2,height:31.3}),kit.ClipOp.Intersect,true);
31
+ if(rounded)canvas.clipRRect(kit.RRectXY(device({x:14,y:21,width:48,height:35}),9,9),kit.ClipOp.Intersect,true);
32
+ if(transform){const {a,d,e,f}=transform;canvas.concat([a,0,((a-1)*region.x+e)*scale,0,d,((d-1)*region.y+f)*scale,0,0,1]);}
33
+ if(opacity<1){p.setAlphaf(opacity);canvas.saveLayer(p,device(item.bounds));}
34
+ canvas.clipRect(device(rect),kit.ClipOp.Intersect,true);
35
+ p.setColor(white===null?kit.Color(22,173,216,1):kit.Color(white?255:0,white?255:0,white?255:0,1));
36
+ canvas.drawRect(device(rect),p);
37
+ }else if(item===shadowItem){
38
+ const filter=kit.MaskFilter.MakeBlur(kit.BlurStyle.Normal,Math.round(item.blur*scale)/2,true);
39
+ p.setMaskFilter(filter);p.setColor(kit.Color(0,0,0,1));
40
+ canvas.clipRect(device(item.rect),kit.ClipOp.Difference,true);
41
+ canvas.drawRect(device(item.rect),p);filter.delete();
42
+ }else{
43
+ p.setColor(item.color);canvas.drawRect(device(item.rect),p);
44
+ if(item.text){
45
+ const font=new kit.Font(builtinTypefaces[0].typeface,7);
46
+ p.setColor(kit.Color(16,16,16,1));
47
+ canvas.drawText('Subtitles',(item.rect.x-region.x)*scale,(item.rect.y-region.y)*scale+7,p,font);
48
+ font.delete();
49
+ }
50
+ }
51
+ p.delete();canvas.restoreToCount(saved);
52
+ }
53
+ }
54
+ function normal(){canvas.clear(background);paint(null);surface.flush();const img=surface.makeImageSnapshot();try{return img.readPixels(0,0,info);}finally{img.delete();}}
55
+ return {video,region,surface,canvas,background,paint,normal,scale,items,get drawn(){return drawn;}};
56
+ }
57
+
58
+ for(const [label,options] of Object.entries({
59
+ shadowTail:{shadow:true,rect:{x:74,y:30,width:60,height:60}},
60
+ basic:{}, fractional:{scale:1.75}, opacity:{opacity:.4}, clipping:{clip:true}, rounded:{rounded:true},
61
+ partial:{rect:{x:-15,y:-6,width:58,height:65}}, transparent:{transparent:true},
62
+ transformed:{transform:{a:1.3,d:.8,e:3.3,f:4.4}},
63
+ transformedFractional:{scale:1.75,opacity:.6,clip:true,transform:{a:1.7,d:.7,e:-4,f:8}},
64
+ }))test(`cropped video mask is pixel-identical to full black/white passes: ${label}`,()=>{
65
+ const s=scene(options);
66
+ try{
67
+ const pixels=s.normal();
68
+ const common={kit,canvas:s.canvas,surface:s.surface,width,height,pixels,region:s.region,scale:s.scale,background:s.background,paint:s.paint};
69
+ const reference=rasterVideoMask(common);
70
+ s.normal();const before=s.drawn;
71
+ const bounds=videoMaskBounds(s.video,s.region,s.scale,width,height);
72
+ if(label==='shadowTail') {
73
+ const dirty={x:s.region.x+(bounds.x-2)/s.scale,y:s.region.y+(bounds.y-2)/s.scale,
74
+ width:(bounds.width+4)/s.scale,height:(bounds.height+4)/s.scale};
75
+ expect(visibleItems({items:s.items},dirty).some(item=>item.op==='drawShadow')).toBe(false);
76
+ }
77
+ const actual=rasterVideoMask({...common,bounds,cullPadding:label==='shadowTail'?2:4});
78
+ expect([...actual.base]).toEqual([...reference.base]);
79
+ expect([...actual.mask]).toEqual([...reference.mask]);
80
+ expect(pixels).not.toBe(actual.base);
81
+ expect(bounds.width*bounds.height).toBeLessThan(width*height);
82
+ if(label==='basic')expect(s.drawn-before).toBeLessThan(8);
83
+ }finally{s.surface.dispose();}
84
+ });
85
+
86
+ test('unbounded transforms use the full pass; wholly offscreen video needs no extra raster',()=>{
87
+ const s=scene({rect:{x:500,y:500,width:10,height:10}});
88
+ try{
89
+ expect(videoMaskBounds({...s.video,transform:{a:1,d:1,e:0,f:0,b:1}},s.region,1,width,height)).toBeNull();
90
+ const pixels=s.normal(),before=s.drawn;
91
+ const result=rasterVideoMask({kit,canvas:s.canvas,surface:s.surface,width,height,pixels,region:s.region,scale:1,background:s.background,paint:s.paint,bounds:videoMaskBounds(s.video,s.region,1,width,height)});
92
+ expect(result.base).toEqual(pixels);expect(result.mask.some(Boolean)).toBe(false);expect(s.drawn).toBe(before);
93
+ }finally{s.surface.dispose();}
94
+ });