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,69 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createPageRenderer, loadGraphics } from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
import { LayoutEngine } from '../../lib/layout/text-layout.js';
|
|
4
|
+
|
|
5
|
+
const original = LayoutEngine.prototype.layout;
|
|
6
|
+
let layouts = 0;
|
|
7
|
+
LayoutEngine.prototype.layout = function(...args) { layouts++; return original.apply(this, args); };
|
|
8
|
+
const renderer = await createPageRenderer({});
|
|
9
|
+
const { CanvasKit: kit } = await loadGraphics();
|
|
10
|
+
const geometry = `Array.from(document.querySelectorAll('[data-check]'), e => {
|
|
11
|
+
const r=e.getBoundingClientRect(); return [e.id,r.x,r.y,r.width,r.height];
|
|
12
|
+
})`;
|
|
13
|
+
const pixels = async () => {
|
|
14
|
+
const encoded = await renderer.screenshot();
|
|
15
|
+
const image = kit.MakeImageFromEncoded(Buffer.from(encoded, 'base64'));
|
|
16
|
+
try {
|
|
17
|
+
return image.readPixels(0, 0, { width: 320, height: 240, colorType: kit.ColorType.RGBA_8888,
|
|
18
|
+
alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB });
|
|
19
|
+
} finally { image.delete(); }
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
renderer.setScripting('host');
|
|
24
|
+
renderer.resize(320, 240, 1);
|
|
25
|
+
await renderer.loadDocument({ url: 'https://repaint.test/', contentType: 'text/html', status: 200,
|
|
26
|
+
source: `<!doctype html><style>
|
|
27
|
+
body { margin:0; background:white; color:#123 }
|
|
28
|
+
#box { position:relative; z-index:2; width:140px; height:76px; overflow:hidden }
|
|
29
|
+
#nested { opacity:.5; background:#ffd; border:1px solid currentColor }
|
|
30
|
+
#behind { position:absolute; z-index:-1; left:5px; top:5px; background:#58a }
|
|
31
|
+
#above { position:absolute; z-index:3; left:30px; top:25px; background:#f80 }
|
|
32
|
+
#gradient { width:160px; background-image:linear-gradient(red,blue); background-clip:text; font-size:22px }
|
|
33
|
+
#wrap { width:100px } #decorated { background:#eee }
|
|
34
|
+
td { padding:3%; border:1px solid currentColor }
|
|
35
|
+
</style><div id="box" data-check>text <span id="nested" data-check>nested</span>
|
|
36
|
+
<span id="behind" data-check>behind</span><span id="above" data-check>above</span></div>
|
|
37
|
+
<div id="gradient" data-check>Gradient text</div>
|
|
38
|
+
<div id="wrap" data-check><span id="decorated" data-check>several wrapped words</span> <span id="plain" data-check>plain</span></div>
|
|
39
|
+
<table style="width:180px"><tr><td id="cell" data-check>table</td><td>other</td></tr></table>` });
|
|
40
|
+
await renderer.whenLoaded();
|
|
41
|
+
await pixels();
|
|
42
|
+
|
|
43
|
+
for (const [id, property, value, expectedLayouts] of [
|
|
44
|
+
['box', 'opacity', '.45', 0], ['box', 'opacity', '0', 0], ['box', 'opacity', '1', 0],
|
|
45
|
+
['box', 'backgroundColor', '#ade', 0], ['decorated', 'backgroundColor', '#fea', 0],
|
|
46
|
+
['cell', 'backgroundColor', '#def', 0], ['gradient', 'opacity', '.6', 0],
|
|
47
|
+
['gradient', 'backgroundSize', '40px 20px', 0],
|
|
48
|
+
['plain', 'backgroundColor', '#fbc', 1], ['plain', 'backgroundColor', 'transparent', 1],
|
|
49
|
+
['box', 'color', '#a30', 1], ['box', 'width', '150px', 1],
|
|
50
|
+
]) {
|
|
51
|
+
const before = layouts;
|
|
52
|
+
const retainedGeometry = await renderer.evaluate(`(() => {
|
|
53
|
+
document.getElementById(${JSON.stringify(id)}).style[${JSON.stringify(property)}]=${JSON.stringify(value)};
|
|
54
|
+
return ${geometry};
|
|
55
|
+
})()`);
|
|
56
|
+
const label = `${id}.${property}=${value}`;
|
|
57
|
+
assert.equal(layouts - before, expectedLayouts, `${label}: layout calls while reading geometry`);
|
|
58
|
+
const retainedPixels = await pixels();
|
|
59
|
+
// Viewport invalidation forces a complete style/layout pass. Restore the
|
|
60
|
+
// original viewport before comparing every measured rect and raster pixel.
|
|
61
|
+
renderer.resize(321, 240, 1);
|
|
62
|
+
renderer.resize(320, 240, 1);
|
|
63
|
+
assert.deepEqual(await renderer.evaluate(geometry), retainedGeometry, `${label}: geometry`);
|
|
64
|
+
assert.deepEqual(await pixels(), retainedPixels, `${label}: pixels`);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
renderer.dispose();
|
|
68
|
+
LayoutEngine.prototype.layout = original;
|
|
69
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Isolated: the host realm installs browser globals in this process.
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
import { createBytePipe } from '../../lib/media/byte-pipe.js';
|
|
5
|
+
|
|
6
|
+
const scenario = process.argv[2], sleep = Bun.sleep, pipe = createBytePipe();
|
|
7
|
+
pipe.append(new Uint8Array(1024));
|
|
8
|
+
const decoders = [], messages = [], errors = [], outputs = [];
|
|
9
|
+
let holdNext = false, finishOpen, changeFormat = false;
|
|
10
|
+
if (scenario === 'epoch') pipe.snapshotAt = time => ({
|
|
11
|
+
bytes: pipe.bytes(), id: pipe.generation > 1 && time >= 2 ? 'b' : 'a',
|
|
12
|
+
startTime: pipe.generation > 1 && time >= 2 ? 2 : 0,
|
|
13
|
+
endTime: pipe.generation > 1 && time < 2 ? 2 : Infinity,
|
|
14
|
+
});
|
|
15
|
+
const renderer = await createPageRenderer({
|
|
16
|
+
onFrame() {}, onVideo: message => messages.push(message),
|
|
17
|
+
fetchResource: async () => ({ ok: true, contentType: 'video/mp4', body: pipe }),
|
|
18
|
+
reportError: error => errors.push(String(error)),
|
|
19
|
+
videoDecoder: {
|
|
20
|
+
probe: async () => ({ duration: 30, video: { width: 20, height: 12 }, audio: { sampleRate: 1000 } }),
|
|
21
|
+
async decode() {
|
|
22
|
+
const record = { seeks: [], frames: 0, at: 0, closed: 0 };
|
|
23
|
+
const decoder = {
|
|
24
|
+
width: changeFormat ? 21 : 20, height: 12, fps: 10,
|
|
25
|
+
seek(ms) { record.seeks.push(ms); record.at = Math.floor(ms / 1000); },
|
|
26
|
+
nextFrame(w, h) {
|
|
27
|
+
record.frames++;
|
|
28
|
+
const bytes = new Uint8Array(w * h * 4).fill(20 + Math.floor(record.at));
|
|
29
|
+
record.at += 0.1;
|
|
30
|
+
return bytes;
|
|
31
|
+
},
|
|
32
|
+
close() { record.closed++; },
|
|
33
|
+
};
|
|
34
|
+
changeFormat = false;
|
|
35
|
+
decoders.push(record);
|
|
36
|
+
if (holdNext) { holdNext = false; await new Promise(resolve => { finishOpen = resolve; }); }
|
|
37
|
+
return decoder;
|
|
38
|
+
},
|
|
39
|
+
decodeAudio: async () => ({ sampleRate: 1000, channels: 1,
|
|
40
|
+
nextSamples: () => new Float32Array(100), close() {} }),
|
|
41
|
+
},
|
|
42
|
+
audio: {
|
|
43
|
+
open(options, listener) { outputs.push({ listener, samples: 0 }); return outputs.length; },
|
|
44
|
+
write(id, bytes) { outputs[id - 1].samples += bytes.length / 2; },
|
|
45
|
+
close(id) { return Promise.resolve(outputs[id - 1].samples * 2); },
|
|
46
|
+
finish() {},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const typed = type => messages.filter(message => message.type === type);
|
|
50
|
+
const waitFor = async condition => {
|
|
51
|
+
for (let attempt = 0; attempt < 200; attempt++) { if (condition()) return; await sleep(5); }
|
|
52
|
+
assert.fail(`transition timed out (${scenario}): ${condition}; ${JSON.stringify({
|
|
53
|
+
messages: messages.map(({ type, id, revision }) => ({ type, id, revision })), decoders, errors,
|
|
54
|
+
})}`);
|
|
55
|
+
};
|
|
56
|
+
const evaluate = code => renderer.evaluate(code);
|
|
57
|
+
const waiting = () => evaluate('globalThis.waitingEvents');
|
|
58
|
+
try {
|
|
59
|
+
renderer.resize(240, 180, 1);
|
|
60
|
+
renderer.setScripting('host');
|
|
61
|
+
await renderer.loadDocument({ url: 'https://continuation.test/', contentType: 'text/html', source:
|
|
62
|
+
'<style>body{margin:0}video{width:100px;height:60px;display:block}</style><video id=v src="clip.mp4"></video>' });
|
|
63
|
+
await renderer.whenLoaded();
|
|
64
|
+
await evaluate('globalThis.waitingEvents=0;globalThis.v=document.getElementById("v");v.addEventListener("waiting",()=>waitingEvents++);v.play()');
|
|
65
|
+
outputs.at(-1).listener({ event: 'request', bytes: 20000 });
|
|
66
|
+
await waitFor(() => typed('video-open').length === 1);
|
|
67
|
+
const first = typed('video-open')[0], initial = decoders[0], beforeWaiting = await waiting();
|
|
68
|
+
outputs[0].listener({ event: 'timing', seconds: 1.5, sentSeconds: 10, sinkSeconds: 8.5 });
|
|
69
|
+
if (scenario !== 'no-growth') pipe.append(new Uint8Array(1024));
|
|
70
|
+
if (scenario === 'epoch' || scenario === 'no-growth') {
|
|
71
|
+
renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
|
|
72
|
+
await sleep(20);
|
|
73
|
+
assert.equal(typed('video-extend').length, 0);
|
|
74
|
+
assert.equal(decoders.length, 1, 'no speculative decoder for unavailable bytes/new epoch');
|
|
75
|
+
renderer.videoReport({ id: first.id, revision: 0, end: 2 });
|
|
76
|
+
await waitFor(() => typed('video-open').length > 1);
|
|
77
|
+
assert.equal(await waiting(), beforeWaiting + 1);
|
|
78
|
+
} else {
|
|
79
|
+
holdNext = true;
|
|
80
|
+
changeFormat = scenario === 'format';
|
|
81
|
+
renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
|
|
82
|
+
await waitFor(() => Boolean(finishOpen));
|
|
83
|
+
const staged = decoders.at(-1);
|
|
84
|
+
renderer.videoReport({ id: first.id, revision: 0, end: 2 });
|
|
85
|
+
renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
|
|
86
|
+
assert.equal(decoders.length, 2, 'one staged decoder');
|
|
87
|
+
assert.equal(await waiting(), beforeWaiting, 'queued EOF does not dispatch waiting');
|
|
88
|
+
assert.equal(typed('video-stop').length, 0);
|
|
89
|
+
if (scenario === 'pause' || scenario === 'controls' || scenario === 'seek' || scenario === 'source') {
|
|
90
|
+
const action = { pause: 'v.pause()', controls: 'v.setAttribute("controls","");v.style.width="101px"',
|
|
91
|
+
seek: 'v.currentTime=4', source: 'v.src="different.mp4";v.load()' }[scenario];
|
|
92
|
+
await evaluate(action);
|
|
93
|
+
await waitFor(() => typed('video-stop').length > 0);
|
|
94
|
+
finishOpen();
|
|
95
|
+
await waitFor(() => staged.closed === 1);
|
|
96
|
+
assert.equal(typed('video-extend').length, 0, 'canceled source/position/layout never extends');
|
|
97
|
+
assert.equal(staged.closed, 1);
|
|
98
|
+
} else {
|
|
99
|
+
finishOpen();
|
|
100
|
+
if (scenario === 'format') {
|
|
101
|
+
await waitFor(() => typed('video-open').length > 1);
|
|
102
|
+
assert.equal(staged.closed, 1);
|
|
103
|
+
assert.equal(typed('video-extend').length, 0);
|
|
104
|
+
assert.equal(await waiting(), beforeWaiting + 1);
|
|
105
|
+
} else {
|
|
106
|
+
await waitFor(() => typed('video-extend').length === 1);
|
|
107
|
+
const extension = typed('video-extend')[0];
|
|
108
|
+
assert.equal(extension.id, first.id);
|
|
109
|
+
assert.equal(extension.revision, 0);
|
|
110
|
+
assert.equal(extension.boundary, 2);
|
|
111
|
+
assert.equal(extension.generation, 2);
|
|
112
|
+
assert.equal(extension.bytes.length, 2048);
|
|
113
|
+
if (scenario === 'worker-failure') {
|
|
114
|
+
renderer.videoReport({ id: first.id, revision: 0, continuationError: 'decoder rejected snapshot' });
|
|
115
|
+
await waitFor(() => typed('video-open').length > 1);
|
|
116
|
+
assert.equal(staged.closed, 1);
|
|
117
|
+
assert.equal(await waiting(), beforeWaiting + 1);
|
|
118
|
+
} else {
|
|
119
|
+
renderer.videoReport({ id: first.id, revision: 1, continued: 2 });
|
|
120
|
+
assert.equal(initial.closed, 1, 'old lazy decoder released after ACK');
|
|
121
|
+
assert.equal(staged.closed, 0);
|
|
122
|
+
renderer.videoReport({ id: first.id, revision: 0, end: 2 });
|
|
123
|
+
renderer.videoReport({ id: first.id, revision: 0, exhaustedAt: 2 });
|
|
124
|
+
await sleep(20);
|
|
125
|
+
assert.equal(typed('video-open').length, 1);
|
|
126
|
+
assert.equal(typed('video-stop').length, 0);
|
|
127
|
+
assert.equal(await waiting(), beforeWaiting);
|
|
128
|
+
assert.equal(outputs.length, 1, 'same measured audio stream');
|
|
129
|
+
assert.equal(await evaluate('v.currentTime'), 1.5);
|
|
130
|
+
// Local fallback must use the newly opened snapshot, positioned lazily.
|
|
131
|
+
renderer.videoReport({ id: first.id, error: 'simulated worker close' });
|
|
132
|
+
await waitFor(() => staged.frames > 0);
|
|
133
|
+
assert.ok(staged.seeks.length > 0);
|
|
134
|
+
assert.equal(initial.frames, 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
assert.deepEqual(errors, []);
|
|
140
|
+
process.stdout.write(`continuation ${scenario} passed\n`);
|
|
141
|
+
} finally { renderer.dispose(); }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Isolated because host scripting installs browser globals in this process.
|
|
2
|
+
import {strict as assert} from 'node:assert';
|
|
3
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
import {createBytePipe} from '../../lib/media/byte-pipe.js';
|
|
5
|
+
|
|
6
|
+
const sleep = Bun.sleep, pipe = createBytePipe();
|
|
7
|
+
pipe.append(new Uint8Array(1024));
|
|
8
|
+
const decoders = [], messages = [], errors = [], outputs = [];
|
|
9
|
+
let holdNext = false, finishOpen;
|
|
10
|
+
const renderer = await createPageRenderer({
|
|
11
|
+
onFrame() {}, onVideo: message => messages.push(message),
|
|
12
|
+
fetchResource: async () => ({ok:true, contentType:'video/mp4', body:pipe}),
|
|
13
|
+
reportError: error => errors.push(String(error)),
|
|
14
|
+
videoDecoder: {
|
|
15
|
+
probe: async () => ({duration:30, video:{width:20,height:12}, audio:{sampleRate:1000}}),
|
|
16
|
+
async decode() {
|
|
17
|
+
const record = {seeks:[], frames:0, at:0};
|
|
18
|
+
const decoder = {width:20,height:12,fps:10,
|
|
19
|
+
seek(ms) {record.seeks.push(ms); record.at=Math.floor(ms/1000);},
|
|
20
|
+
nextFrame(w,h) {record.frames++; const bytes=new Uint8Array(w*h*4).fill(20+Math.floor(record.at));record.at+=0.1;return bytes;},
|
|
21
|
+
close() {},
|
|
22
|
+
};
|
|
23
|
+
decoders.push(record);
|
|
24
|
+
if (holdNext) {holdNext=false; await new Promise(resolve => {finishOpen=resolve;});}
|
|
25
|
+
return decoder;
|
|
26
|
+
},
|
|
27
|
+
decodeAudio: async () => ({sampleRate:1000,channels:1,
|
|
28
|
+
nextSamples:()=>new Float32Array(100), close() {}}),
|
|
29
|
+
},
|
|
30
|
+
audio: {
|
|
31
|
+
open(options, listener) {outputs.push({listener,samples:0});return outputs.length;},
|
|
32
|
+
write(id, bytes) {outputs[id-1].samples+=bytes.length/2;},
|
|
33
|
+
close(id) {return Promise.resolve(outputs[id-1].samples*2);},
|
|
34
|
+
finish() {},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const currentPresentation = () => messages.filter(message=>message.type==='video-open').at(-1);
|
|
38
|
+
const opens = () => messages.filter(message=>message.type==='video-open').length;
|
|
39
|
+
const waitFor = async condition => {
|
|
40
|
+
for (let attempt=0;attempt<200;attempt++) {if(condition())return;await sleep(5);}
|
|
41
|
+
assert.fail(`timed out waiting for media transition: ${condition}; ${JSON.stringify({decoders, messages:messages.map(({type,id})=>({type,id})),errors})}`);
|
|
42
|
+
};
|
|
43
|
+
const clock = seconds => outputs.at(-1).listener({event:'timing',seconds,sentSeconds:10,sinkSeconds:10-seconds});
|
|
44
|
+
const beginRefill = seconds => {
|
|
45
|
+
holdNext=true;finishOpen=null;
|
|
46
|
+
pipe.append(new Uint8Array(1024));
|
|
47
|
+
clock(seconds);
|
|
48
|
+
renderer.videoReport({id:currentPresentation().id,end:seconds});
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
renderer.resize(240,180,1);renderer.setScripting('host');
|
|
52
|
+
await renderer.loadDocument({url:'https://refill.test/',contentType:'text/html',source:
|
|
53
|
+
'<style>body{margin:0}video{width:100px;height:60px;display:block}#second{display:none}</style><video id=v src="clip.mp4"></video><video id=second></video>'});
|
|
54
|
+
await renderer.whenLoaded();
|
|
55
|
+
await renderer.evaluate('document.getElementById("v").play()');
|
|
56
|
+
outputs.at(-1).listener({event:'request',bytes:20000});
|
|
57
|
+
await waitFor(()=>Boolean(currentPresentation()));
|
|
58
|
+
assert.equal(outputs.length,1);
|
|
59
|
+
|
|
60
|
+
// A real presentation refills without probing/decoding the same prefix on
|
|
61
|
+
// the page thread. Audio continues to own time and keeps its output stream.
|
|
62
|
+
const firstOpens=opens();
|
|
63
|
+
beginRefill(2);
|
|
64
|
+
await waitFor(()=>Boolean(finishOpen));
|
|
65
|
+
const resumed=decoders.at(-1);
|
|
66
|
+
finishOpen();
|
|
67
|
+
await waitFor(()=>opens()>firstOpens);
|
|
68
|
+
assert.deepEqual(resumed.seeks,[]);
|
|
69
|
+
assert.equal(resumed.frames,0);
|
|
70
|
+
assert.equal(currentPresentation().time,2);
|
|
71
|
+
assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),2);
|
|
72
|
+
assert.equal(outputs.length,1,'refilling does not reset the measured audio stream');
|
|
73
|
+
|
|
74
|
+
// Controls enabled during the async reopen prevent presentation: the lazy
|
|
75
|
+
// page decoder must seek before it can draw an ordinary local frame.
|
|
76
|
+
const controlOpens=opens();
|
|
77
|
+
beginRefill(3);
|
|
78
|
+
await waitFor(()=>Boolean(finishOpen));
|
|
79
|
+
await renderer.evaluate('document.getElementById("v").setAttribute("controls", "")');
|
|
80
|
+
const controls=decoders.at(-1);
|
|
81
|
+
finishOpen();
|
|
82
|
+
await waitFor(()=>controls.frames>0);
|
|
83
|
+
assert.ok(controls.seeks.length>0);
|
|
84
|
+
assert.equal(controls.seeks[0],3000);
|
|
85
|
+
assert.equal(opens(),controlOpens);
|
|
86
|
+
assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),3);
|
|
87
|
+
|
|
88
|
+
await renderer.evaluate('document.getElementById("v").removeAttribute("controls");document.getElementById("v").style.width="101px"');
|
|
89
|
+
await waitFor(()=>opens()>controlOpens);
|
|
90
|
+
const multipleOpens=opens();
|
|
91
|
+
beginRefill(4);
|
|
92
|
+
await waitFor(()=>Boolean(finishOpen));
|
|
93
|
+
await renderer.evaluate('document.getElementById("second").style.display="block"');
|
|
94
|
+
const multiple=decoders.at(-1);
|
|
95
|
+
finishOpen();
|
|
96
|
+
await waitFor(()=>multiple.frames>0);
|
|
97
|
+
assert.ok(multiple.seeks.length>0);
|
|
98
|
+
assert.equal(multiple.seeks[0],4000);
|
|
99
|
+
assert.equal(opens(),multipleOpens,'multiple visible videos retain local decoding');
|
|
100
|
+
|
|
101
|
+
await renderer.evaluate('document.getElementById("second").style.display="none"');
|
|
102
|
+
await waitFor(()=>opens()>multipleOpens);
|
|
103
|
+
const fallback=decoders.at(-1), beforeFallback=fallback.frames;
|
|
104
|
+
renderer.videoReport({id:currentPresentation().id,error:'simulated decoder failure'});
|
|
105
|
+
await waitFor(()=>fallback.frames>beforeFallback);
|
|
106
|
+
assert.ok(fallback.seeks.length>0,'failed presentation positions the page decoder');
|
|
107
|
+
await renderer.evaluate('document.getElementById("v").pause()');
|
|
108
|
+
const paused=await renderer.evaluate('document.getElementById("v").currentTime');
|
|
109
|
+
clock(5);
|
|
110
|
+
assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),paused);
|
|
111
|
+
assert.deepEqual(errors,[]);
|
|
112
|
+
process.stdout.write('presentation refill avoids duplicate page decode and preserves fallbacks\n');
|
|
113
|
+
} finally {renderer.dispose();}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Isolated: the host realm installs browser globals in this process.
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
|
|
5
|
+
const scenario = process.argv[2], sleep = Bun.sleep;
|
|
6
|
+
const probes = [], pending = [], errors = [];
|
|
7
|
+
let hold = scenario.startsWith('initial') && scenario !== 'initial-rejected';
|
|
8
|
+
let failNext = scenario === 'initial-rejected';
|
|
9
|
+
const renderer = await createPageRenderer({
|
|
10
|
+
reportError: error => errors.push(String(error)),
|
|
11
|
+
fetchResource: async () => ({ ok: true, contentType: 'video/mp4', body: new Uint8Array(8) }),
|
|
12
|
+
videoDecoder: {
|
|
13
|
+
async probe(bytes) {
|
|
14
|
+
const input = { length: bytes.length, hash: Bun.hash(bytes).toString() };
|
|
15
|
+
probes.push(input);
|
|
16
|
+
if (failNext) { failNext = false; throw Error('temporary probe failure'); }
|
|
17
|
+
if (hold) return new Promise(resolve => pending.push(resolve));
|
|
18
|
+
return metadata(bytes.length);
|
|
19
|
+
},
|
|
20
|
+
decode: async () => ({ width: 20, height: 12, fps: 1, close() {}, seek() {},
|
|
21
|
+
nextFrame: (w, h) => new Uint8Array(w * h * 4) }),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const metadata = duration => ({ duration, video: { width: 20, height: 12 } });
|
|
25
|
+
const evaluate = code => renderer.evaluate(code);
|
|
26
|
+
const waitFor = async condition => {
|
|
27
|
+
for (let attempt = 0; attempt < 150; attempt++) { if (await condition()) return; await sleep(5); }
|
|
28
|
+
assert.fail(`probe transition timed out: ${scenario}; ${JSON.stringify({ probes, pending: pending.length, errors })}`);
|
|
29
|
+
};
|
|
30
|
+
const append = buffer => evaluate(`new Promise(resolve => {
|
|
31
|
+
${buffer}.addEventListener('updateend', resolve, {once:true});
|
|
32
|
+
${buffer}.appendBuffer(new Uint8Array([0,0,0,8,102,114,101,101]));
|
|
33
|
+
})`);
|
|
34
|
+
const count = () => evaluate('metadataEvents');
|
|
35
|
+
const duration = () => evaluate('v.duration');
|
|
36
|
+
const finishInitial = async () => {
|
|
37
|
+
await waitFor(async () => await count() === 1);
|
|
38
|
+
await evaluate('v.pause()');
|
|
39
|
+
await sleep(10);
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
renderer.setScripting('host'); renderer.resize(240, 180, 1);
|
|
43
|
+
await renderer.loadDocument({ url: 'https://probe.test/', contentType: 'text/html',
|
|
44
|
+
source: '<!doctype html><video id="v"></video>' });
|
|
45
|
+
await renderer.whenLoaded();
|
|
46
|
+
await evaluate(`new Promise(resolve => {
|
|
47
|
+
globalThis.metadataEvents=0; globalThis.v=document.getElementById('v');
|
|
48
|
+
v.addEventListener('loadedmetadata',()=>metadataEvents++);
|
|
49
|
+
globalThis.ms=new MediaSource();
|
|
50
|
+
ms.addEventListener('sourceopen',()=>{
|
|
51
|
+
globalThis.vb=ms.addSourceBuffer('video/mp4');
|
|
52
|
+
globalThis.ab=ms.addSourceBuffer('audio/mp4');
|
|
53
|
+
globalThis.probeWaits=0;
|
|
54
|
+
{ const pipe=ms.streamPipes().video, original=pipe.waitFor;
|
|
55
|
+
pipe.waitFor=mark=>{probeWaits++;return original(mark)}; }
|
|
56
|
+
${scenario.startsWith('epoch') ? `const pipe=ms.streamPipes().video, original=pipe.snapshotAt;
|
|
57
|
+
pipe.snapshotAt=seconds=>({...original(seconds),id:seconds>=2?'late':'early'});` : ''}
|
|
58
|
+
${scenario === 'moving-snapshot' ? `const pipe=ms.streamPipes().video, original=pipe.snapshotAt;
|
|
59
|
+
pipe.snapshotAt=seconds=>({...original(seconds),startTime:Math.floor(seconds/0.02)*0.02});` : ''}
|
|
60
|
+
void v.play(); resolve();
|
|
61
|
+
},{once:true});
|
|
62
|
+
v.src=URL.createObjectURL(ms);
|
|
63
|
+
})`);
|
|
64
|
+
await append('vb');
|
|
65
|
+
if (scenario === 'initial-rejected') {
|
|
66
|
+
await waitFor(() => probes.length === 1);
|
|
67
|
+
await sleep(10);
|
|
68
|
+
const waits = await evaluate('probeWaits');
|
|
69
|
+
for (let i = 0; i < 5; i++) await append('ab');
|
|
70
|
+
await sleep(15);
|
|
71
|
+
assert.equal(probes.length, 1, 'unchanged rejected initial bytes wait for video growth');
|
|
72
|
+
assert.equal(await evaluate('probeWaits'), waits, 'audio appends do not accumulate pending video waiters');
|
|
73
|
+
await append('vb'); await finishInitial();
|
|
74
|
+
assert.equal(probes.length, 2); assert.equal(await count(), 1);
|
|
75
|
+
} else if (scenario.startsWith('initial')) {
|
|
76
|
+
await waitFor(() => pending.length === 1);
|
|
77
|
+
await append('ab'); await append('ab'); await sleep(15);
|
|
78
|
+
assert.equal(probes.length, 1, 'loadMedia and first append share one initial probe');
|
|
79
|
+
if (scenario === 'initial-growth') {
|
|
80
|
+
await append('vb'); await append('vb');
|
|
81
|
+
pending.shift()(metadata(8));
|
|
82
|
+
await waitFor(() => pending.length === 1);
|
|
83
|
+
assert.equal(probes.length, 2, 'one latest-input recheck after coalesced appends');
|
|
84
|
+
assert.equal(await count(), 0, 'obsolete initial input cannot publish metadata');
|
|
85
|
+
hold = false; pending.shift()(metadata(24));
|
|
86
|
+
await finishInitial(); assert.equal(await duration(), 24);
|
|
87
|
+
} else if (scenario === 'initial-source') {
|
|
88
|
+
await evaluate('v.src="replacement.mp4";v.load()');
|
|
89
|
+
hold = false; pending.shift()(metadata(123));
|
|
90
|
+
await sleep(25); assert.equal(await count(), 1);
|
|
91
|
+
assert.equal(await duration(), 8, 'only the replacement source can publish metadata');
|
|
92
|
+
} else {
|
|
93
|
+
hold = false; pending.shift()(metadata(8));
|
|
94
|
+
await finishInitial(); assert.equal(await count(), 1);
|
|
95
|
+
await append('ab'); await sleep(15);
|
|
96
|
+
assert.equal(probes.length, 1, 'initial probe does not leave a blocked duplicate waiter');
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
await finishInitial(); assert.equal(probes.length, 1);
|
|
100
|
+
if (scenario === 'audio-only') {
|
|
101
|
+
await append('vb'); await waitFor(() => probes.length === 2);
|
|
102
|
+
for (let i = 0; i < 5; i++) await append('ab');
|
|
103
|
+
await sleep(20); assert.equal(probes.length, 2, 'audio appends reuse unchanged video metadata');
|
|
104
|
+
assert.equal(await count(), 1);
|
|
105
|
+
} else if (scenario === 'refresh-growth' || scenario === 'pipe-change') {
|
|
106
|
+
hold = true; await append('vb'); await waitFor(() => pending.length === 1);
|
|
107
|
+
if (scenario === 'pipe-change') await evaluate('ms.removeSourceBuffer(vb);globalThis.vb=ms.addSourceBuffer("video/mp4")');
|
|
108
|
+
await append('vb'); await append('vb');
|
|
109
|
+
assert.equal(probes.length, 2, 'no parallel refresh');
|
|
110
|
+
pending.shift()(metadata(20));
|
|
111
|
+
await waitFor(() => pending.length === 1);
|
|
112
|
+
assert.equal(probes.length, 3); assert.equal(await duration(), 8, 'stale pipe/generation cannot overwrite metadata');
|
|
113
|
+
hold = false; pending.shift()(metadata(40));
|
|
114
|
+
await waitFor(async () => await duration() === 40);
|
|
115
|
+
assert.equal(await count(), 1);
|
|
116
|
+
} else if (scenario === 'failed-retry') {
|
|
117
|
+
failNext = true; await append('vb'); await waitFor(() => probes.length === 2);
|
|
118
|
+
await append('ab'); await waitFor(() => probes.length === 3);
|
|
119
|
+
assert.deepEqual(probes[1], probes[2], 'same failed snapshot is retryable');
|
|
120
|
+
await append('ab'); await sleep(15); assert.equal(probes.length, 3);
|
|
121
|
+
} else if (scenario === 'mime-change') {
|
|
122
|
+
await evaluate('vb.changeType("video/webm")'); await append('ab');
|
|
123
|
+
await waitFor(() => probes.length === 2);
|
|
124
|
+
assert.deepEqual(probes[0], probes[1], 'MIME change invalidates identical byte input');
|
|
125
|
+
} else if (scenario === 'epoch-inflight') {
|
|
126
|
+
hold = true; await append('vb'); await waitFor(() => pending.length === 1);
|
|
127
|
+
await evaluate('v.currentTime=3'); pending.shift()(metadata(20));
|
|
128
|
+
await waitFor(() => pending.length === 1);
|
|
129
|
+
assert.equal(probes.length, 3);
|
|
130
|
+
assert.equal(await duration(), 8, 'seek to another epoch rejects old in-flight metadata');
|
|
131
|
+
hold = false; pending.shift()(metadata(40));
|
|
132
|
+
await waitFor(async () => await duration() === 40);
|
|
133
|
+
} else if (scenario === 'moving-snapshot') {
|
|
134
|
+
hold = true; await append('vb'); await waitFor(() => pending.length === 1);
|
|
135
|
+
await evaluate('v.currentTime=3');
|
|
136
|
+
hold = false; pending.shift()(metadata(20));
|
|
137
|
+
await waitFor(async () => await duration() === 20);
|
|
138
|
+
await sleep(20);
|
|
139
|
+
assert.equal(probes.length, 2, 'clock movement alone cannot restart an in-flight snapshot probe');
|
|
140
|
+
} else if (scenario === 'epoch') {
|
|
141
|
+
await evaluate('v.currentTime=3'); await sleep(20); await append('ab');
|
|
142
|
+
await waitFor(() => probes.length === 2);
|
|
143
|
+
assert.deepEqual(probes[0], probes[1], 'snapshot epoch invalidates identical bytes and generation');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
assert.deepEqual(errors, []);
|
|
147
|
+
process.stdout.write(`media probe ${scenario} passed\n`);
|
|
148
|
+
} finally { renderer.dispose(); }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Host scripting globals are isolated from the test runner.
|
|
2
|
+
import {strict as assert} from 'node:assert';
|
|
3
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
import {createBytePipe} from '../../lib/media/byte-pipe.js';
|
|
5
|
+
|
|
6
|
+
const action = process.argv[2], sleep = Bun.sleep, pipe = createBytePipe();
|
|
7
|
+
pipe.append(new Uint8Array(1024));
|
|
8
|
+
let decodeCount = 0, rejectAudio;
|
|
9
|
+
const outputs = [], errors = [];
|
|
10
|
+
const renderer = await createPageRenderer({
|
|
11
|
+
onFrame() {}, onVideo() {}, reportError:error => errors.push(String(error)),
|
|
12
|
+
fetchResource:async () => ({ok:true, contentType:'video/mp4', body:pipe}),
|
|
13
|
+
videoDecoder:{
|
|
14
|
+
probe:async () => ({duration:30, video:{width:20,height:12}, audio:{sampleRate:1000}}),
|
|
15
|
+
decode:async () => ({width:20,height:12,fps:10, seek() {}, close() {},
|
|
16
|
+
nextFrame:(w,h) => new Uint8Array(w*h*4)}),
|
|
17
|
+
async decodeAudio() {
|
|
18
|
+
decodeCount++;
|
|
19
|
+
if (decodeCount === 2) await new Promise((resolve, reject) => {rejectAudio=reject;});
|
|
20
|
+
let position = 0;
|
|
21
|
+
return {sampleRate:1000,channels:1, close() {}, nextSamples() {
|
|
22
|
+
if (position >= 1000) return null;
|
|
23
|
+
position += 100;
|
|
24
|
+
return new Float32Array(100);
|
|
25
|
+
}};
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
audio:{
|
|
29
|
+
open(options, listener) {outputs.push({listener,bytes:0});return outputs.length;},
|
|
30
|
+
write(id, bytes) {outputs[id-1].bytes+=bytes.length;},
|
|
31
|
+
close(id) {return Promise.resolve(outputs[id-1].bytes);}, finish() {},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const waitFor = async condition => {
|
|
35
|
+
for(let n=0;n<200;n++) {if(condition())return;await sleep(5);}
|
|
36
|
+
assert.fail(`timed out: ${condition}`);
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
renderer.resize(240,180,1);renderer.setScripting('host');
|
|
40
|
+
await renderer.loadDocument({url:'https://refill-cancel.test/',contentType:'text/html',source:
|
|
41
|
+
'<style>video{width:100px;height:60px;display:block}</style><video id=v src="clip.mp4"></video>'});
|
|
42
|
+
await renderer.whenLoaded();
|
|
43
|
+
await renderer.evaluate('globalThis.mediaEvents=[]; const v=document.getElementById("v"); v.addEventListener("error",()=>mediaEvents.push("error")); v.addEventListener("seeked",()=>mediaEvents.push("seeked")); v.play()');
|
|
44
|
+
// The old snapshot ends at sample 1000; the newer, now-complete input
|
|
45
|
+
// makes the replacement decoder's rejection a fatal error if still active.
|
|
46
|
+
pipe.finish();
|
|
47
|
+
outputs[0].listener({event:'request',bytes:3000});
|
|
48
|
+
await waitFor(() => Boolean(rejectAudio));
|
|
49
|
+
|
|
50
|
+
if (action === 'seek') await renderer.evaluate('document.getElementById("v").currentTime=0.2');
|
|
51
|
+
else if (action === 'pause') await renderer.evaluate('document.getElementById("v").pause()');
|
|
52
|
+
else if (action === 'navigate') {
|
|
53
|
+
// The replacement is a static page; it does not need another host realm.
|
|
54
|
+
renderer.setScripting('off');
|
|
55
|
+
await renderer.loadDocument({url:'https://refill-cancel.test/next',contentType:'text/html',source:'<p id=next>Next document</p>'});
|
|
56
|
+
await renderer.whenLoaded();
|
|
57
|
+
} else assert.fail(`unknown action: ${action}`);
|
|
58
|
+
|
|
59
|
+
rejectAudio(Error('obsolete failed audio refill'));
|
|
60
|
+
if (action === 'seek') {
|
|
61
|
+
for(let n=0;n<200;n++) {
|
|
62
|
+
if (await renderer.evaluate('mediaEvents.includes("seeked")')) break;
|
|
63
|
+
await sleep(5);
|
|
64
|
+
}
|
|
65
|
+
assert.equal(await renderer.evaluate('mediaEvents.includes("seeked")'),true);
|
|
66
|
+
assert.equal(await renderer.evaluate('document.getElementById("v").paused'),false);
|
|
67
|
+
assert.equal(await renderer.evaluate('document.getElementById("v").currentTime'),0.2);
|
|
68
|
+
assert.equal(await renderer.evaluate('mediaEvents.includes("error")'),false);
|
|
69
|
+
} else {
|
|
70
|
+
await sleep(80);
|
|
71
|
+
if (action === 'pause') {
|
|
72
|
+
assert.equal(await renderer.evaluate('document.getElementById("v").paused'),true);
|
|
73
|
+
assert.equal(await renderer.evaluate('mediaEvents.includes("error")'),false);
|
|
74
|
+
} else assert.equal(await renderer.evaluate('document.body.textContent'),'Next document');
|
|
75
|
+
}
|
|
76
|
+
assert.deepEqual(errors,[]);
|
|
77
|
+
assert.equal(decodeCount,2,'an obsolete rejection must not close and reopen the media');
|
|
78
|
+
process.stdout.write(`${action} ignores an obsolete refill rejection\n`);
|
|
79
|
+
} finally {renderer.dispose();}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Isolated because host scripting installs browser globals in this process.
|
|
2
|
+
import {strict as assert} from 'node:assert';
|
|
3
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
4
|
+
const sleep=Bun.sleep, report=text=>process.stdout.write(text+'\n');
|
|
5
|
+
const errors=[],frames=[],messages=[];
|
|
6
|
+
let seeked=0;
|
|
7
|
+
const renderer=await createPageRenderer({
|
|
8
|
+
renderMinInterval:80,
|
|
9
|
+
onFrame:frame=>frames.push(frame.tile),onVideo:message=>messages.push(message),
|
|
10
|
+
fetchResource:async url=>({ok:true,url,body:new Uint8Array([1]),contentType:'video/mp4'}),
|
|
11
|
+
reportError:error=>errors.push(String(error)),
|
|
12
|
+
videoDecoder:{
|
|
13
|
+
probe:async()=>({duration:10,video:{width:20,height:12}}),
|
|
14
|
+
decode:async()=>({width:20,height:12,fps:100,
|
|
15
|
+
nextFrame(w,h){const data=new Uint8Array(w*h*4);for(let i=0;i<data.length;i+=4)data.set([60+seeked,90,120,255],i);return data;},
|
|
16
|
+
seek(ms){seeked=Math.floor(ms/100);},close(){}}),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
function pixel(tile,x,y){const at=(Math.round((y-tile.y)*tile.scale)*tile.pixelWidth+Math.round((x-tile.x)*tile.scale))*4;return [...tile.pixels.subarray(at,at+4)];}
|
|
20
|
+
try{
|
|
21
|
+
renderer.resize(240,180,1);renderer.setScripting('host');
|
|
22
|
+
await renderer.loadDocument({url:'https://media.test/',contentType:'text/html',source:'<style>body{margin:0}video{display:block;width:200px;height:120px}#marker{width:20px;height:20px;background:red}</style><video id=v src="clip.mp4"></video><div id=marker></div><video id=native src="clip.mp4" controls style="display:none"></video>'});
|
|
23
|
+
await renderer.whenLoaded();
|
|
24
|
+
await renderer.evaluate('document.getElementById("v").play()');
|
|
25
|
+
await sleep(160);
|
|
26
|
+
assert.ok(messages.some(message=>message.type==='video-open'),'first metadata/play starts independent presentation');
|
|
27
|
+
assert.ok(frames.at(-1).videoLayer);
|
|
28
|
+
frames.length=0;
|
|
29
|
+
await renderer.evaluate('setTimeout(()=>{document.getElementById("marker").style.background="blue"},1)');
|
|
30
|
+
await sleep(180);
|
|
31
|
+
assert.ok(frames.length>0,'scheduled UI frame is still published');
|
|
32
|
+
assert.ok(frames.every(tile=>pixel(tile,5,125).join(',')==='0,0,255,255'),'media ticks must not publish stale red UI before the scheduled rebuild');
|
|
33
|
+
await renderer.evaluate('document.getElementById("v").pause()');
|
|
34
|
+
assert.ok(messages.some(message=>message.type==='video-stop'),'pause stops independent presentation');
|
|
35
|
+
assert.equal(frames.at(-1).videoLayer,null,'paused frame returns to normal raster');
|
|
36
|
+
await renderer.evaluate('document.getElementById("v").currentTime=3');
|
|
37
|
+
await sleep(80);
|
|
38
|
+
assert.ok(seeked>0,'paused seek positions the decoder');
|
|
39
|
+
assert.ok(pixel(frames.at(-1),5,5)[0]>60,'paused seek publishes its decoded picture');
|
|
40
|
+
await renderer.evaluate('document.getElementById("v").style.display="none";document.getElementById("native").style.display="block"');
|
|
41
|
+
await renderer.evaluate('document.getElementById("native").play()');
|
|
42
|
+
const before=frames.length;
|
|
43
|
+
await sleep(140);
|
|
44
|
+
assert.ok(frames.length>before,'native controls/local playback continue repainting');
|
|
45
|
+
assert.equal(frames.at(-1).videoLayer,null,'native controls retain the normal rendering path');
|
|
46
|
+
await renderer.evaluate('document.getElementById("native").pause()');
|
|
47
|
+
assert.deepEqual(errors,[]);report('media repaint integration passed');
|
|
48
|
+
}finally{renderer.dispose();}
|