Rename and fix screenshots

This commit is contained in:
Andras Schmelczer 2026-02-07 22:19:06 +00:00
parent 9e71ed77df
commit e5d5819098
8 changed files with 81 additions and 18 deletions

88
screenshot/src/cache.ts Normal file
View file

@ -0,0 +1,88 @@
import { createHash } from 'crypto';
import { existsSync, mkdirSync, statSync, createReadStream, writeFileSync } from 'fs';
import { join } from 'path';
import type { Readable } from 'stream';
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
export class ScreenshotCache {
private dir: string;
constructor(dir: string) {
this.dir = dir;
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* Build a cache key by quantizing view params and hashing.
* - lat/lng quantized to 2 decimal places
* - zoom quantized to integer
* - filters sorted alphabetically
*/
buildKey(params: Record<string, string>): string {
const normalized: Record<string, string> = {};
// Parse and quantize the view param (lat,lng,zoom)
if (params.v) {
const parts = params.v.split(',');
if (parts.length === 3) {
const lat = parseFloat(parts[0]).toFixed(2);
const lng = parseFloat(parts[1]).toFixed(2);
const zoom = Math.round(parseFloat(parts[2])).toString();
normalized.v = `${lat},${lng},${zoom}`;
} else {
normalized.v = params.v;
}
}
// Sort filters
if (params.f) {
const segments = params.f.split(',').sort();
normalized.f = segments.join(',');
}
if (params.poi) {
const cats = params.poi.split(',').sort();
normalized.poi = cats.join(',');
}
if (params.tab) {
normalized.tab = params.tab;
}
const input = JSON.stringify(normalized);
const hash = createHash('sha256').update(input).digest('hex').substring(0, 16);
return hash;
}
getPath(key: string): string {
return join(this.dir, `${key}.png`);
}
/**
* Returns a readable stream if a fresh cached file exists, null otherwise.
*/
get(key: string): Readable | null {
const path = this.getPath(key);
if (!existsSync(path)) return null;
try {
const stat = statSync(path);
const age = Date.now() - stat.mtimeMs;
if (age > MAX_AGE_MS) return null;
return createReadStream(path);
} catch {
return null;
}
}
/**
* Write screenshot bytes to cache.
*/
set(key: string, data: Buffer): void {
const path = this.getPath(key);
writeFileSync(path, data);
}
}

View file

@ -0,0 +1,205 @@
import { chromium, type Browser, type Page } from 'playwright';
import { existsSync, readdirSync } from 'fs';
const VIEWPORT = { width: 1200, height: 630 };
const NAVIGATION_TIMEOUT = 15_000;
const TILE_BUFFER_MS = 500;
const POOL_SIZE = 3;
let browser: Browser | null = null;
const pagePool: Page[] = [];
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',
'--enable-webgl',
'--ignore-gpu-blocklist',
];
if (hasGpu) {
// Use hardware GPU acceleration
return [...baseArgs, '--enable-gpu', '--use-gl=egl'];
} else {
// Fall back to SwiftShader software rendering
return [...baseArgs, '--disable-gpu', '--use-gl=swiftshader'];
}
}
async function ensureBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) {
const args = getBrowserArgs();
console.log(`Launching browser with args: ${args.join(' ')}`);
browser = await chromium.launch({ args });
}
return browser;
}
async function createPage(): Promise<Page> {
const instance = await ensureBrowser();
const context = await instance.newContext({
viewport: VIEWPORT,
deviceScaleFactor: 1,
});
return context.newPage();
}
async function warmPool(): Promise<void> {
if (warmingUp) return;
warmingUp = true;
try {
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> {
const page = await acquirePage();
// Log browser console messages for diagnostics
page.on('console', (msg) => {
if (msg.type() === 'error' || msg.type() === 'warning') {
console.log(`[browser ${msg.type()}] ${msg.text()}`);
}
});
page.on('pageerror', (err) => {
console.log(`[browser exception] ${err.message}`);
});
try {
// Use domcontentloaded instead of networkidle - let __og_ready handle readiness
const response = await page.goto(url, {
waitUntil: 'domcontentloaded',
timeout: NAVIGATION_TIMEOUT,
});
if (response) {
console.log(`Page loaded: ${response.status()} ${response.statusText()}`);
}
// Wait for the frontend to signal readiness
try {
await page.waitForFunction('window.__og_ready === true', {
timeout: NAVIGATION_TIMEOUT,
});
console.log('Frontend signalled ready');
} catch {
console.warn('Timed out waiting for __og_ready, proceeding with partial screenshot');
}
// Extra buffer for map tiles to finish rendering
await page.waitForTimeout(TILE_BUFFER_MS);
const screenshot = await page.screenshot({ type: 'png' });
return Buffer.from(screenshot);
} finally {
// Remove listeners before releasing page back to pool
page.removeAllListeners('console');
page.removeAllListeners('pageerror');
await releasePage(page);
}
}
export async function checkWebGL(): Promise<Record<string, unknown>> {
const page = await acquirePage();
try {
await page.setContent('<canvas id="c" width="1" height="1"></canvas>');
const info = await page.evaluate(() => {
const canvas = document.getElementById('c') as HTMLCanvasElement;
const gl =
canvas.getContext('webgl2') || canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return { webgl: false, error: 'No WebGL context available' };
const g = gl as WebGLRenderingContext;
const debugExt = g.getExtension('WEBGL_debug_renderer_info');
return {
webgl: true,
version: g.getParameter(g.VERSION),
renderer: debugExt ? g.getParameter(debugExt.UNMASKED_RENDERER_WEBGL) : 'unknown',
vendor: debugExt ? g.getParameter(debugExt.UNMASKED_VENDOR_WEBGL) : 'unknown',
};
});
return info;
} finally {
await releasePage(page);
}
}
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) {
await browser.close().catch(() => {});
browser = null;
}
}
// Pre-warm the pool on module load
warmPool().catch(() => {});

