From 0e06d216e890094ada66e63f758985c87bbb8173 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 30 Oct 2026 07:59:22 -0500 Subject: [PATCH 1/5] test: schedule one live page among vertex stills A hundred page targets can be captured without starting a screencast, and focusing a vertex keeps only that page live. --- poc/hypergraph-browser/session.mjs | 38 +++++++++ poc/hypergraph-browser/session.test.mjs | 103 ++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 poc/hypergraph-browser/session.mjs create mode 100644 poc/hypergraph-browser/session.test.mjs diff --git a/poc/hypergraph-browser/session.mjs b/poc/hypergraph-browser/session.mjs new file mode 100644 index 0000000..6f5b457 --- /dev/null +++ b/poc/hypergraph-browser/session.mjs @@ -0,0 +1,38 @@ +/** Schedules page targets for the hypergraph browser POC. No Chrome here. */ + +export const MAX_PAGE_TARGETS = 100; + +export function createPageSession({maxTargets = MAX_PAGE_TARGETS} = {}) { + const targets = new Map(); + let live = null; + + return { + async open(vertex, driver) { + const url = typeof vertex?.url === 'string' ? vertex.url.trim() : ''; + if (!url) return {skipped: true}; + if (targets.has(vertex.id)) return {skipped: true}; + if (targets.size >= maxTargets) { + throw new Error(`Page target limit is ${maxTargets}`); + } + const targetId = await driver.createTarget(url); + targets.set(vertex.id, targetId); + await driver.bringToFront(targetId); + const jpeg = await driver.capture(targetId); + return {skipped: false, vertexId: vertex.id, jpeg}; + }, + + async focus(vertexId, driver) { + if (live && live !== vertexId) { + await driver.stopScreencast(targets.get(live)); + live = null; + } + if (vertexId == null) return; + const targetId = targets.get(vertexId); + if (!targetId) throw new Error(`No page target for ${vertexId}`); + if (live === vertexId) return; + await driver.bringToFront(targetId); + await driver.startScreencast(targetId); + live = vertexId; + }, + }; +} diff --git a/poc/hypergraph-browser/session.test.mjs b/poc/hypergraph-browser/session.test.mjs new file mode 100644 index 0000000..ab0c39b --- /dev/null +++ b/poc/hypergraph-browser/session.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import {createPageSession} from './session.mjs'; + +function fakeDriver() { + const calls = []; + const screencasting = new Set(); + let next = 0; + return { + calls, + screencasting, + async createTarget(url) { + const targetId = `t${next++}`; + calls.push(['create', url, targetId]); + return targetId; + }, + async bringToFront(targetId) { + calls.push(['front', targetId]); + }, + async capture(targetId) { + calls.push(['capture', targetId]); + return Buffer.from(`jpeg-${targetId}`); + }, + async startScreencast(targetId) { + calls.push(['screencast-start', targetId]); + screencasting.add(targetId); + }, + async stopScreencast(targetId) { + calls.push(['screencast-stop', targetId]); + screencasting.delete(targetId); + }, + }; +} + +const page = (id, url = `https://example.com/${id}`) => ({id, url}); + +test('opening pages captures one still each and starts no screencast', async () => { + const driver = fakeDriver(); + const session = createPageSession(); + const ids = ['a', 'b', 'c', 'd', 'e']; + for (const id of ids) await session.open(page(id), driver); + + assert.deepEqual( + driver.calls.map(([kind]) => kind), + ids.flatMap(() => ['create', 'front', 'capture']), + ); + assert.equal(driver.screencasting.size, 0); + assert.equal(driver.calls.filter(([kind]) => kind.startsWith('screencast')).length, 0); +}); + +test('focusing another page leaves only that page screencasting', async () => { + const driver = fakeDriver(); + const session = createPageSession(); + await session.open(page('a'), driver); + await session.open(page('b'), driver); + driver.calls.length = 0; + + await session.focus('a', driver); + await session.focus('b', driver); + + assert.deepEqual(driver.calls, [ + ['front', 't0'], + ['screencast-start', 't0'], + ['screencast-stop', 't0'], + ['front', 't1'], + ['screencast-start', 't1'], + ]); + assert.deepEqual([...driver.screencasting], ['t1']); +}); + +test('a sixth focus still leaves one screencast', async () => { + const driver = fakeDriver(); + const session = createPageSession(); + for (let i = 0; i < 6; i += 1) await session.open(page(String(i)), driver); + for (let i = 0; i < 6; i += 1) await session.focus(String(i), driver); + assert.equal(driver.screencasting.size, 1); +}); + +test('clearing focus stops the live screencast', async () => { + const driver = fakeDriver(); + const session = createPageSession(); + await session.open(page('a'), driver); + await session.focus('a', driver); + await session.focus(null, driver); + assert.equal(driver.screencasting.size, 0); +}); + +test('the 101st page target is refused', async () => { + const driver = fakeDriver(); + const session = createPageSession(); + for (let i = 0; i < 100; i += 1) await session.open(page(String(i)), driver); + await assert.rejects(session.open(page('overflow'), driver), /100/); + assert.equal(driver.calls.filter(([kind]) => kind === 'create').length, 100); +}); + +test('a page without a url is skipped', async () => { + const driver = fakeDriver(); + const session = createPageSession(); + const result = await session.open({id: 'bare'}, driver); + assert.equal(result.skipped, true); + assert.deepEqual(driver.calls, []); +}); From 3be8c14f23f2e2a8c2bf102e74f98470da06d39e Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 30 Oct 2026 08:02:15 -0500 Subject: [PATCH 2/5] feat: capture vertex pages with local headless Chrome The sidecar keeps one muted Chrome process on localhost and forwards a still per page, then a single screencast for the focused vertex. --- .gitignore | 1 + poc/hypergraph-browser/chrome.mjs | 92 +++ poc/hypergraph-browser/chrome.test.mjs | 19 + poc/hypergraph-browser/fixture.json | 14 + poc/hypergraph-browser/package-lock.json | 920 +++++++++++++++++++++++ poc/hypergraph-browser/package.json | 16 + poc/hypergraph-browser/server.mjs | 113 +++ poc/hypergraph-browser/server.test.mjs | 97 +++ 8 files changed, 1272 insertions(+) create mode 100644 poc/hypergraph-browser/chrome.mjs create mode 100644 poc/hypergraph-browser/chrome.test.mjs create mode 100644 poc/hypergraph-browser/fixture.json create mode 100644 poc/hypergraph-browser/package-lock.json create mode 100644 poc/hypergraph-browser/package.json create mode 100644 poc/hypergraph-browser/server.mjs create mode 100644 poc/hypergraph-browser/server.test.mjs diff --git a/.gitignore b/.gitignore index 249ca46..0e88ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ .mypy_cache/ *.egg-info/ scripts/benchmarks/browser/node_modules/ +/poc/hypergraph-browser/node_modules/ # Editable maturin builds place the extension in the Python source package. /crates/hyper-viz-python/python/hyper_viz/*.so diff --git a/poc/hypergraph-browser/chrome.mjs b/poc/hypergraph-browser/chrome.mjs new file mode 100644 index 0000000..94522b6 --- /dev/null +++ b/poc/hypergraph-browser/chrome.mjs @@ -0,0 +1,92 @@ +import puppeteer from 'puppeteer-core'; + +const MAC_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const FRAME_INTERVAL_MS = 200; +const MAX_EDGE = 480; + +export function chromeExecutablePath(env = process.env, platform = process.platform) { + if (env.CHROME_PATH) return env.CHROME_PATH; + if (platform === 'darwin') return MAC_CHROME; + throw new Error('Set CHROME_PATH to the Chrome executable'); +} + +// One headless Chrome. Frames are acked immediately and forwarded at about 5 Hz. +export async function launchChromeDriver({executablePath = chromeExecutablePath()} = {}) { + const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ['--headless=new', '--mute-audio', '--disable-extensions'], + }); + const pages = new Map(); + const listeners = new Set(); + let sequence = 0; + + const emit = (targetId, jpeg) => { + for (const listener of listeners) listener(targetId, jpeg); + }; + + return { + onFrame(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async createTarget(url) { + const page = sequence === 0 ? (await browser.pages())[0] : await browser.newPage(); + sequence += 1; + const targetId = `chrome-${sequence}`; + const cdp = await page.createCDPSession(); + try { + await page.goto(url, {waitUntil: 'load', timeout: 20_000}); + } catch (error) { + if (!/timeout/i.test(error?.message ?? '')) throw error; + } + pages.set(targetId, {page, cdp, screencasting: false, lastSent: 0, listening: false}); + return targetId; + }, + async bringToFront(targetId) { + const entry = pages.get(targetId); + await entry.page.bringToFront(); + await entry.cdp.send('Page.bringToFront'); + }, + async capture(targetId) { + const shot = await pages.get(targetId).cdp.send('Page.captureScreenshot', { + format: 'jpeg', + quality: 60, + fromSurface: true, + }); + return Buffer.from(shot.data, 'base64'); + }, + async startScreencast(targetId) { + const entry = pages.get(targetId); + if (entry.screencasting) return; + entry.screencasting = true; + if (!entry.listening) { + entry.listening = true; + entry.cdp.on('Page.screencastFrame', (event) => { + entry.cdp.send('Page.screencastFrameAck', {sessionId: event.sessionId}).catch(() => {}); + if (!entry.screencasting) return; + const now = Date.now(); + if (now - entry.lastSent < FRAME_INTERVAL_MS) return; + entry.lastSent = now; + emit(targetId, Buffer.from(event.data, 'base64')); + }); + } + await entry.cdp.send('Page.startScreencast', { + format: 'jpeg', + quality: 50, + maxWidth: MAX_EDGE, + maxHeight: MAX_EDGE, + everyNthFrame: 3, + }); + }, + async stopScreencast(targetId) { + const entry = pages.get(targetId); + if (!entry?.screencasting) return; + entry.screencasting = false; + await entry.cdp.send('Page.stopScreencast').catch(() => {}); + }, + async close() { + await browser.close(); + }, + }; +} diff --git a/poc/hypergraph-browser/chrome.test.mjs b/poc/hypergraph-browser/chrome.test.mjs new file mode 100644 index 0000000..d76f309 --- /dev/null +++ b/poc/hypergraph-browser/chrome.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import {chromeExecutablePath} from './chrome.mjs'; + +test('CHROME_PATH selects the executable', () => { + assert.equal(chromeExecutablePath({CHROME_PATH: '/opt/chrome'}, 'linux'), '/opt/chrome'); +}); + +test('macOS defaults to the system Chrome app', () => { + assert.equal( + chromeExecutablePath({}, 'darwin'), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ); +}); + +test('other platforms require CHROME_PATH', () => { + assert.throws(() => chromeExecutablePath({}, 'linux'), /CHROME_PATH/); +}); diff --git a/poc/hypergraph-browser/fixture.json b/poc/hypergraph-browser/fixture.json new file mode 100644 index 0000000..b48dcc9 --- /dev/null +++ b/poc/hypergraph-browser/fixture.json @@ -0,0 +1,14 @@ +{ + "version": "hypergraph.v1", + "meta": {"id": "pages", "title": "Hypergraph browser"}, + "vertices": [ + {"id": "example", "label": "Example", "kind": "page", "attrs": {"page": "https://example.com/"}}, + {"id": "image", "label": "Image", "kind": "image", "attrs": {"page": "https://www.w3.org/2008/site/images/logo-w3c-screen-lg.png"}}, + {"id": "video", "label": "Video", "kind": "video", "attrs": {"page": "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"}}, + {"id": "pdf", "label": "PDF", "kind": "document", "attrs": {"page": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"}}, + {"id": "org", "label": "Example org", "kind": "page", "attrs": {"page": "https://example.org/"}} + ], + "hyperedges": [ + {"id": "web", "label": "Web", "vertices": ["example", "image", "video", "pdf", "org"]} + ] +} diff --git a/poc/hypergraph-browser/package-lock.json b/poc/hypergraph-browser/package-lock.json new file mode 100644 index 0000000..d8d5d2b --- /dev/null +++ b/poc/hypergraph-browser/package-lock.json @@ -0,0 +1,920 @@ +{ + "name": "hypergraph-browser-poc", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hypergraph-browser-poc", + "dependencies": { + "puppeteer-core": "24.15.0", + "ws": "8.18.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.6.tgz", + "integrity": "sha512-pHUn6ZRt39bP3698HFQlu2ZHCkS/lPcpv7fVQcGBSzNNygw171UXAKrCUhy+TEMw4lEttOKDgNpb04hwUAJeiQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.2", + "tar-fs": "^3.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz", + "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chromium-bidi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-7.2.0.tgz", + "integrity": "sha512-gREyhyBstermK+0RbcJLbFhcQctg92AGgDe/h/taMJEOLRdtSswBAO9KmvltFSQWgM2LrwWu5SIuEUbdm3JsyQ==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1464554", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1464554.tgz", + "integrity": "sha512-CAoP3lYfwAGQTaAXYvA6JZR0fjGUb7qec1qf4mToyoH2TZgUFeIqYcjh6f9jNuhHfuZiEdH+PONHYrLhRQX6aw==", + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.15.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.15.0.tgz", + "integrity": "sha512-2iy0iBeWbNyhgiCGd/wvGrDSo73emNFjSxYOcyAqYiagkYt5q4cPfVXaVDKBsukgc2fIIfLAalBZlaxldxdDYg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.6", + "chromium-bidi": "7.2.0", + "debug": "^4.4.1", + "devtools-protocol": "0.0.1464554", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.3.tgz", + "integrity": "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT", + "optional": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/poc/hypergraph-browser/package.json b/poc/hypergraph-browser/package.json new file mode 100644 index 0000000..4d1747e --- /dev/null +++ b/poc/hypergraph-browser/package.json @@ -0,0 +1,16 @@ +{ + "name": "hypergraph-browser-poc", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node server.mjs", + "test": "node --test session.test.mjs server.test.mjs chrome.test.mjs" + }, + "dependencies": { + "puppeteer-core": "24.15.0", + "ws": "8.18.3" + } +} diff --git a/poc/hypergraph-browser/server.mjs b/poc/hypergraph-browser/server.mjs new file mode 100644 index 0000000..7692f61 --- /dev/null +++ b/poc/hypergraph-browser/server.mjs @@ -0,0 +1,113 @@ +import {createServer} from 'node:http'; +import {readFile} from 'node:fs/promises'; +import {pathToFileURL} from 'node:url'; +import {WebSocketServer} from 'ws'; + +import {createPageSession} from './session.mjs'; +import {chromeExecutablePath, launchChromeDriver} from './chrome.mjs'; + +// Sends captured page bytes to the browser demo. `still` is one JPEG per vertex. +// `frame` is the single live screencast. `focus` selects that vertex, or null to stop. +export async function startPageServer({session, driver, vertices, host = '127.0.0.1', port = 0}) { + const vertexByTarget = new Map(); + const stills = []; + const clients = new Set(); + const created = []; + const pages = { + onFrame: (listener) => driver.onFrame(listener), + createTarget: async (url) => { + const targetId = await driver.createTarget(url); + created.push(targetId); + return targetId; + }, + bringToFront: (targetId) => driver.bringToFront(targetId), + capture: (targetId) => driver.capture(targetId), + startScreencast: (targetId) => driver.startScreencast(targetId), + stopScreencast: (targetId) => driver.stopScreencast(targetId), + }; + + const send = (socket, message) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message)); + }; + const broadcast = (message) => { + for (const socket of clients) send(socket, message); + }; + + driver.onFrame((targetId, jpeg) => { + const vertexId = vertexByTarget.get(targetId); + if (!vertexId) return; + broadcast({type: 'frame', vertexId, jpeg: Buffer.from(jpeg).toString('base64')}); + }); + + for (const vertex of vertices) { + const before = created.length; + const opened = await session.open({id: vertex.id, url: vertex.url ?? vertex.attrs?.page}, pages); + if (opened.skipped) continue; + const targetId = created[before]; + vertexByTarget.set(targetId, opened.vertexId); + stills.push({ + type: 'still', + vertexId: opened.vertexId, + jpeg: Buffer.from(opened.jpeg).toString('base64'), + }); + } + + const httpServer = createServer((_req, res) => { + res.writeHead(200, {'content-type': 'text/plain; charset=utf-8'}); + res.end('hypergraph browser poc\n'); + }); + const sockets = new WebSocketServer({server: httpServer}); + sockets.on('connection', (socket) => { + clients.add(socket); + for (const still of stills) send(socket, still); + socket.on('message', (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + send(socket, {type: 'error', message: 'Malformed JSON'}); + return; + } + if (message?.type !== 'focus') return; + Promise.resolve(session.focus(message.vertexId ?? null, pages)).catch((error) => { + send(socket, {type: 'error', message: error.message}); + }); + }); + socket.on('close', () => clients.delete(socket)); + }); + + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(port, host, resolve); + }); + const address = httpServer.address(); + return { + address, + url: `ws://${address.address}:${address.port}`, + async close() { + for (const socket of clients) socket.close(); + await new Promise((resolve) => sockets.close(resolve)); + await new Promise((resolve) => httpServer.close(resolve)); + if (driver.close) await driver.close(); + }, + }; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + const fixtureUrl = process.argv[2] ? pathToFileURL(process.argv[2]) : new URL('./fixture.json', import.meta.url); + const fixture = JSON.parse(await readFile(fixtureUrl, 'utf8')); + const driver = await launchChromeDriver({executablePath: chromeExecutablePath()}); + const server = await startPageServer({ + session: createPageSession(), + driver, + vertices: fixture.vertices, + port: Number(process.env.PORT ?? 8765), + }); + console.log(`hypergraph browser sidecar ${server.url}`); + const shutdown = () => { + server.close().then(() => process.exit(0), () => process.exit(1)); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); +} diff --git a/poc/hypergraph-browser/server.test.mjs b/poc/hypergraph-browser/server.test.mjs new file mode 100644 index 0000000..07c902f --- /dev/null +++ b/poc/hypergraph-browser/server.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import {createPageSession} from './session.mjs'; +import {startPageServer} from './server.mjs'; + +function fakeDriver() { + const calls = []; + const screencasting = new Set(); + const listeners = new Set(); + let next = 0; + return { + calls, + screencasting, + onFrame(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + emitFrame(targetId, jpeg) { + for (const listener of listeners) listener(targetId, jpeg); + }, + async createTarget(url) { + const targetId = `t${next++}`; + calls.push(['create', url, targetId]); + return targetId; + }, + async bringToFront(targetId) { + calls.push(['front', targetId]); + }, + async capture(targetId) { + calls.push(['capture', targetId]); + return Buffer.from(`jpeg-${targetId}`); + }, + async startScreencast(targetId) { + calls.push(['screencast-start', targetId]); + screencasting.add(targetId); + }, + async stopScreencast(targetId) { + calls.push(['screencast-stop', targetId]); + screencasting.delete(targetId); + }, + async close() { + calls.push(['close']); + }, + }; +} + +function onceMessage(ws) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timed out waiting for a server message')), 1000); + ws.addEventListener('message', (event) => { + clearTimeout(timer); + resolve(JSON.parse(event.data)); + }, {once: true}); + }); +} + +test('the server binds localhost, sends stills, and forwards one live frame', async () => { + const driver = fakeDriver(); + const server = await startPageServer({ + session: createPageSession(), + driver, + vertices: [ + {id: 'example', url: 'https://example.com/'}, + {id: 'bare'}, + ], + }); + assert.equal(server.address.address, '127.0.0.1'); + const ws = new WebSocket(server.url); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, {once: true}); + ws.addEventListener('error', reject, {once: true}); + }); + const still = await onceMessage(ws); + assert.equal(still.type, 'still'); + assert.equal(still.vertexId, 'example'); + assert.equal(Buffer.from(still.jpeg, 'base64').toString(), 'jpeg-t0'); + + ws.send(JSON.stringify({type: 'focus', vertexId: 'example'})); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual([...driver.screencasting], ['t0']); + + const framePromise = onceMessage(ws); + driver.emitFrame('t0', Buffer.from('live')); + const frame = await framePromise; + assert.equal(frame.type, 'frame'); + assert.equal(frame.vertexId, 'example'); + assert.equal(Buffer.from(frame.jpeg, 'base64').toString(), 'live'); + + ws.send(JSON.stringify({type: 'focus', vertexId: null})); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(driver.screencasting.size, 0); + + ws.close(); + await server.close(); + assert.equal(driver.calls.at(-1)[0], 'close'); +}); From 01fd73dbe670639999979b0d1166780933baf08d Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 30 Oct 2026 08:12:27 -0500 Subject: [PATCH 3/5] feat: show a page frame on its vertex A still or live capture replaces the icosphere with a camera-facing quad, and the frame arrives through a mailbox instead of the viewer command queue. --- crates/hyper-viz-bevy/src/lib.rs | 2 + crates/hyper-viz-bevy/src/page_frame.rs | 329 ++++++++++++++++++++++++ crates/hyper-viz-bevy/src/render.rs | 5 + crates/hyper-viz-web/src/runtime.rs | 57 +++- web/src/sdk.mjs | 15 ++ web/test/sdk.test.mjs | 17 +- 6 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 crates/hyper-viz-bevy/src/page_frame.rs diff --git a/crates/hyper-viz-bevy/src/lib.rs b/crates/hyper-viz-bevy/src/lib.rs index e37dd95..8e27644 100644 --- a/crates/hyper-viz-bevy/src/lib.rs +++ b/crates/hyper-viz-bevy/src/lib.rs @@ -46,6 +46,7 @@ mod hyperedge_hull; mod inspect; mod interaction; mod node_visual; +mod page_frame; mod pick; mod render; mod search; @@ -63,6 +64,7 @@ pub mod host { pub use crate::hyperedge_hull::HyperedgeHullSettings; pub use crate::interaction::{LassoState, LocalizeQuery, SelectionState}; pub use crate::node_visual::{NodeLabelMode, NodeRenderSettings}; + pub use crate::page_frame::{PageFrames, accepted as page_frame_accepted}; pub use crate::session::{SessionConfig, restore_session_on_first_graph}; pub use crate::ui::ExternalUi; } diff --git a/crates/hyper-viz-bevy/src/page_frame.rs b/crates/hyper-viz-bevy/src/page_frame.rs new file mode 100644 index 0000000..ddd617e --- /dev/null +++ b/crates/hyper-viz-bevy/src/page_frame.rs @@ -0,0 +1,329 @@ +//! Latest page image for a vertex. Picking still uses the node transform. +//! While a frame is current the icosphere mesh is removed and a camera-facing +//! quad shows the page. + +use std::collections::HashMap; + +use bevy::asset::RenderAssetUsages; +use bevy::prelude::*; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; + +use crate::render::SceneNodeKey; + +pub const MAX_PAGE_EDGE: u32 = 640; +const PAGE_QUAD_WIDTH: f32 = 4.0; +const PAGE_QUAD_HEIGHT: f32 = 2.25; + +#[derive(Clone, Debug)] +pub struct PageImage { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +#[derive(Resource, Default)] +pub struct PageFrames { + pending: HashMap, +} + +impl PageFrames { + /// Keeps only the newest accepted image for `id`. Oversized or malformed + /// frames are ignored so a bad capture cannot replace a good still. + pub fn submit(&mut self, id: &str, width: u32, height: u32, rgba: Vec) -> bool { + if !accepted(id, width, height, rgba.len()) { + return false; + } + self.pending.insert( + id.to_string(), + PageImage { + width, + height, + rgba, + }, + ); + true + } + + pub fn get(&self, id: &str) -> Option<&PageImage> { + self.pending.get(id) + } + + fn take_pending(&mut self) -> HashMap { + std::mem::take(&mut self.pending) + } +} + +pub fn accepted(id: &str, width: u32, height: u32, rgba_len: usize) -> bool { + !id.is_empty() + && width > 0 + && height > 0 + && width <= MAX_PAGE_EDGE + && height <= MAX_PAGE_EDGE + && rgba_len == width as usize * height as usize * 4 +} + +#[derive(Component)] +pub(crate) struct PageQuad; + +#[derive(Component)] +struct PageTexture(Handle); + +#[derive(Resource)] +struct PageQuadMesh(Handle); + +pub(crate) fn apply_page_frames(world: &mut World) { + let pending = { + let Some(mut frames) = world.get_resource_mut::() else { + return; + }; + frames.take_pending() + }; + if pending.is_empty() { + return; + } + let nodes: HashMap = world + .query::<(Entity, &SceneNodeKey)>() + .iter(world) + .map(|(entity, key)| (key.0.clone(), entity)) + .collect(); + for (id, frame) in pending { + let Some(entity) = nodes.get(&id).copied() else { + continue; + }; + show_frame(world, entity, frame); + } +} + +pub(crate) fn billboard_page_quads( + camera: Query<&GlobalTransform, With>, + mut quads: Query<(&mut Transform, &GlobalTransform), With>, +) { + let Some(camera_transform) = camera.iter().next() else { + return; + }; + let camera_position = camera_transform.translation(); + for (mut transform, global) in &mut quads { + let origin = global.translation(); + if origin.distance_squared(camera_position) < 1e-6 { + continue; + } + // Parent rotation stays identity; local rotation is the world facing. + transform.rotation = Transform::from_translation(origin) + .looking_at(camera_position, Vec3::Y) + .rotation; + } +} + +fn show_frame(world: &mut World, entity: Entity, frame: PageImage) { + hide_sphere(world, entity); + if let Some(quad) = find_quad(world, entity) { + replace_texture(world, quad, frame); + return; + } + let mesh = quad_mesh(world); + let image = world.resource_mut::>().add(image_from(frame)); + let material = world + .resource_mut::>() + .add(StandardMaterial { + base_color_texture: Some(image.clone()), + unlit: true, + ..default() + }); + let child = world + .spawn(( + PageQuad, + PageTexture(image), + Mesh3d(mesh), + MeshMaterial3d(material), + Transform::default(), + )) + .id(); + world.entity_mut(entity).add_child(child); +} + +fn hide_sphere(world: &mut World, entity: Entity) { + // Picking uses the node transform. Removing the mesh leaves that transform + // in place and stops the icosphere from drawing over the page. + world.entity_mut(entity).remove::(); + world + .entity_mut(entity) + .remove::>(); +} + +fn find_quad(world: &World, entity: Entity) -> Option { + let children = world.get::(entity)?; + children + .iter() + .find(|&child| world.get::(child).is_some()) +} + +fn replace_texture(world: &mut World, quad: Entity, frame: PageImage) { + let Some(handle) = world + .get::(quad) + .map(|texture| texture.0.clone()) + else { + return; + }; + let mut images = world.resource_mut::>(); + let Some(image) = images.get_mut(&handle) else { + return; + }; + image.texture_descriptor.size = Extent3d { + width: frame.width, + height: frame.height, + depth_or_array_layers: 1, + }; + image.data = Some(frame.rgba); +} + +fn quad_mesh(world: &mut World) -> Handle { + if let Some(mesh) = world.get_resource::() { + return mesh.0.clone(); + } + let handle = world + .resource_mut::>() + .add(Rectangle::new(PAGE_QUAD_WIDTH, PAGE_QUAD_HEIGHT)); + world.insert_resource(PageQuadMesh(handle.clone())); + handle +} + +fn image_from(frame: PageImage) -> Image { + Image::new( + Extent3d { + width: frame.width, + height: frame.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + frame.rgba, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rgba(value: u8) -> Vec { + vec![value, 0, 0, 255] + } + + fn app_with_node(id: &str) -> (App, Entity) { + let mut app = App::new(); + app.init_resource::() + .init_resource::>() + .init_resource::>() + .init_resource::>() + .add_systems(Update, apply_page_frames); + let mesh = app + .world_mut() + .resource_mut::>() + .add(Rectangle::new(1.0, 1.0)); + let material = app + .world_mut() + .resource_mut::>() + .add(StandardMaterial::default()); + let entity = app + .world_mut() + .spawn(( + SceneNodeKey(id.to_string()), + Mesh3d(mesh), + MeshMaterial3d(material), + Transform::default(), + )) + .id(); + (app, entity) + } + + #[test] + fn a_newer_frame_replaces_the_pending_image() { + let mut frames = PageFrames::default(); + assert!(frames.submit("a", 1, 1, rgba(1))); + assert!(frames.submit("a", 1, 1, rgba(2))); + assert_eq!(frames.get("a").unwrap().rgba, rgba(2)); + } + + #[test] + fn an_oversized_frame_is_ignored() { + let mut frames = PageFrames::default(); + assert!(frames.submit("a", 1, 1, rgba(1))); + let too_wide = vec![0; (MAX_PAGE_EDGE as usize + 1) * 4]; + assert!(!frames.submit("a", MAX_PAGE_EDGE + 1, 1, too_wide)); + assert_eq!(frames.get("a").unwrap().rgba, rgba(1)); + } + + #[test] + fn an_unknown_id_does_not_create_a_quad() { + let (mut app, _) = app_with_node("known"); + assert!( + app.world_mut() + .resource_mut::() + .submit("missing", 1, 1, rgba(9)) + ); + app.update(); + assert_eq!( + app.world_mut() + .query::<&PageQuad>() + .iter(app.world()) + .count(), + 0 + ); + } + + #[test] + fn a_known_vertex_hides_its_sphere_and_shows_the_latest_frame() { + let (mut app, node) = app_with_node("known"); + { + let mut frames = app.world_mut().resource_mut::(); + assert!(frames.submit("known", 1, 1, rgba(1))); + assert!(frames.submit("known", 1, 1, rgba(4))); + } + app.update(); + assert!(app.world().get::(node).is_none()); + let quad = app + .world_mut() + .query::<&PageQuad>() + .iter(app.world()) + .count(); + assert_eq!(quad, 1); + let handle = app + .world_mut() + .query::<&PageTexture>() + .single(app.world()) + .unwrap() + .0 + .clone(); + let image = app + .world() + .resource::>() + .get(&handle) + .unwrap(); + assert_eq!(image.data.as_deref(), Some(rgba(4).as_slice())); + } + + #[test] + fn removing_a_vertex_drops_its_frame() { + let (mut app, node) = app_with_node("known"); + assert!( + app.world_mut() + .resource_mut::() + .submit("known", 1, 1, rgba(1)) + ); + app.update(); + app.world_mut().entity_mut(node).despawn(); + assert!( + app.world_mut() + .resource_mut::() + .submit("known", 1, 1, rgba(2)) + ); + app.update(); + assert_eq!( + app.world_mut() + .query::<&PageQuad>() + .iter(app.world()) + .count(), + 0 + ); + } +} diff --git a/crates/hyper-viz-bevy/src/render.rs b/crates/hyper-viz-bevy/src/render.rs index d786c65..4e39a26 100644 --- a/crates/hyper-viz-bevy/src/render.rs +++ b/crates/hyper-viz-bevy/src/render.rs @@ -64,6 +64,7 @@ impl Plugin for RenderPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .init_resource::() .add_systems(Startup, setup_assets) // Apply the scene replacement and remap entities before any consumer @@ -89,6 +90,10 @@ impl Plugin for RenderPlugin { .run_if(resource_exists::) .run_if(crate::explorer_state::spatial_mode), highlight_selected, + crate::page_frame::apply_page_frames, + crate::page_frame::billboard_page_quads + .after(update_node_positions) + .after(crate::page_frame::apply_page_frames), ), ); } diff --git a/crates/hyper-viz-web/src/runtime.rs b/crates/hyper-viz-web/src/runtime.rs index dd788f8..8ec61af 100644 --- a/crates/hyper-viz-web/src/runtime.rs +++ b/crates/hyper-viz-web/src/runtime.rs @@ -9,13 +9,13 @@ use futures_util::future::{AbortHandle, Abortable}; use hyper_viz::{Hypergraph, Projection}; use hyper_viz_bevy::{ HyperVisualizerPlugin, - host::{ExternalUi, SessionConfig}, + host::{ExternalUi, PageFrames, SessionConfig, page_frame_accepted}, }; use js_sys::Function; use std::{cell::RefCell, rc::Rc}; thread_local! { static PANIC_CALLBACK: RefCell> = const { RefCell::new(None) }; } use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -30,10 +30,14 @@ struct Request { #[derive(Clone, Resource)] struct Inbox(Arc>>); +#[derive(Clone, Resource)] +struct FrameInbox(Arc)>>>); + /// JS-owned handle. It never holds the Bevy world or a page-wide callback. #[wasm_bindgen] pub struct BrowserRuntime { inbox: Inbox, + frames: FrameInbox, abort: AbortHandle, initializing: Arc, alive: Arc, @@ -64,6 +68,35 @@ impl BrowserRuntime { Ok(()) } + /// Latest page image for one vertex. This does not enter the command queue. + #[wasm_bindgen(js_name = postFrame)] + pub fn post_frame( + &self, + id: &str, + width: u32, + height: u32, + rgba: &[u8], + ) -> Result<(), JsValue> { + if !self.alive.load(Ordering::Relaxed) { + return Err(JsValue::from_str("Viewer disposed")); + } + if !page_frame_accepted(id, width, height, rgba.len()) { + return Ok(()); + } + let mut frames = self + .frames + .0 + .lock() + .map_err(|_| JsValue::from_str("Page frame mailbox unavailable"))?; + frames.insert(id.to_string(), (width, height, rgba.to_vec())); + if let Ok(proxy) = self.proxy.lock() + && let Some(proxy) = proxy.as_ref() + { + let _ = proxy.send_event(WinitUserEvent::WakeUp); + } + Ok(()) + } + /// Also usable while graphics initialization is still pending. pub fn stop(&self) { self.alive.store(false, Ordering::Relaxed); @@ -128,6 +161,7 @@ pub fn start( .ok_or_else(|| JsValue::from_str("Canvas unavailable"))? .dyn_into::()?; let inbox = Inbox(Arc::new(Mutex::new(VecDeque::new()))); + let frames = FrameInbox(Arc::new(Mutex::new(HashMap::new()))); let alive = Arc::new(AtomicBool::new(true)); let initializing = Arc::new(AtomicBool::new(true)); let startup = Rc::new(RefCell::new(Some(callback))); @@ -135,6 +169,7 @@ pub fn start( let (abort, registration) = AbortHandle::new_pair(); let handle = BrowserRuntime { inbox: inbox.clone(), + frames: frames.clone(), alive: alive.clone(), proxy: proxy.clone(), abort, @@ -203,6 +238,7 @@ pub fn start( .insert_resource(SourceGraph(graph)); app.world_mut().resource_mut::().enabled = true; app.insert_resource(inbox) + .insert_resource(frames) .insert_non_send_resource(Callbacks { callback, alive, @@ -299,11 +335,28 @@ fn pump(world: &mut World) { return; } let queue = world.resource::().clone(); + let frame_inbox = world.resource::().clone(); let requests: Vec<_> = queue .0 .lock() .map(|mut q| q.drain(..).collect()) .unwrap_or_default(); + let pending_frames = frame_inbox + .0 + .lock() + .map(|mut frames| std::mem::take(&mut *frames)) + .unwrap_or_default(); + if !pending_frames.is_empty() { + if let Some(mut pages) = world.get_resource_mut::() { + for (id, (width, height, rgba)) in pending_frames { + pages.submit(&id, width, height, rgba); + } + } else if let Ok(mut frames) = frame_inbox.0.lock() { + for (id, frame) in pending_frames { + frames.entry(id).or_insert(frame); + } + } + } // Winit routes pointer input per canvas; keyboard shortcuts additionally need focus. let focused = { let mut windows = world.query::<&Window>(); diff --git a/web/src/sdk.mjs b/web/src/sdk.mjs index 0fcb2ea..70ff227 100644 --- a/web/src/sdk.mjs +++ b/web/src/sdk.mjs @@ -207,6 +207,21 @@ export function createSDK(platform = {}) { return command({type:'setSettings',settings}); }, getState() { if (disposed) throw Error('Viewer disposed'); return clone(state); }, + setPageFrame(id, width, height, rgba) { + if (disposed) return Promise.reject(Error('Viewer disposed')); + if (typeof id !== 'string' || id.length === 0) return Promise.reject(Error('Page frame requires a vertex id')); + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + return Promise.reject(Error('Page frame dimensions must be positive integers')); + } + if (!(rgba instanceof Uint8Array) || rgba.byteLength !== width * height * 4) { + return Promise.reject(Error('Page frame RGBA size does not match its dimensions')); + } + // The renderer drops anything larger than a still. Do not occupy the command queue. + if (Math.max(width, height) > 640) return Promise.resolve(); + try { runtime.postFrame(id, width, height, rgba); } + catch (error) { return Promise.reject(asError(error)); } + return Promise.resolve(); + }, on(type, callback) { if (disposed) throw Error('Viewer disposed'); if (!listeners.has(type) || typeof callback !== 'function') throw Error('Invalid event subscription'); diff --git a/web/test/sdk.test.mjs b/web/test/sdk.test.mjs index f8786ec..089eff5 100644 --- a/web/test/sdk.test.mjs +++ b/web/test/sdk.test.mjs @@ -15,10 +15,13 @@ function setup({gpu = true, fail = false, timeout = false} = {}) { loaded.push(String(url)); return {createBindings:()=>({default:async()=>{}, start:(selector, callback)=>{ const name=String(url).includes('gpu')?'webgpu':'webgl2'; - const runtime={send(id,raw) { + const runtime={commands:0, frames:[], send(id,raw) { + runtime.commands += 1; const command=JSON.parse(raw); queueMicrotask(()=> callback(JSON.stringify(command.json==='invalid' ? {type:'ack',id,error:'Invalid graph'} : {type:'ack',id,state:{selection:{vertices:[String(id)],hyperedges:[]},graph:{vertices:id}}}))); + }, postFrame(id, width, height, rgba) { + runtime.frames.push({id, width, height, byteLength: rgba.byteLength}); },stop(){queueMicrotask(()=>callback(JSON.stringify({type:'disposed'})));},free(){runtime.freed=true;}}; runtimes.push(runtime); if (!timeout || name==='webgl2') queueMicrotask(()=>callback(JSON.stringify(fail && name==='webgpu' @@ -100,3 +103,15 @@ test('remounting a canvas with a different backend replaces its incompatible con const b=await s.sdk({canvas:c,backend:'webgl2'}); assert.notEqual(b.canvas,c);assert.equal(b.canvas,c.replacement);assert.equal(b.backend,'webgl2');await b.dispose(); }); +test('page frames bypass the command queue and reject after dispose',async()=>{ + const s=setup(); const v=await s.sdk({canvas:s.canvas()}); + const pixels=new Uint8Array(16); + await v.setPageFrame('example', 2, 2, pixels); + assert.equal(s.runtimes[0].frames.length, 1); + assert.equal(s.runtimes[0].frames[0].id, 'example'); + assert.equal(s.runtimes[0].commands, 0); + await v.setPageFrame('big', 641, 1, new Uint8Array(641 * 4)); + assert.equal(s.runtimes[0].frames.length, 1); + await v.dispose(); + await assert.rejects(v.setPageFrame('example', 2, 2, pixels), /disposed/); +}); From 181c66faca05d72505cdca6f8844515f9152afc3 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 30 Oct 2026 08:12:41 -0500 Subject: [PATCH 4/5] feat: add the hypergraph browser demo The demo page loads the page fixture, focuses the selected vertex, and paints each Chrome capture on that vertex. --- poc/hypergraph-browser/server.mjs | 8 +++ web/build.mjs | 1 + web/demo/browser.html | 23 ++++++++ web/demo/browser.mjs | 94 +++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 web/demo/browser.html create mode 100644 web/demo/browser.mjs diff --git a/poc/hypergraph-browser/server.mjs b/poc/hypergraph-browser/server.mjs index 7692f61..ace83a6 100644 --- a/poc/hypergraph-browser/server.mjs +++ b/poc/hypergraph-browser/server.mjs @@ -8,6 +8,14 @@ import {chromeExecutablePath, launchChromeDriver} from './chrome.mjs'; // Sends captured page bytes to the browser demo. `still` is one JPEG per vertex. // `frame` is the single live screencast. `focus` selects that vertex, or null to stop. +// +// Build the viewer once so this demo's WASM frame path exists: +// npm run build --prefix web +// Then start the sidecar and the viewer: +// node poc/hypergraph-browser/server.mjs +// npm run serve --prefix web +// Open http://127.0.0.1:8080/demo/browser.html + export async function startPageServer({session, driver, vertices, host = '127.0.0.1', port = 0}) { const vertexByTarget = new Map(); const stills = []; diff --git a/web/build.mjs b/web/build.mjs index 6d15f72..32478cb 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -37,6 +37,7 @@ try { await cp(join(web,'demo'),join(staging,'demo'),{recursive:true}); await cp(join(root,'fixtures/sample.json'),join(staging,'demo/sample.json')); await cp(join(root,'fixtures/notebook-small.hif.json'),join(staging,'demo/sample.hif.json')); + await cp(join(root,'poc/hypergraph-browser/fixture.json'),join(staging,'demo/pages.json')); await rm(join(staging,'generated'),{recursive:true}); await generateNotices(root,staging); // Both backends must succeed before replacing the previous output. diff --git a/web/demo/browser.html b/web/demo/browser.html new file mode 100644 index 0000000..36f6725 --- /dev/null +++ b/web/demo/browser.html @@ -0,0 +1,23 @@ + + + + +Hyper · Hypergraph browser + +
+

