Files
hyperframes/packages/studio/vite.browser.ts
T
Miguel Ángel ada4e0d289 fix(studio): show each timeline row its own layer instead of a flat bar (#4917)
* fix(studio): show each timeline row its own layer instead of a grey bar

A row got a frame only if it spanned under 92% of the film and its id did not
look like a backdrop; every other row drew a plain grey bar. Every element row
now gets its frame, and the capture fades the row element's siblings first, so
the frame shows that layer and not the whole film. Studio's dev server and the
CLI share one capture helper; Studio's own copy is gone.

* fix(studio): read timeline rows from any realm so each keeps its selector

* refactor(studio-server): always isolate the element in a thumbnail clip

* fix(cli): undo a thumbnail's isolation before its page takes the next capture

* chore(core): generate the dom-realm export and trim thumbnail clip comments

* fix(studio-server): restore sibling opacity after a clip, hash selectors in cache keys

* fix(studio-server): keep faded siblings in a page-owned list so restore is exact

* feat(studio): draw a text layer row as its own live text instead of a captured picture

* refactor(studio): move timeline text reading into its own module

* fix(studio): keep text rows readable and current, and leave painted layers as pictures

* fix(studio): refresh a canvas-edited text row by its own identity and source file

* fix(studio): refresh a canvas-edited text row known only by its data-hf-id
2026-10-03 00:22:49 +00:00

278 lines
9.5 KiB
TypeScript

// Shared Puppeteer browser management and thumbnail generation for Studio dev server.
import { existsSync, readdirSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { homedir } from "node:os";
import { join, win32 as pathWin32 } from "node:path";
import {
getElementScreenshotClip,
thumbnailDeviceScaleFactor,
type ScreenshotClip,
} from "@hyperframes/studio-server";
import { createStudioDevRenderBodyScripts } from "./vite.studioMotion";
import { seekThumbnailPreview } from "./vite.thumbnail";
let browser: import("puppeteer-core").Browser | null = null;
let browserLaunch: Promise<import("puppeteer-core").Browser | null> | null = null;
let browserDescription = "unknown browser";
function systemChromePaths(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string[] {
if (platform === "win32") {
const programFiles = env["PROGRAMFILES"] ?? "C:\\Program Files";
const programFilesX86 = env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
const localAppData = env["LOCALAPPDATA"];
return [
pathWin32.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
pathWin32.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
...(localAppData
? [pathWin32.join(localAppData, "Google", "Chrome", "Application", "chrome.exe")]
: []),
];
}
if (platform === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
];
}
return [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
}
function findPuppeteerCacheChrome(
env: NodeJS.ProcessEnv,
pathExists: (path: string) => boolean,
platform: NodeJS.Platform,
readDirectories: (path: string) => string[] = readdirSync,
): string | undefined {
const chromeRoot = join(
env["PUPPETEER_CACHE_DIR"] ?? join(homedir(), ".cache", "puppeteer"),
"chrome",
);
let versionDirs: string[];
try {
versionDirs = readDirectories(chromeRoot);
} catch {
return undefined;
}
const buildOrder = (directory: string): number =>
(directory.split("-").pop() ?? "")
.split(".")
.map((part) => Number.parseInt(part, 10) || 0)
.reduce((order, part) => order * 100000 + part, 0);
const relativeCandidates =
platform === "win32"
? ["chrome-win64/chrome.exe"]
: [
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"chrome-linux64/chrome",
];
for (const directory of versionDirs.sort((left, right) => buildOrder(right) - buildOrder(left))) {
for (const relativePath of relativeCandidates) {
const executable = join(chromeRoot, directory, relativePath);
if (pathExists(executable)) return executable;
}
}
return undefined;
}
function browserVersion(
executable: string,
run: (file: string) => string = (file) => execFileSync(file, ["--version"], { encoding: "utf8" }),
): string | undefined {
try {
return run(executable).trim() || undefined;
} catch {
return undefined;
}
}
function supportedSystemChrome(
candidates: string[],
versionOf: (path: string) => string | undefined,
): string | undefined {
return candidates.find((candidate) => {
const match = versionOf(candidate)?.match(/(?:Chrome|Chromium)[ /](\d+)/i);
return match ? Number(match[1]) >= 134 : false;
});
}
/** Resolve explicit overrides, Puppeteer's managed browser, then compatible system installs. */
export function findSystemChrome(
env: NodeJS.ProcessEnv = process.env,
pathExists: (path: string) => boolean = existsSync,
platform: NodeJS.Platform = process.platform,
versionOf: (path: string) => string | undefined = browserVersion,
readDirectories: (path: string) => string[] = readdirSync,
): string | undefined {
const override = [
env["HYPERFRAMES_BROWSER_PATH"],
env["PRODUCER_HEADLESS_SHELL_PATH"],
env["PUPPETEER_EXECUTABLE_PATH"],
env["CHROME_PATH"],
env["CHROME_BIN"],
].find((candidate): candidate is string => Boolean(candidate) && pathExists(candidate));
return (
override ??
findPuppeteerCacheChrome(env, pathExists, platform, readDirectories) ??
supportedSystemChrome(systemChromePaths(env, platform).filter(pathExists), versionOf)
);
}
async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | null> {
if (browser?.connected) return browser;
if (browserLaunch) return browserLaunch;
const launch = (async () => {
const puppeteer = await import("puppeteer-core");
const executablePath = findSystemChrome();
if (!executablePath) return null;
browserDescription = `${executablePath}, ${browserVersion(executablePath) ?? "unknown version"}`;
try {
browser = await puppeteer.default.launch({
headless: true,
executablePath,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--enable-webgl",
"--ignore-gpu-blocklist",
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
],
});
} catch (error) {
console.warn(
`[Studio] Thumbnail browser launch failed (${browserDescription}):`,
error instanceof Error ? error.message : error,
);
throw error;
}
return browser;
})();
browserLaunch = launch;
try {
return await launch;
} finally {
if (browserLaunch === launch) browserLaunch = null;
}
}
async function applyStudioRenderBodyScriptsToThumbnailPage(
page: import("puppeteer-core").Page,
projectDir: string,
activeCompositionPath: string,
): Promise<void> {
const scripts = createStudioDevRenderBodyScripts(projectDir, { activeCompositionPath });
for (const script of scripts) await page.addScriptTag({ content: script });
}
async function reapplyStudioRenderBodyScriptsToThumbnailPage(
page: import("puppeteer-core").Page,
): Promise<void> {
await page.evaluate(() => {
const runtimeWindow = window as Window & {
__hfStudioManualEditsApply?: () => number;
__hfStudioMotionApply?: () => number;
};
runtimeWindow.__hfStudioManualEditsApply?.();
runtimeWindow.__hfStudioMotionApply?.();
});
}
export interface GenerateThumbnailOptions {
project: { dir: string };
compPath: string;
seekTime: number;
previewUrl: string;
width: number;
height: number;
outputWidth: number;
outputHeight: number;
format?: "jpeg" | "png";
selector?: string;
selectorIndex?: number;
signal: AbortSignal;
}
async function prepareThumbnailPage(
page: import("puppeteer-core").Page,
opts: GenerateThumbnailOptions,
): Promise<void> {
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: thumbnailDeviceScaleFactor(opts),
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
document.documentElement.style.background = "#1c2028";
document.body.style.background = "#1c2028";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
});
await page
.waitForFunction(`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`, {
timeout: 5000,
})
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await page.evaluate("window.__hfWaitForSeekCompletion?.()");
await applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath);
await page.evaluate("document.fonts?.ready");
await new Promise((resolve) => setTimeout(resolve, 200));
await reapplyStudioRenderBodyScriptsToThumbnailPage(page);
}
async function captureThumbnail(
page: import("puppeteer-core").Page,
format: GenerateThumbnailOptions["format"],
clip: ScreenshotClip | undefined,
): Promise<Buffer> {
const clipOption = clip ? { clip } : {};
const screenshot = await page.screenshot(
format === "png"
? { type: "png", ...clipOption }
: { type: "jpeg", quality: 75, ...clipOption },
);
return Buffer.from(screenshot);
}
export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise<Buffer | null> {
if (opts.signal.aborted) return null;
let page: import("puppeteer-core").Page | null = null;
const closePage = () => void page?.close().catch(() => {});
opts.signal.addEventListener("abort", closePage, { once: true });
try {
const sharedBrowser = await getSharedBrowser();
if (!sharedBrowser || opts.signal.aborted) return null;
page = await sharedBrowser.newPage();
if (opts.signal.aborted) return null;
await prepareThumbnailPage(page, opts);
const clip = opts.selector
? await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex)
: undefined;
if (opts.signal.aborted) return null;
return await captureThumbnail(page, opts.format, clip);
} catch (error) {
if (!opts.signal.aborted) {
console.warn(
`[Studio] Thumbnail generation failed (${browserDescription}):`,
error instanceof Error ? error.message : error,
);
}
return null;
} finally {
opts.signal.removeEventListener("abort", closePage);
await page?.close().catch(() => {});
}
}