import { chromium, type Browser, type BrowserContext, type Page, } from 'playwright'; import { AUTH_STATE_PATH } from './config.js'; import { recordedSizeFor, viewportFor, type Storyboard } from './script.js'; export interface RecordingBrowser { browser: Browser; context: BrowserContext; } export interface LaunchOptions { /** Directory the playwright recorder writes the raw .webm into. */ recordDir: string; } export async function launchRecordingBrowser( storyboard: Storyboard, opts: LaunchOptions ): Promise { const browser = await chromium.launch({ headless: true, args: [ '--disable-blink-features=AutomationControlled', '--enable-gpu', '--use-gl=angle', '--use-angle=gl-egl', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-webgl2', '--enable-gpu-rasterization', '--enable-zero-copy', '--disable-software-rasterizer', // NOTE: --disable-frame-rate-limit / --disable-gpu-vsync used to be // here for screencast smoothness, but with the host GPU nearly full // the uncapped render loop starved the renderer (ERR_INSUFFICIENT_ // RESOURCES + ~31s page stalls on the 1080p take). Vsync-limited // 60fps is plenty for the 50fps output. '--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', ], }); const viewport = viewportFor(storyboard.video); const recordSize = recordedSizeFor(storyboard.video); const context = await browser.newContext({ storageState: AUTH_STATE_PATH, viewport, deviceScaleFactor: storyboard.video.captureScale, recordVideo: { dir: opts.recordDir, size: recordSize }, }); await context.addInitScript((appLanguage) => { if (appLanguage) localStorage.setItem('language', appLanguage); }, storyboard.content.appLanguage ?? 'en'); await suppressDevServerNoise(context); return { browser, context }; } export async function assertHardwareWebGL(page: Page): Promise { const info = await page.evaluate(() => { const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl2'); if (!gl) return { webgl: false, vendor: '', renderer: '' }; const ext = gl.getExtension('WEBGL_debug_renderer_info'); const vendor = String( ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR), ); const renderer = String( ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), ); return { webgl: true, vendor, renderer }; }); console.log( `[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : 'none'}`, ); if ( process.env.ALLOW_SOFTWARE_GL !== '1' && (!info.webgl || /SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`)) ) { throw new Error( 'Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.', ); } } async function suppressDevServerNoise(context: BrowserContext) { await context.addInitScript(() => { const RealWS = window.WebSocket; window.WebSocket = new Proxy(RealWS, { construct(target, args) { const url = String(args[0] ?? ''); const proto = (args[1] as string | string[] | undefined) ?? ''; const protoStr = Array.isArray(proto) ? proto.join(',') : proto; if ( protoStr.includes('vite-hmr') || protoStr.includes('webpack') || url.includes('/ws') || url.includes('sockjs-node') ) { const fake = new EventTarget() as WebSocket; Object.defineProperties(fake, { readyState: { value: RealWS.CLOSED }, url: { value: url }, protocol: { value: '' }, extensions: { value: '' }, bufferedAmount: { value: 0 }, binaryType: { value: 'blob', writable: true }, }); fake.send = () => {}; fake.close = () => fake.dispatchEvent(new Event('close')); queueMicrotask(() => fake.dispatchEvent(new Event('close'))); return fake; } return Reflect.construct(target, args); }, }); Object.defineProperty(window.location, 'reload', { value: () => {}, configurable: true, }); window.addEventListener('error', (e) => e.stopImmediatePropagation(), true); window.addEventListener( 'unhandledrejection', (e) => e.stopImmediatePropagation(), true, ); const styleEl = document.createElement('style'); styleEl.textContent = ` vite-error-overlay, wds-overlay, #webpack-dev-server-client-overlay, #webpack-dev-server-client-overlay-div, iframe[src*="webpack-dev-server"], iframe[id*="webpack"], [id*="webpack-dev-server-client"], [class*="error-overlay"], [class*="webpack-error"], /* Recording-only: hide dashboard chrome that reads as noise/loading on camera (the "Finding the Perfect Postcode" AI-status CTA). The data attribute is inert in production: only this injected rule targets it, and only during a recording. */ [data-video-hide] { display: none !important; visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; } `; (document.head ?? document.documentElement).appendChild(styleEl); const killOverlay = (node: Element) => { const tag = node.tagName?.toLowerCase(); const id = (node as HTMLElement).id?.toLowerCase() ?? ''; if ( tag === 'vite-error-overlay' || tag === 'wds-overlay' || id.includes('webpack-dev-server-client') || id.includes('webpack-error') ) { (node as HTMLElement).remove(); } }; const obs = new MutationObserver((muts) => { for (const m of muts) { m.addedNodes.forEach((n) => { if (n.nodeType === 1) killOverlay(n as Element); }); } }); if (document.body) obs.observe(document.body, { childList: true, subtree: true }); else { document.addEventListener('DOMContentLoaded', () => obs.observe(document.body, { childList: true, subtree: true }), ); } }); }