diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a6e315cb..59fb7dee2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,8 +66,13 @@ jobs: - name: Install ffmpeg on Linux if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y ffmpeg + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i -E 's|https?://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo apt-get -o Acquire::Retries=3 update + sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends ffmpeg - name: Install ffmpeg on Windows if: runner.os == 'Windows' diff --git a/.github/workflows/npm-package.yml b/.github/workflows/npm-package.yml index b2f9a5cbb..3ca9481e6 100644 --- a/.github/workflows/npm-package.yml +++ b/.github/workflows/npm-package.yml @@ -66,8 +66,13 @@ jobs: - name: Prepare the local media toolchain on Linux if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y ffmpeg + for source in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do + if [ -f "$source" ]; then + sudo sed -i -E 's|https?://azure\.archive\.ubuntu\.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" + fi + done + sudo apt-get -o Acquire::Retries=3 update + sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends ffmpeg - name: Prepare the local media toolchain on Windows if: runner.os == 'Windows' run: choco install ffmpeg --yes --no-progress diff --git a/packages/provider-html-local/README.md b/packages/provider-html-local/README.md index dc0efdf2e..35cc7a04b 100644 --- a/packages/provider-html-local/README.md +++ b/packages/provider-html-local/README.md @@ -5,78 +5,161 @@ Trusted local implementation of: - `@hypit/html-program@1#rasterize-visual` → `TimelineVisual`; - `@hypit/html-program@1#rasterize-frames` → ordered PNG BlobRefs. -The Provider consumes the typed `HtmlProgram` directly. It serves the staged -program to independent Puppeteer/Chrome processes, applies absolute integer -frames through `window.__hypitFrameProgram`, decodes compiler-declared source -video frames with FFmpeg, captures PNG pixels through Chrome DevTools Protocol, -and streams ordered frames into one H.264 encoder. It has no external HTML video -engine or producer dependency. +The Provider stages a typed `HtmlProgram`, applies absolute integer frames through +`window.__hypitFrameProgram`, and supplies compiler-declared video samples. +It supports two video export configurations: -Filesystem staging is private to this local Provider. `@hypit/html-program` -retains only the portable program and its pure materialization function; it -does not expose a Node project/staging subpath for hypothetical executors. -Inspection of staged Compositable Surface files and probing the selected media -tools are private here for the same reason: they validate inputs used by this -executor and do not establish a shared media-execution ABI. +| Configuration | Source video | Capture and encoding | +| --- | --- | --- | +| `chromePath`, or managed Chrome selection | Bounded FFmpeg PNG decode store | Puppeteer PNG capture and ordered FFmpeg H.264 encoding | +| `renderer: "electron"`, or `electronPath` | Mediabunny demuxing, WebCodecs decoding, Canvas video layers | Electron shared GPU textures, Canvas cropping, WebCodecs H.264 encoding, FFmpeg MP4 muxing | -## Runtime boundary +PNG frame requests use the selected Chrome browser. Filesystem staging, source +probing and decoded-frame leases belong to this Provider. -The Endpoint facet is `rasterize`. Runtime configuration owns deployment policy: +## Runtime configuration -- `workers`: fixed browser count or `auto`; +The Endpoint facet is `rasterize`. Runtime configuration owns execution policy: + +- `workers`: fixed worker count or `auto`; - `maxWorkers`: automatic ceiling; - `defaultConcurrency`: whole requests admitted in this Endpoint pool; -- `browserCapacity`: Chrome slots reserved across admitted requests; +- `browserCapacity`: worker slots reserved across admitted requests; - `chromePath`, or exact `browserVersion` plus `browserCacheDirectory`; +- `renderer`: `chromium` (default) or `electron` for GPU video export; +- `electronPath`: a user-managed Electron executable, selecting the Electron renderer; +- `electronCacheDirectory`: managed Electron installation and archive cache directory; +- `electronDownloadBaseUrl`: optional Electron release archive base URL; - `ffmpegPath` and `ffprobePath`; - optional process/stage deadlines and byte budgets. -The package recommendation is stored as `hypit.htmlBrowser.version`. Managed -Chrome defaults to `~/.cache/hypit/html-rasterizer/chrome`; its ManagedProgram -state is stored below `.hypit-html-browser-program`. Browser installation occurs -only through explicit Runtime preparation: +For GPU export with declared video sources, `auto` uses one scene worker, sharing +its source decoders and cache across the timeline. Pure motion graphics use +parallel scenes up to the admitted worker ceiling. An explicit worker count +enables that many scenes, limited by the requested frame span. PNG capture adjusts its +active workers using measured throughput. +GPU export requires `browserGpu: "auto"` or `"hardware"`, even canvas dimensions, +shared-texture support, browser support for each source codec and a supported +WebCodecs H.264 configuration. Unsupported configurations fail with a diagnostic. + +The Chrome recommendation is stored as `hypit.htmlBrowser.version`. Managed Chrome +defaults to `~/.cache/hypit/html-rasterizer/chrome`; its ManagedProgram state is +stored below `.hypit-html-browser-program`. Browser installation occurs through +explicit Runtime preparation: ```sh hypit programs prepare --runtime ./hypit.runtime.json --endpoint html.local ``` -Inspection and execution never search environment variables or silently choose a -different system browser. `chromePath` selects a user-managed executable; -`browserVersion` selects one exact managed Chrome for Testing version. The two -selection modes are mutually exclusive. - -## Frame selection and parallelism - -A continuous request retains original absolute frame identities. A sparse request -accepts strictly increasing, non-contiguous frames. Workers are independent -browser processes and may start at any requested frame; they do not wait for an -earlier worker to traverse the program. - -The compiler's typed `frameSources` plan determines exact source-video samples, -including reverse, hold, loop and rational-rate mappings. The Provider probes -needed sources, decodes bounded windows lazily, shares decoded frames only within -the current execution, and leases them under `maxDecodedSourceBytes`. Chrome does -not independently decode those same videos. Arbitrary author HTML is not parsed -to infer source-time semantics. - -Completed PNGs enter a bounded ordered sink. FFmpeg is the necessary local -serialization point for one MP4 stream, not a scheduler or shared metadata -service. Capture of later frames can continue while earlier frames are encoded. -Sparse frame output writes only requested PNGs and is bounded by -`maxRenderedBytes`. - -## Lifecycle and failure - -Each request owns its temporary directory, localhost server, Chrome processes, -decoded-frame store and encoder. Nothing is shared across requests. Cancellation -or failure closes page sessions, source leases, processes and resource I/O. -Process-tree cleanup is best effort after abnormal exits; hard OS termination -requires containment supplied by the deployment. +`chromePath` selects a user-managed Chrome executable; `browserVersion` selects +one exact managed Chrome for Testing version. These selections are mutually +exclusive. `renderer: "electron"` selects the Provider's pinned Electron release. Runtime +preparation installs both managed Chrome and Electron as needed; probes inspect +the selected installations. Electron archives are verified against the release +checksums shipped in the Provider manifest and extracted into a staging directory +before installation. Repeated preparation reuses healthy installations. +On macOS, preparation sets `LSUIElement=true` and applies and verifies a local +ad-hoc signature so the renderer starts as a background application. + +`electronPath` selects a user-managed executable with the same pinned version and +is mutually exclusive with managed Electron cache and download configuration. +On macOS this executable must belong to a background app with `LSUIElement=true`. +Relative configured paths resolve against the Runtime data root. The default +Electron cache is `~/.cache/hypit/html-rasterizer/electron`. + +Example `html.local` Endpoint configuration: + +```json +{ + "renderer": "electron", + "workers": "auto", + "quality": "standard" +} +``` -The executor waits for page frame listeners, declared fonts, dynamic image -decodes and compositor settlement before accepting a screenshot. Readiness is -scoped to compiler-published capture roots when available; real global stylesheet -edits intentionally fall back to whole-document inspection. +The current pinned Electron release is 45.0.0. Custom download bases use +`/v45.0.0/electron-v45.0.0--.zip` and must serve the official +archive bytes. A failed download leaves the selected installation unavailable +until preparation succeeds. + +On macOS the executable is inside `Electron.app/Contents/MacOS/Electron`; on +Windows it is `electron.exe`; on Linux it is `electron`. Electron's shared-texture +API is experimental. The integration has been exercised on macOS / Apple Silicon; +Windows and Linux require validation on their target GPU and driver combinations. +Linux additionally needs the display environment required by Electron. + +## Frames and encoding + +A continuous request retains original absolute frame identities. A sparse PNG +request accepts strictly increasing, non-contiguous frames. Workers can start at +any requested frame. The compiler's `frameSources` plan determines reverse, hold, +loop and rational-rate video sampling. PNG capture leases decoded images under +`maxDecodedSourceBytes` and places them over compiler-owned video elements. + +GPU capture decodes source video inside each scene renderer. Chromium selects +hardware or software decoding according to source support. Forward sampling +keeps an independent decoder cursor for each video layer. Layers share the source +input and sample cache, so aligned requests reuse the same decoded frame. Reverse +sampling divides the cache budget across active layers and decodes bounded forward +intervals. Holds and loops reuse cached samples. Canvas layers retain the video +elements' styles and placement. Inactive layers release their decoder cursors. +`maxDecodedSourceBytes` defaults to 2.25 GiB, is divided across workers and bounds +retained sample bytes, allowing one oversized sample per worker. Decoder queues, +demuxer caches and compositor surfaces require additional memory. Worker count also multiplies +source decoding; more workers can reduce throughput for video-heavy projects. + +Source color tags are preserved. Untagged SDR uses a BT.601 matrix and sRGB +transfer. Planar alpha samples retain source color metadata through an in-memory +copy before Canvas composition. The published Provider includes the Mediabunny +1.62.1 browser bundle, original source and license under `browser/mediabunny/`. +Mediabunny is distributed under +[MPL-2.0](https://github.com/Vanilagy/mediabunny/blob/main/LICENSE). + +The GPU path renders each Program in a fixed-size same-origin iframe. A separate +32-bit frame identifier below the authored viewport identifies the accepted +texture. Native texture imports are submitted one at a time in ready-frame order; +snapshot copying and encoding proceed concurrently. Only the 8 KiB identifier +region is read back to the CPU. Canvas crops +that region before creating an immutable `VideoFrame` snapshot. Scenes can +prepare subsequent frames while a shared queue feeds snapshots to the encoder +in timeline order. Admission reserves space for the next required frame to +prevent later frames from blocking it. + +`maxPendingFrameBytes` defaults to 128 MiB and controls admission using queued +snapshot pixels and pending compressed output. One oversized snapshot can enter +an empty queue. Encoding already in progress can produce compressed chunks above that admission threshold; +native encoder buffers and compositor surfaces require additional memory. Each +worker releases source decoders and samples when its capture completes. Scene +surfaces remain alive through the encoder flush. + +WebCodecs uses H.264 High with `quality` latency and a hardware preference. +At 1920 × 1080 / 30 fps, `quality` selects target bitrates of 6 Mbps (`draft`), +12 Mbps (`standard`), or 24 Mbps (`high`). Targets scale with pixel count and frame +rate, with a 64 Kbps minimum. Actual hardware encoder availability is determined +by Chromium. FFmpeg assigns the requested rational frame time base and muxes the +encoded stream. PNG video export uses x264 CRF 28, 23, or 18 respectively. +These settings do not imply identical rate control or picture quality. + +For PNG video capture, `maxPendingFrameBytes` bounds the ordered PNG queue. +`maxRenderedBytes` limits the final MP4 or total requested PNG bytes; GPU capture +also checks compressed bytes as they arrive. Output validation decodes every frame +and verifies dimensions, codec, exact frame rate and frame count before storage. + +## Lifecycle and page readiness + +Each request owns its temporary directory, localhost server, browser processes, +decoded-frame store and encoder. Cancellation and failure stop that execution and +release its resources. Process-tree cleanup is best effort after abnormal exits; +hard OS termination requires containment supplied by the deployment. + +Frame preparation waits for page frame listeners, declared fonts and dynamic +image decodes. GPU capture updates its texture identifier after preparation and +accepts a shared surface only when its identifier matches. The authored page and +identifier participate in the same local frame-tree rendering update. +Authored asynchronous drawing must finish +inside the Program's frame completion hooks. Child pages rendered in another +renderer process are rejected by GPU capture; the iframe container makes +`window.top` refer to the capture shell. ## Direct use @@ -85,10 +168,9 @@ import { rasterizeHtmlProgram } from "@hypit/provider-html-local"; const visual = await rasterizeHtmlProgram( { program, range: { startFrame: 240, endFrameExclusive: 360 } }, - { resources, workers: 4 }, + { resources, renderer: "electron", workers: "auto" }, ); ``` -Omit `range` for the complete program. Audio is not part of this result; -`@hypit/media-local` and the author assembly prepare audio and mux the -final artifact. +Omit `range` for the complete program. Audio is prepared and muxed by +`@hypit/media-local` and the author assembly. diff --git a/packages/provider-html-local/package.json b/packages/provider-html-local/package.json index cfb8838f4..75e3100de 100644 --- a/packages/provider-html-local/package.json +++ b/packages/provider-html-local/package.json @@ -9,18 +9,43 @@ }, "files": [ "dist", - "README.md" + "src/gpu-worker.cjs", + "src/gpu-encoder.cjs", + "src/browser-video.mjs", + "README.md", + "browser" ], "hypit": { "activation": "./src/activation.ts", "htmlBrowser": { "version": "152.0.7928.2" + }, + "htmlElectron": { + "version": "45.0.0", + "checksums": { + "electron-v45.0.0-darwin-arm64.zip": "896c3a50b148e89a158dca08a6996a022ddf39b23aac871e107f5b4087f8caa9", + "electron-v45.0.0-darwin-x64.zip": "7877c282e74a52f8d87949536a25735cf9982de1265b544c9cd258dd14eff4de", + "electron-v45.0.0-linux-arm64.zip": "a80d92b1ad6c111545a12632b8a75cedce614a41866e500810d4050d947f90e9", + "electron-v45.0.0-linux-x64.zip": "22f6a10bd3f7fef987ddbb379d3375ce664ad0c98a9a50f80c89f1718a0c3e3a", + "electron-v45.0.0-win32-arm64.zip": "fbae273960b24b81ab7a5cdcf34dd83a8781ee6799ccc621c1d85d5ce197252d", + "electron-v45.0.0-win32-x64.zip": "ae53887a23bb640369c93dbd38c408a2af5c0deb86f2fa76c6c244cb444b7f47" + } + }, + "browserAssets": { + "mediabunny": [ + "dist/bundles/mediabunny.min.mjs", + "LICENSE", + "src" + ] } }, "dependencies": { + "@electron/get": "5.1.0", "@hypit/html-program": "workspace:^0.1.0", "@hypit/media": "workspace:^0.1.0", "@puppeteer/browsers": "3.2.2", + "extract-zip": "2.0.1", + "proper-lockfile": "4.1.2", "puppeteer-core": "25.10.0", "tsx": "4.21.0" }, @@ -32,8 +57,8 @@ "@fontsource-variable/inter": "5.3.0", "@hypit/caption-fine": "workspace:*", "@hypit/composition": "workspace:*", - "@hypit/executor": "workspace:*", "@hypit/endpoint": "workspace:*", + "@hypit/executor": "workspace:*", "@hypit/html-program": "workspace:*", "@hypit/hypit": "workspace:*", "@hypit/media": "workspace:*", @@ -43,6 +68,9 @@ "@hypit/script": "workspace:*", "@hypit/timeline": "workspace:*", "@hypit/visual-track": "workspace:*", + "@types/proper-lockfile": "4.1.4", + "electron": "45.0.0", + "mediabunny": "1.62.1", "sharp": "0.35.4" } } diff --git a/packages/provider-html-local/src/activation.ts b/packages/provider-html-local/src/activation.ts index 543414fe5..41d96a0f0 100644 --- a/packages/provider-html-local/src/activation.ts +++ b/packages/provider-html-local/src/activation.ts @@ -15,6 +15,8 @@ import { createLocalHtmlProvider } from "./provider.js"; import { configuredBrowserPath, browserExecutablePath, selectedBrowserVersion } from "./browser.js"; import type { HtmlBrowserGpu, HtmlQuality, HtmlWorkers } from "./provider.js"; import { localHtmlBrowserProgram } from "./program.js"; +import { electronExecutablePath, recommendedElectronVersion } from "./electron.js"; +import type { ElectronOptions } from "./electron.js"; const localHTMLRuntimeAdapter = createRuntimeEndpointAdapterFacet({ use: "@hypit/provider-html-local", @@ -22,7 +24,7 @@ const localHTMLRuntimeAdapter = createRuntimeEndpointAdapterFacet({ if (context.pool === undefined) throw new Error("local HTML renderer Provider Pool is required"); const config = runtimeConfigObject(context.config, "local HTML renderer"); runtimeConfigExact(config, [ - "nodePath", "chromePath", "browserVersion", "browserCacheDirectory", "browserDownloadBaseUrl", "ffprobePath", "ffmpegPath", "workers", "maxWorkers", "quality", "browserGpu", + "nodePath", "chromePath", "renderer", "electronPath", "electronCacheDirectory", "electronDownloadBaseUrl", "browserVersion", "browserCacheDirectory", "browserDownloadBaseUrl", "ffprobePath", "ffmpegPath", "workers", "maxWorkers", "quality", "browserGpu", "defaultConcurrency", "browserCapacity", "initializationTimeoutMs", "frameTimeoutMs", "processTimeoutMs", "maxProcessOutputBytes", "artifactStagingConcurrency", "maxPendingFrameBytes", "maxDecodedSourceBytes", "maxRenderedBytes", ], "local HTML renderer"); runtimeConfigString(config.nodePath, "HTML renderer nodePath"); @@ -41,6 +43,18 @@ const localHTMLRuntimeAdapter = createRuntimeEndpointAdapterFacet({ } const configuredNode = runtimeConfigString(config.nodePath, "HTML renderer nodePath"); const configuredChrome = runtimeConfigString(config.chromePath, "HTML renderer chromePath"); + const configuredElectron = runtimeConfigString(config.electronPath, "HTML renderer electronPath"); + const renderer = runtimeConfigString(config.renderer, "HTML renderer renderer"); + if (renderer !== undefined && renderer !== "chromium" && renderer !== "electron") throw new Error("HTML renderer renderer must be chromium or electron"); + const electronCache = runtimeConfigString(config.electronCacheDirectory, "HTML renderer electronCacheDirectory"); + const electronMirror = runtimeConfigString(config.electronDownloadBaseUrl, "HTML renderer electronDownloadBaseUrl"); + const electron: ElectronOptions = { + ...(renderer === undefined ? {} : { renderer }), + ...(configuredElectron === undefined ? {} : { electronPath: resolve(context.dataRoot, configuredElectron) }), + ...(electronCache === undefined ? {} : { electronCacheDirectory: resolve(context.dataRoot, electronCache) }), + ...(electronMirror === undefined ? {} : { electronDownloadBaseUrl: electronMirror }), + }; + const electronPath = electronExecutablePath(electron); const browserVersion = runtimeConfigString(config.browserVersion, "HTML renderer browserVersion"); const browserDownloadBaseUrl = runtimeConfigString(config.browserDownloadBaseUrl, "HTML renderer browserDownloadBaseUrl"); const configuredCache = runtimeConfigString(config.browserCacheDirectory, "HTML renderer browserCacheDirectory"); @@ -48,6 +62,7 @@ const localHTMLRuntimeAdapter = createRuntimeEndpointAdapterFacet({ chromePath: resolve(context.dataRoot, configuredChrome), }) }); const browser = { + ...electron, ...(browserVersion === undefined ? {} : { browserVersion }), ...(browserDownloadBaseUrl === undefined ? {} : { browserDownloadBaseUrl }), ...(chromePath === undefined ? {} : { chromePath }), @@ -100,6 +115,8 @@ const localHTMLRuntimeAdapter = createRuntimeEndpointAdapterFacet({ ffmpegPath, }), diagnose: async () => [ + ...(electronPath === undefined ? [] : [{ severity: "info" as const, code: "HTML_GPU_EXPORT", subject: context.instance, + message: `Shared-texture video export using Electron ${recommendedElectronVersion}: ${electronPath}` }]), { severity: "info", code: "HTML_BROWSER_SELECTION", subject: context.instance, message: `${chromePath === undefined ? `Managed Chrome Headless Shell ${selectedBrowserVersion(browser)} (${browserVersion === undefined ? "Provider recommendation" : "Profile version"})` : "Profile browser"}: ${browserExecutablePath(browser)}` }, ...await diagnoseRuntimeExecutable({ root: context.dataRoot, configured: configuredFfmpeg, fallback: "ffmpeg", subject: "FFmpeg" }), diff --git a/packages/provider-html-local/src/browser-video.mjs b/packages/provider-html-local/src/browser-video.mjs new file mode 100644 index 000000000..b499ced73 --- /dev/null +++ b/packages/provider-html-local/src/browser-video.mjs @@ -0,0 +1,185 @@ +/** Installed inside the scene renderer; decoded samples stay in the browser. */ +export async function installBrowserVideo(moduleUrl, maxBytes) { + const media = await import(moduleUrl); + const sources = new Map(); + const cache = new Map(); + let resident = 0, samplesRead = 0, decoderStarts = 0, cacheHits = 0, peak = 0; + const open = (url) => { + let pending = sources.get(url); + if (pending === undefined) { + pending = (async () => { + const input = new media.Input({ source: new media.UrlSource(url), formats: media.ALL_FORMATS }); + try { + const track = await input.getPrimaryVideoTrack(); + if (track === null || !await track.canDecode()) { + throw new Error(`Browser video decoder does not support ${url}`); + } + // Untagged SDR follows the FFmpeg source path's BT.601 matrix and sRGB transfer. + const decoderConfig = track.getDecoderConfig.bind(track); + track.getDecoderConfig = async () => { + const config = await decoderConfig(); + if (!config) return config; + return { ...config, colorSpace: { + primaries: config.colorSpace?.primaries ?? "bt709", + transfer: config.colorSpace?.transfer ?? "iec61966-2-1", + matrix: config.colorSpace?.matrix ?? "smpte170m", + fullRange: config.colorSpace?.fullRange ?? false, + } }; + }; + return { input, sink: new media.VideoSampleSink(track), cursors: new Map(), + colorSpace: (await track.getDecoderConfig())?.colorSpace, + firstTimestamp: await track.getFirstTimestamp(), width: track.displayWidth, height: track.displayHeight }; + } catch (error) { input.dispose(); throw error; } + })(); + sources.set(url, pending); + } + return pending; + }; + const retain = (key, sample, protectedKey) => { + const previous = cache.get(key); + if (previous !== undefined) { + sample.close(); + return; + } + const bytes = sample.allocationSize(); + while (resident + bytes > maxBytes) { + const victim = [...cache.keys()].find(key => key !== protectedKey); + if (victim === undefined) { + if (key !== protectedKey) { sample.close(); return; } + break; + } + const entry = cache.get(victim); + entry.sample.close(); + resident -= entry.bytes; + cache.delete(victim); + } + cache.set(key, { sample, bytes }); + resident += bytes; + peak = Math.max(peak, resident); + }; + const closeCursor = async (cursor) => { + cursor.current?.close(); cursor.next?.close(); + await cursor.iterator?.return(); + }; + const readSample = async (source, id, timestamp, endTimestamp) => { + // Each layer advances independently; the source and retained samples are shared. + let cursor = source.cursors.get(id); + if (!cursor) { cursor = {}; source.cursors.set(id, cursor); } + if (!cursor.iterator || timestamp < cursor.current?.timestamp || timestamp >= cursor.endTimestamp + || timestamp - cursor.current?.timestamp > 2) { + if (cursor.iterator) await cursor.iterator.return(); + cursor.current?.close(); cursor.next?.close(); + decoderStarts++; + cursor.endTimestamp = endTimestamp ?? Infinity; + cursor.iterator = source.sink.samples(timestamp, endTimestamp); + cursor.current = (await cursor.iterator.next()).value; + cursor.next = (await cursor.iterator.next()).value; + if (cursor.current) samplesRead++; + if (cursor.next) samplesRead++; + } + while (cursor.next && cursor.next.timestamp <= timestamp) { + cursor.current?.close(); + cursor.current = cursor.next; + cursor.next = (await cursor.iterator.next()).value; + if (cursor.next) samplesRead++; + } + if (!cursor.current) throw new Error(`Browser video has no sample at ${timestamp}`); + const sample = cursor.current; + // Planar alpha samples need the source color tags before RGB conversion. + if (/^I\d{3}A/.test(sample.format)) { + const bytes = new Uint8Array(sample.allocationSize()); + const layout = await sample.copyTo(bytes); + return new media.VideoSample(bytes, { format: sample.format, codedWidth: sample.codedWidth, + codedHeight: sample.codedHeight, timestamp: sample.timestamp, duration: sample.duration, + rotation: sample.rotation, flip: sample.flip, visibleRect: sample.visibleRect, + displayWidth: sample.displayWidth, displayHeight: sample.displayHeight, + colorSpace: source.colorSpace, layout }); + } + return sample.clone(); + }; + const w = window; + w.__hypitVideoStats = () => ({ samplesRead, decoderStarts, cacheHits, peak }); + w.__hypitDrawVideoSources = async (updates) => { + const active = new Map(updates.map(update => [update.id, update.url])); + const activeUrls = new Set(updates.map(update => update.url)); + for (const [url, pending] of sources) { + const source = await pending; + for (const [id, cursor] of source.cursors) { + if (active.get(id) === url) continue; + await closeCursor(cursor); source.cursors.delete(id); + } + if (!activeUrls.has(url)) { source.input.dispose(); sources.delete(url); } + } + for (const canvas of Array.from(document.querySelectorAll("[data-hypit-source-frame-canvas]"))) { + if (!active.has(canvas.dataset.hypitSourceFrameCanvas) && canvas.style.display !== "none") { + canvas.style.display = "none"; + canvas.width = canvas.height = 1; + } + } + for (const update of updates) { + const selection = update.sample; + if (selection === undefined) + throw new Error("Browser video sampling metadata is missing"); + const source = await open(update.url); + const keyFor = (frame) => `${update.url}:${frame}`; + const key = keyFor(selection.frame); + if (!cache.has(key)) { + // Reverse playback decodes a bounded forward interval, then reuses its samples. + const count = selection.reverse ? Math.max(1, Math.min(64, + Math.floor(maxBytes / Math.max(1, updates.length) / (source.sampleBytes ?? source.width * source.height * 4)))) : 1; + const start = selection.reverse ? Math.max(0, selection.frame - count + 1) : selection.frame; + const length = selection.reverse ? selection.frame - start + 1 : count; + const timestamps = Array.from({ length }, (_, index) => source.firstTimestamp + + (start + index + 0.5) * selection.fps.den / selection.fps.num); + const endTimestamp = selection.reverse ? source.firstTimestamp + + (selection.frame + 1) * selection.fps.den / selection.fps.num : undefined; + for (let index = 0; index < timestamps.length; index++) { + const sample = await readSample(source, update.id, timestamps[index], endTimestamp); + source.sampleBytes = sample.allocationSize(); + retain(keyFor(start + index), sample, key); + } + } else cacheHits++; + const entry = cache.get(key); + if (entry === undefined) + throw new Error("Browser video cache lost a requested sample"); + cache.delete(key); + cache.set(key, entry); + const video = document.getElementById(update.id); + if (video === null) + throw new Error(`HTML Program source element ${update.id} is missing`); + let canvas = document.querySelector(`[data-hypit-source-frame-canvas="${CSS.escape(update.id)}"]`); + if (canvas === null) { + canvas = document.createElement("canvas"); + canvas.dataset.hypitSourceFrameCanvas = update.id; + video.insertAdjacentElement("afterend", canvas); + } + canvas.className = video.className; + canvas.style.cssText = video.style.cssText; + canvas.style.visibility = video.dataset.hypitAuthoredVisibility ?? video.style.visibility; + canvas.style.display = video.style.display; + if (canvas.width !== source.width) + canvas.width = source.width; + if (canvas.height !== source.height) + canvas.height = source.height; + video.style.visibility = "hidden"; + const context = canvas.getContext("2d"); + if (context === null) + throw new Error("Browser video canvas could not be created"); + context.clearRect(0, 0, canvas.width, canvas.height); + entry.sample.draw(context, 0, 0, canvas.width, canvas.height); + } + }; + let disposing; + w.__hypitDisposeVideo = () => disposing ??= (async () => { + for (const entry of cache.values()) entry.sample.close(); + cache.clear(); resident = 0; + const pending = [...sources.values()]; sources.clear(); + await Promise.all(pending.map(async value => { + const source = await value.catch(() => undefined); + if (!source) return; + try { await Promise.all([...source.cursors.values()].map(closeCursor)); } + finally { source.input.dispose(); } + })); + })(); + window.addEventListener("pagehide", () => { void w.__hypitDisposeVideo().catch(() => {}); }, { once: true }); +} diff --git a/packages/provider-html-local/src/capture-process.ts b/packages/provider-html-local/src/capture-process.ts index af9acbcfa..a7c8eede6 100644 --- a/packages/provider-html-local/src/capture-process.ts +++ b/packages/provider-html-local/src/capture-process.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { killRenderTree } from "./process-tree.js"; import type { CaptureInput } from "./capture.js"; import type { HtmlRasterProgress } from "./render.js"; +import { electronEnvironment } from "./electron.js"; const cleanupMs = 5_000; @@ -17,11 +18,15 @@ export async function runCaptureProcess( ): Promise { signal.throwIfAborted(); await new Promise((resolve, reject) => { - const child = spawn(process.execPath, [ + const gpu = input.frames === undefined && input.config.electronPath !== undefined; + const child = spawn(gpu ? input.config.electronPath! : process.execPath, gpu ? [ + fileURLToPath(new URL("../src/gpu-worker.cjs", import.meta.url)), input.directory, entry.href, + ] : [ "--import", import.meta.resolve("tsx"), "--import", new URL("./capture-exit.js", import.meta.url).href, fileURLToPath(entry), ], { + ...(gpu ? { env: electronEnvironment() } : {}), detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe", "ipc"], }); diff --git a/packages/provider-html-local/src/capture-worker.ts b/packages/provider-html-local/src/capture-worker.ts index d1d52dafc..0b5949a9d 100644 --- a/packages/provider-html-local/src/capture-worker.ts +++ b/packages/provider-html-local/src/capture-worker.ts @@ -1,6 +1,7 @@ import { releaseCaptureExitCleanup } from "./capture-exit.js"; import { captureStagedVisual } from "./capture.js"; import type { CaptureInput } from "./capture.js"; +import { createRequire } from "node:module"; const controller = new AbortController(); const message = (error: unknown) => error instanceof Error ? error.message : String(error); @@ -21,6 +22,7 @@ const finish = (value: { type: "completed" } | { type: "failed"; error: string } process.send?.(value, undefined, undefined, (error) => { if (error) { console.error(message(error)); process.exitCode = 1; } if (process.connected) process.disconnect(); + if (process.versions.electron !== undefined) createRequire(import.meta.url)("electron").app.exit(error ? 1 : 0); }); }; controller.signal.addEventListener("abort", () => { diff --git a/packages/provider-html-local/src/capture.ts b/packages/provider-html-local/src/capture.ts index 449378e54..c2011a22d 100644 --- a/packages/provider-html-local/src/capture.ts +++ b/packages/provider-html-local/src/capture.ts @@ -5,7 +5,7 @@ import type { Browser } from "puppeteer-core"; import type { HtmlProgram } from "@hypit/html-program"; import type { MediaFrameRange } from "@hypit/media"; import type { HtmlRasterProgress, resolveExecutionOptions } from "./render.js"; -import { assert, mediaExecutablePath, openProcessInput } from "./process.js"; +import { assert, mediaExecutablePath, openProcessInput, runProcess } from "./process.js"; import { verifyOutput } from "./output.js"; import { distributeFrameRange, requestedFrameRanges, sourceFrameAt, sourceWindows, videoSlots } from "./sampling.js"; import type { VideoSlot } from "./sampling.js"; @@ -17,6 +17,8 @@ import { decodeSourceFrameWindow, probeSourceVideo } from "./source-frame-decode import { SourceFrameStore } from "./source-frame-store.js"; import type { SourceFrameWindow } from "./source-frame-store.js"; import { serveHtmlProgram } from "./server.js"; +import { createGpuCapture, gpuCapturePages } from "./gpu-capture.js"; +import type { SourceFrameUpdate } from "./opaque-capture.js"; export type CaptureInput = { readonly document: HtmlProgram; @@ -39,11 +41,12 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort const selected = input.frames === undefined ? undefined : new Set(input.frames); const at = (index: number) => input.frames?.[index] ?? range.startFrame + index; const fps = { num: document.frameRate.numerator, den: document.frameRate.denominator }; - const limit = renderWorkerLimit(config, frameCount, fps.num / fps.den); - const concurrency = new CaptureConcurrency(limit, config.workers === "auto"); - // Short contiguous batches retain sequential capture while allowing free browsers - // to help with expensive passages. This queue exists only inside this render. - const batchCount = Math.max(limit, Math.ceil(frameCount / Math.max(1, Math.round(fps.num / fps.den)))); + const useGpu = input.frames === undefined && config.electronPath !== undefined; + const limit = renderWorkerLimit(config, frameCount, fps.num / fps.den, useGpu && document.frameSources.length > 0); + const concurrency = new CaptureConcurrency(limit, config.workers === "auto" && !useGpu); + // GPU snapshots enter a bounded reorder queue; short consecutive batches + // keep nearby source requests in the same decoder. + const batchCount = useGpu ? Math.max(limit, Math.ceil(frameCount / 4)) : Math.max(limit, Math.ceil(frameCount / Math.max(1, Math.round(fps.num / fps.den)))); const batches = distributeFrameRange({ startFrame: 0, endFrameExclusive: frameCount }, batchCount); let nextBatch = 0; const started = performance.now(); @@ -78,6 +81,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort } }; let server: Awaited> | undefined; + let gpu: Awaited> | undefined; let encoderCompletion: Promise | undefined; let sourceFrameStore: SourceFrameStore | undefined; // Closing pages interrupts an in-flight capture as well as the next loop iteration. @@ -100,7 +104,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort metadata: Awaited> }>(); // Chrome needs dimensions before initialization. Pixel frames themselves // are decoded lazily for the screenshots that lease them. - for (const source of plannedSources) { + for (const source of useGpu ? [] : plannedSources) { const path = resolve(work, source.src); assert(path.startsWith(`${work}${sep}`), "HTML renderer source is outside the staged project"); const metadata = await probeSourceVideo({ executable: ffprobePath, path, @@ -109,7 +113,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort sources.set(source.src, { path, fps: source.fps, metadata }); } let extraction = 0; - sourceFrameStore = new SourceFrameStore({ maxBytes: config.maxDecodedSourceBytes, signal, + if (!useGpu) sourceFrameStore = new SourceFrameStore({ maxBytes: config.maxDecodedSourceBytes, signal, decode: (src, window) => { const source = sources.get(src); assert(source !== undefined, `HTML renderer source ${src} is outside the requested render`); @@ -136,12 +140,17 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort } return payloads; }; - server = await serveHtmlProgram(work); + const gpuPages = useGpu ? await gpuCapturePages(document.canvas) : undefined; + server = await serveHtmlProgram(work, gpuPages?.pages); + if (gpuPages !== undefined) { + gpu = await stage("GPU encoder initialization", config.initializationTimeoutMs, + () => createGpuCapture(input, server!.url, gpuPages, controller, limit)); + } const serverUrl = server.url; const outputFrames = join(work, "frames"); const output = join(work, "visual.mp4"); const crf = { draft: 28, standard: 23, high: 18 }[config.quality]; - const encoder = input.frames === undefined ? openProcessInput({ executable: ffmpegPath, + const encoder = input.frames === undefined && !useGpu ? openProcessInput({ executable: ffmpegPath, argv: ["-v", "error", "-y", "-f", "image2pipe", "-framerate", `${fps.num}/${fps.den}`, "-vcodec", "png", "-i", "pipe:0", "-frames:v", String(frameCount), "-an", "-c:v", "libx264", "-crf", String(crf), "-preset", config.quality === "draft" ? "veryfast" : "medium", @@ -154,8 +163,8 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort frameCount, maxPendingBytes: config.maxPendingFrameBytes, signal, writer: { write: encoder.write, close: async () => { await encoder.close(); } }, }); - if (encoder === undefined) await mkdir(outputFrames); - else { + if (input.frames !== undefined) await mkdir(outputFrames); + if (encoder !== undefined) { encoderCompletion = encoder.completed; void encoder.completed.catch((error) => { orderedFrames!.fail(error); @@ -184,30 +193,44 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort let setupMs = 0; try { signal.throwIfAborted(); - session = await puppeteer.launch({ executablePath: config.chromePath, headless: true, - defaultViewport: { width: document.canvas.width, height: document.canvas.height, deviceScaleFactor: 1 }, - args: ["--no-sandbox", "--disable-dev-shm-usage", - ...(config.browserGpu === "software" ? ["--disable-gpu"] : []), - ] }); - sessions.add(session); - signal.throwIfAborted(); - const page = await session.newPage(); - if (config.initializationTimeoutMs !== undefined) { - page.setDefaultTimeout(config.initializationTimeoutMs); - page.setDefaultNavigationTimeout(config.initializationTimeoutMs); - } - await page.evaluateOnNewDocument(input.frameSelectionPrelude); - const browserPid = session.process()?.pid; - onProgress?.({ phase: "worker-initializing", worker, range, browserPid, elapsedMs: elapsedMs() }); - await stage(`worker ${worker} initialization`, config.initializationTimeoutMs, async () => { - await page.goto(serverUrl, { waitUntil: "load" }); - await page.evaluate(async () => { - const program = (window as unknown as { __hypitFrameProgram?: { prepare(): Promise } }).__hypitFrameProgram; - if (program === undefined) throw new Error("HTML Program page ABI is missing"); - await program.prepare(); + let browserPid: number | undefined; + let videoStats: (() => Promise) | undefined; + let releaseVideo: (() => Promise) | undefined; + let captureFrame: (frame: number, sources: readonly SourceFrameUpdate[], ordinal: number, prepared: () => void) + => Promise>>>>; + if (gpu !== undefined) { + onProgress({ phase: "worker-initializing", worker, range, browserPid: undefined, elapsedMs: elapsedMs() }); + const gpuWorker = await stage(`worker ${worker} initialization`, config.initializationTimeoutMs, () => gpu!.createWorker()); + browserPid = gpuWorker.browserPid; + captureFrame = gpuWorker.capture; + videoStats = gpuWorker.stats; + releaseVideo = gpuWorker.releaseSources; + } else { + session = await puppeteer.launch({ executablePath: config.chromePath, headless: true, + defaultViewport: { width: document.canvas.width, height: document.canvas.height, deviceScaleFactor: 1 }, + args: ["--no-sandbox", "--disable-dev-shm-usage", + ...(config.browserGpu === "software" ? ["--disable-gpu"] : []), + ] }); + sessions.add(session); + signal.throwIfAborted(); + const page = await session.newPage(); + if (config.initializationTimeoutMs !== undefined) { + page.setDefaultTimeout(config.initializationTimeoutMs); + page.setDefaultNavigationTimeout(config.initializationTimeoutMs); + } + await page.evaluateOnNewDocument(input.frameSelectionPrelude); + browserPid = session.process()?.pid; + onProgress?.({ phase: "worker-initializing", worker, range, browserPid, elapsedMs: elapsedMs() }); + await stage(`worker ${worker} initialization`, config.initializationTimeoutMs, async () => { + await page.goto(serverUrl, { waitUntil: "load" }); + await page.evaluate(async () => { + const program = (window as unknown as { __hypitFrameProgram?: { prepare(): Promise } }).__hypitFrameProgram; + if (program === undefined) throw new Error("HTML Program page ABI is missing"); + await program.prepare(); + }); }); - }); - const captureFrame = await createOpaqueFrameCapture(page, document.canvas); + captureFrame = await createOpaqueFrameCapture(page, document.canvas); + } setupMs = performance.now() - launched; startupMs = Math.max(startupMs, setupMs); ready = true; @@ -226,24 +249,26 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort for (let index = batch.startFrame; index < batch.endFrameExclusive; index++) { const frame = at(index); signal.throwIfAborted(); - const required = sourceDemand(frameRanges(index, index + 1)); + const required = useGpu ? [] : sourceDemand(frameRanges(index, index + 1)); // A preference is the unconsumed suffix, not the whole batch: frames // behind this Worker must become ordinary eviction candidates. - const preferred = sourceDemand(frameRanges(index, batch.endFrameExclusive)); + const preferred = useGpu ? [] : sourceDemand(frameRanges(index, batch.endFrameExclusive)); const sourceAcquireStarted = performance.now(); - const sourceLease = await sourceFrameStore!.acquire(required, preferred); + const sourceLease = useGpu ? undefined : await sourceFrameStore!.acquire(required, preferred); sourceAcquireWorkerMs += performance.now() - sourceAcquireStarted; let captured: Awaited>; try { - const payloads = activeFramePayloads(frame); - const sourceUpdates = [...payloads].map(([id, payload]) => ({ id, - url: new URL(relative(work, payload.framePath).split(sep).map(encodeURIComponent).join("/"), serverUrl).href })); + const sourceUpdates: SourceFrameUpdate[] = useGpu + ? slotIndex.at(frame).map(slot => ({ id: slot.id, url: new URL(slot.src, serverUrl).href, + sample: { frame: sourceFrameAt(slot, frame), fps: slot.sourceFps, reverse: slot.sourceRate.numerator < 0n } })) + : [...activeFramePayloads(frame)].map(([id, payload]) => ({ id, + url: new URL(relative(work, payload.framePath).split(sep).map(encodeURIComponent).join("/"), serverUrl).href })); captured = await stage(`worker ${worker} frame ${frame}`, config.frameTimeoutMs, - () => captureFrame(frame, sourceUpdates)); + () => captureFrame(frame, sourceUpdates, index, () => sourceLease?.release())); } finally { // Output submission may block behind an earlier frame. Never // retain source leases across that independent backpressure. - sourceLease.release(); + sourceLease?.release(); } if (input.frames !== undefined) { capturedBytes += captured.buffer.byteLength; @@ -252,9 +277,9 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort timing.seekMs += captured.seekMs; timing.prepareMs += captured.prepareMs; timing.screenshotMs += captured.screenshotMs; - if (orderedFrames === undefined) { + if (input.frames !== undefined) { await writeFile(join(outputFrames, `${String(index).padStart(9, "0")}.png`), captured.buffer); - } else { + } else if (orderedFrames !== undefined) { const outputSubmitStarted = performance.now(); await orderedFrames.submit(index, captured.buffer); outputSubmitWorkerMs += performance.now() - outputSubmitStarted; @@ -274,7 +299,9 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort } } console.log(`Capture worker ${worker}: ${completed} frames; startup ${Math.round(setupMs)} ms; ` - + `seek ${Math.round(timing.seekMs)} ms; prepare ${Math.round(timing.prepareMs)} ms; PNG ${Math.round(timing.screenshotMs)} ms`); + + `seek ${Math.round(timing.seekMs)} ms; prepare ${Math.round(timing.prepareMs)} ms; ${useGpu ? "texture and encode" : "PNG"} ${Math.round(timing.screenshotMs)} ms`); + if (videoStats !== undefined) console.log(`Capture worker ${worker} video: ${JSON.stringify(await videoStats())}`); + if (releaseVideo !== undefined) await stage(`worker ${worker} media cleanup`, 5_000, releaseVideo); onProgress({ phase: "worker-complete", worker, completed, browserPid, elapsedMs: elapsedMs() }); } catch (error) { controller.abort(error); @@ -290,7 +317,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort } }; const initialWorkers = concurrency.target; - console.log(`Capture: opaque fast PNG; ${config.workers === "auto" ? "auto" : "fixed"} workers ${initialWorkers}, limit ${limit}`); + console.log(`Capture: ${useGpu ? "shared GPU texture" : "opaque fast PNG"}; ${config.workers === "auto" ? "auto" : "fixed"} workers ${initialWorkers}, limit ${limit}`); for (let i = 0; i < initialWorkers; i++) launch(); // New workers can join while earlier jobs run; collect every launched job. let awaited = 0; @@ -304,14 +331,24 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort if (failure !== undefined) throw failure; signal.throwIfAborted(); await Promise.all(closing.values()); - console.log(`Capture source working set: ${sourceTotal} unique frames demanded; decoded ${sourceFrameStore.decodedFrames} frames; ` + if (sourceFrameStore !== undefined) console.log(`Capture source working set: ${sourceTotal} unique frames demanded; decoded ${sourceFrameStore.decodedFrames} frames; ` + `redecoded ${Math.max(0, sourceFrameStore.decodedFrames - sourceTotal)}; decoder runs ${extraction}; ` + `decoded bytes ${sourceFrameStore.decodedBytes}; largest frame ${sourceFrameStore.largestDecodedFrameBytes} bytes; ` + `peak ${sourceFrameStore.peakResidentBytes} bytes; budget ${config.maxDecodedSourceBytes} bytes; ` + `source acquire ${Math.round(sourceAcquireWorkerMs)} worker-ms; output submit ${Math.round(outputSubmitWorkerMs)} worker-ms`); if (input.frames !== undefined) return outputFrames; onProgress({ phase: "encoding", elapsedMs: elapsedMs() }); - await orderedFrames!.close(); + if (gpu !== undefined) { + const raw = await gpu.finish(); + await gpu.close(); + // Use the same integer clock for input packets, setts and the MP4 track. + // Each frame occupies fps.den ticks, including the final packet. + await runProcess({ executable: ffmpegPath, + argv: ["-v", "error", "-y", "-r", String(fps.num), "-i", raw, + "-an", "-c:v", "copy", "-bsf:v", `setts=pts=N*${fps.den}:dts=N*${fps.den}:duration=${fps.den}:time_base=1/${fps.num}`, "-video_track_timescale", String(fps.num), + "-movflags", "+faststart", output], + timeoutMs: config.processTimeoutMs, maxOutputBytes: config.maxProcessOutputBytes, signal }); + } else await orderedFrames!.close(); const outputStat = await stat(output); assert(outputStat.size > 0 && outputStat.size <= config.maxRenderedBytes, "HTML renderer output is empty or exceeds its byte limit"); await verifyOutput({ path: output, document: { ...document, frameCount }, ffprobePath, @@ -326,6 +363,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort await Promise.allSettled([...sessions].map(close)); if (sourceFrameStore !== undefined) await sourceFrameStore.close(); if (encoderCompletion !== undefined) await Promise.allSettled([encoderCompletion]); + if (gpu !== undefined) await gpu.close(); if (server !== undefined) await server.close(); } } diff --git a/packages/provider-html-local/src/concurrency.ts b/packages/provider-html-local/src/concurrency.ts index c678b6ebf..707dc33ae 100644 --- a/packages/provider-html-local/src/concurrency.ts +++ b/packages/provider-html-local/src/concurrency.ts @@ -18,7 +18,10 @@ export function renderWorkerLimit( config: { readonly maxWorkers: number; readonly workers: "auto" | number }, frameCount: number, fps: number, + gpuVideo = false, ): number { + // One GPU scene shares its source decoders and cache across the full timeline. + if (config.workers === "auto" && gpuVideo) return 1; return Math.min(config.maxWorkers, config.workers === "auto" ? Math.max(1, Math.ceil(frameCount / fps)) : frameCount); } diff --git a/packages/provider-html-local/src/electron-install.ts b/packages/provider-html-local/src/electron-install.ts new file mode 100644 index 000000000..eff5be221 --- /dev/null +++ b/packages/provider-html-local/src/electron-install.ts @@ -0,0 +1,5 @@ +import { installHtmlElectron } from "./electron.js"; + +const [cacheDir, baseUrl] = process.argv.slice(2); +if (process.argv.length < 3 || process.argv.length > 4 || !cacheDir) throw new Error("Expected the Electron cache directory and optional archive base URL"); +await installHtmlElectron(cacheDir, baseUrl); diff --git a/packages/provider-html-local/src/electron.ts b/packages/provider-html-local/src/electron.ts new file mode 100644 index 000000000..e005b2943 --- /dev/null +++ b/packages/provider-html-local/src/electron.ts @@ -0,0 +1,134 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { access, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; +import { constants, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { downloadArtifact } from "@electron/get"; +import extract from "extract-zip"; +import { lock } from "proper-lockfile"; + +const release = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).hypit.htmlElectron as { + version: string; checksums: Record; +}; +export const recommendedElectronVersion = release.version; +export type ElectronOptions = { + readonly renderer?: "chromium" | "electron"; + readonly electronPath?: string; + readonly electronCacheDirectory?: string; + /** Release archive base URL; selected archives must match the Provider's checksums. */ + readonly electronDownloadBaseUrl?: string; +}; + +export function usesElectron(options: ElectronOptions): boolean { + const renderer = options.renderer ?? (options.electronPath === undefined ? "chromium" : "electron"); + if (!["chromium", "electron"].includes(renderer)) throw new Error("HTML renderer renderer must be chromium or electron"); + if (renderer !== "electron" && [options.electronPath, options.electronCacheDirectory, options.electronDownloadBaseUrl].some(value => value !== undefined)) { + throw new Error("Electron configuration requires renderer electron"); + } + if (options.electronPath !== undefined && (options.electronPath.trim() === "" + || options.electronCacheDirectory !== undefined || options.electronDownloadBaseUrl !== undefined)) { + throw new Error("electronPath must be nonempty and is mutually exclusive with managed Electron configuration"); + } + return renderer === "electron"; +} + +export function electronCacheDirectory(options: ElectronOptions): string { + return resolve(options.electronCacheDirectory ?? join(homedir(), ".cache", "hypit", "html-rasterizer", "electron")); +} +function executableRelativePath(): string { + if (process.platform === "darwin") return "Electron.app/Contents/MacOS/Electron"; + if (process.platform === "win32") return "electron.exe"; + if (process.platform === "linux") return "electron"; + throw new Error(`Managed Electron is unavailable for ${process.platform}`); +} +function archiveName(): string { + const name = `electron-v${release.version}-${process.platform}-${process.arch}.zip`; + if (!release.checksums[name]) throw new Error(`Managed Electron is unavailable for ${process.platform}/${process.arch}`); + return name; +} +export function electronDownloadUrl(options: ElectronOptions): string { + const value = options.electronDownloadBaseUrl ?? "https://github.com/electron/electron/releases/download"; + let base: URL; + try { base = new URL(value); } catch { throw new Error("electronDownloadBaseUrl must be an absolute HTTP(S) URL"); } + if (!["http:", "https:"].includes(base.protocol) || base.username || base.password || base.search || base.hash) { + throw new Error("electronDownloadBaseUrl must be an HTTP(S) archive base without credentials, query or fragment"); + } + return `${base.href.replace(/\/+$/u, "")}/v${release.version}/${archiveName()}`; +} +export function electronExecutablePath(options: ElectronOptions): string | undefined { + if (!usesElectron(options)) return undefined; + if (options.electronPath !== undefined) return resolve(options.electronPath); + electronDownloadUrl(options); + return join(electronCacheDirectory(options), `${release.version}-${process.platform}-${process.arch}`, executableRelativePath()); +} +export function electronEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.NODE_OPTIONS; + return env; +} +export async function requireElectronExecutable(path: string): Promise { + try { + if (!(await stat(path)).isFile()) throw new Error("Electron path is not a file"); + await access(path, process.platform === "win32" ? constants.F_OK : constants.X_OK); + const { stdout } = await promisify(execFile)(path, ["-p", "process.versions.electron"], { + timeout: 15_000, windowsHide: true, env: { ...electronEnvironment(), ELECTRON_RUN_AS_NODE: "1" }, + }); + if (stdout.trim() !== release.version) throw new Error(`Expected Electron ${release.version}, received ${stdout.trim()}`); + if (process.platform === "darwin") { + const { stdout: agent } = await promisify(execFile)("/usr/bin/plutil", [ + "-extract", "LSUIElement", "raw", "-expect", "bool", "-o", "-", join(dirname(dirname(path)), "Info.plist"), + ], { timeout: 15_000 }).catch(() => { + throw new Error('macOS Electron must be a background app with LSUIElement=true; use renderer "electron" for managed preparation, or select a prepared background app'); + }); + if (agent.trim() !== "true") throw new Error("macOS Electron requires LSUIElement=true"); + } + } catch (error) { + throw new Error(`HTML renderer Electron is unavailable at ${path}: ${error instanceof Error ? error.message : String(error)}. Prepare the selected Runtime with hypit runtime up --runtime , or correct its electronPath.`); + } +} + +/** Explicit Runtime preparation installs a checksum-verified, versioned distribution. */ +export async function installHtmlElectron(cacheDir: string, downloadBaseUrl?: string): Promise { + const options = { renderer: "electron" as const, electronCacheDirectory: cacheDir, + ...(downloadBaseUrl === undefined ? {} : { electronDownloadBaseUrl: downloadBaseUrl }) }; + const executable = electronExecutablePath(options)!; + const directory = join(electronCacheDirectory(options), `${release.version}-${process.platform}-${process.arch}`); + const cacheRoot = electronCacheDirectory(options); + await mkdir(cacheRoot, { recursive: true }); + // Independent Runtime profiles can select the same archive cache. + const unlock = await lock(cacheRoot, { stale: 600_000, retries: { + retries: 360, factor: 1, minTimeout: 1_000, maxTimeout: 1_000, + } }); + try { + try { await requireElectronExecutable(executable); return; } catch { /* Explicit preparation repairs this selection. */ } + const url = electronDownloadUrl(options); + process.stdout.write(`Preparing Electron ${release.version} at ${executable}\nDownload source: ${url}\n`); + await mkdir(dirname(directory), { recursive: true }); + const stage = await mkdtemp(`${directory}-staging-`); + try { + const archive = await downloadArtifact({ version: release.version, artifactName: "electron", + platform: process.platform, arch: process.arch, cacheRoot: join(electronCacheDirectory(options), "downloads"), + checksums: release.checksums, downloadOptions: { signal: AbortSignal.timeout(300_000) }, + mirrorOptions: { resolveAssetURL: async details => { + if (details.version !== `v${release.version}`) throw new Error("Electron environment version override conflicts with the Provider selection"); + return url; + } }, + }); + await extract(archive, { dir: stage }); + if (process.platform === "darwin") { + const bundle = join(stage, "Electron.app"); + // Launch Services reads this before Electron can execute the worker script. + await promisify(execFile)("/usr/bin/plutil", ["-insert", "LSUIElement", "-bool", "YES", join(bundle, "Contents", "Info.plist")]); + await promisify(execFile)("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", + "--preserve-metadata=identifier,entitlements,flags,runtime", bundle], { timeout: 60_000 }); + await promisify(execFile)("/usr/bin/codesign", ["--verify", "--deep", "--strict", bundle], { timeout: 60_000 }); + } + await requireElectronExecutable(join(stage, executableRelativePath())); + await rm(directory, { recursive: true, force: true }); + await rename(stage, directory); + await requireElectronExecutable(executable); + } finally { await rm(stage, { recursive: true, force: true }); } + } finally { await unlock(); } +} diff --git a/packages/provider-html-local/src/gpu-capture.ts b/packages/provider-html-local/src/gpu-capture.ts new file mode 100644 index 000000000..7034acec6 --- /dev/null +++ b/packages/provider-html-local/src/gpu-capture.ts @@ -0,0 +1,244 @@ +import { createRequire } from "node:module"; +import { createWriteStream } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { finished } from "node:stream/promises"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BrowserWindow, IpcMainEvent, OffscreenSharedTexture } from "electron"; +import type { Page } from "puppeteer-core"; +import type { CaptureInput } from "./capture.js"; +import { createFramePreparer } from "./opaque-capture.js"; +import type { SourceFrameUpdate } from "./opaque-capture.js"; +import { assert } from "./process.js"; + +export async function gpuCapturePages({ width, height }: { width: number; height: number }) { + const shell = `/__hypit_capture_${randomUUID()}.html`; + const encoder = `/__hypit_encoder_${randomUUID()}.html`; + const decoder = `/__hypit_decoder_${randomUUID()}.mjs`; + const video = `/__hypit_video_${randomUUID()}.mjs`; + const bundle = await readFile(import.meta.url.endsWith(".ts") + ? new URL("../../bundles/mediabunny.min.mjs", import.meta.resolve("mediabunny")) + : new URL("../browser/mediabunny/dist/bundles/mediabunny.min.mjs", import.meta.url), "utf8"); + const videoModule = await readFile(new URL("../src/browser-video.mjs", import.meta.url), "utf8"); + const html = ``; + return { shell, encoder, decoder, video, pages: new Map([[shell, html], [encoder, "Hypit encoder"], [decoder, bundle], [video, videoModule]]) }; +} + +type PendingFrame = { token: number; ordinal: number; resolve(): void; reject(error: unknown): void }; +type Scene = { window: BrowserWindow; pending?: PendingFrame | undefined; + latest?: OffscreenSharedTexture | undefined; busy: boolean }; + +/** One request owns its windows, texture references and ordered H.264 stream. */ +export async function createGpuCapture(input: CaptureInput, origin: string, + pages: Awaited>, controller: AbortController, workers: number) { + const { BrowserWindow, ipcMain, sharedTexture } = createRequire(import.meta.url)("electron") as typeof import("electron"); + const { config, document } = input; + const { width, height } = document.canvas; + const frameCount = input.range.endFrameExclusive - input.range.startFrame; + const fps = document.frameRate.numerator / document.frameRate.denominator; + assert(width % 2 === 0 && height % 2 === 0, "Shared-texture H.264 export requires even canvas dimensions"); + assert(frameCount < 2 ** 32, "Shared-texture export exceeds the frame identifier range"); + assert(sharedTexture?.importSharedTexture !== undefined, "This Electron build does not provide sharedTexture"); + const signal = controller.signal; + const raw = join(input.directory, "encoded.h264"); + const writer = createWriteStream(raw); + const writerClosed = finished(writer).catch(() => {}); + const scenes = new Set(); + let transferring = false; + let closing = false, bytesWritten = 0, chunks = 0, lastTimestamp = -1; + const encoding = new BrowserWindow({ show: false, width: 300, height: 200, webPreferences: { + preload: fileURLToPath(new URL("../src/gpu-encoder.cjs", import.meta.url)), sandbox: false, backgroundThrottling: false, + } }); + const fail = (error: unknown) => { if (!closing) controller.abort(error instanceof Error ? error : new Error(String(error))); }; + writer.on("error", fail); + const trusted = (event: IpcMainEvent) => event.sender === encoding.webContents; + function processTexture() { + if (closing || transferring) return; + const scene = [...scenes].filter(item => !item.busy && item.pending !== undefined && item.latest !== undefined) + .sort((a, b) => a.pending!.ordinal - b.pending!.ordinal)[0]; + if (scene === undefined) return; + const texture = scene.latest!; + scene.latest = undefined; + scene.busy = true; + let imported: ReturnType; + try { + imported = sharedTexture.importSharedTexture({ textureInfo: texture.textureInfo, allReferencesReleased: () => texture.release() }); + } catch (error) { texture.release(); fail(error); return; } + // Electron limits each native transfer handshake to one second. Submit the + // earliest ready frame and wait for its import acknowledgement before another. + // Snapshot copying and encoding can continue concurrently in the receiver. + transferring = true; + const request = { token: scene.pending!.token, ordinal: scene.pending!.ordinal }; + void Promise.resolve().then(() => sharedTexture.sendSharedTexture( + { frame: encoding.webContents.mainFrame, importedSharedTexture: imported }, request)) + .catch(fail).finally(() => { + imported.release(); transferring = false; processTexture(); + }).catch(fail); + } + const observed = (event: IpcMainEvent, token: number, accepted: boolean) => { + if (!trusted(event) || closing) return; + const scene = [...scenes].find(item => item.pending?.token === token); + if (scene === undefined) return; + scene.busy = false; + if (accepted) { + const pending = scene.pending!; + scene.pending = undefined; + scene.latest?.release(); scene.latest = undefined; + pending.resolve(); + } + processTexture(); + }; + const captureError = (event: IpcMainEvent, error: string) => { if (trusted(event)) fail(new Error(error)); }; + ipcMain.on("hypit-observed", observed); + ipcMain.on("hypit-capture-error", captureError); + ipcMain.handle("hypit-encoded", async (event, bytes: Uint8Array, timestamp: number) => { + assert(event.sender === encoding.webContents, "Unexpected encoder sender"); + signal.throwIfAborted(); + assert(timestamp > lastTimestamp && chunks < frameCount, "Encoder emitted an unexpected frame timestamp"); + lastTimestamp = timestamp; chunks++; bytesWritten += bytes.byteLength; + assert(bytesWritten <= config.maxRenderedBytes, "HTML renderer output exceeds its byte limit"); + await new Promise((resolve, reject) => writer.write(Buffer.from(bytes), error => error ? reject(error) : resolve())); + }); + const closeScene = (scene: Scene) => { + scene.pending?.reject(signal.reason ?? new Error("GPU capture closed")); scene.pending = undefined; + scene.latest?.release(); scene.latest = undefined; + if (!scene.window.isDestroyed()) scene.window.destroy(); + scenes.delete(scene); + }; + const close = async () => { + if (!closing) { + closing = true; + signal.removeEventListener("abort", aborted); + for (const scene of scenes) closeScene(scene); + if (!encoding.isDestroyed()) encoding.destroy(); + ipcMain.off("hypit-observed", observed); + ipcMain.off("hypit-capture-error", captureError); + ipcMain.removeHandler("hypit-encoded"); + writer.destroy(); + } + await writerClosed; + }; + const aborted = () => { void close(); }; + signal.addEventListener("abort", aborted, { once: true }); + encoding.webContents.on("render-process-gone", (_event, detail) => fail(new Error(`GPU encoder exited: ${detail.reason}`))); + encoding.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + + const waitMessage = (channel: string) => new Promise((resolve, reject) => { + const done = (event: IpcMainEvent) => { if (trusted(event)) { cleanup(); resolve(); } }; + const abort = () => { cleanup(); reject(signal.reason); }; + const cleanup = () => { ipcMain.off(channel, done); signal.removeEventListener("abort", abort); }; + ipcMain.on(channel, done); signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + signal.throwIfAborted(); + const ready = waitMessage("hypit-encoder-ready"); + void ready.catch(() => {}); + await encoding.loadURL(new URL(pages.encoder, origin).href); + await ready; + const configured = waitMessage("hypit-configured"); + void configured.catch(() => {}); + const bitrate = Math.max(64_000, Math.round({ draft: 6_000_000, standard: 12_000_000, high: 24_000_000 }[config.quality] + * width * height / (1920 * 1080) * fps / 30)); + encoding.webContents.send("hypit-configure", { width, height, fps, bitrate, frameCount, maxPendingBytes: config.maxPendingFrameBytes }); + await configured; + } catch (error) { controller.abort(error); await close(); throw error; } + + return { + async createWorker() { + signal.throwIfAborted(); + const window = new BrowserWindow({ show: false, width: Math.max(width, 256), height: height + 8, + useContentSize: true, backgroundColor: "#000000", webPreferences: { backgroundThrottling: false, + offscreen: { useSharedTexture: true, sharedTexturePixelFormat: "argb", deviceScaleFactor: 1 } } }); + const scene: Scene = { window, busy: false }; + scenes.add(scene); + window.webContents.setFrameRate(240); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("render-process-gone", (_event, detail) => fail(new Error(`GPU scene exited: ${detail.reason}`))); + window.webContents.on("paint", event => { + const texture = event.texture; + if (texture === undefined) return; + if (closing || texture.textureInfo.widgetType !== "frame") { texture.release(); return; } + scene.latest?.release(); scene.latest = texture; + processTexture(); + }); + const sameRenderer = () => { + const top = window.webContents.mainFrame; + assert(top.framesInSubtree.every(frame => frame.processId === top.processId), + "Shared-texture export requires child pages in the same renderer process"); + }; + await window.loadURL("about:blank"); + window.webContents.debugger.attach("1.3"); + await window.webContents.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: input.frameSelectionPrelude }); + await window.loadURL(new URL(pages.shell, origin).href); + const child = window.webContents.mainFrame.frames.find(frame => frame.url === new URL("index.html", origin).href); + assert(child !== undefined, "GPU capture shell did not load the HTML Program"); + sameRenderer(); + await child.executeJavaScript("__hypitFrameProgram.prepare()"); + const viewport = await child.executeJavaScript("({width:innerWidth,height:innerHeight})") as { width: number; height: number }; + assert(viewport.width === width && viewport.height === height, "GPU capture changed the HTML Program viewport"); + const page: Pick = { + evaluate: ((fn: (...args: unknown[]) => unknown, ...args: unknown[]) => + child.executeJavaScript(`(${fn.toString()})(...${JSON.stringify(args)})`)) as Page["evaluate"], + }; + const prepare = await createFramePreparer(page); + await child.executeJavaScript(`import(${JSON.stringify(new URL(pages.video, origin).href)}) + .then(module=>module.installBrowserVideo(${JSON.stringify(new URL(pages.decoder, origin).href)}, + ${Math.max(1, Math.floor(config.maxDecodedSourceBytes / workers))}))`); + return { + browserPid: window.webContents.getOSProcessId(), + async stats() { return child.executeJavaScript("__hypitVideoStats()"); }, + async releaseSources() { await child.executeJavaScript("__hypitDisposeVideo()"); }, + async capture(frame: number, sources: readonly SourceFrameUpdate[], ordinal: number, prepared: () => void) { + signal.throwIfAborted(); sameRenderer(); + const timing = await prepare(frame, sources); + // Finish source readiness before waiting for earlier output ordinals. + prepared(); + const started = performance.now(); + sameRenderer(); signal.throwIfAborted(); + // This local frame tree commits together. Ignore surfaces received + // before the ready page publishes its new frame identifier. + scene.latest?.release(); scene.latest = undefined; + const token = ordinal + 1; + const observed = new Promise((resolve, reject) => { scene.pending = { token, ordinal, resolve, reject }; }); + void observed.catch(() => {}); + let repaint: ReturnType | undefined; + try { + await child.executeJavaScript(`parent.__hypitCaptureCommit(${token})`); + processTexture(); + repaint = setInterval(() => { + if (!closing && !scene.busy && scene.pending?.token === token) { + void child.executeJavaScript(`parent.__hypitCaptureCommit(${token})`).catch(fail); + } + }, 100); + await observed; + } finally { clearInterval(repaint); } + return { buffer: Buffer.alloc(0), ...timing, screenshotMs: performance.now() - started }; + }, + }; + }, + async finish() { + signal.throwIfAborted(); + const accepted = await encoding.webContents.executeJavaScript("window.__hypitFinishEncoding()"); + assert(accepted === frameCount && chunks === frameCount, "GPU encoder did not complete every requested frame"); + await new Promise((resolve, reject) => writer.end((error?: Error | null) => error ? reject(error) : resolve())); + signal.throwIfAborted(); + return raw; + }, + close, + }; +} diff --git a/packages/provider-html-local/src/gpu-encoder.cjs b/packages/provider-html-local/src/gpu-encoder.cjs new file mode 100644 index 000000000..1894d48ad --- /dev/null +++ b/packages/provider-html-local/src/gpu-encoder.cjs @@ -0,0 +1,147 @@ +const { ipcRenderer, sharedTexture, contextBridge } = require("electron"); + +let encoder, options, canvas, context, failure, draining, finishing = false; +let nextOrdinal = 0, accepted = 0, pendingBytes = 0, queuedBytes = 0; +const frames = new Map(), output = new Set(), capacity = new Set(); +const notify = () => { for (const wake of capacity) wake(); capacity.clear(); }; +function check() { if (failure) throw failure; } +function fail(error) { + if (failure) return; + failure = error instanceof Error ? error : new Error(String(error)); + for (const frame of frames.values()) frame.close(); + queuedBytes -= frames.size * (options?.frameBytes ?? 0); + frames.clear(); notify(); + if (encoder?.state === "configured") encoder.close(); + ipcRenderer.send("hypit-capture-error", String(failure.stack ?? failure)); +} +async function waitForCapacity(ready) { + while (!ready()) { + check(); + await new Promise(resolve => capacity.add(resolve)); + } + check(); +} + +// Copying the completed surface lets its scene prepare another frame while the +// encoder consumes ordered snapshots. Reserve room for the next required frame. +function canAccept(ordinal) { + const bytes = options.frameBytes; + const used = queuedBytes + pendingBytes; + if (ordinal === nextOrdinal) return used === 0 || used + bytes <= options.maxPendingBytes; + return used + bytes * 2 <= options.maxPendingBytes; +} +async function reserve(ordinal) { + while (!canAccept(ordinal)) { + check(); + await new Promise(resolve => capacity.add(resolve)); + } + check(); queuedBytes += options.frameBytes; +} +function drain() { + if (draining || failure) return; + draining = (async () => { + while (frames.has(nextOrdinal)) { + await waitForCapacity(() => encoder.encodeQueueSize < options.encodeCapacity + && pendingBytes < options.maxPendingBytes); + const frame = frames.get(nextOrdinal); + frames.delete(nextOrdinal); queuedBytes -= options.frameBytes; + try { + encoder.encode(frame, { keyFrame: nextOrdinal % Math.max(1, Math.round(options.fps * 2)) === 0 }); + } finally { frame.close(); } + nextOrdinal++; notify(); + } + })().catch(fail).finally(() => { + draining = undefined; + if (!failure && frames.has(nextOrdinal)) drain(); + }); +} + +ipcRenderer.on("hypit-configure", async (_event, input) => { + try { + if (options) throw Error("GPU encoder was configured twice"); + options = { ...input, frameBytes: input.width * input.height * 4 }; + options.encodeCapacity = Math.max(1, Math.min(8, Math.floor(input.maxPendingBytes / options.frameBytes))); + const config = { codec: "avc1.640034", width: input.width, height: input.height, bitrate: input.bitrate, + framerate: input.fps, latencyMode: "quality", hardwareAcceleration: "prefer-hardware", avc: { format: "annexb" } }; + if (!(await VideoEncoder.isConfigSupported(config)).supported) { + throw Error("The selected Electron/GPU does not support the requested H.264 encoding configuration"); + } + canvas = new OffscreenCanvas(input.width, input.height); + context = canvas.getContext("2d", { alpha: false, colorSpace: "srgb" }); + if (!context) throw Error("GPU encoder canvas is unavailable"); + context.imageSmoothingEnabled = false; + encoder = new VideoEncoder({ error: fail, output(chunk) { + if (failure) return; + const bytes = new Uint8Array(chunk.byteLength); + chunk.copyTo(bytes); pendingBytes += bytes.length; + const sent = ipcRenderer.invoke("hypit-encoded", bytes, chunk.timestamp).catch(fail).finally(() => { + pendingBytes -= bytes.length; output.delete(sent); notify(); + }); + output.add(sent); + } }); + encoder.addEventListener("dequeue", notify); + encoder.configure(config); + ipcRenderer.send("hypit-configured"); + } catch (error) { fail(error); } +}); + +sharedTexture.setSharedTextureReceiver(async ({ importedSharedTexture: imported }, request) => { + let source, frame, reserved = false; + try { + check(); + if (!options || finishing) throw Error("GPU frame arrived outside an active export"); + if (!Number.isSafeInteger(request.ordinal) || request.ordinal < nextOrdinal + || request.ordinal >= options.frameCount || frames.has(request.ordinal)) { + throw Error("GPU capture delivered an unexpected output ordinal"); + } + source = imported.getVideoFrame(); + if (!["BGRA", "BGRX", "RGBA", "RGBX"].includes(source.format)) { + throw Error(`Unsupported shared texture pixel format: ${source.format}`); + } + if (source.displayWidth !== Math.max(options.width, 256) || source.displayHeight !== options.height + 8) { + ipcRenderer.send("hypit-observed", request.token, false); return; + } + const rect = { x: 0, y: options.height, width: 256, height: 8 }; + const bytes = new Uint8Array(source.allocationSize({ rect })); + const [plane] = await source.copyTo(bytes, { rect }); + let token = 0, valid = true; + for (let bit = 0; bit < 32; bit++) { + const x = (bit * 8 + 4) * 4; + const top = bytes[plane.offset + 2 * plane.stride + x], bottom = bytes[plane.offset + 6 * plane.stride + x]; + if (top > 224 && bottom < 32) token += 2 ** bit; + else if (!(top < 32 && bottom > 224)) valid = false; + } + if (!valid || token !== request.token) { + ipcRenderer.send("hypit-observed", request.token, false); return; + } + await reserve(request.ordinal); reserved = true; check(); + context.drawImage(source, 0, 0, options.width, options.height, 0, 0, options.width, options.height); + const timestamp = Math.round(request.ordinal * 1e6 / options.fps); + const end = Math.round((request.ordinal + 1) * 1e6 / options.fps); + frame = new VideoFrame(canvas, { timestamp, duration: end - timestamp }); + frames.set(request.ordinal, frame); frame = undefined; + reserved = false; accepted++; + drain(); + ipcRenderer.send("hypit-observed", request.token, true); + } catch (error) { fail(error); } + finally { + if (reserved) { queuedBytes -= options.frameBytes; notify(); } + frame?.close(); source?.close(); imported.release(); + } +}); + +contextBridge.exposeInMainWorld("__hypitFinishEncoding", async () => { + check(); finishing = true; + while (draining) await draining; + check(); + if (frames.size || accepted !== options.frameCount || nextOrdinal !== options.frameCount) { + throw Error("GPU encoder did not receive every output frame"); + } + await encoder.flush(); await Promise.all(output); check(); encoder.close(); return nextOrdinal; +}); +window.addEventListener("pagehide", () => { + for (const frame of frames.values()) frame.close(); + frames.clear(); + if (encoder?.state === "configured") encoder.close(); +}); +window.addEventListener("DOMContentLoaded", () => ipcRenderer.send("hypit-encoder-ready")); diff --git a/packages/provider-html-local/src/gpu-worker.cjs b/packages/provider-html-local/src/gpu-worker.cjs new file mode 100644 index 000000000..1a1485c2a --- /dev/null +++ b/packages/provider-html-local/src/gpu-worker.cjs @@ -0,0 +1,28 @@ +const { app } = require("electron"); +const path = require("node:path"); + +if (process.platform === "darwin") app.setActivationPolicy("accessory"); +app.setPath("userData", path.join(process.argv[2], "electron")); +app.on("window-all-closed", () => {}); +app.commandLine.appendSwitch("disable-frame-rate-limit"); +app.commandLine.appendSwitch("disable-gpu-vsync"); +app.commandLine.appendSwitch("force-color-profile", "srgb"); +if (process.platform === "darwin") { + app.commandLine.appendSwitch("enable-features", "VTVideoEncodeAcceleratorOpaqueRgbSharedImageEncode"); +} + +// The owner can send its request before the ESM capture module has loaded. +const pending = []; +const queue = value => pending.push(value); +process.on("message", queue); +app.whenReady().then(async () => { + const { register } = await import("tsx/esm/api"); + register(); + await import(process.argv[3]); + process.off("message", queue); + for (const value of pending) process.emit("message", value); +}).catch(error => { + console.error(error); + if (process.connected) process.send({ type: "failed", error: String(error) }); + else app.exit(1); +}); diff --git a/packages/provider-html-local/src/opaque-capture.ts b/packages/provider-html-local/src/opaque-capture.ts index 8a534e01e..c940e548a 100644 --- a/packages/provider-html-local/src/opaque-capture.ts +++ b/packages/provider-html-local/src/opaque-capture.ts @@ -5,22 +5,24 @@ type RenderWindow = Window & { __hypitFrameProgram: { prepare(): Promise; applyFrame(frame: number): Promise }; __hypitHtmlVisualError?: string; __hypitCaptureRoots?: readonly Element[]; + __hypitPrepareImages(): Promise; + __hypitDrawVideoSources?(sources: readonly SourceFrameUpdate[]): Promise; }; -export type SourceFrameUpdate = { readonly id: string; readonly url: string }; +export type SourceFrameUpdate = { readonly id: string; readonly url: string; + readonly sample?: { readonly frame: number; readonly fps: { readonly num: number; readonly den: number }; readonly reverse: boolean } }; -/** - * Capture one already-composited page state as an opaque PNG. The Provider owns - * the page ABI, exact source-frame overlays and CDP session directly; there is - * no intermediate rendering engine or patched installed dependency. - */ -export async function createOpaqueFrameCapture(page: Page, canvas: { readonly width: number; readonly height: number }) { - const cdp = await page.createCDPSession(); - // MP4 has no alpha channel. The authored Canvas paints over this final matte. - await cdp.send("Emulation.setDefaultBackgroundColorOverride", { color: { r: 0, g: 0, b: 0, a: 1 } }); +/** Install page-owned resource readiness and prepare deterministic frame states. */ +export async function createFramePreparer(page: Pick) { // Image readiness belongs to this page. A seek can select a new image through // attributes, a CSS class or a pseudo-element after initial page readiness. await page.evaluate(() => { + // The Provider supplies compiler-declared video pixels in adjacent layers. + // Hide native pixels while retaining the elements for authored frame updates. + for (const video of Array.from(document.querySelectorAll("video[data-hypit-source-frame]"))) { + video.dataset.hypitAuthoredVisibility = video.style.visibility; + video.style.visibility = "hidden"; + } const decoded = new Map>(); const dirtyRoots = new Set(); const animatedRoots = new Set(); @@ -46,7 +48,15 @@ export async function createOpaqueFrameCapture(page: Page, canvas: { readonly wi background(value: string, pending: Promise[]) { for (const match of value.matchAll(/url\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"'()]+))\s*\)/gu)) { const url = (match[1] ?? match[2] ?? match[3] ?? "").trim(); - if (url) pending.push(this.load(url)); + if (url) { + const resource = new URL(url, document.baseURI); + const page = new URL(document.URL); + const fragment = resource.hash; + resource.hash = page.hash = ""; + // Local SVG paint servers are resolved by this document's renderer. + if (fragment && resource.href === page.href) continue; + pending.push(this.load(url)); + } } }, style(style: CSSStyleDeclaration, pending: Promise[]) { @@ -55,6 +65,9 @@ export async function createOpaqueFrameCapture(page: Page, canvas: { readonly wi } }, inlineSignature(value: string): string { + // Ordinary transform/opacity writes need no CSS parser. Escaped names + // still go through CSSOM so escaped resource functions are recognized. + if (!/\\|url\(|image-set\(|cross-fade\(|var\(/iu.test(value)) return ""; const style = document.createElement("span").style; style.cssText = value; const candidates = [style.backgroundImage, style.maskImage, style.borderImageSource, style.listStyleImage, style.content]; @@ -239,14 +252,15 @@ export async function createOpaqueFrameCapture(page: Page, canvas: { readonly wi }); return async (frame: number, sources: readonly SourceFrameUpdate[] = []) => { const start = performance.now(); - await page.evaluate(async (input) => { + const prepareMs = await page.evaluate(async (input) => { const w = window as unknown as RenderWindow; await w.__hypitFrameProgram.applyFrame(input.frame); const active = new Set(input.sources.map(source => source.id)); for (const image of Array.from(document.querySelectorAll('[data-hypit-source-frame-image]'))) { if (!active.has(image.dataset.hypitSourceFrameImage!)) image.style.display = 'none'; } - await Promise.all(input.sources.map(async source => { + if (w.__hypitDrawVideoSources !== undefined) await w.__hypitDrawVideoSources(input.sources); + else await Promise.all(input.sources.map(async source => { const video = document.getElementById(source.id) as HTMLVideoElement | null; if (video === null) throw new Error(`HTML Program source element ${source.id} is missing.`); let image = document.querySelector(`[data-hypit-source-frame-image="${CSS.escape(source.id)}"]`); @@ -268,20 +282,30 @@ export async function createOpaqueFrameCapture(page: Page, canvas: { readonly wi if (image.src !== source.url) image.src = source.url; await image.decode(); })); - }, { frame, sources }); - const sought = performance.now(); - await page.evaluate(async () => { - const w = window as unknown as RenderWindow & { __hypitPrepareImages: () => Promise }; + const prepareStarted = performance.now(); await w.__hypitPrepareImages(); if (w.__hypitHtmlVisualError !== undefined) throw new Error(w.__hypitHtmlVisualError); - }); + return performance.now() - prepareStarted; + }, { frame, sources }); const prepared = performance.now(); + return { seekMs: prepared - start - prepareMs, prepareMs }; + }; +} + +/** Capture the prepared page state as an opaque PNG. */ +export async function createOpaqueFrameCapture(page: Page, canvas: { readonly width: number; readonly height: number }) { + const cdp = await page.createCDPSession(); + // MP4 has no alpha channel. The authored Canvas paints over this final matte. + await cdp.send("Emulation.setDefaultBackgroundColorOverride", { color: { r: 0, g: 0, b: 0, a: 1 } }); + const prepare = await createFramePreparer(page); + return async (frame: number, sources: readonly SourceFrameUpdate[] = []) => { + const timing = await prepare(frame, sources); + const started = performance.now(); const result = await cdp.send("Page.captureScreenshot", { format: "png", optimizeForSpeed: true, fromSurface: true, captureBeyondViewport: false, clip: { x: 0, y: 0, width: canvas.width, height: canvas.height, scale: 1 }, }); - return { buffer: Buffer.from(result.data, "base64"), - seekMs: sought - start, prepareMs: prepared - sought, screenshotMs: performance.now() - prepared }; + return { buffer: Buffer.from(result.data, "base64"), ...timing, screenshotMs: performance.now() - started }; }; } diff --git a/packages/provider-html-local/src/options.ts b/packages/provider-html-local/src/options.ts index 300fb2f9f..3fa3cf95b 100644 --- a/packages/provider-html-local/src/options.ts +++ b/packages/provider-html-local/src/options.ts @@ -2,16 +2,16 @@ export type HtmlWorkers = number | "auto"; export type HtmlQuality = "draft" | "standard" | "high"; export type HtmlBrowserGpu = "auto" | "software" | "hardware"; -export type HtmlExecutionOptions = import("./browser.js").BrowserOptions & { +export type HtmlExecutionOptions = import("./browser.js").BrowserOptions & import("./electron.js").ElectronOptions & { readonly ffprobePath?: string; - /** Selected FFmpeg command for both source extraction and final H.264 encoding. */ + /** FFmpeg command for source extraction, PNG video encoding and GPU stream muxing. */ readonly ffmpegPath?: string; - /** Parallel Chrome workers inside one render. This is separate from Provider request concurrency. */ + /** Parallel capture workers inside one render, separate from Provider request concurrency. */ readonly workers?: HtmlWorkers; /** Upper bound for auto's browser reservation; explicit workers remain fixed. */ readonly maxWorkers?: number; readonly quality?: HtmlQuality; - /** Chrome's rasterizer, default hardware. Use software without a usable GPU. */ + /** Browser rasterizer, default hardware. Use software for PNG capture without a usable GPU. */ readonly browserGpu?: HtmlBrowserGpu; readonly processTimeoutMs?: number; readonly initializationTimeoutMs?: number; @@ -19,9 +19,9 @@ export type HtmlExecutionOptions = import("./browser.js").BrowserOptions & { readonly maxProcessOutputBytes?: number; /** Artifact staging lifecycles in flight inside one render. */ readonly artifactStagingConcurrency?: number; - /** Bytes of completed, out-of-order PNGs retained while an MP4 waits for its next frame. */ + /** Admission budget for pending PNGs, or GPU snapshots and compressed output. */ readonly maxPendingFrameBytes?: number; - /** Bytes of decoded source PNGs resident in one render's reusable working set. */ + /** Retained decoded source samples across the render's workers. */ readonly maxDecodedSourceBytes?: number; readonly maxRenderedBytes?: number; }; diff --git a/packages/provider-html-local/src/output.ts b/packages/provider-html-local/src/output.ts index 294d9286c..4a2b5b1ae 100644 --- a/packages/provider-html-local/src/output.ts +++ b/packages/provider-html-local/src/output.ts @@ -67,7 +67,8 @@ export async function verifyOutput(args: { assert(stream.width === args.document.canvas.width && stream.height === args.document.canvas.height, "Rendered visual canvas differs from its document"); const rate = parseRational(stream.avg_frame_rate ?? stream.r_frame_rate, "Rendered visual frame rate"); - assert(equalsRational(rate, args.document.frameRate), "Rendered visual frame rate differs from its document"); + assert(equalsRational(rate, args.document.frameRate), + `Rendered visual frame rate ${rate.numerator}/${rate.denominator} differs from its document ${args.document.frameRate.numerator}/${args.document.frameRate.denominator}`); assert(outputFrameCount(stream) === args.document.frameCount, "Rendered visual frame count differs from its document"); } diff --git a/packages/provider-html-local/src/process-tree.ts b/packages/provider-html-local/src/process-tree.ts index a7d5275e1..14ef0ceef 100644 --- a/packages/provider-html-local/src/process-tree.ts +++ b/packages/provider-html-local/src/process-tree.ts @@ -85,4 +85,23 @@ export function killRenderDescendantsSync(pid: number): void { } } } + // A signal can remain pending after kill returns. Keep the owner alive until + // its children have stopped; zombies are already stopped and await reaping. + if (descendants.length === 1) return; + const deadline = Date.now() + cleanupMs; + const pause = new Int32Array(new SharedArrayBuffer(4)); + while (true) { + let states: string; + try { + states = execFileSync("ps", ["-p", descendants.slice(1).join(","), "-o", "stat="], { + encoding: "utf8", timeout: cleanupMs, stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + if ((error as { status?: unknown }).status === 1) return; + throw error; + } + if (states.trim().split(/\s+/u).every(state => state === "" || state.startsWith("Z"))) return; + if (Date.now() >= deadline) throw new Error("Rasterizer descendants did not stop after SIGKILL"); + Atomics.wait(pause, 0, 0, 20); + } } diff --git a/packages/provider-html-local/src/program.ts b/packages/provider-html-local/src/program.ts index 02094de4d..4940ac9bc 100644 --- a/packages/provider-html-local/src/program.ts +++ b/packages/provider-html-local/src/program.ts @@ -5,6 +5,8 @@ import type { ManagedProgram, ManagedProgramState } from "@hypit/runtime-local/e import { browserCacheDirectory, browserDownloadBaseUrl, browserDownloadUrl, browserExecutablePath, configuredBrowserPath, requireBrowserExecutable, selectedBrowserVersion } from "./browser.js"; import type { BrowserOptions } from "./browser.js"; import { runProcess } from "./process.js"; +import { electronCacheDirectory, electronDownloadUrl, electronExecutablePath, recommendedElectronVersion, requireElectronExecutable } from "./electron.js"; +import type { ElectronOptions } from "./electron.js"; type MediaToolVersion = | { readonly state: "ready"; readonly version: string } @@ -42,7 +44,7 @@ async function probeHtmlMediaTools(ffprobePath: string, ffmpegPath?: string): Pr * rasterization receives the same selected executable instead of invoking browser discovery. */ export function localHtmlBrowserProgram( - input: BrowserOptions & { + input: BrowserOptions & ElectronOptions & { readonly id: string; readonly nodePath: string; readonly ffprobePath: string; @@ -52,26 +54,43 @@ export function localHtmlBrowserProgram( const version = selectedBrowserVersion(input); const selectedPath = browserExecutablePath(input); const baseUrl = browserDownloadBaseUrl(input); + const electronPath = electronExecutablePath(input); const probeBrowser = async (): Promise => { try { await requireBrowserExecutable(selectedPath, version); } catch (error) { return { state: "down", detail: error instanceof Error ? error.message : String(error) }; } return { state: "ready" }; }; + const probeElectron = async (): Promise => { + if (electronPath === undefined) return { state: "ready" }; + try { await requireElectronExecutable(electronPath); return { state: "ready" }; } + catch (error) { return { state: "down", detail: error instanceof Error ? error.message : String(error) }; } + }; + const managedChrome = configuredBrowserPath(input) === undefined; + const managedElectron = electronPath !== undefined && input.electronPath === undefined; return { id: input.id, - ...(configuredBrowserPath(input) === undefined ? { + ...(managedChrome || managedElectron ? { // Projects sharing this installation also share the existing Program lifecycle lock/logs. - stateRoot: join(browserCacheDirectory(input), ".hypit-html-browser-program"), + stateRoot: join(managedChrome ? browserCacheDirectory(input) : electronCacheDirectory(input), ".hypit-html-browser-program"), installation: { - probe: probeBrowser, - commands: [{ label: `Install Chrome Headless Shell ${version} at ${selectedPath} from ${browserDownloadUrl(input)}`, command: input.nodePath, args: [ + probe: async () => { + if (managedChrome) { const state = await probeBrowser(); if (state.state !== "ready") return state; } + return managedElectron ? probeElectron() : { state: "ready" as const }; + }, + commands: [...(managedChrome ? [{ label: `Install Chrome Headless Shell ${version} at ${selectedPath} from ${browserDownloadUrl(input)}`, command: input.nodePath, args: [ "--import", import.meta.resolve("tsx"), fileURLToPath(new URL("./browser-install.js", import.meta.url)), browserCacheDirectory(input), version!, ...(baseUrl === undefined ? [] : [baseUrl]), - ] }], + ] }] : []), ...(managedElectron ? [{ label: `Install Electron ${recommendedElectronVersion} at ${electronPath} from ${electronDownloadUrl(input)}`, command: input.nodePath, args: [ + "--import", import.meta.resolve("tsx"), + fileURLToPath(new URL("./electron-install.js", import.meta.url)), electronCacheDirectory(input), + ...(input.electronDownloadBaseUrl === undefined ? [] : [input.electronDownloadBaseUrl]), + ] }] : [])], }, } : {}), async probe(): Promise { + const electron = await probeElectron(); + if (electron.state !== "ready") return electron; const browser = await probeBrowser(); if (browser.state !== "ready") return browser; return await probeHtmlMediaTools(input.ffprobePath, input.ffmpegPath); diff --git a/packages/provider-html-local/src/provider.ts b/packages/provider-html-local/src/provider.ts index 1f4418902..33d1b40c5 100644 --- a/packages/provider-html-local/src/provider.ts +++ b/packages/provider-html-local/src/provider.ts @@ -16,7 +16,7 @@ export type CreateLocalHtmlProviderOptions = HtmlExecutionOptions & { readonly nodePath?: string; /** Whole render requests admitted concurrently; independent of frame workers. */ readonly defaultConcurrency?: number; - /** Shared Chrome slots across Need executions using this pool. */ + /** Shared capture worker slots across Need executions using this pool. */ readonly browserCapacity?: number; }; @@ -45,7 +45,8 @@ export function createLocalHtmlProvider(config: CreateLocalHtmlProviderOptions) const { program, range } = request.constraints; return { [browsers]: renderWorkerLimit(reserved, range === undefined ? program.frameCount : range.endFrameExclusive - range.startFrame, - program.frameRate.numerator / program.frameRate.denominator) }; + program.frameRate.numerator / program.frameRate.denominator, + reserved.electronPath !== undefined && program.frameSources.length > 0) }; }, }), handler: async (context) => { @@ -76,7 +77,7 @@ export function createLocalHtmlProvider(config: CreateLocalHtmlProviderOptions) verifyHtmlFrameRequest(request.constraints); const document = request.constraints.program; return { [browsers]: renderWorkerLimit(reserved, request.constraints.frames.length, - document.frameRate.numerator / document.frameRate.denominator) }; + document.frameRate.numerator / document.frameRate.denominator, false) }; }, }), handler: async context => { diff --git a/packages/provider-html-local/src/render.ts b/packages/provider-html-local/src/render.ts index e9d6604f2..7422068d5 100644 --- a/packages/provider-html-local/src/render.ts +++ b/packages/provider-html-local/src/render.ts @@ -17,6 +17,7 @@ import { autoWorkerLimit, renderWorkerLimit } from "./concurrency.js"; import { requestedFrameRanges } from "./sampling.js"; import { browserCacheDirectory, browserExecutablePath, configuredBrowserPath, requireBrowserExecutable, selectedBrowserVersion } from "./browser.js"; import { htmlArtifactRelativePath, stageHtmlProgram } from "./stage.js"; +import { electronExecutablePath, requireElectronExecutable } from "./electron.js"; export type HtmlRasterProgress = | { readonly phase: "staging" | "encoding" | "storing"; readonly elapsedMs: number } @@ -44,7 +45,10 @@ export function resolveExecutionOptions(options: HtmlExecutionOptions) { assert(["draft", "standard", "high"].includes(quality), "HTML renderer quality is invalid"); const browserGpu = options.browserGpu ?? "hardware"; assert(["auto", "software", "hardware"].includes(browserGpu), "HTML renderer browserGpu is invalid"); + const electronPath = electronExecutablePath(options); + assert(electronPath === undefined || browserGpu !== "software", "Shared-texture export requires browserGpu auto or hardware"); return { + electronPath, chromePath: configuredBrowserPath(options), browserVersion: selectedBrowserVersion(options), browserCacheDirectory: browserCacheDirectory(options), @@ -62,8 +66,8 @@ export function resolveExecutionOptions(options: HtmlExecutionOptions) { processTimeoutMs: positiveInteger(options.processTimeoutMs ?? 30 * 60_000, "processTimeoutMs"), maxProcessOutputBytes: positiveInteger(options.maxProcessOutputBytes ?? 4 * 1024 * 1024, "maxProcessOutputBytes"), artifactStagingConcurrency: positiveInteger(options.artifactStagingConcurrency ?? 4, "artifactStagingConcurrency"), - maxPendingFrameBytes: positiveInteger(options.maxPendingFrameBytes ?? 256 * 1024 * 1024, "maxPendingFrameBytes"), - maxDecodedSourceBytes: positiveInteger(options.maxDecodedSourceBytes ?? 1024 * 1024 * 1024, "maxDecodedSourceBytes"), + maxPendingFrameBytes: positiveInteger(options.maxPendingFrameBytes ?? 128 * 1024 * 1024, "maxPendingFrameBytes"), + maxDecodedSourceBytes: positiveInteger(options.maxDecodedSourceBytes ?? 2304 * 1024 * 1024, "maxDecodedSourceBytes"), maxRenderedBytes: positiveInteger(options.maxRenderedBytes ?? 16 * 1024 * 1024 * 1024, "maxRenderedBytes"), }; } @@ -85,8 +89,10 @@ export async function rasterizeHtmlFrames(request: HtmlFrameRequest, options: Ht } async function capture(request: HtmlRasterRequest | HtmlFrameRequest, options: HtmlRasterOptions): Promise { - const config = { ...resolveExecutionOptions(options), chromePath: browserExecutablePath(options) }; const frames = "frames" in request ? request.frames : undefined; + const execution = resolveExecutionOptions(options); + const config = { ...execution, + chromePath: frames === undefined && execution.electronPath !== undefined ? "" : browserExecutablePath(options) }; const document = request.program; const range = frames === undefined ? (request as HtmlRasterRequest).range ?? { startFrame: 0, endFrameExclusive: document.frameCount } @@ -115,12 +121,14 @@ async function capture(request: HtmlRasterRequest | HtmlFrameRequest, options: H }; try { signal.throwIfAborted(); - await requireBrowserExecutable(config.chromePath, config.browserVersion); + const gpu = frames === undefined && config.electronPath !== undefined; + if (gpu) await requireElectronExecutable(config.electronPath!); + else await requireBrowserExecutable(config.chromePath, config.browserVersion); await options.onDiagnostic?.({ level: "info", message: - `Capture ${frameCount} frames; workers ${config.workers} (limit ${renderWorkerLimit(config, frameCount, document.frameRate.numerator / document.frameRate.denominator)}); opaque fast PNG; GPU ${config.browserGpu}; browser ${config.chromePath}` + `Capture ${frameCount} frames; workers ${config.workers} (limit ${renderWorkerLimit(config, frameCount, document.frameRate.numerator / document.frameRate.denominator, gpu && document.frameSources.length > 0)}); ${gpu ? "shared GPU texture" : "opaque fast PNG"}; GPU ${config.browserGpu}; browser ${gpu ? config.electronPath : config.chromePath}` + `; artifact staging concurrency ${config.artifactStagingConcurrency}` + `; decoded source budget ${config.maxDecodedSourceBytes} bytes` - + (frames === undefined ? `; quality ${config.quality}; ordered FFmpeg pipe; pending PNG budget ${config.maxPendingFrameBytes} bytes; encoder ${config.ffmpegPath}` : "; PNG output") }); + + (frames === undefined ? `; quality ${config.quality}; ${gpu ? "WebCodecs H.264 and FFmpeg mux" : "ordered FFmpeg pipe"}; pending frame budget ${config.maxPendingFrameBytes} bytes; FFmpeg ${config.ffmpegPath}` : "; PNG output") }); options.onProgress?.({ phase: "staging", elapsedMs: 0 }); work = await mkdtemp(join(tmpdir(), "hypit-html-local-")); const read: import("./stage.js").HtmlArtifactReader = async (artifact, readSignal) => { diff --git a/packages/provider-html-local/src/server.ts b/packages/provider-html-local/src/server.ts index 753edce4c..fa41d649f 100644 --- a/packages/provider-html-local/src/server.ts +++ b/packages/provider-html-local/src/server.ts @@ -5,17 +5,22 @@ import { extname, resolve, sep } from "node:path"; const mediaTypes: Readonly> = { ".html": "text/html; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".mp4": "video/mp4", ".webm": "video/webm", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", }; /** Serve one staged program and its lazily decoded frames to local capture pages. */ -export async function serveHtmlProgram(root: string): Promise<{ readonly url: string; close(): Promise }> { +export async function serveHtmlProgram(root: string, pages: ReadonlyMap = new Map()): Promise<{ readonly url: string; close(): Promise }> { const base = resolve(root); const server = createServer(async (request, response) => { try { const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://127.0.0.1").pathname); + const page = pages.get(pathname); + if (page !== undefined) { + response.writeHead(200, { "Content-Type": mediaTypes[extname(pathname)] ?? "text/html; charset=utf-8" }).end(page); return; + } const path = resolve(base, pathname === "/" ? "index.html" : `.${pathname}`); if (path !== base && !path.startsWith(`${base}${sep}`)) { response.writeHead(403).end(); return; @@ -34,7 +39,9 @@ export async function serveHtmlProgram(root: string): Promise<{ readonly url: st "Accept-Ranges": "bytes", ...(range === null ? {} : { "Content-Range": `bytes ${start}-${end}/${info.size}` }), }); - createReadStream(path, { start, end }).on("error", () => response.destroy()).pipe(response); + const stream = createReadStream(path, { start, end }); + response.once("close", () => stream.destroy()); + stream.on("error", () => response.destroy()).pipe(response); } catch (error) { response.writeHead((error as NodeJS.ErrnoException).code === "ENOENT" ? 404 : 500).end(); } @@ -47,6 +54,10 @@ export async function serveHtmlProgram(root: string): Promise<{ readonly url: st if (address === null || typeof address === "string") throw new Error("HTML Program server did not bind a TCP port"); return { url: `http://127.0.0.1:${address.port}/`, - close: () => new Promise((resolveClose, reject) => server.close(error => error ? reject(error) : resolveClose())), + close: () => new Promise((resolveClose, reject) => { + server.close(error => error ? reject(error) : resolveClose()); + // Capture has finished; browser media requests can still hold keep-alive sockets. + server.closeAllConnections(); + }), }; } diff --git a/packages/provider-html-local/test/range.test.ts b/packages/provider-html-local/test/range.test.ts index c89caa310..d847e2115 100644 --- a/packages/provider-html-local/test/range.test.ts +++ b/packages/provider-html-local/test/range.test.ts @@ -68,8 +68,8 @@ test("the render deadline is global while stage deadlines are explicit deploymen assert.equal(defaults.initializationTimeoutMs, undefined); assert.equal(defaults.frameTimeoutMs, undefined); assert.equal(defaults.artifactStagingConcurrency, 4); - assert.equal(defaults.maxPendingFrameBytes, 256 * 1024 * 1024); - assert.equal(defaults.maxDecodedSourceBytes, 1024 * 1024 * 1024); + assert.equal(defaults.maxPendingFrameBytes, 128 * 1024 * 1024); + assert.equal(defaults.maxDecodedSourceBytes, 2304 * 1024 * 1024); const explicit = resolveExecutionOptions({ initializationTimeoutMs: 31_000, frameTimeoutMs: 16_000, artifactStagingConcurrency: 3, maxPendingFrameBytes: 12_345, maxDecodedSourceBytes: 54_321 }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8abdd0f4b..99c1b9302 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,10 +660,10 @@ importers: version: link:../media-local '@puppeteer/browsers': specifier: 3.2.2 - version: 3.2.2 + version: 3.2.2(yauzl@2.10.0) puppeteer-core: specifier: 25.10.0 - version: 25.10.0 + version: 25.10.0(yauzl@2.10.0) sharp: specifier: 0.35.4 version: 0.35.4(@types/node@24.10.1) @@ -1889,6 +1889,9 @@ importers: packages/provider-html-local: dependencies: + '@electron/get': + specifier: 5.1.0 + version: 5.1.0 '@hypit/html-program': specifier: workspace:^0.1.0 version: link:../html-program @@ -1897,10 +1900,16 @@ importers: version: link:../media '@puppeteer/browsers': specifier: 3.2.2 - version: 3.2.2 + version: 3.2.2(yauzl@2.10.0) + extract-zip: + specifier: 2.0.1 + version: 2.0.1 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 puppeteer-core: specifier: 25.10.0 - version: 25.10.0 + version: 25.10.0(yauzl@2.10.0) tsx: specifier: 4.21.0 version: 4.21.0 @@ -1941,6 +1950,15 @@ importers: '@hypit/visual-track': specifier: workspace:* version: link:../visual-track + '@types/proper-lockfile': + specifier: 4.1.4 + version: 4.1.4 + electron: + specifier: 45.0.0 + version: 45.0.0 + mediabunny: + specifier: 1.62.1 + version: 1.62.1 sharp: specifier: 0.35.4 version: 0.35.4(@types/node@24.10.1) @@ -3144,6 +3162,14 @@ packages: search-insights: optional: true + '@electron-internal/extract-zip@1.0.6': + resolution: {integrity: sha512-/aQLgFDdCSnwChfUwqekSmE1q2dCTzhUy5saIHBb5vyYn5LzWaKPaIz1wM0lDMCZYBoIG4svJNvAJb6vkI8BuA==} + engines: {node: '>=22.12.0'} + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -4001,6 +4027,12 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@types/dom-mediacapture-transform@0.1.12': + resolution: {integrity: sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4022,12 +4054,21 @@ packages: '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -4143,6 +4184,9 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4189,6 +4233,15 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4232,12 +4285,20 @@ packages: resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} engines: {node: '>=20.19.0'} + electron@45.0.0: + resolution: {integrity: sha512-yjz4uhMBcO6L5X7CWgRMobf2EMJPnb9j4zuf+nu/TU/6E4Sqfx7fn/a2eACo4qngjml/0JnsoZhxYroJLxLd2w==} + engines: {node: '>= 22.12.0'} + hasBin: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -4250,6 +4311,10 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -4267,6 +4332,14 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4292,9 +4365,16 @@ packages: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -4415,6 +4495,9 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mediabunny@1.62.1: + resolution: {integrity: sha512-lYzeerDwo9QLhBCipqQDXOSV7InvaIFplllZQviPO5rPwH+IUr8M1uvumk1olIUgMRGkPiInRriv25WFErUgMw==} + micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -4440,6 +4523,9 @@ packages: resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==} engines: {node: '>=18.0.0'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4454,9 +4540,15 @@ packages: resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} engines: {node: '>=20.19.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -4483,9 +4575,19 @@ packages: preact-render-to-string: optional: true + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + puppeteer-core@25.10.0: resolution: {integrity: sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==} engines: {node: '>=22.12.0'} @@ -4502,6 +4604,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -4542,6 +4648,9 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4568,6 +4677,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -4604,6 +4717,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} + engines: {node: '>=20.18.1'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -4726,6 +4843,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -4750,6 +4870,9 @@ packages: resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -4915,6 +5038,21 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' + '@electron-internal/extract-zip@1.0.6': {} + + '@electron/get@5.1.0': + dependencies: + debug: 4.4.3 + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.5 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.30.1 + transitivePeerDependencies: + - supports-color + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -5250,10 +5388,12 @@ snapshots: '@oxc-project/types@0.148.0': {} - '@puppeteer/browsers@3.2.2': + '@puppeteer/browsers@3.2.2(yauzl@2.10.0)': dependencies: modern-tar: 0.8.5 yargs: 18.1.0 + optionalDependencies: + yauzl: 2.10.0 '@rolldown/binding-android-arm-eabi@1.2.7': optional: true @@ -5417,6 +5557,12 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@types/dom-mediacapture-transform@0.1.12': + dependencies: + '@types/dom-webcodecs': 0.1.13 + + '@types/dom-webcodecs@0.1.13': {} + '@types/estree@1.0.9': {} '@types/hast@3.0.5': @@ -5440,10 +5586,21 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.10.1 + optional: true + '@ungap/structured-clone@1.3.3': {} '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@24.10.1)(lightningcss@1.33.0))(vue@3.5.41(typescript@5.9.3))': @@ -5575,6 +5732,8 @@ snapshots: boolbase@2.0.0: {} + buffer-crc32@0.2.13: {} + ccount@2.0.1: {} character-entities-html4@2.1.0: {} @@ -5617,6 +5776,10 @@ snapshots: csstype@3.2.3: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -5663,16 +5826,30 @@ snapshots: domelementtype: 3.0.0 domhandler: 6.0.1 + electron@45.0.0: + dependencies: + '@electron-internal/extract-zip': 1.0.6 + '@electron/get': 5.1.0 + '@types/node': 24.10.1 + transitivePeerDependencies: + - supports-color + emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@4.5.0: {} entities@7.0.1: {} entities@8.0.0: {} + env-paths@3.0.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -5732,6 +5909,20 @@ snapshots: estree-walker@2.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -5747,10 +5938,16 @@ snapshots: get-east-asian-width@1.6.0: {} + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 + graceful-fs@4.2.11: {} + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.5 @@ -5883,6 +6080,11 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mediabunny@1.62.1: + dependencies: + '@types/dom-mediacapture-transform': 0.1.12 + '@types/dom-webcodecs': 0.1.13 + micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -5906,6 +6108,8 @@ snapshots: modern-tar@0.8.5: {} + ms@2.1.3: {} + nanoid@3.3.16: {} nanoid@3.3.18: {} @@ -5914,12 +6118,18 @@ snapshots: dependencies: boolbase: 2.0.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + oniguruma-to-es@3.1.1: dependencies: emoji-regex-xs: 1.0.0 regex: 6.1.0 regex-recursion: 6.0.2 + pend@1.2.0: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -5940,11 +6150,24 @@ snapshots: preact@10.29.8: {} + progress@2.0.3: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} - puppeteer-core@25.10.0: + pump@3.0.4: dependencies: - '@puppeteer/browsers': 3.2.2 + end-of-stream: 1.4.5 + once: 1.4.0 + + puppeteer-core@25.10.0(yauzl@2.10.0): + dependencies: + '@puppeteer/browsers': 3.2.2(yauzl@2.10.0) chromium-bidi: 17.0.2(devtools-protocol@0.0.1666840) devtools-protocol: 0.0.1666840 typed-query-selector: 2.12.2 @@ -5968,6 +6191,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + rfdc@1.4.1: {} rolldown@1.2.7: @@ -6079,6 +6304,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 + signal-exit@3.0.7: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -6105,6 +6332,12 @@ snapshots: dependencies: ansi-regex: 6.2.2 + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -6136,6 +6369,9 @@ snapshots: undici-types@7.16.0: {} + undici@7.30.1: + optional: true + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -6260,6 +6496,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + ws@8.21.3: {} y18n@5.0.8: {} @@ -6275,6 +6513,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + zod@3.25.76: {} zwitch@2.0.4: {} diff --git a/scripts/benchmark-html-gpu-results.md b/scripts/benchmark-html-gpu-results.md new file mode 100644 index 000000000..f49f5ddae --- /dev/null +++ b/scripts/benchmark-html-gpu-results.md @@ -0,0 +1,120 @@ +# HTML GPU export measurements + +Status: completed. Renderer baseline: `608b595988a5c0d15a3f8880a1066d977fee59c4`. Target repetitions: 5. + +Host: Apple M1 Pro, 32 GiB RAM, darwin 27.0.0, Node v26.0.0. + +Completed jobs: 76. Successful exports: 80. Expected injected failures: 2. Failed jobs: 0. + +## Current defaults and interpretation + +Retain a 2.25 GiB source-sample budget and a 128 MiB pending-frame budget. The five-observation staggered-video median decreased from 23.114 to 17.835 seconds, a 22.8% reduction. Decoder iterator starts decreased from 53 to 14. The 3/4 GiB cases also used 14 starts. Logical sample budgets do not establish total process or GPU memory usage. + +For 1080p MG with four workers, 128/256/512 MiB pending queues produced five-observation medians of 16.044/16.259/16.150 seconds. Retain automatic worker selection: its five-observation MG median was 16.690 seconds, versus 20.377 for four workers and 18.265 for six. Other explicit worker counts have single screening observations. The timing ranges overlap, and sessions include uncontrolled desktop load; these measurements do not establish a universal optimum. + +The automatic video strategy remains one scene. The two/four-worker staggered-video screening exports took 28.974/48.136 seconds. The 4K video control exported 300 frames; the scaled 4K MG control exported 600 frames. Their results are summarized separately below. + +## Repository validation + +Repository type checking passed. The initial local full suite completed with 1,121 passes, two process-test timeouts and 24 skips. Those two tests passed in an isolated sequential run together with the Provider suite: 64 passed, 11 skipped. The cause of the initial timeouts is unconfirmed. Both Windows and Linux full CI checks, independent npm package verification and the other CI checks passed at `feefcbb9`; subsequent harness/report commits receive their own CI run. + +The existing default-value assertions were updated to the selected budgets. Report grouping now includes the fixture name. Resume identities cover actual production source, harness, inputs and host rather than documentation-only commit changes. Legacy state migrated after comparing the original source, harness, fixture fingerprint and host. Resuming the completed experiment retained all 76 jobs and started no exports. + +The complete dataset contains 76 passed jobs, 80 successful exports, two expected injected failures and 31,200 independently checked frame identifiers with zero mismatches. No completed job left observed descendants. Earlier Windows/Linux package checks do not establish GPU hardware compatibility; the GPU measurements here cover Apple Silicon macOS. + +## Measurement settings + +Export seconds include Provider preparation, capture, encoding, muxing, full output decode validation and storage. Independent frame-ID checks run afterward. Each job starts a fresh Node process; the prepared Electron cache persists. Normal desktop activity remains uncontrolled. + +Memory is sampled every 250 ms across the export host and its descendants, including Electron processes with independent process groups. RSS, macOS physical footprint and retained sample allocation are distinct quantities. Exclusive device memory is not measured. + +The four-layer 4K video case composes 1080p sources. The scaled 4K MG case retains authored Canvas raster resolutions. Timing-only jobs keep output in memory; independent verification jobs also save MP4 files. + +## Timing and memory results + +| Group | Fixture | Configuration | Frames per export | Completed | Failed | Median seconds | Range, seconds | Median peak RSS, MiB | Median peak physical footprint, MiB | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| defaults | video | {} | 600 | 1 | 0 | 12.132 | 12.132 to 12.132 | 961.953 | 658.547 | +| source | staggered | {"workers":1,"maxDecodedSourceBytes":1073741824,"maxPendingFrameBytes":268435456} | 600 | 5 | 0 | 23.114 | 20.046 to 29.098 | 1049.422 | 832.172 | +| source | staggered | {} | 600 | 5 | 0 | 17.835 | 15.490 to 23.682 | 995.719 | 825.501 | +| source | staggered | {"workers":1,"maxDecodedSourceBytes":3221225472,"maxPendingFrameBytes":134217728} | 600 | 5 | 0 | 20.835 | 15.709 to 37.089 | 929.641 | 821.501 | +| source | staggered | {"workers":1,"maxDecodedSourceBytes":4294967296,"maxPendingFrameBytes":134217728} | 600 | 5 | 0 | 18.651 | 15.686 to 25.486 | 993.078 | 825.703 | +| queue | mg | {"workers":4,"maxPendingFrameBytes":134217728} | 600 | 5 | 0 | 16.044 | 14.705 to 21.481 | 1765.703 | 1840.161 | +| queue | mg | {"workers":4,"maxPendingFrameBytes":268435456} | 600 | 5 | 0 | 16.259 | 15.376 to 21.019 | 1719.000 | 1892.521 | +| queue | mg | {"workers":4,"maxPendingFrameBytes":536870912} | 600 | 5 | 0 | 16.150 | 14.894 to 16.829 | 1778.578 | 1849.005 | +| workers | mg | {"workers":1} | 600 | 1 | 0 | 26.855 | 26.855 to 26.855 | 896.109 | 849.235 | +| workers | mg | {"workers":2} | 600 | 1 | 0 | 21.917 | 21.917 to 21.917 | 1300.406 | 1238.658 | +| workers | mg | {"workers":3} | 600 | 1 | 0 | 16.295 | 16.295 to 16.295 | 1530.266 | 1540.020 | +| workers | mg | {"workers":4} | 600 | 5 | 0 | 20.377 | 14.456 to 24.258 | 1583.844 | 1816.255 | +| workers | mg | {"workers":6} | 600 | 5 | 0 | 18.265 | 15.083 to 21.226 | 2029.563 | 2402.337 | +| workers | mg | {"workers":8} | 600 | 1 | 0 | 17.077 | 17.077 to 17.077 | 2284.375 | 2902.840 | +| workers | mg | {"workers":"auto"} | 600 | 5 | 0 | 16.690 | 16.103 to 18.608 | 2738.469 | 2914.778 | +| video-workers | staggered | {"workers":2} | 600 | 1 | 0 | 28.974 | 28.974 to 28.974 | 986.266 | 1297.597 | +| video-workers | staggered | {"workers":4} | 600 | 1 | 0 | 48.136 | 48.136 to 48.136 | 1473.688 | 1913.459 | +| defaults | aligned | {} | 600 | 1 | 0 | 13.349 | 13.349 to 13.349 | 962.688 | 815.828 | +| defaults | mg4k | {} | 600 | 1 | 0 | 46.725 | 46.725 to 46.725 | 2715.266 | 5292.106 | +| defaults | uhd | {} | 300 | 1 | 0 | 19.586 | 19.586 to 19.586 | 988.422 | 1170.298 | +| workers | mg | {"workers":5} | 600 | 1 | 0 | 16.053 | 16.053 to 16.053 | 2085.453 | 2171.366 | +| workers | mg | {"workers":7} | 600 | 1 | 0 | 15.687 | 15.687 to 15.687 | 2512.344 | 2696.010 | + +Each fixture is summarized separately. Worker finalists were selected from one screening observation; their initial observations are subject to selection bias. Source and queue configurations are interleaved. These measurements describe the tested fixtures and do not establish a universal optimum. + +## Frame identifiers and cleanup + +Independent frame-ID observations: 31200. Failed verification records: 0. Observed processes remaining after completed jobs: 0. + +| Job | Export | Observations | Mismatches | Verification error | +| --- | --- | --- | --- | --- | +| accuracy-defaults | accuracy-defaults | 5400 | 0 | | +| defaults-video | defaults-video | 1800 | 0 | | +| mg-defaults | mg-defaults | 600 | 0 | | +| accuracy-workers-6 | accuracy-workers-6 | 600 | 0 | | +| accuracy-workers-4 | accuracy-workers-4 | 600 | 0 | | +| accuracy-queue-fastest | accuracy-queue-fastest | 600 | 0 | | +| accuracy-low-cache | accuracy-low-cache | 1080 | 0 | | +| accuracy-small-queue | accuracy-small-queue | 120 | 0 | | +| accuracy-fastest | accuracy-fastest | 5400 | 0 | | +| concurrent-2 | concurrent-2-0 | 1800 | 0 | | +| concurrent-2 | concurrent-2-1 | 1800 | 0 | | +| concurrent-4 | concurrent-4-0 | 1800 | 0 | | +| concurrent-4 | concurrent-4-1 | 1800 | 0 | | +| concurrent-4 | concurrent-4-2 | 1800 | 0 | | +| concurrent-4 | concurrent-4-3 | 1800 | 0 | | +| concurrent-faults | fault-peer | 1800 | 0 | | +| recovery | recovery | 1800 | 0 | | +| accuracy-workers-auto | accuracy-workers-auto | 600 | 0 | | + +## Additional cases + +| Job | Passed | Exports | Batch seconds | Peak RSS, MiB | Peak physical footprint, MiB | +| --- | --- | --- | --- | --- | --- | +| warmup | true | 1 | 3.500 | 834.484 | 560.438 | +| accuracy-defaults | true | 1 | 16.045 | 988.125 | 800.938 | +| mg-defaults | true | 1 | 18.630 | 2742.813 | 2866.371 | +| accuracy-workers-6 | true | 1 | 18.996 | 1952.984 | 2395.212 | +| accuracy-workers-4 | true | 1 | 21.000 | 1598.844 | 1855.787 | +| accuracy-queue-fastest | true | 1 | 19.164 | 1818.563 | 1799.818 | +| accuracy-low-cache | true | 1 | 37.242 | 873.203 | 730.188 | +| accuracy-small-queue | true | 1 | 9.752 | 1445.641 | 1150.067 | +| accuracy-fastest | true | 1 | 18.080 | 881.594 | 821.688 | +| concurrent-2 | true | 2 | 28.753 | 1443.922 | 1085.193 | +| concurrent-4 | true | 4 | 30.303 | 2702.125 | 2379.132 | +| concurrent-faults | true | 3 | 17.159 | 1637.203 | 827.317 | +| recovery | true | 1 | 14.624 | 959.219 | 635.063 | +| accuracy-workers-auto | true | 1 | 15.274 | 2764.234 | 2947.278 | + +Injected cancellation and renderer termination pass when the intended export fails, its action was triggered, peers complete correctly and cleanup succeeds. Recovery is measured in a subsequent job. + +## Experiment sessions + +Started: 2026-10-24T21:01:42.289Z. Finished: 2026-10-26T12:45:24.615Z. + +Resumed: 2026-10-26T12:13:57.835Z, checkout `feefcbb933a04fe4e513b19d3d9d265ee4788768`. Committed benchmark scripts and corrected stale default assertions; renderer/dependency trees and original input fingerprint verified unchanged. + +Resumed: 2026-10-26T12:37:05.806Z, checkout `ca0e9cbc2bea42a3256c3a784ea14fb6f153768d`. Extend worker screening to every integer from 1 through 8 and repeat the automatic baseline; correct report grouping. Production code and fixture inputs verified unchanged. + +Completed observations span interrupted and resumed sessions. Medians and ranges retain both sessions; desktop load may differ between them. + +Failed jobs: none. + +Raw case inputs, logs, results, environment snapshots and memory samples are stored in state.json and jobs/ beside the generated report. Runner usage is documented in benchmark-html-gpu.md. diff --git a/scripts/benchmark-html-gpu.md b/scripts/benchmark-html-gpu.md new file mode 100644 index 000000000..fc37524f9 --- /dev/null +++ b/scripts/benchmark-html-gpu.md @@ -0,0 +1,71 @@ +# HTML GPU export benchmark + +Run from the repository with installed workspace dependencies, Node.js, FFmpeg, +FFprobe and a prepared Electron renderer. The measurement runner uses macOS +process groups and physical-footprint sampling. Use the compiled projects from +the PR #437 video and complex-MG benchmark fixtures. + +```sh +node --import tsx scripts/benchmark-html-gpu.mjs \ + --video-project /path/to/video-project \ + --mg-project /path/to/complex-mg \ + --out dist/html-gpu-benchmark +``` + +Each project supplies `output/program.json`, `output/resources.json` and the +resource files referenced by that map. The video project contains two 360-frame, +1080p30 sources with frame identifiers. Source identifiers occupy the first +320 pixels at the bottom of each source, using ten 32-pixel bits. The composed +video and MG projects contain timeline identifiers at the coordinates documented +in `html-gpu-benchmark-fixtures.mjs`. Preflight checks verify these identifiers +before parameter comparisons start. + +The script runs the complete sequence without further input: + +1. Warm up and verify default video, eight-layer and MG frame identifiers. +2. Compare four source/queue configurations in five interleaved repetitions. +3. Compare 128, 256 and 512 MiB pending queues in five interleaved repetitions. +4. Screen MG worker counts 1 through 8 and automatic selection, then repeat + the two fastest candidates and the automatic baseline to five observations each. Compare two/four workers for staggered video. +5. Export aligned video, 4K video composition and scaled 4K MG with defaults. +6. Verify the fastest complete source/queue candidate's layer identifiers. +7. Verify two/four concurrent exports, cancellation and renderer termination + alongside a successful export, then verify recovery. + +`--repetitions` accepts five or more repetitions. Each timed job starts an +isolated Node process. Export wall time includes Provider preparation, capture, +encoding, muxing, full output decode validation and storage. Independent frame-ID +verification runs afterward. Sampling measures the isolated export host and its +descendants. Retained sample allocation, RSS and macOS physical footprint have +different meanings. Device memory is not measured separately. + +The source/queue comparison includes the previous 1 GiB / 256 MiB configuration, +the current defaults and explicit 3/4 GiB / 128 MiB configurations. Worker +finalists are selected from one screening observation, so their initial +observations are subject to selection bias. The report preserves all runs and +failed cases. Compare medians and ranges within the measured fixtures. + +Every job writes its logs, input, result and memory samples under `jobs/`. +`state.json`, `summary.json` and `REPORT.md` update after each job. Rerunning the +same command resumes remaining jobs when source, input and host fingerprints match. +Documentation and test-only commits preserve the source-content identity. +Completed failures stay in the report; use a fresh output directory for a new +experiment. Concurrent runners sharing an output directory are rejected. + +Each job has a five-minute deadline followed by bounded process-tree cleanup. +Signals interrupt the current job and leave remaining work available for resume. +Before each job, the runner requires at least 2 GiB of free output-volume space. +Exports used for independent verification are saved; timing-only exports stay in +memory. The runner records failed exports and proceeds through independent cases. +A frame-ID preflight failure stops the experiment before parameter comparisons. + +The script produces local reports. PR descriptions and repository changes are +published separately after reviewing those reports. + +To regenerate reports from a saved experiment without rendering: + +```sh +node scripts/html-gpu-benchmark-report.mjs dist/html-gpu-benchmark +``` + +The report groups each fixture separately and records resumed experiment sessions. diff --git a/scripts/benchmark-html-gpu.mjs b/scripts/benchmark-html-gpu.mjs new file mode 100644 index 000000000..2fa3bf15a --- /dev/null +++ b/scripts/benchmark-html-gpu.mjs @@ -0,0 +1,233 @@ +import { readFile, writeFile, mkdir, rename, rm, statfs } from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { createHash } from 'node:crypto'; +import { cpus, totalmem, release } from 'node:os'; +import { setTimeout as delay } from 'node:timers/promises'; +import { writeBenchmarkReport } from './html-gpu-benchmark-report.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +process.chdir(root); +const { values: args } = parseArgs({options:{ + 'video-project':{type:'string'}, 'mg-project':{type:'string'}, out:{type:'string',default:'dist/html-gpu-benchmark'}, + repetitions:{type:'string',default:'5'}, job:{type:'string'}, help:{type:'boolean'}, +}}); +if(args.help){console.log('node --import tsx scripts/benchmark-html-gpu.mjs --video-project --mg-project [--out ] [--repetitions 5]');process.exit(0);} +const MiB=1024**2, script=fileURLToPath(import.meta.url); +async function json(file,value){await mkdir(dirname(file),{recursive:true});await writeFile(file+'.tmp',JSON.stringify(value,null,2));await rename(file+'.tmp',file);} +function command(binary,argv){const r=spawnSync(binary,argv,{encoding:'utf8',timeout:10000,maxBuffer:4*MiB});if(r.status!==0)throw Error(`${binary}: ${r.error??r.stderr}`);return r.stdout.trim();} +function alive(pid){try{process.kill(pid,0);return true;}catch{return false;}} + +if(args.job){ + const job=JSON.parse(await readFile(args.job,'utf8')); + const { loadFixtures, verifyIdentifiers }=await import('./html-gpu-benchmark-fixtures.mjs'); + const { MemoryResourceStore }=await import('../packages/executor/src/resources.ts'); + const { rasterizeHtmlProgram, resolveExecutionOptions }=await import('../packages/provider-html-local/src/render.ts'); + const fixtures=await loadFixtures(job.videoProject,job.mgProject), controllers=new Set(), pids=new Set(); + const interrupted=()=>{for(const controller of controllers)controller.abort(Error('Benchmark interrupted'));}; + process.on('SIGTERM',interrupted);process.on('SIGINT',interrupted); + const event=phase=>console.log('BENCH_EVENT '+JSON.stringify({phase})); + async function render(item){ + const fixture=fixtures[item.doc], resources=new MemoryResourceStore(), diagnostics=[], controller=new AbortController(); + for(const [artifact,bytes]of fixture.blobs)await resources.write(artifact,bytes); + controllers.add(controller);let fired=false,visual,error,file,verification; + const ownPids=new Set(); + const began=performance.now(), effective=resolveExecutionOptions(item.config??{}); + try{ + visual=await rasterizeHtmlProgram({program:fixture.program,...(item.range?{range:item.range}:{})},{ + resources,renderer:'electron',processTimeoutMs:180000,initializationTimeoutMs:30000,frameTimeoutMs:30000, + ...item.config,signal:controller.signal, + onDiagnostic:async d=>diagnostics.push(d), + onProgress:e=>{ + if(e.browserPid){pids.add(e.browserPid);ownPids.add(e.browserPid);} + if(item.action&&!fired&&e.phase==='worker-progress'&&e.completed>10){fired=true; + if(item.action==='cancel')controller.abort(Error('Benchmark cancellation')); + else if(ownPids.size)process.kill([...ownPids].at(-1),'SIGKILL'); + } + }, + }); + }catch(e){error=String(e);}finally{controllers.delete(controller);} + const seconds=(performance.now()-began)/1000; + if(visual&&item.verify){file=resolve(job.directory,item.id+'.mp4');await writeFile(file,await resources.get(visual.artifact.resource));} + const videoStats=diagnostics.flatMap(d=>[...d.message.matchAll(/video: (\{[^\n]+\})/g)].map(m=>JSON.parse(m[1]))); + return {id:item.id,doc:item.doc,group:item.group,config:item.config??{}, + effective:{sourceMiB:effective.maxDecodedSourceBytes/MiB,pendingMiB:effective.maxPendingFrameBytes/MiB,workers:effective.workers}, + seconds,frames:visual?.frameCount,bytes:visual?.artifact.size,error,action:item.action,actionTriggered:fired, + file,verifyKind:item.verify,verification,diagnostics,videoStats}; + } + let result; + try{ + event('export');const began=performance.now(); + const exports=await Promise.all(job.items.map(render)), seconds=(performance.now()-began)/1000; + event('verification'); + for(const item of exports)if(item.file){ + try{item.verification=verifyIdentifiers(item.file,item.verifyKind,item.frames);} + catch(e){item.verification={error:String(e)};} + } + await delay(1500); + const survivors=[...pids].filter(alive); + const passed=exports.every(r=>r.action?r.actionTriggered&&!!r.error:!r.error&&r.frames>0) + &&exports.every(r=>!r.verifyKind||(r.verification&&!r.verification.error&&r.verification.mismatches===0))&&survivors.length===0; + result={id:job.id,passed,seconds,exports,survivors}; + }catch(e){result={id:job.id,passed:false,error:String(e)};} + await json(job.resultFile,result);event('done');process.exitCode=result.passed?0:1; +}else{ + if(process.platform!=='darwin')throw Error('This measurement runner requires macOS process and memory tools.'); + if(!args['video-project']||!args['mg-project'])throw Error('Provide --video-project and --mg-project; see --help.'); + const reps=Number(args.repetitions);if(!Number.isInteger(reps)||reps<5)throw Error('Use at least five repetitions.'); + const directory=resolve(args.out),videoProject=resolve(args['video-project']),mgProject=resolve(args['mg-project']); + await mkdir(directory,{recursive:true}); + const lock=resolve(directory,'runner.lock'); + try{await mkdir(lock);}catch(e){if(e.code!=='EEXIST')throw e;const pid=Number(await readFile(resolve(lock,'pid'),'utf8').catch(()=>0));if(alive(pid)&&pid>0)throw Error('Benchmark already running: '+pid);await rm(lock,{recursive:true,force:true});await mkdir(lock);} + await writeFile(resolve(lock,'pid'),String(process.pid)); + let stopping=false,current; + const stop=()=>{stopping=true;if(current)try{process.kill(-current,'SIGTERM');}catch{}}; + process.on('SIGTERM',stop);process.on('SIGINT',stop); + try{ + command('ffmpeg',['-version']);command('ffprobe',['-version']); + const { loadProject }=await import('./html-gpu-benchmark-fixtures.mjs'); + const fingerprint=createHash('sha256'); + fingerprint.update(JSON.stringify({scheme:3,node:process.version,platform:process.platform,arch:process.arch, + cpu:cpus()[0]?.model,release:release(),memory:totalmem()})); + const isSource=file=>!/(?:^|\/)(?:test|dist|node_modules)(?:\/|$)/.test(file)&&/\.(?:[cm]?[jt]s|json|yaml)$/.test(file); + const sourceList=spawnSync('git',['ls-files','--cached','--others','--exclude-standard','-z','--', + 'packages','package.json','pnpm-lock.yaml','tsconfig.json'],{encoding:'utf8',maxBuffer:4*MiB}); + if(sourceList.status!==0)throw Error('Cannot enumerate benchmark source files: '+sourceList.stderr); + const sources=[...new Set(sourceList.stdout.split('\0').filter(file=>file&&isSource(file)))].sort(); + const harness=['scripts/benchmark-html-gpu.mjs','scripts/html-gpu-benchmark-fixtures.mjs','scripts/html-gpu-benchmark-report.mjs']; + for(const file of [...sources,...harness]){fingerprint.update(file+'\0');fingerprint.update(await readFile(resolve(root,file)));fingerprint.update('\0');} + const inputs=[]; + for(const project of [videoProject,mgProject]){const data=await loadProject(project);inputs.push(JSON.stringify(data.program));for(const [artifact,bytes]of data.blobs)inputs.push(artifact.resource,bytes);} + inputs.push(String(reps));for(const input of inputs){fingerprint.update(input);fingerprint.update('\0');} + const identity=fingerprint.digest('hex'),stateFile=resolve(directory,'state.json'); + let state;try{state=JSON.parse(await readFile(stateFile,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;} + if(state&&state.identityScheme!==3){ + const legacyHead=state.resumptions?.at(-1)?.head??state.head; + const oldFile=file=>{const result=spawnSync('git',['show',legacyHead+':'+file],{maxBuffer:4*MiB}); + if(result.status!==0)throw Error('Cannot recover the legacy benchmark harness; select a fresh --out directory.');return result.stdout;}; + const legacyHarness=harness.map(oldFile),oldRunner=legacyHarness[0].toString(); + const stripIdentity=text=>{const a=text.indexOf(" const fingerprint=createHash('sha256');"),b=text.indexOf('\n state??={',a)+1; + if(a<0||b<1)throw Error('Unrecognized benchmark identity format.');return text.slice(0,a)+''+text.slice(b).replace(' state.identityScheme=3;\n','');}; + const changed=command('git',['diff','--name-only',legacyHead,'--','packages','package.json','pnpm-lock.yaml','tsconfig.json']) + .split('\n').filter(file=>file&&isSource(file)); + const untracked=spawnSync('git',['ls-files','--others','--exclude-standard','-z','--','packages'],{encoding:'utf8'}); + if(untracked.status!==0||untracked.stdout.split('\0').some(file=>file&&isSource(file)))throw Error('Untracked production source prevents legacy resume.'); + const sameHost=state.host.platform===process.platform&&state.host.release===release()&&state.host.node===process.version + &&state.host.cpu===cpus()[0]?.model&&state.host.totalMemoryMiB===totalmem()/MiB; + if(changed.length||!sameHost||stripIdentity(oldRunner)!==stripIdentity(await readFile(script,'utf8')) + ||!legacyHarness[1].equals(await readFile(resolve(root,harness[1])))||!legacyHarness[2].equals(await readFile(resolve(root,harness[2])))) + throw Error('Legacy benchmark source or host changed; select a fresh --out directory.'); + const legacy=createHash('sha256').update(legacyHead);for(const bytes of [...legacyHarness,...inputs])legacy.update(bytes); + if(legacy.digest('hex')!==state.identity)throw Error('Legacy script and fixture fingerprint did not match.'); + state.identityMigration={at:new Date().toISOString(),legacyHead,previous:state.identity, + reason:'Verified identical production source, measurement harness, fixtures and host before migrating to a source-content identity.'}; + state.identity=identity; + } + if(state&&state.identity!==identity)throw Error('Source, fixtures, host or repetition count changed. Select a fresh --out directory.'); + state??={identity,head:command('git',['rev-parse','HEAD']),dirty:!!command('git',['status','--porcelain']), + startedAt:new Date().toISOString(),host:{platform:process.platform,release:release(),cpu:cpus()[0]?.model,totalMemoryMiB:totalmem()/MiB,node:process.version}, + videoProject,mgProject,repetitions:reps,results:[]}; + state.identityScheme=3; + let readUsage; + try{const {default:koffi}=await import('koffi');readUsage=koffi.load('/usr/lib/libproc.dylib').func('int proc_pid_rusage(int pid, int flavor, void *buffer)');}catch{} + function processes(rootPid, observed) { + const raw=spawnSync('ps',['-axo','pid=,ppid=,pgid=,rss=,stat=,lstart=,comm='],{encoding:'utf8',env:{...process.env,LC_ALL:'C'}}); + if(raw.status!==0)throw Error('Process sampler failed: '+raw.stderr); + const rows=raw.stdout.split('\n').flatMap(line=>{ + const m=line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d+)\s+(.*)$/); + if(!m||m[5].startsWith('Z'))return[]; + return[{pid:+m[1],ppid:+m[2],pgid:+m[3],rssMiB:+m[4]/1024,started:m[6],command:Buffer.from(m[7]).toString()}]; + }); + const owned=new Set([rootPid]); + for(const row of rows){const old=observed.get(row.pid);if(old&&old.started===row.started&&old.command===row.command)owned.add(row.pid);} + for(let i=0;i<32;i++){const size=owned.size;for(const row of rows)if(owned.has(row.ppid))owned.add(row.pid);if(size===owned.size)break;} + return rows.filter(row=>owned.has(row.pid)).map(row=>{ + observed.set(row.pid,{started:row.started,command:row.command}); + row.footprintMiB=null;if(readUsage){const b=Buffer.alloc(96);if(readUsage(row.pid,0,b)===0)row.footprintMiB=Number(b.readBigUInt64LE(72))/MiB;} + return row; + }); + } + function stats(){return{at:new Date().toISOString(),swap:command('sysctl',['vm.swapusage']),thermal:command('pmset',['-g','therm'])};} + const median=xs=>{xs=[...xs].sort((a,b)=>a-b);return xs.length%2?xs[Math.floor(xs.length/2)]:(xs[xs.length/2-1]+xs[xs.length/2])/2;}; + async function summarize(){ + await writeBenchmarkReport(directory,state); + await json(stateFile,state); + } + async function run(id,items){ + if(stopping)return; + if(state.results.some(r=>r.id===id))return; + const space=await statfs(directory);if(space.bavail*space.bsize<2*1024**3)throw Error('Less than 2 GiB disk space available. Resume after freeing space.'); + const jobDir=resolve(directory,'jobs',id);await mkdir(jobDir,{recursive:true}); + const resultFile=resolve(jobDir,'result.json'),jobFile=resolve(jobDir,'job.json'); + await json(jobFile,{id,items,directory:jobDir,resultFile,videoProject,mgProject}); + const before=stats(),samples=[],log=createWriteStream(resolve(jobDir,'output.log'));let phase='startup',timedOut=false,closed=false;const observed=new Map(); + const child=spawn(process.execPath,['--expose-gc','--import','tsx',script,'--job',jobFile],{cwd:root,detached:true,stdio:['ignore','pipe','pipe'],env:{...process.env,NODE_NO_WARNINGS:'1'}}); + current=child.pid;console.log('START',id);let buffer=''; + child.stdout.on('data',chunk=>{log.write(chunk);buffer+=chunk.toString();let i;while((i=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,i);buffer=buffer.slice(i+1);if(line.startsWith('BENCH_EVENT '))phase=JSON.parse(line.slice(12)).phase;}}); + child.stderr.on('data',chunk=>log.write(chunk)); + const timer=setInterval(()=>{try{const rows=processes(child.pid,observed);samples.push({ms:Date.now(),phase,processes:rows,rssMiB:rows.reduce((s,r)=>s+r.rssMiB,0),footprintMiB:readUsage?rows.reduce((s,r)=>s+(r.footprintMiB??0),0):null});}catch(e){log.write(String(e)+'\n');}},250); + const timeout=setTimeout(()=>{timedOut=true;try{process.kill(-child.pid,'SIGTERM');}catch{}},300000); + const force=setTimeout(()=>{if(!closed)try{process.kill(-child.pid,'SIGKILL');}catch{}},315000); + let code,signal,spawnError; + await new Promise(done=>{child.on('error',e=>{spawnError=String(e);done();});child.on('close',(c,s)=>{code=c;signal=s;closed=true;done();});}); + clearInterval(timer);clearTimeout(timeout);clearTimeout(force);current=undefined;log.end(); + let remaining=child.pid?processes(child.pid,observed):[]; + for(let n=0;remaining.length&&n<10;n++){await delay(200);remaining=processes(child.pid,observed);} + const leaked=remaining; + if(remaining.length){for(const row of remaining)try{process.kill(row.pid,'SIGTERM');}catch{}await delay(1000);for(const row of processes(child.pid,observed))try{process.kill(row.pid,'SIGKILL');}catch{}} + let result;try{result=JSON.parse(await readFile(resultFile,'utf8'));}catch{result={id,passed:false,error:'Child did not write a result'};} + const captured=samples.filter(s=>s.phase==='export'); + Object.assign(result,{code,signal,spawnError,timedOut,interrupted:stopping,before,after:stats(),leaked, + peakRssMiB:Math.max(0,...captured.map(s=>s.rssMiB)),peakFootprintMiB:readUsage?Math.max(0,...captured.map(s=>s.footprintMiB)):null}); + result.passed=!!result.passed&&code===0&&!timedOut&&!stopping&&!leaked.length; + await json(resolve(jobDir,'samples.json'),samples); + if(!stopping)state.results.push(result);else state.lastInterrupted=result; + await summarize();console.log('DONE',id,result.passed?'PASS':'FAIL',result.seconds?.toFixed(2)); + } + const item=(id,doc,config,group,verify)=>({id,doc,config,group,verify}); + const repeat=async(group,doc,configs)=>{for(let r=0;r['accuracy-defaults','defaults-video','mg-defaults'].includes(r.id)&&!r.passed)){state.status='preflight_failed';await summarize();throw Error('Frame-ID preflight failed; see the saved report.');} + await repeat('source','staggered',[ + {workers:1,maxDecodedSourceBytes:1024*MiB,maxPendingFrameBytes:256*MiB}, + {},{workers:1,maxDecodedSourceBytes:3072*MiB,maxPendingFrameBytes:128*MiB}, + {workers:1,maxDecodedSourceBytes:4096*MiB,maxPendingFrameBytes:128*MiB}, + ]); + await repeat('queue','mg',[128,256,512].map(n=>({workers:4,maxPendingFrameBytes:n*MiB}))); + const workerOptions=[1,2,3,4,5,6,7,8,'auto']; + for(const workers of workerOptions)await run('workers-screen-'+workers,[item('workers-screen-'+workers,'mg',{workers},'workers')]); + const ranked=state.results.flatMap(r=>r.passed?(r.exports??[]):[]).filter(r=>r.group==='workers'&&r.id.startsWith('workers-screen-')).sort((a,b)=>a.seconds-b.seconds); + const finalists=[];for(const row of ranked)if(!finalists.some(c=>c.workers===row.config.workers)&&finalists.length<2)finalists.push(row.config); + if(!finalists.some(config=>config.workers==='auto'))finalists.push({workers:'auto'}); + state.workerFinalists=finalists;await summarize(); + for(let r=1;rr.passed).flatMap(r=>r.exports??[]).filter(r=>r.group==='queue'); + const queueGroups=Object.entries(Object.groupBy(queueRuns,r=>JSON.stringify(r.config))).filter(([,v])=>v.length===reps).sort((a,b)=>median(a[1].map(x=>x.seconds))-median(b[1].map(x=>x.seconds))); + if(queueGroups.length)await run('accuracy-queue-fastest',[item('accuracy-queue-fastest','mg',JSON.parse(queueGroups[0][0]),undefined,'mg')]); + await run('accuracy-low-cache',[{...item('accuracy-low-cache','accuracy',{maxDecodedSourceBytes:32*MiB},undefined,'accuracy'),range:{startFrame:0,endFrameExclusive:120}}]); + await run('accuracy-small-queue',[{...item('accuracy-small-queue','mg',{workers:4,maxPendingFrameBytes:1},undefined,'mg'),range:{startFrame:0,endFrameExclusive:120}}]); + const bestSource=state.results.filter(r=>r.passed).flatMap(r=>r.exports??[]).filter(r=>r.group==='source'); + const sourceGroups=Object.groupBy(bestSource,r=>JSON.stringify(r.config)); + const rankedSource=Object.entries(sourceGroups).filter(([,v])=>v.length===reps).sort((a,b)=>median(a[1].map(x=>x.seconds))-median(b[1].map(x=>x.seconds))); + if(rankedSource.length)await run('accuracy-fastest',[item('accuracy-fastest','accuracy',JSON.parse(rankedSource[0][0]),undefined,'accuracy')]); + for(const n of [2,4])await run('concurrent-'+n,Array.from({length:n},(_,i)=>item(`concurrent-${n}-${i}`,'video',{},undefined,'video'))); + await run('concurrent-faults',[ + item('fault-peer','video',{},undefined,'video'), + {...item('fault-cancel','mg',{}),action:'cancel'}, + {...item('fault-crash','video',{}),action:'crash'}, + ]); + await run('recovery',[item('recovery','video',{},undefined,'video')]); + state.status=stopping?'interrupted':state.results.some(r=>!r.passed)?'completed_with_failures':'completed';state.finishedAt=new Date().toISOString();await summarize(); + console.log('REPORT',resolve(directory,'REPORT.md'));if(state.status!=='completed')process.exitCode=1; + }finally{await rm(lock,{recursive:true,force:true});} +} diff --git a/scripts/html-gpu-benchmark-fixtures.mjs b/scripts/html-gpu-benchmark-fixtures.mjs new file mode 100644 index 000000000..835ed3b6b --- /dev/null +++ b/scripts/html-gpu-benchmark-fixtures.mjs @@ -0,0 +1,85 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { sealComposition, sealVisualTrack } from '../packages/composition/src/index.ts'; +import { sealTimeline } from '../packages/timeline/src/index.ts'; +import { compileHtmlProgram, htmlVisual } from '../packages/html-program/src/index.ts'; + +export async function loadProject(directory) { + const program = JSON.parse(await readFile(resolve(directory, 'output/program.json'), 'utf8')); + const paths = JSON.parse(await readFile(resolve(directory, 'output/resources.json'), 'utf8')); + const blobs = await Promise.all(program.artifacts.map(async ({ artifact }) => { + if (!paths[artifact.resource]) throw Error('Missing project resource: ' + artifact.resource); + return [artifact, await readFile(resolve(directory, paths[artifact.resource]))]; + })); + return { program, blobs }; +} + +export async function loadFixtures(videoDirectory, mgDirectory) { +const video = await loadProject(videoDirectory), motion = await loadProject(mgDirectory); +const blobs = video.blobs, mg = motion.program; +function tiled(layers,width=1920,height=1080,frames=600,aligned=false,accuracy=false){const rate={numerator:30,denominator:1},videos=blobs.filter(([a])=>a.mediaType==='video/mp4').map(([a])=>a),cols=Math.ceil(Math.sqrt(layers)),rows=Math.ceil(layers/cols),w=Math.floor(width/cols),h=Math.floor(height/rows);const style=(l,t,w,h)=>Object.entries({position:'absolute',left:l+'px',top:t+'px',width:w+'px',height:h+'px'}).map(([name,value])=>({name,value})); +const elements=[{id:'root',order:0,kind:'box',style:style(0,0,width,height)}]; +for(let i=0;i`,setup:`const ctx=root.querySelector('canvas').getContext('2d');return frame=>{ctx.clearRect(0,0,${width},${height});for(let i=0;i<128;i++){ctx.fillStyle=i%2?'#17a589':'#316ac5';ctx.fillRect((i*29+frame*5)%${width},(i*47+frame*2)%${accuracy?300:height},12,12);}for(let i=0;i<16;i++){ctx.fillStyle=(frame>>i&1)?'#fff':'#000';ctx.fillRect(${accuracy?width-320:0}+i*20,${height-16},20,16);}};`}); +elements.push({id:'mg',parent:'root',order:layers+1,kind:'program',style:style(0,0,width,height),program:graphics}); +return compileHtmlProgram(sealComposition({id:'tile',canvas:{width,height,clearColor:'#000000'},tracks:[sealVisualTrack({id:'video',visualIr:'hypit.visual-ir@1',timelineId:'tile',presents:[{id:'scene',span:{startFrame:0,endFrameExclusive:frames},order:0,z:0,elements}]})]}),sealTimeline({id:'tile',frameCount:frames,frameRate:rate}));} + +const mg4k = { ...mg, canvas: { ...mg.canvas, width: 3840, height: 2160 }, + html: mg.html.replace('', '') }; +return Object.fromEntries(Object.entries({ + video: video.program, mg, mg4k, staggered: tiled(8), aligned: tiled(8,1920,1080,600,true), + accuracy: tiled(8,1920,1080,600,false,true), uhd: tiled(4,3840,2160,300), +}).map(([name,program]) => [name, { program, blobs: name.startsWith('mg') ? motion.blobs : blobs }])); +} + +function decode(file, filter, width) { + const result = spawnSync('ffmpeg', ['-v','error','-i',file,'-vf',filter,'-pix_fmt','gray','-f','rawvideo','pipe:1'], + { maxBuffer: 32*1024*1024, timeout: 120000 }); + if (result.status !== 0) throw Error('Identifier decode failed: ' + (result.error ?? result.stderr)); + if (result.stdout.length % width) throw Error('Truncated identifier decode'); + return result.stdout; +} + +// The supplied benchmark projects encode source frame IDs in the bottom-left of each source, +// and timeline IDs in the output. Coordinates match the 1080p fixtures used in PR #437. +export function verifyIdentifiers(file, kind, count) { + const errors = [], checks = []; + const record = (frame, channel, actual, expected) => { + if (actual !== expected) { if (errors.length < 20) errors.push({frame,channel,actual,expected}); } + return actual !== expected ? 1 : 0; + }; + let mismatches = 0, observations = 0; + if (kind === 'accuracy') { + const branches = Array.from({length:9},(_,i)=>`[s${i}]crop=320:8:${i<8?(i%3)*640:1600}:${i<8?Math.floor(i/3)*360+350:1068}[c${i}]`); + const graph = `[0:v]split=9${branches.map((_,i)=>`[s${i}]`).join('')};${branches.join(';')};${branches.map((_,i)=>`[c${i}]`).join('')}vstack=inputs=9,format=gray[out]`; + const r = spawnSync('ffmpeg',['-v','error','-i',file,'-filter_complex',graph,'-map','[out]','-f','rawvideo','pipe:1'],{maxBuffer:32*1024*1024,timeout:120000}); + if (r.status !== 0) throw Error('Layer identifier decode failed: '+(r.error ?? r.stderr)); + const frames = r.stdout.length/(320*72); + if (frames !== count) throw Error(`Identifier frame count ${frames}, expected ${count}`); + for(let f=0;fa-b); if(values[Math.floor(values.length/2)]>128)actual|=1<f], + ['source-a','crop=214:16:64:920',10,f=>f<150?f:f<300?449-f:f<450?120:60+Math.floor((f-450)*1.5)%60], + ['source-b','crop=80:6:1376:484',10,f=>f%360], + ] : [['timeline','crop=160:8:0:1072',10,f=>f]]; + for(const [channel,crop,bits,expected] of channels) { + const raw=decode(file,crop+`,scale=${bits}:1:flags=area`,bits), frames=raw.length/bits; + if(frames!==count)throw Error(`Identifier frame count ${frames}, expected ${count}`); + for(let f=0;f128)actual|=1< a - b); + if (!values.length) return null; + const middle = Math.floor(values.length / 2); + return values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2; +} + +export function summarizeBenchmark(state) { + const groups = new Map(); + for (const result of state.results) for (const item of result.exports ?? []) { + if (!item.group) continue; + const key = `${item.group} / ${item.doc} / ${JSON.stringify(item.config)}`; + const group = groups.get(key) ?? { key, group: item.group, fixture: item.doc, config: item.config, all: [] }; + group.all.push({ ...item, passed: result.passed, rss: result.peakRssMiB, footprint: result.peakFootprintMiB }); + groups.set(key, group); + } + const rows = [...groups.values()].map(({ all, ...group }) => { + const good = all.filter(item => item.passed); + const times = good.map(item => item.seconds); + return { ...group, completed: good.length, failed: all.length - good.length, + frameCounts: [...new Set(good.map(item => item.frames))], + medianSeconds: median(times), minSeconds: times.length ? Math.min(...times) : null, + maxSeconds: times.length ? Math.max(...times) : null, + medianPeakRssMiB: median(good.map(item => item.rss)), + medianPeakFootprintMiB: median(good.map(item => item.footprint)), + decoderStarts: good.map(item => item.videoStats?.reduce((sum, value) => sum + value.decoderStarts, 0)), + samplesRead: good.map(item => item.videoStats?.reduce((sum, value) => sum + value.samplesRead, 0)), + retainedSamplePeakBytes: good.map(item => item.videoStats?.reduce((sum, value) => sum + value.peak, 0)), + }; + }); + const checked = state.results.flatMap(result => (result.exports ?? []) + .filter(item => item.verification).map(item => ({ job: result.id, item: item.id, passed: result.passed, ...item.verification }))); + return { status: state.status ?? 'running', head: state.head, host: state.host, repetitions: state.repetitions, + startedAt: state.startedAt, finishedAt: state.finishedAt, resumptions: state.resumptions ?? [], + completedJobs: state.results.length, failedJobs: state.results.filter(result => !result.passed).map(result => result.id), + successfulExports: state.results.filter(result => result.passed).flatMap(result => result.exports ?? []) + .filter(item => !item.error).length, + expectedFailures: state.results.filter(result => result.passed).flatMap(result => result.exports ?? []) + .filter(item => item.action && item.error).length, + verifiedObservations: checked.reduce((sum, item) => sum + (item.observations ?? 0), 0), + verificationFailures: checked.filter(item => item.error || item.mismatches || !item.passed), checked, rows, + additionalCases: state.results.filter(result => !(result.exports ?? []).some(item => item.group)) + .map(result => ({ id: result.id, passed: result.passed, seconds: result.seconds, + exports: result.exports?.length ?? 0, peakRssMiB: result.peakRssMiB, peakFootprintMiB: result.peakFootprintMiB })), + leaks: state.results.flatMap(result => (result.leaked ?? []).map(process => ({ job: result.id, ...process }))), + }; +} + +export function benchmarkMarkdown(summary) { + const n = value => Number.isFinite(value) ? value.toFixed(3) : 'n/a'; + const host = summary.host; + const lines = ['# HTML GPU export measurements', '', + `Status: ${summary.status}. Renderer baseline: \`${summary.head}\`. Target repetitions: ${summary.repetitions}.`, '', + `Host: ${host.cpu}, ${(host.totalMemoryMiB / 1024).toFixed(0)} GiB RAM, ${host.platform} ${host.release}, Node ${host.node}.`, '', + `Completed jobs: ${summary.completedJobs}. Successful exports: ${summary.successfulExports}. Expected injected failures: ${summary.expectedFailures}. Failed jobs: ${summary.failedJobs.length}.`, '', + '## Measurement settings', '', + 'Export seconds include Provider preparation, capture, encoding, muxing, full output decode validation and storage. Independent frame-ID checks run afterward. Each job starts a fresh Node process; the prepared Electron cache persists. Normal desktop activity remains uncontrolled.', '', + 'Memory is sampled every 250 ms across the export host and its descendants, including Electron processes with independent process groups. RSS, macOS physical footprint and retained sample allocation are distinct quantities. Exclusive device memory is not measured.', '', + 'The four-layer 4K video case composes 1080p sources. The scaled 4K MG case retains authored Canvas raster resolutions. Timing-only jobs keep output in memory; independent verification jobs also save MP4 files.', '', + '## Timing and memory results', '', + '| Group | Fixture | Configuration | Frames per export | Completed | Failed | Median seconds | Range, seconds | Median peak RSS, MiB | Median peak physical footprint, MiB |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |']; + for (const row of summary.rows) lines.push(`| ${row.group} | ${row.fixture} | ${JSON.stringify(row.config)} | ${row.frameCounts.join(" / ")} | ${row.completed} | ${row.failed} | ${n(row.medianSeconds)} | ${n(row.minSeconds)} to ${n(row.maxSeconds)} | ${n(row.medianPeakRssMiB)} | ${n(row.medianPeakFootprintMiB)} |`); + lines.push('', 'Each fixture is summarized separately. Worker finalists were selected from one screening observation; their initial observations are subject to selection bias. Source and queue configurations are interleaved. These measurements describe the tested fixtures and do not establish a universal optimum.', '', + '## Frame identifiers and cleanup', '', + `Independent frame-ID observations: ${summary.verifiedObservations}. Failed verification records: ${summary.verificationFailures.length}. Observed processes remaining after completed jobs: ${summary.leaks.length}.`, '', + '| Job | Export | Observations | Mismatches | Verification error |', '| --- | --- | --- | --- | --- |'); + for (const item of summary.checked) lines.push(`| ${item.job} | ${item.item} | ${item.observations ?? 'n/a'} | ${item.mismatches ?? 'n/a'} | ${item.error ?? ''} |`); + lines.push('', '## Additional cases', '', + '| Job | Passed | Exports | Batch seconds | Peak RSS, MiB | Peak physical footprint, MiB |', + '| --- | --- | --- | --- | --- | --- |'); + for (const item of summary.additionalCases) lines.push(`| ${item.id} | ${item.passed} | ${item.exports} | ${n(item.seconds)} | ${n(item.peakRssMiB)} | ${n(item.peakFootprintMiB)} |`); + lines.push('', 'Injected cancellation and renderer termination pass when the intended export fails, its action was triggered, peers complete correctly and cleanup succeeds. Recovery is measured in a subsequent job.', '', '## Experiment sessions', '', + `Started: ${summary.startedAt}. Finished: ${summary.finishedAt ?? 'in progress'}.`); + for (const session of summary.resumptions) lines.push('', `Resumed: ${session.at}, checkout \`${session.head}\`. ${session.reason}`); + if (summary.resumptions.length) lines.push('', 'Completed observations span interrupted and resumed sessions. Medians and ranges retain both sessions; desktop load may differ between them.'); + lines.push('', `Failed jobs: ${summary.failedJobs.join(', ') || 'none'}.`, '', + 'Raw case inputs, logs, results, environment snapshots and memory samples are stored in state.json and jobs/ beside the generated report. Runner usage is documented in benchmark-html-gpu.md.', ''); + return lines.join('\n'); +} + +export async function writeBenchmarkReport(directory, state) { + const summary = summarizeBenchmark(state); + for (const [name, contents] of [ + ['summary.json', JSON.stringify(summary, null, 2)], ['REPORT.md', benchmarkMarkdown(summary)], + ]) { + await writeFile(resolve(directory, name + '.tmp'), contents); + await rename(resolve(directory, name + '.tmp'), resolve(directory, name)); + } + return summary; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const directory = resolve(process.argv[2] ?? 'dist/html-gpu-benchmark'); + const state = JSON.parse(await readFile(resolve(directory, 'state.json'), 'utf8')); + await writeBenchmarkReport(directory, state); + console.log(resolve(directory, 'REPORT.md')); +} diff --git a/scripts/pack-independent-package.mjs b/scripts/pack-independent-package.mjs index f2d2028c5..6257f9e6d 100644 --- a/scripts/pack-independent-package.mjs +++ b/scripts/pack-independent-package.mjs @@ -207,9 +207,17 @@ export async function packIndependentPackage(packageDirectory, outputDirectory, try { const compiled = await compileSources(packageRoot, stage); for (const entry of manifest.files ?? []) { - if (entry === "dist" || entry === "LICENSE") continue; + if (entry === "dist" || entry === "LICENSE" || (entry === "browser" && manifest.hypit?.browserAssets)) continue; await copyDeclaredEntry(packageRoot, stage, entry); } + // Browser-only libraries ship their runtime bundle and original source/license. + for (const [name, entries] of Object.entries(manifest.hypit?.browserAssets ?? {})) { + const source = resolve(packageRoot, "node_modules", name); + const target = resolve(stage, "browser", name); + if (!inside(resolve(stage, "browser"), target)) throw new Error(`Invalid browser asset package ${name}`); + await mkdir(target, { recursive: true }); + for (const entry of [...entries, "package.json"]) await copyDeclaredEntry(source, target, entry); + } const localLicense = resolve(packageRoot, "LICENSE"); const license = await stat(localLicense).then(() => localLicense).catch(() => resolve(repositoryRoot, "LICENSE")); await cp(license, resolve(stage, "LICENSE"));