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
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { beforeAll, expect, test } from 'bun:test';
|
|
2
|
+
import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
|
|
3
|
+
import { createMediaSourceScope } from '../lib/renderer/media-source.js';
|
|
4
|
+
import { createWebMStream, ebmlHeader } from '../lib/media/webm-stream.js';
|
|
5
|
+
import { advanceAudio } from '../lib/media/position-audio.js';
|
|
6
|
+
import { createWebMAudioSnapshots } from '../lib/media/webm-snapshots.js';
|
|
7
|
+
|
|
8
|
+
let fixture, elements, clusters, init;
|
|
9
|
+
const join = parts => new Uint8Array(Bun.concatArrayBuffers(parts));
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
await wasmpeg.load();
|
|
12
|
+
fixture = await Bun.file(new URL('./fixtures/mse-audio-chirp.webm', import.meta.url)).bytes();
|
|
13
|
+
elements = createWebMStream().append(fixture);
|
|
14
|
+
clusters = elements.filter(element => element.id === 0x1f43b675);
|
|
15
|
+
init = join(elements.slice(0, elements.indexOf(clusters[0])).map(element => element.bytes));
|
|
16
|
+
});
|
|
17
|
+
function context() {
|
|
18
|
+
const tasks = [], scope = createMediaSourceScope({ origin:'https://gap.test', schedule:fn=>tasks.push(fn), DOMException });
|
|
19
|
+
const flush = () => { while(tasks.length) tasks.shift()(); };
|
|
20
|
+
const source = new scope.MediaSource();source.attach('blob:https://gap.test/audio');flush();
|
|
21
|
+
const buffer = source.addSourceBuffer('audio/webm; codecs="opus"');
|
|
22
|
+
return {source, buffer, append(bytes){buffer.appendBuffer(bytes);flush();}};
|
|
23
|
+
}
|
|
24
|
+
async function decode(bytes) {
|
|
25
|
+
const decoder=await wasmpeg.decodeAudio(bytes,{format:'webm'}), chunks=[];
|
|
26
|
+
try { for(let samples;(samples=decoder.nextSamples());)chunks.push(samples); }
|
|
27
|
+
finally { decoder.close(); }
|
|
28
|
+
return new Float32Array(Bun.concatArrayBuffers(chunks));
|
|
29
|
+
}
|
|
30
|
+
function live(bytes) {
|
|
31
|
+
const result=bytes.slice(), header=ebmlHeader(bytes), width=header.length-4;
|
|
32
|
+
result[4]=(1 << (9-width))-1;result.fill(0xff,5,header.length);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('continuous Opus retains its original bytes, pre-skip and every PCM sample',async()=>{
|
|
37
|
+
const c=context();c.append(fixture);c.source.endOfStream();
|
|
38
|
+
const selected=c.buffer._pipe.snapshotAt(0);
|
|
39
|
+
expect(selected.id).toBeGreaterThan(0);
|
|
40
|
+
expect(selected.startTime).toBe(0);
|
|
41
|
+
expect(selected.bytes).toBe(c.buffer._pipe.bytes());
|
|
42
|
+
expect(await decode(selected.bytes)).toEqual(await decode(fixture));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test.each([false,true])('a sparse %s live WebM seek reaches the target instead of waiting for missing earlier PCM',async unknown=>{
|
|
46
|
+
const c=context();c.append(init);
|
|
47
|
+
for(const index of [0,3]){
|
|
48
|
+
const bytes=unknown?live(clusters[index].bytes):clusters[index].bytes;
|
|
49
|
+
for(let at=0;at<bytes.length;at+=137)c.append(bytes.subarray(at,at+137));
|
|
50
|
+
}
|
|
51
|
+
const pipe=c.buffer._pipe, first=pipe.snapshotAt(0), selected=pipe.snapshotAt(7);
|
|
52
|
+
expect(selected.startTime).toBe(5.981);
|
|
53
|
+
expect(selected.endTime).toBe(Infinity);
|
|
54
|
+
expect(first.endTime).toBe(selected.startTime);
|
|
55
|
+
expect(selected.id).not.toBe(first.id);
|
|
56
|
+
const reference=await decode(join([init,clusters[3].bytes]));
|
|
57
|
+
// Decode the retained run with its original CodecDelay/OpusHead. Its first
|
|
58
|
+
// decoded sample is at 5.981s (FFmpeg's frame PTS), not at zero or at seek7.
|
|
59
|
+
expect(await decode(selected.bytes)).toEqual(reference);
|
|
60
|
+
let sound, opens=0, active=true;
|
|
61
|
+
const open=async time=>{
|
|
62
|
+
const selected=pipe.snapshotAt(time),decoder=await wasmpeg.decodeAudio(selected.bytes,{format:'webm'});
|
|
63
|
+
sound?.decoder.close();opens++;
|
|
64
|
+
sound={...selected,decoder,position:Math.round(selected.startTime*decoder.sampleRate),pending:null,
|
|
65
|
+
pipe,openedGeneration:pipe.generation,snapshotId:selected.id};
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
await open(0);
|
|
69
|
+
let timer;
|
|
70
|
+
try {
|
|
71
|
+
const reached=await Promise.race([
|
|
72
|
+
advanceAudio(()=>sound,7,{needsMore:()=>true,active:()=>active,reopen:()=>open(7)}),
|
|
73
|
+
new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('seek waited for missing timeline')),500);}),
|
|
74
|
+
]);
|
|
75
|
+
expect(reached).toBe(true);expect(opens).toBe(2);expect(sound.position).toBe(7*48000);
|
|
76
|
+
const pending=sound.pending??sound.decoder.nextSamples(), offset=Math.round((7-selected.startTime)*48000);
|
|
77
|
+
expect(pending).toEqual(reference.subarray(offset,offset+pending.length));
|
|
78
|
+
expect(pipe.snapshotAt(0).bytes).toEqual(first.bytes);
|
|
79
|
+
} finally { clearTimeout(timer);active=false;pipe.finish();sound.decoder.close(); }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('out-of-order clusters can fill a hole without retaining the old arrival order',async()=>{
|
|
83
|
+
const c=context();c.append(init);c.append(clusters[0].bytes);c.append(clusters[3].bytes);
|
|
84
|
+
const old=c.buffer._pipe.snapshotAt(7), oldHash=Bun.hash(old.bytes);
|
|
85
|
+
c.append(clusters[2].bytes);c.append(clusters[1].bytes);
|
|
86
|
+
const selected=c.buffer._pipe.snapshotAt(0);
|
|
87
|
+
expect(selected.startTime).toBe(0);
|
|
88
|
+
expect(selected.id).not.toBe(old.id);
|
|
89
|
+
expect(Bun.hash(old.bytes)).toBe(oldHash);
|
|
90
|
+
const reference=join([init,...clusters.slice(0,4).map(group=>group.bytes)]);
|
|
91
|
+
expect(await decode(selected.bytes)).toEqual(await decode(reference));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function syntheticCluster(time,toc=0x10,flags=0x80) {
|
|
95
|
+
// One non-laced 40ms Opus packet; payload need not decode for metadata tests.
|
|
96
|
+
return Uint8Array.from([0x1f,0x43,0xb6,0x75,0x8b,0xe7,0x82,time>>8,time&255,
|
|
97
|
+
0xa3,0x85,0x81,0,0,flags,toc]);
|
|
98
|
+
}
|
|
99
|
+
test('Opus TOC duration, rather than a fixed 20ms, defines contiguous runs',()=>{
|
|
100
|
+
const c=context();c.append(init);c.append(syntheticCluster(0));c.append(syntheticCluster(40));
|
|
101
|
+
expect(c.buffer._pipe.snapshotAt(.06).startTime).toBe(0);
|
|
102
|
+
expect(c.buffer._pipe.snapshotAt(.06).bytes).toBe(c.buffer._pipe.bytes());
|
|
103
|
+
c.append(syntheticCluster(500));
|
|
104
|
+
expect(c.buffer._pipe.snapshotAt(.52).startTime).toBe(.5);
|
|
105
|
+
expect(c.buffer._pipe.snapshotAt(.02).endTime).toBe(.5);
|
|
106
|
+
});
|
|
107
|
+
test('unsupported lacing or inconsistent pre-skip safely keeps the ordinary decoder route',()=>{
|
|
108
|
+
const c=context();c.append(init);c.append(syntheticCluster(0,0x10,0x82));
|
|
109
|
+
expect(c.buffer._pipe.snapshotAt(7)).toEqual({id:0,startTime:0,endTime:Infinity,bytes:c.buffer._pipe.bytes()});
|
|
110
|
+
const modified=init.slice();
|
|
111
|
+
const opus=modified.findIndex((byte,index)=>byte===79&&new TextDecoder().decode(modified.subarray(index,index+8))==='OpusHead');
|
|
112
|
+
expect(opus).toBeGreaterThan(0);modified[opus+10]^=1;
|
|
113
|
+
const d=context();d.append(modified);d.append(clusters[3].bytes);
|
|
114
|
+
expect(d.buffer._pipe.snapshotAt(7).id).toBe(0);
|
|
115
|
+
expect(d.buffer._pipe.snapshotAt(7).bytes).toBe(d.buffer._pipe.bytes());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test.each([false,true])('continuous %s live Opus reopens with exact samples and at least one second of preroll',async unknown=>{
|
|
119
|
+
const c=context();c.append(init);
|
|
120
|
+
for(const cluster of clusters)c.append(unknown?live(cluster.bytes):cluster.bytes);
|
|
121
|
+
c.source.endOfStream();
|
|
122
|
+
const expected=await decode(fixture), pipe=c.buffer._pipe;
|
|
123
|
+
const initial=pipe.snapshotAt(0);
|
|
124
|
+
for(const target of [3,3.08,3.2,6,6.08,6.2]){
|
|
125
|
+
const snapshot=pipe.snapshotAt(target);
|
|
126
|
+
expect(snapshot.startTime).toBeGreaterThan(0);
|
|
127
|
+
expect(target-snapshot.startTime).toBeGreaterThanOrEqual(1);
|
|
128
|
+
expect(snapshot.id).toBe(initial.id);
|
|
129
|
+
expect(pipe.snapshotAt(target)).toBe(snapshot);
|
|
130
|
+
const pcm=await decode(snapshot.bytes), offset=Math.round((target-snapshot.startTime)*48000);
|
|
131
|
+
const wanted=expected.subarray(Math.round(target*48000),Math.round(target*48000)+9600);
|
|
132
|
+
expect(pcm.subarray(offset,offset+wanted.length)).toEqual(wanted);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test.each([false,true])('appending %s live Clusters preserves lazy snapshots and updates the current suffix without another epoch',async unknown=>{
|
|
137
|
+
const c=context();c.append(init);
|
|
138
|
+
for(const cluster of clusters.slice(0,3))c.append(unknown?live(cluster.bytes):cluster.bytes);
|
|
139
|
+
const old=c.buffer._pipe.snapshotAt(5), initial=c.buffer._pipe.snapshotAt(0);
|
|
140
|
+
// Do not read old.bytes until after append: the lazy copy must keep its
|
|
141
|
+
// original packet boundary rather than consult the growing byte pipe.
|
|
142
|
+
for(const cluster of clusters.slice(3))c.append(unknown?live(cluster.bytes):cluster.bytes);
|
|
143
|
+
const fresh=c.buffer._pipe.snapshotAt(5);
|
|
144
|
+
expect(fresh.id).toBe(old.id);expect(fresh.id).toBe(initial.id);
|
|
145
|
+
expect(fresh.startTime).toBe(old.startTime);
|
|
146
|
+
expect(fresh.bytes.length).toBeGreaterThan(old.bytes.length);
|
|
147
|
+
const oldHash=Bun.hash(old.bytes), freshHash=Bun.hash(fresh.bytes);
|
|
148
|
+
expect(c.buffer._pipe.snapshotAt(5)).toBe(fresh);
|
|
149
|
+
expect(Bun.hash(old.bytes)).toBe(oldHash);
|
|
150
|
+
expect(Bun.hash(fresh.bytes)).toBe(freshHash);
|
|
151
|
+
const expected=await decode(fixture), pcm=await decode(old.bytes);
|
|
152
|
+
const offset=Math.round((5-old.startTime)*48000);
|
|
153
|
+
expect(pcm.subarray(offset,offset+9600)).toEqual(expected.subarray(5*48000,5*48000+9600));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('declared SeekPreRoll is honored when greater than the conservative default',()=>{
|
|
157
|
+
const modified=init.slice();
|
|
158
|
+
// The fixture declares 80,000,000ns as a four-byte unsigned SeekPreRoll.
|
|
159
|
+
const at=modified.findIndex((byte,i)=>byte===0x56&&modified[i+1]===0xbb&&modified[i+2]===0x84);
|
|
160
|
+
expect(at).toBeGreaterThan(0);
|
|
161
|
+
new DataView(modified.buffer).setUint32(at+3,4_000_000_000);
|
|
162
|
+
const c=context();c.append(modified);
|
|
163
|
+
for(const cluster of clusters)c.append(cluster.bytes);
|
|
164
|
+
const selected=c.buffer._pipe.snapshotAt(6);
|
|
165
|
+
expect(selected.startTime).toBeGreaterThan(0);
|
|
166
|
+
expect(6-selected.startTime).toBeGreaterThanOrEqual(4);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test.each([false,true])('fast reopening inside a sparse %s live run retains that run\'s sample origin',async unknown=>{
|
|
170
|
+
const c=context();c.append(init);c.append(unknown?live(clusters[0].bytes):clusters[0].bytes);
|
|
171
|
+
for(const cluster of clusters.slice(2))c.append(unknown?live(cluster.bytes):cluster.bytes);
|
|
172
|
+
const first=c.buffer._pipe.snapshotAt(4), selected=c.buffer._pipe.snapshotAt(7.2);
|
|
173
|
+
expect(first.startTime).toBe(3.981);
|
|
174
|
+
expect(selected.id).toBe(first.id);
|
|
175
|
+
expect(selected.startTime).toBeGreaterThan(first.startTime);
|
|
176
|
+
expect(7.2-selected.startTime).toBeGreaterThanOrEqual(1);
|
|
177
|
+
const expected=await decode(join([init,...clusters.slice(2).map(e=>e.bytes)]));
|
|
178
|
+
const pcm=await decode(selected.bytes), start=Math.round((7.2-first.startTime)*48000);
|
|
179
|
+
const skip=Math.round(7.2*48000)-Math.round(selected.startTime*48000);
|
|
180
|
+
expect(pcm.subarray(skip,skip+9600)).toEqual(expected.subarray(start,start+9600));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('timestamp drift beyond rounding tolerance declines fast trimming without inventing PCM time',()=>{
|
|
184
|
+
const c=context();c.append(init);
|
|
185
|
+
for(let i=0;i<100;i++)c.append(syntheticCluster(i*41));
|
|
186
|
+
// Each timestamp is within a one-tick adjacency tolerance, but cumulatively
|
|
187
|
+
// the packets no longer agree with a continuous exact-sample clock.
|
|
188
|
+
const selected=c.buffer._pipe.snapshotAt(3);
|
|
189
|
+
expect(selected.startTime).toBe(0);
|
|
190
|
+
expect(selected.bytes).toBe(c.buffer._pipe.bytes());
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('a fractional initial sample is rounded once before adding exact packet sample counts',()=>{
|
|
194
|
+
const modified=init.slice();
|
|
195
|
+
const at=modified.findIndex((byte,i)=>byte===0x2a&&modified[i+1]===0xd7&&modified[i+2]===0xb1&&modified[i+3]===0x83);
|
|
196
|
+
expect(at).toBeGreaterThan(0);
|
|
197
|
+
modified.set([0,0x7a,0x12],at+4); // TimestampScale=31250ns; timecode1 is 1.5 samples.
|
|
198
|
+
const c=context();c.append(modified);
|
|
199
|
+
for(let i=0;i<40;i++)c.append(syntheticCluster(1+i*1280)); // 40ms Opus packets.
|
|
200
|
+
const first=c.buffer._pipe.snapshotAt(0), selected=c.buffer._pipe.snapshotAt(1.05);
|
|
201
|
+
expect(first.startTime).toBe(.00003125);
|
|
202
|
+
expect(selected.startTime).toBeGreaterThan(first.startTime);
|
|
203
|
+
expect(Math.round(selected.startTime*48000)).toBe(Math.round(first.startTime*48000)+1920);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('audio EOF refill uses the fast prefix and keeps the full stream sample cursor',async()=>{
|
|
207
|
+
const c=context();c.append(init);
|
|
208
|
+
for(const cluster of clusters.slice(0,3))c.append(cluster.bytes);
|
|
209
|
+
const pipe=c.buffer._pipe, expected=await decode(fixture);
|
|
210
|
+
let sound, opens=0, active=true, reopenedAt;
|
|
211
|
+
const open=async time=>{
|
|
212
|
+
const snapshot=pipe.snapshotAt(time),decoder=await wasmpeg.decodeAudio(snapshot.bytes,{format:'webm'});
|
|
213
|
+
sound?.decoder.close();opens++;
|
|
214
|
+
if(opens>1)reopenedAt=snapshot.startTime;
|
|
215
|
+
sound={...snapshot,decoder,position:Math.round(snapshot.startTime*decoder.sampleRate),pending:null,
|
|
216
|
+
pipe,openedGeneration:pipe.generation,snapshotId:snapshot.id};
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
219
|
+
await open(0);
|
|
220
|
+
let timer;
|
|
221
|
+
const append=setTimeout(()=>{for(const cluster of clusters.slice(3))c.append(cluster.bytes);},20);
|
|
222
|
+
try{
|
|
223
|
+
const reached=await Promise.race([
|
|
224
|
+
advanceAudio(()=>sound,6.5,{needsMore:()=>true,active:()=>active,reopen:()=>open(6.5)}),
|
|
225
|
+
new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('audio refill stalled')),1000);}),
|
|
226
|
+
]);
|
|
227
|
+
expect(reached).toBe(true);expect(opens).toBe(2);expect(reopenedAt).toBeGreaterThan(0);
|
|
228
|
+
expect(sound.position).toBe(6.5*48000);
|
|
229
|
+
const pending=sound.pending??sound.decoder.nextSamples();
|
|
230
|
+
expect(pending).toEqual(expected.subarray(sound.position,sound.position+pending.length));
|
|
231
|
+
}finally{clearTimeout(timer);clearTimeout(append);active=false;pipe.finish();sound.decoder.close();}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('a lazy fast snapshot retains its own run when later arrivals fill an earlier hole',async()=>{
|
|
235
|
+
const c=context();c.append(init);
|
|
236
|
+
for(const index of [0,2,3,4])c.append(clusters[index].bytes);
|
|
237
|
+
const old=c.buffer._pipe.snapshotAt(7.2), oldStart=old.startTime;
|
|
238
|
+
c.append(clusters[1].bytes);
|
|
239
|
+
const fresh=c.buffer._pipe.snapshotAt(7.2);
|
|
240
|
+
expect(fresh.id).not.toBe(old.id);
|
|
241
|
+
expect(old.startTime).toBe(oldStart);
|
|
242
|
+
const expected=await decode(join([init,...clusters.slice(2).map(e=>e.bytes)]));
|
|
243
|
+
const pcm=await decode(old.bytes), skip=Math.round(7.2*48000)-Math.round(old.startTime*48000);
|
|
244
|
+
const offset=Math.round((7.2-3.981)*48000);
|
|
245
|
+
expect(pcm.subarray(skip,skip+9600)).toEqual(expected.subarray(offset,offset+9600));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
function ebmlElement(id, payload, width = 4) {
|
|
249
|
+
const size = new Uint8Array(width);
|
|
250
|
+
let value = payload.length;
|
|
251
|
+
for (let i = width - 1; i >= 0; i--) { size[i] = value & 255; value = Math.floor(value / 256); }
|
|
252
|
+
size[0] |= 1 << (8 - width);
|
|
253
|
+
return join([Uint8Array.from(id), size, payload]);
|
|
254
|
+
}
|
|
255
|
+
function childElements(bytes) {
|
|
256
|
+
const parent = ebmlHeader(bytes), result = [];
|
|
257
|
+
for (let at = parent.length; at < bytes.length;) {
|
|
258
|
+
const head = ebmlHeader(bytes, at), data = at + head.length, end = data + head.size;
|
|
259
|
+
result.push({id:head.id, at, data, end});
|
|
260
|
+
at = end;
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
function longFiniteCluster() {
|
|
265
|
+
const parts = [Uint8Array.from([0xe7,0x81,0])];
|
|
266
|
+
// Merge the existing real eight-second fixture into one finite Cluster.
|
|
267
|
+
// Only Block relative timestamps change; encoded Opus packets stay intact.
|
|
268
|
+
for (const cluster of clusters) {
|
|
269
|
+
const children = childElements(cluster.bytes), timestamp = children.find(item => item.id === 0xe7);
|
|
270
|
+
let origin = 0;
|
|
271
|
+
for (let at = timestamp.data; at < timestamp.end; at++) origin = origin * 256 + cluster.bytes[at];
|
|
272
|
+
for (const child of children.filter(item => item.id === 0xa3 || item.id === 0xa0)) {
|
|
273
|
+
const copy = cluster.bytes.slice(child.at, child.end);
|
|
274
|
+
const block = child.id === 0xa3 ? {data:ebmlHeader(copy).length} : childElements(copy).find(item => item.id === 0xa1);
|
|
275
|
+
let trackBytes = 1, mask = 0x80;
|
|
276
|
+
while (!(copy[block.data] & mask)) { trackBytes++; mask >>= 1; }
|
|
277
|
+
const view = new DataView(copy.buffer, copy.byteOffset + block.data + trackBytes, 2);
|
|
278
|
+
view.setInt16(0, view.getInt16(0) + origin);
|
|
279
|
+
parts.push(copy);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return ebmlElement([0x1f,0x43,0xb6,0x75], join(parts));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
test('one long finite Opus Cluster reopens at packet granularity with exact PCM and unchanged preroll', async () => {
|
|
286
|
+
const cluster = longFiniteCluster(), original = cluster.slice(), c = context();
|
|
287
|
+
c.append(init); c.append(cluster); c.source.endOfStream();
|
|
288
|
+
const expected = await decode(fixture), pipe = c.buffer._pipe;
|
|
289
|
+
expect(await decode(pipe.snapshotAt(0).bytes)).toEqual(expected);
|
|
290
|
+
for (const target of [3, 6, 6.08, 7.2]) {
|
|
291
|
+
const snapshot = pipe.snapshotAt(target), prefix = target - snapshot.startTime;
|
|
292
|
+
expect(prefix).toBeGreaterThanOrEqual(1);
|
|
293
|
+
expect(prefix).toBeLessThanOrEqual(1.021);
|
|
294
|
+
const pcm = await decode(snapshot.bytes);
|
|
295
|
+
const skip = Math.round(target * 48000) - Math.round(snapshot.startTime * 48000);
|
|
296
|
+
expect(pcm.subarray(skip, skip + 9600)).toEqual(expected.subarray(Math.round(target * 48000), Math.round(target * 48000) + 9600));
|
|
297
|
+
}
|
|
298
|
+
expect(cluster).toEqual(original);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test.each([1, 2, 4, 8])('finite Cluster size field width %s becomes a valid unknown-size prefix without mutating input', width => {
|
|
302
|
+
const c = context(), originals = []; c.append(init);
|
|
303
|
+
for (let i = 0; i < 40; i++) {
|
|
304
|
+
const ordinary = syntheticCluster(i * 40);
|
|
305
|
+
const bytes = ebmlElement([0x1f,0x43,0xb6,0x75], ordinary.subarray(ebmlHeader(ordinary).length), width);
|
|
306
|
+
originals.push([bytes, bytes.slice()]); c.append(bytes);
|
|
307
|
+
}
|
|
308
|
+
const selected = c.buffer._pipe.snapshotAt(1.4);
|
|
309
|
+
expect(selected.startTime).toBeGreaterThan(0);
|
|
310
|
+
const emitted = createWebMStream().append(selected.bytes), blocks = emitted.filter(item => item.cluster);
|
|
311
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
312
|
+
expect(ebmlHeader(blocks[0].cluster.prefix[0])).toMatchObject({size:Infinity, length:4 + width});
|
|
313
|
+
expect(blocks.every(item => item.bytes[0] === 0xa3)).toBe(true);
|
|
314
|
+
for (const [bytes, original] of originals) expect(bytes).toEqual(original);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('finite Cluster checksums, positional, encrypted and unknown metadata retain ordinary decoder bytes', () => {
|
|
318
|
+
const base = syntheticCluster(0), body = base.subarray(ebmlHeader(base).length);
|
|
319
|
+
for (const metadata of [[0xbf,0x84,0,0,0,0], [0xa7,0x81,0], [0xab,0x81,0], [0xaf,0x81,0], [0x80,0x80]]) {
|
|
320
|
+
const c = context(); c.append(init);
|
|
321
|
+
c.append(ebmlElement([0x1f,0x43,0xb6,0x75], join([body.subarray(0,4), Uint8Array.from(metadata), body.subarray(4)])));
|
|
322
|
+
const selected = c.buffer._pipe.snapshotAt(5);
|
|
323
|
+
expect(selected.id).toBe(0);
|
|
324
|
+
expect(selected.bytes).toBe(c.buffer._pipe.bytes());
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('finite BlockGroup, leading Void, truncation and track encodings preserve conservative boundaries', async () => {
|
|
329
|
+
const simple = syntheticCluster(0), body = simple.subarray(ebmlHeader(simple).length);
|
|
330
|
+
const group = ebmlElement([0xa0], ebmlElement([0xa1], body.subarray(6), 1), 1);
|
|
331
|
+
const c = context(); c.append(init);
|
|
332
|
+
for (let i = 0; i < 40; i++) {
|
|
333
|
+
const timestamp = Uint8Array.from([0xe7,0x82,i*40>>8,i*40&255]);
|
|
334
|
+
c.append(ebmlElement([0x1f,0x43,0xb6,0x75], join([Uint8Array.from([0xec,0x81,0]), timestamp, group])));
|
|
335
|
+
}
|
|
336
|
+
const selected = c.buffer._pipe.snapshotAt(1.4);
|
|
337
|
+
const blocks = createWebMStream().append(selected.bytes).filter(item => item.cluster);
|
|
338
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
339
|
+
expect(blocks.every(item => item.bytes[0] === 0xa0)).toBe(true);
|
|
340
|
+
expect(blocks[0].cluster.prefix.some(bytes => bytes[0] === 0xec)).toBe(true);
|
|
341
|
+
|
|
342
|
+
const index = createWebMAudioSnapshots();
|
|
343
|
+
for (const element of createWebMStream().append(init)) index.append(element);
|
|
344
|
+
index.append({id:0x1f43b675, bytes:simple.subarray(0,simple.length - 1)});
|
|
345
|
+
expect(index.at(5,()=>simple)).toMatchObject({id:0, startTime:0, bytes:simple});
|
|
346
|
+
|
|
347
|
+
const trackElement = elements.find(item => item.id === 0x1654ae6b);
|
|
348
|
+
const track = childElements(trackElement.bytes)[0];
|
|
349
|
+
const encodedTrack = ebmlElement([0xae], join([trackElement.bytes.subarray(track.data,track.end), Uint8Array.from([0x6d,0x80,0x80])]));
|
|
350
|
+
const encodedTracks = ebmlElement([0x16,0x54,0xae,0x6b], encodedTrack), encrypted = createWebMAudioSnapshots();
|
|
351
|
+
for (const element of createWebMStream().append(init)) encrypted.append(element.id === 0x1654ae6b ? {id:element.id,bytes:encodedTracks} : element);
|
|
352
|
+
encrypted.append({id:0x1f43b675,bytes:simple});
|
|
353
|
+
expect(encrypted.at(5,()=>simple)).toMatchObject({id:0,startTime:0,bytes:simple});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('finite packet cuts retain a large immutable metadata prefix only once in the selected output', () => {
|
|
357
|
+
const padding = Uint8Array.from({length:4000*2},(_,i)=>i%2?0x80:0xec);
|
|
358
|
+
const parts = [padding, Uint8Array.from([0xe7,0x81,0])];
|
|
359
|
+
for(let i=0;i<1000;i++) {
|
|
360
|
+
// One 20ms Opus packet per Block; the payload is metadata-only here.
|
|
361
|
+
parts.push(Uint8Array.from([0xa3,0x85,0x81,i*20>>8,i*20&255,0x80,0x08]));
|
|
362
|
+
}
|
|
363
|
+
const bytes = ebmlElement([0x1f,0x43,0xb6,0x75],join(parts)), original=bytes.slice(), c=context();
|
|
364
|
+
c.append(init);c.append(bytes);
|
|
365
|
+
const selected=c.buffer._pipe.snapshotAt(18), parsed=createWebMStream().append(selected.bytes);
|
|
366
|
+
const blocks=parsed.filter(item=>item.cluster);
|
|
367
|
+
expect(selected.startTime).toBe(17);
|
|
368
|
+
expect(blocks.length).toBe(150);
|
|
369
|
+
expect(blocks[0].cluster.prefix.length).toBe(4002);
|
|
370
|
+
expect(selected.bytes.length).toBeLessThan(init.length+padding.length+7*151+32);
|
|
371
|
+
expect(bytes).toEqual(original);
|
|
372
|
+
});
|