mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior * fix: address WebGPU capture review feedback * fix: retain overlapping GPU seek work * fix: satisfy runtime seek completion types * fix: drain concurrent GPU seek work * fix: prevent WebGPU capture barrier starvation * fix: keep WebGPU presentation active during render seeks
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
type CheckSection,
|
||||
} from "../utils/checkPipeline.js";
|
||||
import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js";
|
||||
import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Run the full verification gate", "hyperframes check"],
|
||||
@@ -106,6 +107,12 @@ export function createCheckCommand(
|
||||
"Auto-transcode browser-hostile video codecs (default: hyperframes.json media.autoProxy, which defaults on)",
|
||||
default: undefined,
|
||||
},
|
||||
"browser-gpu": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)",
|
||||
default: undefined,
|
||||
},
|
||||
snapshots: {
|
||||
type: "boolean",
|
||||
description: "Save the five contrast-pass PNGs under snapshots/",
|
||||
@@ -175,6 +182,7 @@ function parseCheckOptions(args: Record<string, unknown>): CheckOptions {
|
||||
frameCheck: parseFrameCheck(args["frame-check"]),
|
||||
layout: parseLayout(args.layout),
|
||||
autoProxy: args.proxy as boolean | undefined,
|
||||
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,8 @@ async function runLayoutAudit(
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||||
const { assertWebGpuRequirement, resolveCaptureBrowserGpuMode, resolveLocalBrowserGpuMode } =
|
||||
await import("../browser/gpuPolicy.js");
|
||||
const html = await bundleProjectHtml(projectDir);
|
||||
const server = await serveStaticProjectHtml(
|
||||
projectDir,
|
||||
@@ -213,10 +215,19 @@ async function runLayoutAudit(
|
||||
|
||||
try {
|
||||
const browser = await ensureBrowser();
|
||||
const requestedGpuMode = resolveLocalBrowserGpuMode();
|
||||
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(
|
||||
requestedGpuMode,
|
||||
browser.executablePath,
|
||||
);
|
||||
assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode);
|
||||
chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
|
||||
args: buildChromeArgs(
|
||||
{ width: 1920, height: 1080, captureMode: "screenshot" },
|
||||
{ browserGpuMode: resolvedGpuMode },
|
||||
),
|
||||
});
|
||||
|
||||
const page = await chromeBrowser.newPage();
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { seekAllAdaptersInBrowser } from "./motionShot.js";
|
||||
|
||||
const motionShotSourcePath = join(dirname(fileURLToPath(import.meta.url)), "motionShot.ts");
|
||||
const motionWindow = window as Window & {
|
||||
__player?: { renderSeek?: (time: number) => void };
|
||||
__hfWaitForSeekCompletion?: () => Promise<void>;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
delete motionWindow.__player;
|
||||
delete motionWindow.__hfWaitForSeekCompletion;
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("motion-shot adapter seeking", () => {
|
||||
it("awaits GPU work registered by a standalone hf-seek listener", async () => {
|
||||
document.body.innerHTML =
|
||||
'<div data-composition-id="gpu" data-requires-webgpu data-duration="2"></div>';
|
||||
let finishGpuWork!: () => void;
|
||||
const gpuWork = new Promise<void>((resolve) => {
|
||||
finishGpuWork = resolve;
|
||||
});
|
||||
const handler = (event: Event) => {
|
||||
(
|
||||
event as CustomEvent<{
|
||||
waitUntil(promise: PromiseLike<unknown>): void;
|
||||
}>
|
||||
).detail.waitUntil(gpuWork);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
|
||||
let settled = false;
|
||||
const seeking = seekAllAdaptersInBrowser(1.5).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishGpuWork();
|
||||
await seeking;
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("launches through the shared GPU policy and WebGPU requirement guard", () => {
|
||||
const source = readFileSync(motionShotSourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("resolveCaptureBrowserGpuMode");
|
||||
expect(source).toContain("assertWebGpuRequirement(html");
|
||||
expect(source).toContain("{ browserGpuMode: resolvedGpuMode }");
|
||||
expect(source).not.toContain('"--disable-gpu"');
|
||||
});
|
||||
|
||||
it("awaits the runtime completion hook without dispatching a second legacy seek event", async () => {
|
||||
let finishGpuWork!: () => void;
|
||||
const gpuWork = new Promise<void>((resolve) => {
|
||||
finishGpuWork = resolve;
|
||||
});
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("hf-seek", handler);
|
||||
motionWindow.__player = {
|
||||
renderSeek(time) {
|
||||
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time } }));
|
||||
},
|
||||
};
|
||||
motionWindow.__hfWaitForSeekCompletion = () => gpuWork;
|
||||
|
||||
let settled = false;
|
||||
const seeking = seekAllAdaptersInBrowser(2).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishGpuWork();
|
||||
await seeking;
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to a completion-aware hf-seek when the runtime seek hook throws", async () => {
|
||||
let finishGpuWork!: () => void;
|
||||
const gpuWork = new Promise<void>((resolve) => {
|
||||
finishGpuWork = resolve;
|
||||
});
|
||||
const handler = vi.fn((event: Event) => {
|
||||
(
|
||||
event as CustomEvent<{
|
||||
waitUntil(promise: PromiseLike<unknown>): void;
|
||||
}>
|
||||
).detail.waitUntil(gpuWork);
|
||||
});
|
||||
window.addEventListener("hf-seek", handler);
|
||||
motionWindow.__player = {
|
||||
renderSeek() {
|
||||
throw new Error("runtime seek failed");
|
||||
},
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const seeking = seekAllAdaptersInBrowser(2.5).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishGpuWork();
|
||||
await seeking;
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
|
||||
it("drains runtime work when a runtime seek hook dispatches and then throws", async () => {
|
||||
let finishRuntimeGpuWork!: () => void;
|
||||
let finishFallbackGpuWork!: () => void;
|
||||
const runtimeGpuWork = new Promise<void>((resolve) => {
|
||||
finishRuntimeGpuWork = resolve;
|
||||
});
|
||||
const fallbackGpuWork = new Promise<void>((resolve) => {
|
||||
finishFallbackGpuWork = resolve;
|
||||
});
|
||||
let runtimeCompletion: Promise<void> | undefined;
|
||||
let dispatchCount = 0;
|
||||
const handler = (event: Event) => {
|
||||
dispatchCount += 1;
|
||||
(
|
||||
event as CustomEvent<{
|
||||
waitUntil(promise: PromiseLike<unknown>): void;
|
||||
}>
|
||||
).detail.waitUntil(dispatchCount === 1 ? runtimeGpuWork : fallbackGpuWork);
|
||||
};
|
||||
window.addEventListener("hf-seek", handler);
|
||||
motionWindow.__player = {
|
||||
renderSeek(time) {
|
||||
const pending: PromiseLike<unknown>[] = [];
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hf-seek", {
|
||||
detail: {
|
||||
time,
|
||||
waitUntil(promise: PromiseLike<unknown>) {
|
||||
pending.push(promise);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
runtimeCompletion = Promise.all(pending).then(() => undefined);
|
||||
throw new Error("runtime seek failed after dispatch");
|
||||
},
|
||||
};
|
||||
motionWindow.__hfWaitForSeekCompletion = () => runtimeCompletion ?? Promise.resolve();
|
||||
|
||||
let settled = false;
|
||||
const seeking = seekAllAdaptersInBrowser(3).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(dispatchCount).toBe(2);
|
||||
|
||||
finishFallbackGpuWork();
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
finishRuntimeGpuWork();
|
||||
await seeking;
|
||||
window.removeEventListener("hf-seek", handler);
|
||||
expect(settled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import {
|
||||
assertWebGpuRequirement,
|
||||
resolveCaptureBrowserGpuMode,
|
||||
resolveLocalBrowserGpuMode,
|
||||
} from "../browser/gpuPolicy.js";
|
||||
import {
|
||||
buildOnionSvg,
|
||||
ghostAlphas,
|
||||
@@ -156,23 +162,27 @@ function compositeGhostFrames(
|
||||
// Runs IN THE BROWSER. Self-contained (only `tt` + window/document — never a
|
||||
// Node-side closure variable), pauses/seeks every adapter to time `tt`: GSAP
|
||||
// `__timelines`, the Web Animations API, `__hfAnime` instances, then dispatches
|
||||
// `hf-seek` and nudges the three/GSAP render hooks. This one routine backs BOTH
|
||||
// `hf-seek` and nudges the three/GSAP render hooks. GPU work registered through
|
||||
// `waitUntil()` is awaited before returning. This one routine backs BOTH
|
||||
// the ghost-frame capture and the marker sampler below — see installSeekHelper
|
||||
// for why it's installed as a page global instead of being duplicated inline
|
||||
// (Puppeteer's page.evaluate only serializes the single function passed to it,
|
||||
// so a Node-side function can't be *called* from inside another evaluate
|
||||
// callback; it can only be reused by installing its source as a real page
|
||||
// global once, up front).
|
||||
function seekAllAdaptersInBrowser(tt: number): void {
|
||||
const tryCall = (fn: () => void): void => {
|
||||
export async function seekAllAdaptersInBrowser(tt: number): Promise<void> {
|
||||
const tryCall = (fn: () => void): boolean => {
|
||||
try {
|
||||
fn();
|
||||
return true;
|
||||
} catch {
|
||||
/* best-effort */
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const w = window as unknown as {
|
||||
__player?: { renderSeek?: (t: number) => void; seek?: (t: number) => void };
|
||||
__hfReseekGpu?: (t: number) => void;
|
||||
__hfWaitForSeekCompletion?: () => Promise<void>;
|
||||
__hfThreeTime?: number;
|
||||
__hfThreeRender?: () => void;
|
||||
__hfAnime?: Array<{ pause?: () => void; seek?: (timeMs: number) => void }>;
|
||||
@@ -187,11 +197,16 @@ function seekAllAdaptersInBrowser(tt: number): void {
|
||||
>;
|
||||
};
|
||||
const timeMs = Math.max(0, tt * 1000);
|
||||
let runtimeSeeked = false;
|
||||
const pendingGpuWork: PromiseLike<unknown>[] = [];
|
||||
|
||||
tryCall(() => {
|
||||
if (typeof w.__player?.renderSeek === "function") w.__player.renderSeek(tt);
|
||||
else if (typeof w.__player?.seek === "function") w.__player.seek(tt);
|
||||
});
|
||||
if (typeof w.__player?.renderSeek === "function") {
|
||||
runtimeSeeked = tryCall(() => w.__player?.renderSeek?.(tt));
|
||||
} else if (typeof w.__player?.seek === "function") {
|
||||
runtimeSeeked = tryCall(() => w.__player?.seek?.(tt));
|
||||
} else if (typeof w.__hfReseekGpu === "function") {
|
||||
runtimeSeeked = tryCall(() => w.__hfReseekGpu?.(tt));
|
||||
}
|
||||
|
||||
Object.values(w.__timelines ?? {}).forEach((tl) => {
|
||||
tryCall(() => {
|
||||
@@ -223,12 +238,31 @@ function seekAllAdaptersInBrowser(tt: number): void {
|
||||
});
|
||||
}
|
||||
|
||||
tryCall(() => {
|
||||
w.__hfThreeTime = tt;
|
||||
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: tt } }));
|
||||
w.__hfThreeRender?.();
|
||||
w.gsap?.ticker?.tick?.();
|
||||
});
|
||||
w.__hfThreeTime = tt;
|
||||
if (!runtimeSeeked) {
|
||||
let acceptingGpuWork = true;
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hf-seek", {
|
||||
detail: {
|
||||
time: tt,
|
||||
waitUntil(promise: PromiseLike<unknown>) {
|
||||
if (!acceptingGpuWork) {
|
||||
throw new Error("hf-seek waitUntil() must be called synchronously");
|
||||
}
|
||||
pendingGpuWork.push(promise);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
acceptingGpuWork = false;
|
||||
}
|
||||
}
|
||||
tryCall(() => w.__hfThreeRender?.());
|
||||
tryCall(() => w.gsap?.ticker?.tick?.());
|
||||
|
||||
await Promise.all([Promise.all(pendingGpuWork), w.__hfWaitForSeekCompletion?.()]);
|
||||
}
|
||||
|
||||
// Installs seekAllAdaptersInBrowser as a real `window` global, once per page
|
||||
@@ -243,6 +277,7 @@ async function installSeekHelper(page: import("puppeteer-core").Page): Promise<v
|
||||
// Launch headless Chrome, load the composition sized to its canvas, wait for the
|
||||
// timelines + fonts to be ready. Returns the browser (caller closes it), page, size.
|
||||
async function openCompositionPage(
|
||||
html: string,
|
||||
url: string,
|
||||
executablePath: string,
|
||||
): Promise<{
|
||||
@@ -251,30 +286,21 @@ async function openCompositionPage(
|
||||
size: FrameSize;
|
||||
}> {
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||||
const size = resolveCompositionViewportFromHtml(html);
|
||||
const requestedGpuMode = resolveLocalBrowserGpuMode();
|
||||
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(requestedGpuMode, executablePath);
|
||||
assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode);
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
"--enable-webgl",
|
||||
"--use-gl=angle",
|
||||
"--use-angle=swiftshader",
|
||||
],
|
||||
args: buildChromeArgs(
|
||||
{ ...size, captureMode: "screenshot" },
|
||||
{ browserGpuMode: resolvedGpuMode },
|
||||
),
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const navigationTimeout = resolveDiagnosticNavigationTimeoutMs();
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
|
||||
const size = await page.evaluate(() => {
|
||||
const root = document.querySelector("[data-composition-id][data-width][data-height]");
|
||||
const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0;
|
||||
const h = root ? parseInt(root.getAttribute("data-height") ?? "", 10) : 0;
|
||||
return {
|
||||
width: Number.isFinite(w) && w > 0 ? Math.min(w, 4096) : 1920,
|
||||
height: Number.isFinite(h) && h > 0 ? Math.min(h, 4096) : 1080,
|
||||
};
|
||||
});
|
||||
await page.setViewport(size);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
|
||||
await page
|
||||
@@ -436,10 +462,10 @@ async function resolveScopedRequests(
|
||||
// SAME tick — before the browser clears the GL drawing buffer (works without
|
||||
// preserveDrawingBuffer; page.screenshot can't see the GL buffer here).
|
||||
function captureGhostFrame(page: import("puppeteer-core").Page, t: number): Promise<string> {
|
||||
return page.evaluate((tt: number) => {
|
||||
(window as unknown as { __hfSeekAllAdapters?: (time: number) => void }).__hfSeekAllAdapters?.(
|
||||
tt,
|
||||
);
|
||||
return page.evaluate(async (tt: number) => {
|
||||
await (
|
||||
window as unknown as { __hfSeekAllAdapters?: (time: number) => Promise<void> }
|
||||
).__hfSeekAllAdapters?.(tt);
|
||||
const root = (document.querySelector("[data-composition-id]") ?? document.body) as HTMLElement;
|
||||
const rb = root.getBoundingClientRect();
|
||||
const off = document.createElement("canvas");
|
||||
@@ -537,8 +563,8 @@ async function captureMarkerOnionSkin(
|
||||
await applyOrbitCameraIfAngled(page, requests, camera);
|
||||
|
||||
const elements = (await page.evaluate(
|
||||
(selectors: string[], ts: number[]) => {
|
||||
const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => void })
|
||||
async (selectors: string[], ts: number[]) => {
|
||||
const seek = (window as unknown as { __hfSeekAllAdapters?: (t: number) => Promise<void> })
|
||||
.__hfSeekAllAdapters;
|
||||
|
||||
const rigs = selectors.map((sel) => {
|
||||
@@ -563,7 +589,7 @@ async function captureMarkerOnionSkin(
|
||||
});
|
||||
const out = selectors.map((selector) => ({ selector, samples: [] as PageSample[] }));
|
||||
for (const t of ts) {
|
||||
seek?.(t);
|
||||
await seek?.(t);
|
||||
rigs.forEach((rig, i) => {
|
||||
if (!rig) return;
|
||||
const pts = rig.markers.map((m) => {
|
||||
@@ -638,7 +664,7 @@ export async function captureMotionPathShot(
|
||||
let browserInstance: import("puppeteer-core").Browser | undefined;
|
||||
try {
|
||||
const browser = await ensureBrowser();
|
||||
const opened = await openCompositionPage(server.url, browser.executablePath);
|
||||
const opened = await openCompositionPage(html, server.url, browser.executablePath);
|
||||
browserInstance = opened.browser;
|
||||
const { page, size } = opened;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { setCommandExitCode, requestCliExit } from "../utils/commandResult.js";
|
||||
// fallow-ignore-file code-duplication
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
startBackgroundPreview,
|
||||
stopBackgroundPreview,
|
||||
} from "./previewLifecycle.js";
|
||||
import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
|
||||
interface BrowserLaunchOptions {
|
||||
noOpen?: boolean;
|
||||
@@ -80,6 +82,7 @@ interface BrowserLaunchOptions {
|
||||
interface StudioLaunchOptions extends BrowserLaunchOptions {
|
||||
projectName?: string;
|
||||
autoProxy?: boolean;
|
||||
browserGpuMode?: BrowserGpuMode;
|
||||
}
|
||||
|
||||
interface EmbeddedStudioOptions extends StudioLaunchOptions {
|
||||
@@ -190,6 +193,12 @@ export default defineCommand({
|
||||
description:
|
||||
"Launch the opened browser with --disable-gpu (requires --browser-path). For hosts where hardware acceleration crashes the graphics driver (e.g. NVIDIA Xid resets); with the system default browser use --no-open instead.",
|
||||
},
|
||||
"browser-gpu": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Use hardware GPU for Studio thumbnails and frame capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect)",
|
||||
default: undefined,
|
||||
},
|
||||
proxy: {
|
||||
type: "boolean",
|
||||
description:
|
||||
@@ -198,6 +207,9 @@ export default defineCommand({
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const browserGpuMode = resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined);
|
||||
if (args["browser-gpu"] === true) process.env.PRODUCER_BROWSER_GPU_MODE = "hardware";
|
||||
if (args["browser-gpu"] === false) process.env.PRODUCER_BROWSER_GPU_MODE = "software";
|
||||
const startPort = parseInt(args.port ?? "3002", 10);
|
||||
const preferredContextPort = hasExplicitPreviewPort(process.argv) ? startPort : undefined;
|
||||
|
||||
@@ -380,6 +392,7 @@ export default defineCommand({
|
||||
try {
|
||||
background = await startBackgroundPreview(dir, startPort, {
|
||||
forceNew: Boolean(args["force-new"]),
|
||||
browserGpuMode,
|
||||
});
|
||||
} catch (error) {
|
||||
clack.log.error(errorMessage(error));
|
||||
@@ -417,6 +430,7 @@ export default defineCommand({
|
||||
userDataDir,
|
||||
remoteDebuggingPort,
|
||||
browserNoGpu,
|
||||
browserGpuMode,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1046,6 +1060,7 @@ async function runEmbeddedMode(
|
||||
projectDir: dir,
|
||||
projectName: pName,
|
||||
autoProxy: options?.autoProxy,
|
||||
browserGpuMode: options?.browserGpuMode,
|
||||
});
|
||||
const serverBuildSignature = await loadPreviewServerBuildSignature();
|
||||
|
||||
@@ -1057,6 +1072,8 @@ async function runEmbeddedMode(
|
||||
dir,
|
||||
!!options?.forceNew,
|
||||
serverBuildSignature,
|
||||
undefined,
|
||||
options?.browserGpuMode,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
s.stop(c.error("Failed to start studio"));
|
||||
|
||||
@@ -94,6 +94,32 @@ describe("background preview lifecycle", () => {
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts a replacement when the existing server uses a different GPU policy", async () => {
|
||||
const hardwareServer = { ...server, browserGpuMode: "hardware" as const };
|
||||
const softwareServer = {
|
||||
...server,
|
||||
port: 3211,
|
||||
pid: "5432",
|
||||
browserGpuMode: "software" as const,
|
||||
};
|
||||
let scans = 0;
|
||||
const scan = vi.fn(async () =>
|
||||
++scans < 2 ? [hardwareServer] : [hardwareServer, softwareServer],
|
||||
);
|
||||
const spawn = vi.fn(() => ({ pid: 5432, unref: vi.fn() }));
|
||||
|
||||
const result = await startBackgroundPreview(projectDir, 3002, {
|
||||
browserGpuMode: "software",
|
||||
scan,
|
||||
spawn,
|
||||
sleep: async () => {},
|
||||
stateHome: mkdtempSync(join(tmpdir(), "hf-preview-state-")),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ type: "started", port: 3211, pid: 5432 });
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns after a detached child becomes reachable and records its session", async () => {
|
||||
let scans = 0;
|
||||
const scan = vi.fn(async () => (++scans < 2 ? [] : [server]));
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
|
||||
import type { BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
import { killProcessTree } from "../utils/orphanCleanup.js";
|
||||
|
||||
export interface PreviewSession {
|
||||
@@ -41,6 +42,7 @@ interface LifecycleDependencies {
|
||||
kill?: (pid: number) => void;
|
||||
stateHome?: string;
|
||||
forceNew?: boolean;
|
||||
browserGpuMode?: BrowserGpuMode;
|
||||
}
|
||||
|
||||
function defaultStateHome(): string {
|
||||
@@ -97,8 +99,18 @@ function removePreviewSession(projectDir: string, stateHome = defaultStateHome()
|
||||
rmSync(previewSessionPath(projectDir, stateHome), { force: true });
|
||||
}
|
||||
|
||||
function matchingServer(servers: ActiveServer[], projectDir: string): ActiveServer | null {
|
||||
return servers.find((server) => normalized(server.projectDir) === normalized(projectDir)) ?? null;
|
||||
function matchingServer(
|
||||
servers: ActiveServer[],
|
||||
projectDir: string,
|
||||
browserGpuMode?: BrowserGpuMode,
|
||||
): ActiveServer | null {
|
||||
return (
|
||||
servers.find(
|
||||
(server) =>
|
||||
normalized(server.projectDir) === normalized(projectDir) &&
|
||||
(browserGpuMode === undefined || server.browserGpuMode === browserGpuMode),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function stopProcess(pid: number): void {
|
||||
@@ -147,10 +159,11 @@ function startedServer(
|
||||
projectDir: string,
|
||||
existing: ActiveServer | null,
|
||||
forceNew: boolean,
|
||||
browserGpuMode?: BrowserGpuMode,
|
||||
): ActiveServer | null {
|
||||
const candidates =
|
||||
forceNew && existing ? servers.filter((server) => server.port !== existing.port) : servers;
|
||||
return matchingServer(candidates, projectDir);
|
||||
return matchingServer(candidates, projectDir, browserGpuMode);
|
||||
}
|
||||
|
||||
export function buildBackgroundPreviewArgs(argv: string[]): string[] {
|
||||
@@ -198,7 +211,7 @@ export async function startBackgroundPreview(
|
||||
| { type: "started"; port: number; pid: number; logPath: string }
|
||||
> {
|
||||
const scan = dependencies.scan ?? scanActiveServers;
|
||||
const existing = matchingServer(await scan(startPort), projectDir);
|
||||
const existing = matchingServer(await scan(startPort), projectDir, dependencies.browserGpuMode);
|
||||
if (existing && !dependencies.forceNew) {
|
||||
return {
|
||||
type: "reused",
|
||||
@@ -218,6 +231,7 @@ export async function startBackgroundPreview(
|
||||
projectDir,
|
||||
existing,
|
||||
dependencies.forceNew === true,
|
||||
dependencies.browserGpuMode,
|
||||
);
|
||||
if (server) {
|
||||
const session = {
|
||||
|
||||
@@ -53,6 +53,13 @@ describe("transparent snapshot capture", () => {
|
||||
expect(source).toContain("autoProxy: args.proxy as boolean | undefined");
|
||||
expect(source).toContain("opts.autoProxy");
|
||||
});
|
||||
|
||||
it("resolves and forwards the shared local browser GPU policy", () => {
|
||||
const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8");
|
||||
expect(source).toContain("resolveLocalBrowserGpuMode");
|
||||
expect(source).toContain("browserGpuMode: opts.browserGpuMode");
|
||||
expect(source).toContain('"browser-gpu": {');
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSnapshotVideoFrameTime", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { c } from "../ui/colors.js";
|
||||
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||
import { parseAngle, type Camera } from "./motionShotLayout.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js";
|
||||
|
||||
// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so
|
||||
// the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]:
|
||||
@@ -244,6 +245,7 @@ async function captureSnapshots(
|
||||
zoom?: ZoomTarget;
|
||||
zoomScale?: number;
|
||||
autoProxy?: boolean;
|
||||
browserGpuMode?: BrowserGpuMode;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");
|
||||
@@ -261,6 +263,7 @@ async function captureSnapshots(
|
||||
const { browser: chromeBrowser, page } = await openSettledCompositionPage(html, server.url, {
|
||||
renderReadyTimeoutMs: opts.timeout ?? 5000,
|
||||
renderReadyWarningSuffix: "snapshots may be inaccurate",
|
||||
browserGpuMode: opts.browserGpuMode,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -638,6 +641,12 @@ export default defineCommand({
|
||||
"Auto-transcode browser-hostile video codecs for snapshots (default: on; overrides hyperframes.json media.autoProxy)",
|
||||
default: undefined,
|
||||
},
|
||||
"browser-gpu": {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)",
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const project = resolveProject(args.dir);
|
||||
@@ -687,6 +696,7 @@ export default defineCommand({
|
||||
zoom: zoomTarget,
|
||||
zoomScale,
|
||||
autoProxy: args.proxy as boolean | undefined,
|
||||
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
|
||||
@@ -418,12 +418,20 @@ async function validateInBrowser(
|
||||
const browser = await ensureBrowser();
|
||||
const puppeteer = await import("puppeteer-core");
|
||||
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
|
||||
const requestedGpuMode = resolveCliChromeGpuMode();
|
||||
const { assertWebGpuRequirement, resolveCaptureBrowserGpuMode } =
|
||||
await import("../browser/gpuPolicy.js");
|
||||
const resolvedGpuMode = await resolveCaptureBrowserGpuMode(
|
||||
requestedGpuMode,
|
||||
browser.executablePath,
|
||||
);
|
||||
assertWebGpuRequirement(html, requestedGpuMode, resolvedGpuMode);
|
||||
const chromeBrowser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
executablePath: browser.executablePath,
|
||||
args: buildChromeArgs(
|
||||
{ ...viewport, captureMode: "screenshot" },
|
||||
{ browserGpuMode: resolveCliChromeGpuMode() },
|
||||
{ browserGpuMode: resolvedGpuMode },
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user