192 lines
6.3 KiB
TypeScript
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 }),
|
|
);
|
|
}
|
|
});
|
|
}
|