buninu-browser 0.2.2 → 0.2.4
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/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +53 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +20 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +12 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +289 -131
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +42 -0
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +22 -27
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +40 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +244 -82
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +27 -10
- package/lib/renderer/custom-element-reactions.js +42 -0
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +36 -5
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +33 -5
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +278 -147
- package/lib/renderer/page-renderer.js +840 -301
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +145 -0
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +632 -108
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/compositor.test.js +38 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +279 -1
- package/test/display-list.test.js +26 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/download.test.js +8 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/headless-shell.test.js +22 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-playback-events.test.js +22 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +160 -2
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +27 -5
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +168 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +136 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { loadGraphics } from "../lib/renderer/page-renderer.js";
|
|
3
|
+
import { RasterReadback } from "../lib/paint/raster-readback.js";
|
|
4
|
+
|
|
5
|
+
test("raster readback reuses native scratch while published shared frames stay immutable", async () => {
|
|
6
|
+
const { CanvasKit: kit } = await loadGraphics();
|
|
7
|
+
const malloc = kit._malloc, free = kit._free;
|
|
8
|
+
const pointers = new Set();
|
|
9
|
+
let allocations = 0, releases = 0;
|
|
10
|
+
kit._malloc = size => { const pointer = malloc(size); pointers.add(pointer); allocations++; return pointer; };
|
|
11
|
+
kit._free = pointer => { if (pointers.delete(pointer)) releases++; free(pointer); };
|
|
12
|
+
const surface = kit.MakeSurface(4, 4);
|
|
13
|
+
const capture = new RasterReadback(kit, { shared: true });
|
|
14
|
+
const read = (size = 4) => {
|
|
15
|
+
const image = surface.makeImageSnapshot();
|
|
16
|
+
try { return capture.read(image, size, size); } finally { image.delete(); }
|
|
17
|
+
};
|
|
18
|
+
try {
|
|
19
|
+
surface.getCanvas().clear(kit.Color(255, 0, 0, 0.5));
|
|
20
|
+
const first = read();
|
|
21
|
+
const afterFirst = allocations;
|
|
22
|
+
expect(first.buffer).toBeInstanceOf(SharedArrayBuffer);
|
|
23
|
+
expect([...first.subarray(0, 4)]).toEqual([255, 0, 0, 128]);
|
|
24
|
+
surface.getCanvas().clear(kit.Color(0, 0, 255, 1));
|
|
25
|
+
const second = read();
|
|
26
|
+
expect(allocations).toBe(afterFirst);
|
|
27
|
+
expect([...second.subarray(0, 4)]).toEqual([0, 0, 255, 255]);
|
|
28
|
+
expect([...first.subarray(0, 4)]).toEqual([255, 0, 0, 128]);
|
|
29
|
+
expect(read(2)).toHaveLength(16);
|
|
30
|
+
expect(allocations).toBe(afterFirst);
|
|
31
|
+
// Structured cloning shares pixels without another allocation or copy.
|
|
32
|
+
const clone = structuredClone(first);
|
|
33
|
+
expect(clone.buffer).toBeInstanceOf(SharedArrayBuffer);
|
|
34
|
+
expect([...clone]).toEqual([...first]);
|
|
35
|
+
} finally {
|
|
36
|
+
capture.dispose(); capture.dispose(); surface.dispose();
|
|
37
|
+
kit._malloc = malloc; kit._free = free;
|
|
38
|
+
}
|
|
39
|
+
expect(releases).toBe(allocations);
|
|
40
|
+
});
|
package/test/render-tree.test.js
CHANGED
|
@@ -40,6 +40,34 @@ test("keeps node IDs stable across generations of the same DOM", () => {
|
|
|
40
40
|
window.happyDOM.abort();
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test("closed details show only their first summary and update when opened", async () => {
|
|
44
|
+
const { StyleEngine } = await import("../lib/style/computed-style.js");
|
|
45
|
+
const { document, window } = parseHTMLDocument(`<details><p>Before summary</p><summary>Menu</summary><summary>Extra summary</summary><p>Contents</p></details>`);
|
|
46
|
+
const builder = new RenderTreeBuilder();
|
|
47
|
+
const styles = new StyleEngine();
|
|
48
|
+
const first = builder.build(document, styles.compute(document));
|
|
49
|
+
expect(renderTreeText(first)).toBe("▶ Menu");
|
|
50
|
+
document.querySelector("details").setAttribute("open", "");
|
|
51
|
+
const second = builder.build(document, styles.compute(document));
|
|
52
|
+
expect(renderTreeText(second)).toContain("▼ Menu");
|
|
53
|
+
expect(renderTreeText(second)).toContain("Extra summary");
|
|
54
|
+
expect(renderTreeText(second)).toContain("Contents");
|
|
55
|
+
expect(renderTreeText(second)).toContain("Before summary");
|
|
56
|
+
window.happyDOM.abort();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("details without summary generate the default label and author CSS can override hidden", async () => {
|
|
60
|
+
const { StyleEngine } = await import("../lib/style/computed-style.js");
|
|
61
|
+
const { document, window } = parseHTMLDocument(`<details style="padding:20px;border:3px solid;color:red">Contents</details><p hidden style="display:block">Visible</p>`);
|
|
62
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
63
|
+
expect(renderTreeText(tree)).toBe("▶ DetailsVisible");
|
|
64
|
+
const summary = [...tree.nodesById.values()].find(node => node.tagName === "SUMMARY");
|
|
65
|
+
expect(summary.style.padding).toEqual([0, 0, 0, 0]);
|
|
66
|
+
expect(summary.style.borderWidths).toEqual([0, 0, 0, 0]);
|
|
67
|
+
expect(summary.style.color).toBe("rgba(255, 0, 0, 1)");
|
|
68
|
+
window.happyDOM.abort();
|
|
69
|
+
});
|
|
70
|
+
|
|
43
71
|
test("includes visible elements appended beside body under html", async () => {
|
|
44
72
|
const { StyleEngine } = await import("../lib/style/computed-style.js");
|
|
45
73
|
const { document, window } = parseHTMLDocument(`<html><head></head><body><p>body</p></body></html>`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
test('CanvasKit renderer lifetimes release native surfaces and failed video readbacks', async () => {
|
|
4
|
+
const child = Bun.spawn([process.execPath, new URL('./fixtures/renderer-memory.js', import.meta.url).pathname],
|
|
5
|
+
{ stdout: 'pipe', stderr: 'pipe' });
|
|
6
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
7
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
8
|
+
]);
|
|
9
|
+
expect({ code, stderr }).toEqual({ code: 0, stderr: '' });
|
|
10
|
+
expect(stdout).toContain('renderer memory lifecycle passed');
|
|
11
|
+
}, 30_000);
|
|
@@ -17,3 +17,27 @@ test("finds :has() compounds and sibling-target compounds in a sheet", () => {
|
|
|
17
17
|
expect(traits.descendantFeatures.has(".esc:a")).toBe(true);
|
|
18
18
|
expect(traits.sibling && traits.firstChild && traits.lastChild).toBe(true);
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
test("focus pseudo-class features retain their complete names", () => {
|
|
22
|
+
const traits = sheetTraits('.a:focus .x{}.b:focus-visible .x{}.c:focus-within .x{}');
|
|
23
|
+
expect(traits.descendantStateCompounds.map(entry=>entry.states)).toEqual([[":focus"],[":focus-visible"],[":focus-within"]]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("nested rule groups retain their first selector's invalidation dependencies", () => {
|
|
27
|
+
const traits=sheetTraits('@media(min-width:1px){@layer app{.panel:focus-within .label{color:red}.panel:has([data-open]){color:blue}}}');
|
|
28
|
+
expect(traits.descendantStateCompounds.map(entry=>entry.states)).toEqual([[":focus-within"]]);
|
|
29
|
+
expect([...traits.hasCompounds]).toEqual(['.panel:has([data-open])']);
|
|
30
|
+
expect(traits.descendantFeatures.has('.panel')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("functional alternatives constrain state invalidation without assuming negated requirements", () => {
|
|
34
|
+
const traits = sheetTraits(':is(.a,.b):focus-within .x{}:where(.outer > .target,button):focus .x{}:not(:is(.a,.b)):focus .x{}');
|
|
35
|
+
expect(traits.descendantStateCompounds.map(entry=>entry.alternatives))
|
|
36
|
+
.toEqual([[[[".a"],[".b"]]],[[[".target"],["t:button"]]],[]]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("observed attributes include nested, subject and escaped selectors", () => {
|
|
40
|
+
const traits = sheetTraits(String.raw`@media (min-width:1px){[data-open] .x:has([aria-expanded="true"]){} }
|
|
41
|
+
:is([title],[data-foo\:bar]){}.x[DATA-STATE]{}[ns|data-mode]{}[|data-empty]{}[data\2d escaped]{}`);
|
|
42
|
+
expect([...traits.observedAttributes].sort()).toEqual(["aria-expanded","data-empty","data-escaped","data-foo:bar","data-mode","data-open","data-state","title"]);
|
|
43
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { SharedFramePool, FrameLeaseTracker } from "../lib/paint/shared-frame-pool.js";
|
|
3
|
+
|
|
4
|
+
test("pixel reuse waits for both current renderer ownership and every compositor lease", () => {
|
|
5
|
+
const pool = new SharedFramePool();
|
|
6
|
+
const first = pool.acquire(16); first.fill(7); pool.publish(1, first);
|
|
7
|
+
pool.publish(2, first); // Scroll publication with the same current pixels.
|
|
8
|
+
pool.release(1);
|
|
9
|
+
const second = pool.acquire(16); second.fill(9); pool.publish(3, second);
|
|
10
|
+
const third = pool.acquire(16);
|
|
11
|
+
expect(third.buffer).not.toBe(first.buffer);
|
|
12
|
+
expect([...first]).toEqual(Array(16).fill(7));
|
|
13
|
+
pool.release(2);
|
|
14
|
+
expect(pool.acquire(16).buffer).toBe(first.buffer);
|
|
15
|
+
pool.release(3);
|
|
16
|
+
expect(pool.acquire(16).buffer).not.toBe(second.buffer); // Still renderer's current tile.
|
|
17
|
+
pool.clear();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("an old frame stays immutable while its asynchronous screenshot encoder is reading it", async () => {
|
|
21
|
+
const pool = new SharedFramePool();
|
|
22
|
+
const released = [];
|
|
23
|
+
const tracker = new FrameLeaseTracker(serial => { released.push(serial); pool.release(serial); });
|
|
24
|
+
const first = pool.acquire(8); first.fill(1); pool.publish(1, first); tracker.replace(1, first);
|
|
25
|
+
let finish;
|
|
26
|
+
const captured = tracker.capture(async pixels => { await new Promise(resolve => finish = resolve); return [...pixels]; });
|
|
27
|
+
const second = pool.acquire(8); second.fill(2); pool.publish(2, second); tracker.replace(2, second);
|
|
28
|
+
expect(released).toEqual([]);
|
|
29
|
+
const third = pool.acquire(8); third.fill(3); pool.publish(3, third); tracker.replace(3, third);
|
|
30
|
+
expect(released).toEqual([2]);
|
|
31
|
+
expect(pool.acquire(8).buffer).toBe(second.buffer);
|
|
32
|
+
finish();
|
|
33
|
+
expect(await captured).toEqual(Array(8).fill(1));
|
|
34
|
+
expect(released).toEqual([2, 1]);
|
|
35
|
+
expect(pool.acquire(8).buffer).toBe(first.buffer);
|
|
36
|
+
pool.clear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("idle pixel cache stays bounded and failed captures release their retired frames", async () => {
|
|
40
|
+
const pool = new SharedFramePool({ maxBuffers: 1, maxBytes: 8 });
|
|
41
|
+
const frames = Array.from({ length: 4 }, (_, i) => {
|
|
42
|
+
const pixels = pool.acquire(8); pool.publish(i, pixels); return pixels;
|
|
43
|
+
});
|
|
44
|
+
pool.release(0); pool.release(1); pool.release(2);
|
|
45
|
+
expect(pool.acquire(8).buffer).toBe(frames[2].buffer);
|
|
46
|
+
expect(frames.some(frame => pool.acquire(8).buffer === frame.buffer)).toBe(false);
|
|
47
|
+
const released = [];
|
|
48
|
+
const tracker = new FrameLeaseTracker(serial => released.push(serial));
|
|
49
|
+
tracker.replace(10, "old");
|
|
50
|
+
const pending = tracker.capture(() => { throw new Error("encoder failed"); });
|
|
51
|
+
tracker.replace(11, "new");
|
|
52
|
+
await expect(pending).rejects.toThrow("encoder failed");
|
|
53
|
+
expect(released).toEqual([10]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a resize replaces idle warm-up buffers with reusable current-size buffers", () => {
|
|
57
|
+
const pool = new SharedFramePool({ maxBuffers: 2 });
|
|
58
|
+
const small = pool.acquire(4); pool.publish(1, small);
|
|
59
|
+
const medium = pool.acquire(8); pool.publish(2, medium); pool.release(1);
|
|
60
|
+
const large = pool.acquire(32); pool.publish(3, large); pool.release(2);
|
|
61
|
+
const next = pool.acquire(32); pool.publish(4, next); pool.release(3);
|
|
62
|
+
expect(pool.acquire(32).buffer).toBe(large.buffer);
|
|
63
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { RendererHost } from "../lib/renderer/host.js";
|
|
3
|
+
|
|
4
|
+
let renderer;
|
|
5
|
+
const requests = [];
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
renderer = new RendererHost({ timeout: 20_000, fetch: async url => {
|
|
8
|
+
requests.push(String(url));
|
|
9
|
+
return new Response("#target { color: blue }", { headers: { "content-type": "text/css" } });
|
|
10
|
+
}});
|
|
11
|
+
await renderer.start();
|
|
12
|
+
});
|
|
13
|
+
afterAll(() => renderer?.close());
|
|
14
|
+
const read = (code = "") => renderer.call("evaluate", `(()=>{${code};return getComputedStyle(document.getElementById('target')).color})()`);
|
|
15
|
+
async function load(source) {
|
|
16
|
+
await renderer.call("loadDocument", { url: "https://style.test/", contentType: "text/html", status: 200, source });
|
|
17
|
+
await renderer.call("whenLoaded");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("inline sheet media and type are respected after mutation and viewport changes", async () => {
|
|
21
|
+
await renderer.call("resize", 800, 600, 1);
|
|
22
|
+
await load(`<!doctype html><style>#target{color:red}</style>
|
|
23
|
+
<style media="print">#target{color:purple}</style>
|
|
24
|
+
<style id="responsive" media="screen and (max-width:500px)">#target{color:green}</style>
|
|
25
|
+
<style id="typed" type="text/plain">#target{color:blue}</style><div id="target">Text</div>`);
|
|
26
|
+
expect(await read()).toBe("rgba(255, 0, 0, 1)");
|
|
27
|
+
await renderer.call("resize", 400, 600, 1);
|
|
28
|
+
expect(await read()).toBe("rgba(0, 128, 0, 1)");
|
|
29
|
+
expect(await read("document.getElementById('responsive').setAttribute('media','print')")).toBe("rgba(255, 0, 0, 1)");
|
|
30
|
+
expect(await read("document.getElementById('typed').setAttribute('type','text/css')")).toBe("rgba(0, 0, 255, 1)");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("linked sheet media can change without refetching and disabled sheets fetch on enable", async () => {
|
|
34
|
+
requests.length = 0;
|
|
35
|
+
await load(`<!doctype html><style>#target{color:red}</style>
|
|
36
|
+
<link id="linked" rel="stylesheet" href="/linked.css" media="print"><div id="target">Text</div>`);
|
|
37
|
+
expect(await read()).toBe("rgba(255, 0, 0, 1)");
|
|
38
|
+
expect(requests).toEqual(["https://style.test/linked.css"]);
|
|
39
|
+
expect(await read("document.getElementById('linked').setAttribute('media','screen')")).toBe("rgba(0, 0, 255, 1)");
|
|
40
|
+
expect(requests.length).toBe(1);
|
|
41
|
+
expect(await read("document.getElementById('linked').setAttribute('disabled','')")).toBe("rgba(255, 0, 0, 1)");
|
|
42
|
+
await load(`<!doctype html><style>#target{color:red}</style>
|
|
43
|
+
<link id="linked" rel="stylesheet" disabled href="/disabled.css"><div id="target">Text</div>`);
|
|
44
|
+
expect(await read()).toBe("rgba(255, 0, 0, 1)");
|
|
45
|
+
expect(requests.length).toBe(1);
|
|
46
|
+
await read("document.getElementById('linked').removeAttribute('disabled')");
|
|
47
|
+
const deadline = Date.now() + 3000;
|
|
48
|
+
while (await read() !== "rgba(0, 0, 255, 1)" && Date.now() < deadline) await Bun.sleep(10);
|
|
49
|
+
expect(await read()).toBe("rgba(0, 0, 255, 1)");
|
|
50
|
+
expect(requests).toEqual(["https://style.test/linked.css", "https://style.test/disabled.css"]);
|
|
51
|
+
});
|
package/test/svg.test.js
CHANGED
|
@@ -39,6 +39,35 @@ test("parses transform lists into one matrix", () => {
|
|
|
39
39
|
expect([a, b, c, d].map((value) => Math.round(value))).toEqual([0, 1, -1, 0]);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test("SVG paint follows the CSS cascade, inherited HTML fill and descendant currentColor", () => {
|
|
43
|
+
const { document, window } = parseHTMLDocument(`<main style="fill:green;color:blue"><svg viewBox="0 0 24 24" style="opacity:.5">
|
|
44
|
+
<path id="inherited" d="M0 0h3v3z"/>
|
|
45
|
+
<path id="overridden" fill="red" stroke-width="1" d="M4 0h3v3z"/>
|
|
46
|
+
<g style="color:red;fill:currentColor;opacity:.5"><path d="M8 0h3v3z"/></g>
|
|
47
|
+
<path id="inline" fill="red" style="fill:yellow" d="M12 0h3v3z"/>
|
|
48
|
+
</svg></main>`);
|
|
49
|
+
const styles = new StyleEngine().compute(document, ["#overridden {fill:blue;stroke:currentColor;stroke-width:2;fill-opacity:50%;fill-rule:evenodd}"]);
|
|
50
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
51
|
+
const svg = [...tree.nodesById.values()].find(node => node.tagName === "SVG").svg;
|
|
52
|
+
expect(svg.shapes.map(shape => shape.fill)).toEqual([
|
|
53
|
+
"rgba(0, 128, 0, 1)", "rgba(0, 0, 255, 1)", "rgba(255, 0, 0, 1)", "rgba(255, 255, 0, 1)",
|
|
54
|
+
]);
|
|
55
|
+
expect(svg.shapes[1]).toMatchObject({ stroke: "rgba(0, 0, 255, 1)", strokeWidth: 2, fillOpacity: .5, fillRule: "evenodd" });
|
|
56
|
+
expect(svg.shapes[0].fillOpacity).toBe(1); // Root opacity is applied by its CSS box, once.
|
|
57
|
+
expect(svg.shapes[2].fillOpacity).toBe(.5);
|
|
58
|
+
window.happyDOM.abort();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("SVG CSS display removes a subtree while visible descendants can override visibility", () => {
|
|
62
|
+
const { document, window } = parseHTMLDocument(`<svg><g class="hidden"><path d="M0 0h3v3z"/><path class="visible" d="M4 0h3v3z"/></g>
|
|
63
|
+
<g class="none"><path class="visible" d="M8 0h3v3z"/></g></svg>`);
|
|
64
|
+
const styles = new StyleEngine().compute(document, [".hidden {visibility:hidden}.visible{visibility:visible}.none{display:none}"]);
|
|
65
|
+
const svg = extractSvg(document.querySelector("svg"), "black", styles);
|
|
66
|
+
expect(svg.shapes).toHaveLength(1);
|
|
67
|
+
expect(svg.shapes[0].d).toBe("M4 0h3v3z");
|
|
68
|
+
window.happyDOM.abort();
|
|
69
|
+
});
|
|
70
|
+
|
|
42
71
|
test("inline SVG is a replaced box with margins and vertical-align in the line", () => {
|
|
43
72
|
const { document, window } = parseHTMLDocument(`<body style="margin:0"><p style="margin:0">
|
|
44
73
|
<svg viewBox="0 0 16 16" width="16" height="16" style="margin-right:8px;vertical-align:text-bottom"><path d="M0 0h16v16z"/></svg>Readme</p></body>`);
|
package/test/text-layout.test.js
CHANGED
|
@@ -8,6 +8,94 @@ function boxFor(tree, layout, tagName) {
|
|
|
8
8
|
return layout.boxes.find((box) => tree.nodesById.get(box.nodeId)?.tagName === tagName);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
test("display contents removes the wrapper box while preserving inherited text styles", () => {
|
|
12
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0;font-size:10px;line-height:10px">
|
|
13
|
+
before <div id="wrapper" style="display:contents;padding:50px;border:10px solid;position:absolute;color:red"><span>inside</span></div> after
|
|
14
|
+
</body>`);
|
|
15
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
16
|
+
const layout = layoutText(tree, { x:0, y:0, width:200, measureText:text=>text.length });
|
|
17
|
+
expect(layout.boxes.some(box => tree.nodesById.get(box.nodeId)?.domNode?.id === "wrapper")).toBe(false);
|
|
18
|
+
expect(layout.fragments.length).toBe(1);
|
|
19
|
+
expect(layout.fragments[0].text.trim()).toBe("before inside after");
|
|
20
|
+
expect([...tree.nodesById.values()].find(node => node.type === "text" && node.text === "inside").style.color).toBe("rgba(255, 0, 0, 1)");
|
|
21
|
+
window.happyDOM.abort();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("display contents promotes nested children into flex and grid item placement", () => {
|
|
25
|
+
for (const display of ["flex", "grid"]) {
|
|
26
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><main style="display:${display};grid-template-columns:20px 20px 20px;gap:5px">
|
|
27
|
+
<div style="display:contents"><i style="width:20px;height:10px"></i><span style="display:contents"><i style="width:20px;height:10px"></i></span></div>
|
|
28
|
+
<i style="width:20px;height:10px"></i></main></body>`);
|
|
29
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
30
|
+
const layout = layoutText(tree, { x:0, y:0, width:200 });
|
|
31
|
+
expect(layout.boxes.filter(box => tree.nodesById.get(box.nodeId)?.tagName === "I").map(box => [box.x,box.y,box.width,box.height]))
|
|
32
|
+
.toEqual([[0,0,20,10],[25,0,20,10],[50,0,20,10]]);
|
|
33
|
+
window.happyDOM.abort();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("percentage and calc padding reserve an aspect-ratio box and reflow on resize", () => {
|
|
38
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
39
|
+
<div id="ratio" style="position:relative;padding-bottom:calc(100% * 9 / 16)">
|
|
40
|
+
<div id="fill" style="position:absolute;inset:0"></div>
|
|
41
|
+
</div><div id="next" style="height:20px"></div>
|
|
42
|
+
</body>`);
|
|
43
|
+
const styles = new StyleEngine().compute(document);
|
|
44
|
+
const tree = new RenderTreeBuilder().build(document, styles);
|
|
45
|
+
for (const width of [320, 640]) {
|
|
46
|
+
const layout = layoutText(tree, { x:0, y:0, width });
|
|
47
|
+
const rect = id => layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
48
|
+
expect(rect('ratio').height).toBe(width * 9 / 16);
|
|
49
|
+
expect(rect('fill').height).toBe(width * 9 / 16);
|
|
50
|
+
expect(rect('next').y).toBe(width * 9 / 16);
|
|
51
|
+
}
|
|
52
|
+
window.happyDOM.abort();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("nested absolute percentage heights use their own containing block, including on resize", () => {
|
|
56
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0">
|
|
57
|
+
<div style="position:relative;height:500px"><div style="position:absolute;bottom:0;width:100%;height:60px">
|
|
58
|
+
<div id="track" style="position:absolute;bottom:56px;width:100%;height:6px">
|
|
59
|
+
<div id="fill" style="position:absolute;bottom:0;width:100%;height:100%"></div>
|
|
60
|
+
</div>
|
|
61
|
+
</div></div></body>`);
|
|
62
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
63
|
+
const engine = new LayoutEngine();
|
|
64
|
+
for (const width of [320, 640]) {
|
|
65
|
+
const layout = engine.layout(tree, { x:0, y:0, width });
|
|
66
|
+
const rect = id => layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === id);
|
|
67
|
+
expect(rect('fill').height).toBe(6);
|
|
68
|
+
expect(rect('fill').y).toBe(rect('track').y);
|
|
69
|
+
expect(rect('fill').width).toBe(width);
|
|
70
|
+
}
|
|
71
|
+
window.happyDOM.abort();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("legacy vertical line clamping limits lines and adds an ellipsis without changing DOM text", () => {
|
|
75
|
+
for (const display of ["-webkit-box", "block"]) {
|
|
76
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="display:${display};-webkit-box-orient:vertical;-webkit-line-clamp:2;font-size:10px;line-height:10px;width:40px;overflow:hidden;overflow-wrap:anywhere">abcdefghijklmnop</div></body>`);
|
|
77
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
78
|
+
const layout = layoutText(tree, { x:0, y:0, width:200, measureText:text=>text.length*10 });
|
|
79
|
+
expect(layout.fragments.length).toBe(display === "block" ? 4 : 2);
|
|
80
|
+
const painted = layout.fragments.flatMap(f=>f.runs).map(r=>r.text).join('');
|
|
81
|
+
expect(painted).toBe(display === "block" ? "abcdefghijklmnop" : "abcdefg…");
|
|
82
|
+
expect(document.querySelector('div').textContent).toBe("abcdefghijklmnop");
|
|
83
|
+
window.happyDOM.abort();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("empty blocks do not add adjoining vertical margins twice", () => {
|
|
88
|
+
const { document, window } = parseHTMLDocument(`<body style="margin:0"><div style="padding-top:1px">
|
|
89
|
+
<div style="height:20px;margin-bottom:12px"></div>
|
|
90
|
+
<div style="margin-top:12px"></div><div></div>
|
|
91
|
+
<div id="next" style="height:20px;margin-top:12px"></div>
|
|
92
|
+
</div></body>`);
|
|
93
|
+
const tree = new RenderTreeBuilder().build(document, new StyleEngine().compute(document));
|
|
94
|
+
const layout = layoutText(tree, { x:0, y:0, width:200 });
|
|
95
|
+
expect(layout.boxes.find(box => tree.nodesById.get(box.nodeId)?.domNode?.id === 'next').y).toBe(33);
|
|
96
|
+
window.happyDOM.abort();
|
|
97
|
+
});
|
|
98
|
+
|
|
11
99
|
test("creates positioned immutable line fragments with fixed-column wrapping", () => {
|
|
12
100
|
const { document, window } = parseHTMLDocument(
|
|
13
101
|
`<body style="margin:0">1234567890ABCDEFGHIJ<br>中文😀abcdef</body>`,
|
|
@@ -277,6 +365,86 @@ function layoutFixture(html, css = "", width = 400) {
|
|
|
277
365
|
return { ...parsed, tree, layout, box };
|
|
278
366
|
}
|
|
279
367
|
|
|
368
|
+
test("column flex distributes growth, scaled shrink and constrained free space", () => {
|
|
369
|
+
for (const [children, expected] of [
|
|
370
|
+
['<i style="flex:1;min-height:0"></i><b style="flex:2;min-height:0"></b>', [100,200]],
|
|
371
|
+
['<i style="height:200px;min-height:0"></i><b style="height:400px;min-height:0"></b>', [100,200]],
|
|
372
|
+
['<i style="flex:1;max-height:70px"></i><b style="flex:1"></b>', [70,230]],
|
|
373
|
+
['<i style="flex:.25"></i><b style="flex:.25"></b>', [75,75]],
|
|
374
|
+
]) {
|
|
375
|
+
const {window,box} = layoutFixture(`<main>${children}</main>`, 'main{display:flex;flex-direction:column;height:300px}');
|
|
376
|
+
expect([box('i').height,box('b').height]).toEqual(expected);
|
|
377
|
+
expect(box('b').y).toBe(expected[0]);
|
|
378
|
+
window.happyDOM.abort();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("column flex automatic minimum protects content, while scrollable items can shrink", () => {
|
|
383
|
+
for (const [overflow,height] of [["visible",100],["auto",80],["hidden",80]]) {
|
|
384
|
+
const {window,box} = layoutFixture('<main><section><i></i></section></main>',
|
|
385
|
+
`main{display:flex;flex-direction:column;height:80px}section{overflow:${overflow}}i{display:block;height:100px}`);
|
|
386
|
+
expect(box('section').height).toBe(height);
|
|
387
|
+
window.happyDOM.abort();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("an omitted flex basis uses content in an auto-height column capped by max-height", () => {
|
|
392
|
+
for (const [flex,expected] of [["1",250],["1 1",250],["1 1 0",0],["1 1 0%",250]]) {
|
|
393
|
+
const {window,box} = layoutFixture('<main><header></header><section><i></i></section></main>',
|
|
394
|
+
`main{display:flex;flex-direction:column;max-height:300px}header{height:50px;flex:none}
|
|
395
|
+
section{flex:${flex};min-height:0;overflow-y:auto}i{display:block;height:800px}`);
|
|
396
|
+
expect(box('section').height).toBe(expected);
|
|
397
|
+
expect(box('main').height).toBe(expected+50);
|
|
398
|
+
window.happyDOM.abort();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("column auto margins absorb free space before justify-content", () => {
|
|
403
|
+
const {window,box} = layoutFixture('<main><i></i><b></b></main>',
|
|
404
|
+
'main{display:flex;flex-direction:column;height:300px;justify-content:space-between}i{height:40px;margin-top:auto}b{height:60px;margin-bottom:auto}');
|
|
405
|
+
expect([box('i').y,box('b').y]).toEqual([100,140]);
|
|
406
|
+
window.happyDOM.abort();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("stretched and inset-sized flex columns center within their final used height", () => {
|
|
410
|
+
for (const css of [
|
|
411
|
+
'main{display:flex;height:300px}section{display:flex;flex-direction:column;justify-content:center}',
|
|
412
|
+
'main{position:relative;height:300px}section{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center}',
|
|
413
|
+
]) {
|
|
414
|
+
const {window,box} = layoutFixture('<main><section><i></i></section></main>',css+'i{display:block;height:60px;width:30px}');
|
|
415
|
+
expect(box('section').height).toBe(300);
|
|
416
|
+
expect(box('i').y).toBe(120);
|
|
417
|
+
window.happyDOM.abort();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("nested column flex growth fills the viewport-sized modal wrapper", () => {
|
|
422
|
+
const {window,box} = layoutFixture('<main><section><article><i></i></article></section></main>',
|
|
423
|
+
'main{display:flex;flex-direction:column;height:800px}section{display:flex;flex-direction:column;flex-grow:1;justify-content:center}article{display:flex;justify-content:center}i{height:300px;width:200px}');
|
|
424
|
+
expect(box('section').height).toBe(800);
|
|
425
|
+
expect(box('i')).toMatchObject({x:100,y:250,width:200,height:300});
|
|
426
|
+
window.happyDOM.abort();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("percentage HTML image dimensions size flex items within their container", () => {
|
|
430
|
+
const {window,box,layout} = layoutFixture('<main><img width="100%" height="100%"></main>',
|
|
431
|
+
'main{display:flex;width:84px;height:84px}');
|
|
432
|
+
expect(box('img')).toMatchObject({width:84,height:84});
|
|
433
|
+
const image=layout.fragments.flatMap(fragment=>fragment.runs).find(run=>run.type==='image');
|
|
434
|
+
expect(image).toMatchObject({width:84,height:84});
|
|
435
|
+
window.happyDOM.abort();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("author auto sizing overrides HTML dimension hints and uses the image's intrinsic size", () => {
|
|
439
|
+
const {document,window}=parseHTMLDocument('<body style="margin:0"><img width="30" height="20" style="width:auto;height:auto"></body>');
|
|
440
|
+
const element=document.querySelector('img'),resources=new Map([[element,{width:120,height:60}]]);
|
|
441
|
+
const styles=new StyleEngine().compute(document);
|
|
442
|
+
const tree=new RenderTreeBuilder().build(document,styles,resources);
|
|
443
|
+
const layout=layoutText(tree,{width:400,lineHeight:20,measureText:text=>text.length*10,fontMetrics:()=>({ascent:16,height:20})});
|
|
444
|
+
expect(layout.fragments.flatMap(fragment=>fragment.runs).find(run=>run.type==='image')).toMatchObject({width:120,height:60});
|
|
445
|
+
window.happyDOM.abort();
|
|
446
|
+
});
|
|
447
|
+
|
|
280
448
|
test("nests block boxes and includes ancestor padding and borders", () => {
|
|
281
449
|
const { window, layout, box } = layoutFixture(
|
|
282
450
|
`<section><div>text</div></section>`,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import wasmpeg from '../vendor/wasmpeg/src/js/index.js';
|
|
3
|
+
import { seekVideoKeyframe } from '../lib/media/video-keyframe.js';
|
|
4
|
+
|
|
5
|
+
test('seeking within a GOP longer than ten seconds preserves the decoded frame timestamp', async () => {
|
|
6
|
+
await wasmpeg.load();
|
|
7
|
+
const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/long-video.mp4', import.meta.url)).arrayBuffer());
|
|
8
|
+
const decoder = await wasmpeg.decode(bytes);
|
|
9
|
+
try {
|
|
10
|
+
// This thirty-second fixture has a different picture at every frame,
|
|
11
|
+
// but only its first frame is a keyframe.
|
|
12
|
+
const firstHash = Bun.hash(decoder.nextFrame(32, 18));
|
|
13
|
+
for (const target of [15, 20, 25, 29]) {
|
|
14
|
+
const timestamp = seekVideoKeyframe(decoder, target);
|
|
15
|
+
expect(Bun.hash(decoder.nextFrame(32, 18))).toBe(firstHash);
|
|
16
|
+
expect(timestamp).toBe(0);
|
|
17
|
+
}
|
|
18
|
+
} finally { decoder.close(); }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a later MP4 initialization keeps absolute seek times, including seeks back across its first GOP', async () => {
|
|
22
|
+
await wasmpeg.load();
|
|
23
|
+
const bytes = new Uint8Array(await Bun.file(new URL('./fixtures/mse-switch-large.mp4', import.meta.url)).arrayBuffer());
|
|
24
|
+
const view = new DataView(bytes.buffer);
|
|
25
|
+
const visit = (from, to, callback) => {
|
|
26
|
+
for (let offset = from; offset + 8 <= to;) {
|
|
27
|
+
const size = view.getUint32(offset);
|
|
28
|
+
if (size < 8 || offset + size > to) break;
|
|
29
|
+
const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
|
|
30
|
+
callback(type, offset + 8);
|
|
31
|
+
if (['moov', 'trak', 'mdia', 'moof', 'traf'].includes(type)) visit(offset + 8, offset + size, callback);
|
|
32
|
+
offset += size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
let timescale;
|
|
36
|
+
visit(0, bytes.length, (type, data) => {
|
|
37
|
+
if (type === 'mdhd') timescale = view.getUint32(data + (bytes[data] === 1 ? 20 : 12));
|
|
38
|
+
});
|
|
39
|
+
let fragments = 0;
|
|
40
|
+
visit(0, bytes.length, (type, data) => {
|
|
41
|
+
if (type !== 'tfdt') return;
|
|
42
|
+
fragments++;
|
|
43
|
+
if (bytes[data] === 1) view.setBigUint64(data + 4, view.getBigUint64(data + 4) + BigInt(6 * timescale));
|
|
44
|
+
else view.setUint32(data + 4, view.getUint32(data + 4) + 6 * timescale);
|
|
45
|
+
});
|
|
46
|
+
expect(fragments).toBeGreaterThan(0);
|
|
47
|
+
const decoder = await wasmpeg.decode(bytes, { format: 'mp4' });
|
|
48
|
+
try {
|
|
49
|
+
const hashes = [];
|
|
50
|
+
for (let pixels; (pixels = decoder.nextFrame(32, 18));) hashes.push(Bun.hash(pixels));
|
|
51
|
+
expect(hashes).toHaveLength(60);
|
|
52
|
+
// The fixture's keyframes are 2s apart. After moving tfdt by six seconds,
|
|
53
|
+
// seek(8s) must return frame 20, not frame 0 or an offset-relative frame.
|
|
54
|
+
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);
|
|
59
|
+
}
|
|
60
|
+
} finally { decoder.close(); }
|
|
61
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { VideoPresentation, compositeVideo } from "../lib/renderer/video-presentation.js";
|
|
3
|
+
|
|
4
|
+
test("video predecode is bounded and waits for measured audio, skipping stale frames", async () => {
|
|
5
|
+
let frames = 0, closed = 0;
|
|
6
|
+
const reports = [];
|
|
7
|
+
const engine = { decode: async () => ({ width: 2, height: 2, fps: 30,
|
|
8
|
+
nextFrame: () => { frames++; return new Uint8Array(16).fill(frames); }, close: () => closed++ }) };
|
|
9
|
+
const video = new VideoPresentation(engine, () => {}, r => reports.push(r));
|
|
10
|
+
try {
|
|
11
|
+
await video.open({ id: 1, time: 0, hasAudio: true, audioId: 4, audioStart: 0 });
|
|
12
|
+
await Bun.sleep(100);
|
|
13
|
+
expect(frames).toBeLessThanOrEqual(24);
|
|
14
|
+
expect(video.queue.length).toBeLessThanOrEqual(video.limit);
|
|
15
|
+
expect(video.frame.time).toBe(0);
|
|
16
|
+
const first = reports.length;
|
|
17
|
+
await Bun.sleep(80);
|
|
18
|
+
expect(reports).toHaveLength(first);
|
|
19
|
+
video.timing(4, 0.5);
|
|
20
|
+
await Bun.sleep(60);
|
|
21
|
+
expect(video.frame.time).toBeCloseTo(0.5);
|
|
22
|
+
expect(reports.filter(r => r.time > 0).length).toBe(1);
|
|
23
|
+
video.stop(1);
|
|
24
|
+
video.timing(4, 2);
|
|
25
|
+
await Bun.sleep(30);
|
|
26
|
+
expect(video.frame).toBeNull();
|
|
27
|
+
expect(closed).toBe(1);
|
|
28
|
+
} finally { video.stop(); }
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a late decoder open cannot replace a newer playback generation", async () => {
|
|
32
|
+
let resolve, closed = 0;
|
|
33
|
+
const video = new VideoPresentation({ decode: () => new Promise(r => resolve = r) }, () => {}, () => {});
|
|
34
|
+
const opening = video.open({ id: 1, time: 0 });
|
|
35
|
+
video.stop();
|
|
36
|
+
resolve({ close: () => closed++ });
|
|
37
|
+
await opening;
|
|
38
|
+
expect(closed).toBe(1);
|
|
39
|
+
expect(video.config).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("reopening at a keyframe does not show the prefix before the resume position", async () => {
|
|
43
|
+
let at = 0;
|
|
44
|
+
const reports = [];
|
|
45
|
+
const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
46
|
+
seek() { at = 0; }, nextFrame(w, h) { at++; return new Uint8Array(w * h * 4).fill(at); }, close() {} }) },
|
|
47
|
+
() => {}, report => reports.push(report));
|
|
48
|
+
try {
|
|
49
|
+
video.timing(9, 2);
|
|
50
|
+
await video.open({ id: 2, time: 2, hasAudio: true, audioId: 9, audioStart: 0 });
|
|
51
|
+
await Bun.sleep(100);
|
|
52
|
+
expect(reports.some(report => report.time >= 1.9)).toBe(true);
|
|
53
|
+
expect(reports.every(report => report.time === undefined || report.time >= 1.9)).toBe(true);
|
|
54
|
+
} finally { video.stop(); }
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a presentation keeps absolute epoch times and ends before replaced frames", async () => {
|
|
58
|
+
let at = 0;
|
|
59
|
+
const seeks = [], reports = [];
|
|
60
|
+
const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
61
|
+
seek(ms) { seeks.push(ms); at = 0; },
|
|
62
|
+
nextFrame(w, h) { return new Uint8Array(w * h * 4).fill(++at); }, close() {} }) },
|
|
63
|
+
() => {}, report => reports.push(report));
|
|
64
|
+
const tick = seconds => {
|
|
65
|
+
clearTimeout(video.timer);
|
|
66
|
+
video.timing(9, seconds - 12);
|
|
67
|
+
video.step();
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
video.timing(9, 0.2);
|
|
71
|
+
await video.open({ id: 3, time: 12.2, startTime: 12, endTime: 12.5,
|
|
72
|
+
hasAudio: true, audioId: 9, audioStart: 12 });
|
|
73
|
+
expect(Math.min(...seeks)).toBe(12000);
|
|
74
|
+
expect(video.frame.time).toBeCloseTo(12.2);
|
|
75
|
+
expect(reports.some(report => report.end !== undefined)).toBe(false);
|
|
76
|
+
tick(12.49);
|
|
77
|
+
expect(video.frame.time).toBeCloseTo(12.4);
|
|
78
|
+
expect(reports.some(report => report.end !== undefined)).toBe(false);
|
|
79
|
+
tick(12.5);
|
|
80
|
+
tick(13);
|
|
81
|
+
expect(reports.filter(report => report.end !== undefined)).toEqual([{ id: 3, end: 12.5 }]);
|
|
82
|
+
expect(reports.filter(report => report.time !== undefined).every(report => report.time >= 12.2 && report.time < 12.5)).toBe(true);
|
|
83
|
+
} finally { video.stop(); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a short epoch snapshot reports its actual EOF so playback can request more bytes", async () => {
|
|
87
|
+
let at = 0;
|
|
88
|
+
const reports = [];
|
|
89
|
+
const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
90
|
+
nextFrame(w, h) { return ++at <= 2 ? new Uint8Array(w * h * 4).fill(at) : null; }, close() {} }) },
|
|
91
|
+
() => {}, report => reports.push(report));
|
|
92
|
+
try {
|
|
93
|
+
video.timing(10, 0.25);
|
|
94
|
+
await video.open({ id: 4, time: 40, startTime: 40, endTime: 45,
|
|
95
|
+
hasAudio: true, audioId: 10, audioStart: 40 });
|
|
96
|
+
expect(video.frame.time).toBeCloseTo(40.1);
|
|
97
|
+
expect(reports.find(report => report.end !== undefined)?.end).toBeCloseTo(40.2);
|
|
98
|
+
} finally { video.stop(); }
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a later epoch discards queued old frames at its boundary without accepting stale bindings", async () => {
|
|
102
|
+
const reports = [];
|
|
103
|
+
const video = new VideoPresentation({ decode: async () => ({ width: 2, height: 2, fps: 10,
|
|
104
|
+
nextFrame(w, h) { return new Uint8Array(w * h * 4); }, close() {} }) },
|
|
105
|
+
() => {}, report => reports.push(report));
|
|
106
|
+
try {
|
|
107
|
+
video.timing(11, 0);
|
|
108
|
+
await video.open({ id: 5, time: 10, startTime: 10,
|
|
109
|
+
hasAudio: true, audioId: 11, audioStart: 10 });
|
|
110
|
+
expect(video.queue.some(frame => frame.time > 10.3)).toBe(true);
|
|
111
|
+
video.bind(4, 11, 10, 10, 10.1);
|
|
112
|
+
expect(video.config.endTime).toBeUndefined();
|
|
113
|
+
video.bind(5, undefined, undefined, undefined, 10.3);
|
|
114
|
+
expect(video.config.audioId).toBe(11);
|
|
115
|
+
expect(video.config.audioStart).toBe(10);
|
|
116
|
+
expect(video.config.time).toBe(10);
|
|
117
|
+
clearTimeout(video.timer);
|
|
118
|
+
video.timing(11, 0.3);
|
|
119
|
+
video.step();
|
|
120
|
+
expect(video.frame.time).toBeCloseTo(10.2);
|
|
121
|
+
expect(reports.find(report => report.end !== undefined)).toEqual({ id: 5, end: 10.3 });
|
|
122
|
+
expect(video.queue).toHaveLength(0);
|
|
123
|
+
} finally { video.stop(); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("video layer preserves opaque controls, translucent overlays and clipped pixels", () => {
|
|
127
|
+
const base = new Uint8Array([0,0,0,255, 255,255,255,255, 0,0,128,255, 10,20,30,255]);
|
|
128
|
+
const mask = new Uint8Array([255,255,255,0, 0,0,0,0, 127,127,127,0, 0,0,0,0]);
|
|
129
|
+
const tile = { pixelWidth: 4, pixelHeight: 1, pixels: base,
|
|
130
|
+
videoLayer: { base, mask, rect: { x: 0, y: 0, width: 4, height: 1 } } };
|
|
131
|
+
const frame = { width: 1, height: 1, pixels: new Uint8Array([200,100,0,255]) };
|
|
132
|
+
expect([...compositeVideo(tile, frame).pixels]).toEqual([
|
|
133
|
+
200,100,0,255, 255,255,255,255, 100,50,128,255, 10,20,30,255,
|
|
134
|
+
]);
|
|
135
|
+
expect(compositeVideo(tile, null)).toBe(tile);
|
|
136
|
+
});
|