// Shared Puppeteer browser management and thumbnail generation for Studio dev server. import { existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { win32 as pathWin32 } from "node:path"; import { createStudioDevRenderBodyScripts, readStudioDevManualEditManifestContent, readStudioDevMotionManifestContent, } from "./vite.studioMotion"; import { seekThumbnailPreview } from "./vite.thumbnail"; // ── Shared Puppeteer browser ───────────────────────────────────────────────── let _browser: import("puppeteer-core").Browser | null = null; let _browserLaunchPromise: Promise | null = null; 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"]; } return ["/usr/bin/google-chrome", "/usr/bin/chromium-browser"]; } /** Resolve the same explicit browser overrides used by the CLI before system paths. */ export function findSystemChrome( env: NodeJS.ProcessEnv = process.env, pathExists: (path: string) => boolean = existsSync, platform: NodeJS.Platform = process.platform, ): string | undefined { const override = env["HYPERFRAMES_BROWSER_PATH"] ?? env["PRODUCER_HEADLESS_SHELL_PATH"]; if (override && pathExists(override)) return override; return systemChromePaths(env, platform).find((path) => pathExists(path)); } async function getSharedBrowser(): Promise { if (_browser?.connected) return _browser; if (_browserLaunchPromise) return _browserLaunchPromise; const launchPromise = (async () => { const puppeteer = await import("puppeteer-core"); const executablePath = findSystemChrome(); if (!executablePath) return null; _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", ], }); return _browser; })(); _browserLaunchPromise = launchPromise; try { return await launchPromise; } finally { if (_browserLaunchPromise === launchPromise) _browserLaunchPromise = null; } } // In-flight thumbnail dedup const _thumbnailInflight = new Map>(); const THUMBNAIL_CACHE_VERSION = "v4"; interface ScreenshotClip { x: number; y: number; width: number; height: number; } async function applyStudioRenderBodyScriptsToThumbnailPage( page: import("puppeteer-core").Page, projectDir: string, activeCompositionPath: string, ): Promise { const scripts = createStudioDevRenderBodyScripts(projectDir, { activeCompositionPath, }); for (const script of scripts) { await page.addScriptTag({ content: script }); } } async function reapplyStudioRenderBodyScriptsToThumbnailPage( page: import("puppeteer-core").Page, ): Promise { await page.evaluate(() => { const runtimeWindow = window as Window & { __hfStudioManualEditsApply?: () => number; __hfStudioMotionApply?: () => number; }; if (typeof runtimeWindow.__hfStudioManualEditsApply === "function") { runtimeWindow.__hfStudioManualEditsApply(); } if (typeof runtimeWindow.__hfStudioMotionApply === "function") { runtimeWindow.__hfStudioMotionApply(); } }); } export interface GenerateThumbnailOptions { project: { dir: string }; compPath: string; seekTime: number; previewUrl: string; width: number; height: number; format: "jpeg" | "png"; selector?: string; selectorIndex?: number; } export async function generateThumbnail(opts: GenerateThumbnailOptions): Promise { const selectorKey = opts.selector ? `_${opts.selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${opts.selectorIndex ?? 0}` : ""; const manualManifestContent = readStudioDevManualEditManifestContent(opts.project.dir); const manualManifestKey = manualManifestContent.trim() ? `_${createHash("sha1").update(manualManifestContent).digest("hex").slice(0, 16)}` : ""; const motionManifestContent = readStudioDevMotionManifestContent(opts.project.dir); const motionManifestKey = motionManifestContent.trim() ? `_${createHash("sha1").update(motionManifestContent).digest("hex").slice(0, 16)}` : ""; const cacheKey = `${THUMBNAIL_CACHE_VERSION}${manualManifestKey}${motionManifestKey}_${opts.compPath.replace(/\//g, "_")}_${opts.seekTime.toFixed(2)}${selectorKey}.${opts.format === "png" ? "png" : "jpg"}`; let bufferPromise = _thumbnailInflight.get(cacheKey); if (!bufferPromise) { bufferPromise = (async () => { let page: Awaited> | null = null; try { const browser = await getSharedBrowser(); if (!browser) return null; page = await browser.newPage(); await page.setViewport({ width: opts.width, height: opts.height, deviceScaleFactor: opts.format === "png" ? 1 : 0.5, }); 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 applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath); await page.evaluate("document.fonts?.ready"); await new Promise((r) => setTimeout(r, 200)); await reapplyStudioRenderBodyScriptsToThumbnailPage(page); let clip: ScreenshotClip | undefined; if (opts.selector) { clip = await page.evaluate( (selector: string, selectorIndex: number | undefined) => { const matches = Array.from(document.querySelectorAll(selector)).filter( (el): el is HTMLElement => el instanceof HTMLElement, ); const safeIndex = Math.max( 0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)), ); const el = matches[safeIndex] ?? null; if (!(el instanceof HTMLElement)) return undefined; const rect = el.getBoundingClientRect(); if (rect.width < 4 || rect.height < 4) return undefined; const pad = 8; const x = Math.max(0, rect.left - pad); const y = Math.max(0, rect.top - pad); const maxWidth = window.innerWidth - x; const maxHeight = window.innerHeight - y; return { x, y, width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), }; }, opts.selector, opts.selectorIndex, ); } const buf = await page.screenshot( opts.format === "png" ? { type: "png", ...(clip ? { clip } : {}) } : { type: "jpeg", quality: 75, ...(clip ? { clip } : {}) }, ); await page.close(); return buf as Buffer; } catch (err) { if (page) await page.close().catch(() => {}); console.warn( "[Studio] Thumbnail generation failed:", err instanceof Error ? err.message : err, ); return null; } })(); _thumbnailInflight.set(cacheKey, bufferPromise); const clearInflight = () => _thumbnailInflight.delete(cacheKey); bufferPromise.then(clearInflight, clearInflight); } return bufferPromise; }