perfect-postcode/video/src/browser.ts
2026-06-28 11:59:44 +01:00

192 lines
6.3 KiB
TypeScript

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<RecordingBrowser> {
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<void> {
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 }),
);
}
});
}