Speed up screenshotting

This commit is contained in:
Andras Schmelczer 2026-02-03 21:46:59 +00:00
parent 23f863e171
commit 1dc12f813d

View file

@ -1,60 +1,124 @@
import { chromium, type Browser, type BrowserContext } from 'playwright'; import { chromium, type Browser, type Page } from 'playwright';
import { existsSync, readdirSync } from 'fs';
const VIEWPORT = { width: 1200, height: 630 }; const VIEWPORT = { width: 1200, height: 630 };
const NAVIGATION_TIMEOUT = 15_000; const NAVIGATION_TIMEOUT = 15_000;
const TILE_BUFFER_MS = 500; const TILE_BUFFER_MS = 500;
const MAX_CONCURRENT = 2; const POOL_SIZE = 3;
let browser: Browser | null = null; let browser: Browser | null = null;
let concurrency = 0; const pagePool: Page[] = [];
const queue: Array<{ resolve: () => void }> = []; let warmingUp = false;
/**
* Detect if a GPU is available for hardware-accelerated rendering.
* Checks for DRI devices on Linux which indicate GPU + driver availability.
*/
function detectGpu(): boolean {
try {
// Check for DRI render nodes (Linux)
if (existsSync('/dev/dri')) {
const devices = readdirSync('/dev/dri');
// Look for renderD* (render nodes) or card* (display cards)
const hasGpu = devices.some((d) => d.startsWith('renderD') || d.startsWith('card'));
if (hasGpu) {
console.log(`GPU detected: /dev/dri contains ${devices.join(', ')}`);
return true;
}
}
} catch {
// Ignore errors - fall back to software rendering
}
console.log('No GPU detected, using SwiftShader software rendering');
return false;
}
const hasGpu = detectGpu();
function getBrowserArgs(): string[] {
const baseArgs = ['--no-sandbox', '--disable-dev-shm-usage'];
if (hasGpu) {
// Use hardware GPU acceleration
return [...baseArgs, '--enable-gpu', '--enable-webgl'];
} else {
// Fall back to SwiftShader software rendering
return [...baseArgs, '--use-gl=angle', '--use-angle=swiftshader'];
}
}
async function ensureBrowser(): Promise<Browser> { async function ensureBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) { if (!browser || !browser.isConnected()) {
browser = await chromium.launch({ const args = getBrowserArgs();
args: [ console.log(`Launching browser with args: ${args.join(' ')}`);
'--no-sandbox', browser = await chromium.launch({ args });
'--disable-dev-shm-usage',
'--use-gl=angle',
'--use-angle=swiftshader',
],
});
} }
return browser; return browser;
} }
async function acquireSlot(): Promise<void> { async function createPage(): Promise<Page> {
if (concurrency < MAX_CONCURRENT) { const instance = await ensureBrowser();
concurrency++; const context = await instance.newContext({
return; viewport: VIEWPORT,
} deviceScaleFactor: 1,
return new Promise<void>((resolve) => {
queue.push({ resolve });
}); });
return context.newPage();
} }
function releaseSlot(): void { async function warmPool(): Promise<void> {
concurrency--; if (warmingUp) return;
const next = queue.shift(); warmingUp = true;
if (next) {
concurrency++; try {
next.resolve(); const instance = await ensureBrowser();
while (pagePool.length < POOL_SIZE && instance.isConnected()) {
const page = await createPage();
pagePool.push(page);
}
} finally {
warmingUp = false;
}
}
async function acquirePage(): Promise<Page> {
// Try to get a page from the pool
const page = pagePool.shift();
if (page && !page.isClosed()) {
// Refill pool in background
warmPool().catch(() => {});
return page;
}
// No pooled page available, create one
const newPage = await createPage();
// Start warming pool in background
warmPool().catch(() => {});
return newPage;
}
async function releasePage(page: Page): Promise<void> {
try {
if (page.isClosed()) return;
// Reset page state for reuse
await page.goto('about:blank', { timeout: 5000 }).catch(() => {});
if (!page.isClosed() && pagePool.length < POOL_SIZE) {
pagePool.push(page);
} else {
await page.context().close().catch(() => {});
}
} catch {
await page.context().close().catch(() => {});
} }
} }
export async function takeScreenshot(url: string): Promise<Buffer> { export async function takeScreenshot(url: string): Promise<Buffer> {
await acquireSlot(); const page = await acquirePage();
let context: BrowserContext | null = null;
try { try {
const instance = await ensureBrowser(); // Use domcontentloaded instead of networkidle - let __og_ready handle readiness
context = await instance.newContext({ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });
viewport: VIEWPORT,
deviceScaleFactor: 1,
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle', timeout: NAVIGATION_TIMEOUT });
// Wait for the frontend to signal readiness // Wait for the frontend to signal readiness
try { try {
@ -71,16 +135,22 @@ export async function takeScreenshot(url: string): Promise<Buffer> {
const screenshot = await page.screenshot({ type: 'png' }); const screenshot = await page.screenshot({ type: 'png' });
return Buffer.from(screenshot); return Buffer.from(screenshot);
} finally { } finally {
if (context) { await releasePage(page);
await context.close().catch(() => {});
}
releaseSlot();
} }
} }
export async function closeBrowser(): Promise<void> { export async function closeBrowser(): Promise<void> {
// Close all pooled pages
for (const page of pagePool) {
await page.context().close().catch(() => {});
}
pagePool.length = 0;
if (browser) { if (browser) {
await browser.close().catch(() => {}); await browser.close().catch(() => {});
browser = null; browser = null;
} }
} }
// Pre-warm the pool on module load
warmPool().catch(() => {});