82
screenshot/src/server.ts Normal file
View file

@ -0,0 +1,82 @@
import express from 'express';
import { ScreenshotCache } from './cache.js';
import { takeScreenshot, checkWebGL, closeBrowser } from './screenshot.js';
const PORT = parseInt(process.env.PORT || '8002', 10);
const NARROWIT_URL = process.env.NARROWIT_URL || 'http://localhost:8001';
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
const cache = new ScreenshotCache(CACHE_DIR);
const app = express();
app.get('/health', (_req, res) => {
res.status(200).send('ok');
});
app.get('/debug', async (_req, res) => {
try {
const info = await checkWebGL();
res.json(info);
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/screenshot', async (req, res) => {
try {
const params: Record<string, string> = {};
for (const key of ['v', 'f', 'poi', 'tab', 'og']) {
const val = req.query[key];
if (typeof val === 'string' && val) {
params[key] = val;
}
}
const cacheKey = cache.buildKey(params);
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('X-Cache', 'HIT');
cached.pipe(res);
return;
}
// Build the URL for the frontend in screenshot mode
const qs = new URLSearchParams(params);
qs.set('screenshot', '1');
const url = `${NARROWIT_URL}/?${qs}`;
console.log(`Taking screenshot: ${url}`);
const png = await takeScreenshot(url);
// Cache it
cache.set(cacheKey, png);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('X-Cache', 'MISS');
res.send(png);
} catch (err) {
console.error('Screenshot failed:', err);
res.status(500).json({ error: 'Screenshot failed' });
}
});
const server = app.listen(PORT, () => {
console.log(`Screenshot service listening on port ${PORT}`);
console.log(` NARROWIT_URL: ${NARROWIT_URL}`);
console.log(` CACHE_DIR: ${CACHE_DIR}`);
});
// Graceful shutdown
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, async () => {
console.log(`Received ${signal}, shutting down...`);
server.close();
await closeBrowser();
process.exit(0);
});
}