HYPER / HYPERGRAPH BROWSER

+

One live page. Stills for the rest.

+

Each vertex shows a headless Chrome capture of its page. Click one vertex to stream that page. The others keep their last still.

+
+
+
+

Pages

Starting…
+
+

Loading…

+ +
Click a vertex to focus its page.
+
+
+
The sidecar must be running on 127.0.0.1:8765. Audio stays muted.
+ + diff --git a/web/demo/browser.mjs b/web/demo/browser.mjs new file mode 100644 index 0000000..6de7c36 --- /dev/null +++ b/web/demo/browser.mjs @@ -0,0 +1,94 @@ +import {createHyperViewer} from '../index.mjs'; + +const sidecar = new URL(location.href).searchParams.get('sidecar') ?? 'ws://127.0.0.1:8765'; +const status = document.querySelector('.status'); +const errorBox = document.querySelector('.error'); +const selectionBox = document.querySelector('.selection'); +const canvas = document.querySelector('canvas'); + +function report(error) { + errorBox.hidden = false; + errorBox.textContent = error?.message ?? String(error); +} + +async function pageFrame(jpeg) { + const bytes = Uint8Array.from(atob(jpeg), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], {type: 'image/jpeg'})); + const longest = Math.max(bitmap.width, bitmap.height); + const scale = longest > 640 ? 640 / longest : 1; + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const surface = new OffscreenCanvas(width, height); + const context = surface.getContext('2d', {willReadFrequently: true}); + context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + const pixels = context.getImageData(0, 0, width, height).data; + return {width, height, rgba: new Uint8Array(pixels)}; +} + +const viewer = await createHyperViewer({canvas, backend: new URL(location.href).searchParams.get('backend') ?? 'auto'}); +document.querySelector('.backend').textContent = viewer.backend; +const fixture = await (await fetch('./pages.json')).text(); +await viewer.loadJson(fixture); + +const pending = new Map(); +let drawing = false; +function enqueue(message) { + pending.set(message.vertexId, message.jpeg); + pump().catch(report); +} +async function pump() { + if (drawing) return; + drawing = true; + try { + while (pending.size) { + const batch = [...pending]; + pending.clear(); + for (const [vertexId, jpeg] of batch) { + const frame = await pageFrame(jpeg); + await viewer.setPageFrame(vertexId, frame.width, frame.height, frame.rgba); + } + } + } finally { + drawing = false; + if (pending.size) pump().catch(report); + } +} + +let desiredFocus = null; +function sendFocus(vertexId) { + desiredFocus = vertexId; + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({type: 'focus', vertexId: desiredFocus})); + } +} + +viewer.on('selectionchange', (selection) => { + selectionBox.textContent = JSON.stringify(selection, null, 2); + sendFocus(selection.vertices.length === 1 ? selection.vertices[0] : null); +}); +viewer.on('error', report); + +status.textContent = 'Connecting to the page sidecar…'; +const socket = new WebSocket(sidecar); +socket.addEventListener('open', () => { + status.textContent = 'Sidecar connected. Waiting for page stills.'; + if (desiredFocus !== null) sendFocus(desiredFocus); +}); +socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (message.type === 'error') { + report(Error(message.message)); + return; + } + if (message.type === 'still' || message.type === 'frame') { + status.textContent = message.type === 'frame' + ? `Streaming ${message.vertexId}` + : `Still ready for ${message.vertexId}`; + enqueue(message); + } +}); +socket.addEventListener('close', () => { + status.textContent = 'Sidecar disconnected.'; +}); +socket.addEventListener('error', () => report(Error(`Cannot reach the page sidecar at ${sidecar}`))); From c1ec7b58ab8d0057707869bfa3f84ab0876afec8 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 30 Oct 2026 08:16:51 -0500 Subject: [PATCH 5/5] fix: deliver the first frame of a focused page Headless Chrome often paints a static page once. Skipping every third frame dropped that paint, so focus produced no live image. Forward every paint and keep the listener near 5 Hz, and let the sidecar own process signals so its close is the one that stops Chrome. --- poc/hypergraph-browser/chrome.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/poc/hypergraph-browser/chrome.mjs b/poc/hypergraph-browser/chrome.mjs index 94522b6..f368be1 100644 --- a/poc/hypergraph-browser/chrome.mjs +++ b/poc/hypergraph-browser/chrome.mjs @@ -16,6 +16,11 @@ export async function launchChromeDriver({executablePath = chromeExecutablePath( executablePath, headless: true, args: ['--headless=new', '--mute-audio', '--disable-extensions'], + // The sidecar owns shutdown. Puppeteer's own signal handlers close Chrome + // and can leave this process waiting on a second browser.close(). + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, }); const pages = new Map(); const listeners = new Set(); @@ -76,7 +81,9 @@ export async function launchChromeDriver({executablePath = chromeExecutablePath( quality: 50, maxWidth: MAX_EDGE, maxHeight: MAX_EDGE, - everyNthFrame: 3, + // A static page may paint only once. Skipping frames would drop that + // paint. The listener below keeps the forwarded rate near 5 Hz. + everyNthFrame: 1, }); }, async stopScreencast(targetId) { @@ -86,7 +93,7 @@ export async function launchChromeDriver({executablePath = chromeExecutablePath( await entry.cdp.send('Page.stopScreencast').catch(() => {}); }, async close() { - await browser.close(); + await browser.close().catch(() => {}); }, }; }