/** * Documentation Screenshot Generator * * Captures every screenshot referenced by README.md and docs/theming.md: * theme presets, hero styles, grid layouts, light mode, the standard pages * (subpage overview, about, map), the lightbox with its EXIF panel, a mobile * viewport and — if ADMIN_PASSWORD is set — the admin panel. * * Uses Playwright to wait for images to fully load (not just blurhash placeholders). * * Usage: * npx tsx scripts/screenshots.ts * SCREENSHOT_GRID_PATH=/japan/koyasan-2023 npx tsx scripts/screenshots.ts * SCREENSHOT_PORT=3100 npx tsx scripts/screenshots.ts * SCREENSHOT_ONLY=admin,lightbox npx tsx scripts/screenshots.ts * * The album used for the grid shots is discovered by walking the running site, * since album slugs come from Immich album names. SCREENSHOT_GRID_PATH picks a * specific one instead, SCREENSHOT_SUBPAGE_PATH the collection overview. * * Prerequisites: * - Nothing else listening on SCREENSHOT_PORT (the script manages its own * server; a foreign server on that port would be screenshot instead) * - Playwright browsers installed: npx playwright install chromium * * Note: this rewrites content/settings.yaml, content/gallery.yaml and * content/about.md while it runs and restores all three afterwards — including * on Ctrl+C. On a host serving live traffic from the same files, the public site * changes with every capture. Run it against a copy of the repo instead. * * Because the screenshots end up in a public repository, the run replaces the * site identity (title, footer, watermark, legal block, about page) with neutral * demo values and blanks out every stored password — see DEMO_SETTINGS. * * Output: * docs/screenshots/*.png */ import { spawn, type ChildProcess } from 'child_process'; import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; import type { Browser, Page } from '@playwright/test'; // Next reads .env.local itself; this script runs outside Next, so ADMIN_PASSWORD // and friends have to be pulled in explicitly. Must run before the constants below. loadEnvLocal(); const THEMES = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; /** Preset used for everything that is not itself a theme comparison. */ const SHOWCASE_THEME = 'studio-modern'; const PORT = process.env.SCREENSHOT_PORT ?? '3000'; const BASE_URL = `http://localhost:${PORT}`; /** * Album detail page used for the grid screenshots. Discovered by walking the * running site, because album slugs come from Immich album names and differ per * installation. Set SCREENSHOT_GRID_PATH to pick a specific album instead. */ const GRID_PATH_OVERRIDE = process.env.SCREENSHOT_GRID_PATH; /** Collection overview page. Derived from the grid path when not set. */ const SUBPAGE_PATH_OVERRIDE = process.env.SCREENSHOT_SUBPAGE_PATH; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; /** * Comma-separated section keys to capture, e.g. `SCREENSHOT_ONLY=admin,lightbox`. * A full pass takes the better part of an hour, so re-shooting one section after * a layout change should not mean redoing all of it. Empty means everything. */ const ONLY_SECTIONS = (process.env.SCREENSHOT_ONLY ?? '') .split(',') .map((key) => key.trim()) .filter(Boolean); const wanted = (key: string) => ONLY_SECTIONS.length === 0 || ONLY_SECTIONS.includes(key); const OUTPUT_DIR = path.join(process.cwd(), 'docs', 'screenshots'); const CONTENT_DIR = path.join(process.cwd(), 'content'); const SETTINGS_YAML = path.join(CONTENT_DIR, 'settings.yaml'); const GALLERY_YAML = path.join(CONTENT_DIR, 'gallery.yaml'); const ABOUT_MD = path.join(CONTENT_DIR, 'about.md'); /** Every file the run rewrites, and therefore has to restore. */ const MANAGED_FILES = [SETTINGS_YAML, GALLERY_YAML, ABOUT_MD]; const backupPath = (file: string) => `${file}.screenshot-bak`; /** Pages that never carry a photo grid — skipped while looking for an album. */ const EXCLUDED_PATHS = new Set(['/', '/about', '/map', '/impressum', '/admin']); const VIEWPORT = { width: 1440, height: 900 }; const MOBILE_VIEWPORT = { width: 375, height: 812 }; const IMAGE_LOAD_TIMEOUT = 8000; // ms to wait for images after networkidle /** Settled once the dev server has re-read the changed YAML (mtime-based cache). */ const CONFIG_RELOAD_DELAY = 400; /** * Neutral stand-ins for everything identifying in settings.yaml. The screenshots * are published, so the site must not show the operator's name, handle, postal * address or watermark. Dotted keys are merged into the parsed YAML. */ const DEMO_SETTINGS: Record = { // A run loads every page dozens of times; without this it would write a few // hundred fake page views into content/analytics.json. analytics: false, title: 'My Portfolio Gallery', 'seo.title': 'My Portfolio Gallery', 'footer.name': 'My Portfolio Gallery', 'footer.instagram': 'myportfolio', 'watermark.text': 'Folio ', 'legal.name': 'Jane Doe', 'legal.address': '1 Example Street', 'legal.zipCity': '12345 Example City', 'legal.country': 'Germany', 'legal.extraInfo': '', }; async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); // Read the originals to restore later. The on-disk backups are what the signal // handlers and the top-level catch restore from — an in-memory copy is gone the // moment the process is killed, and this script rewrites live content files. const originals = new Map(MANAGED_FILES.map((f) => [f, fs.readFileSync(f, 'utf8')])); for (const [file, content] of originals) fs.writeFileSync(backupPath(file), content); installRestoreHandlers(); const originalSettings = originals.get(SETTINGS_YAML)!; const originalGallery = originals.get(GALLERY_YAML)!; /** Writes settings.yaml with the demo identity plus the given overrides. */ const applySettings = async (overrides: Record = {}) => { fs.writeFileSync(SETTINGS_YAML, settingsWith(originalSettings, overrides)); await sleep(CONFIG_RELOAD_DELAY); }; // Passwords live in gallery.yaml in plain text and are rendered by the admin // page builder — blank them for the whole run rather than per screenshot. fs.writeFileSync(GALLERY_YAML, withRedactedPasswords(originalGallery)); // The about page carries a real name, location and portrait. Swap in demo text // and use a gallery photo as the portrait so no face ends up in the docs. fs.writeFileSync(ABOUT_MD, demoAboutMarkdown(originalGallery)); await applySettings(); console.log(`\n⏳ Starting dev server on ${BASE_URL} ...`); let browser: Browser | undefined; try { await ensureServerHealthy(); const { chromium } = await import('@playwright/test'); browser = await chromium.launch(); // ── Resolve the pages used throughout the run ── const scout = await browser.newContext({ viewport: VIEWPORT }); const scoutPage = await scout.newPage(); const gridPath = GRID_PATH_OVERRIDE ?? (await discoverGridPath(scoutPage)); const subpagePath = SUBPAGE_PATH_OVERRIDE ?? (await resolveSubpagePath(scoutPage, gridPath)); await scout.close(); console.log(`🔎 Album page: ${gridPath}`); console.log(`🔎 Collection page: ${subpagePath ?? '(none found — skipping)'}`); // ── Theme presets: homepage + grid ── if (wanted('themes')) { for (const theme of THEMES) { section(`Theme: ${theme}`); await applySettings({ 'theme.preset': theme }); await withPage(browser, VIEWPORT, async (page) => { await captureHome(page, `theme-${theme}-home`); await captureGrid(page, gridPath, `theme-${theme}-grid`); }); } } // ── Light mode ── if (wanted('light')) { section('Light mode'); await applySettings({ 'theme.preset': SHOWCASE_THEME }); await withPage( browser, VIEWPORT, async (page) => { await captureHome(page, `theme-${SHOWCASE_THEME}-home-light`); await captureGrid(page, gridPath, `theme-${SHOWCASE_THEME}-grid-light`); }, // ThemeToggle reads this on module load and sets data-theme on . () => window.localStorage.setItem('theme', 'light'), ); } // ── Hero styles ── if (wanted('hero')) { for (const hero of HERO_STYLES) { section(`Hero style: ${hero} (theme: studio)`); await applySettings({ 'theme.preset': 'studio', 'theme.heroStyle': hero }); await withPage(browser, VIEWPORT, (page) => captureHome(page, `hero-${hero}-home`)); } } // ── Grid layouts ── if (wanted('grid')) { for (const layout of GRID_LAYOUTS) { section(`Grid layout: ${layout}`); await applySettings({ 'theme.preset': SHOWCASE_THEME, 'grid.layout': layout }); await withPage(browser, VIEWPORT, (page) => captureGrid(page, gridPath, `grid-${layout}`)); } } // ── Standard pages ── if (wanted('pages')) { section('Pages: collection, about, map'); await applySettings({ 'theme.preset': SHOWCASE_THEME }); await withPage(browser, VIEWPORT, async (page) => { if (subpagePath) await captureGrid(page, subpagePath, 'page-collection'); await captureHome(page, 'page-about', '/about'); await capturePage(page, '/map', 'page-map', 6000); }); } // ── Lightbox with EXIF panel ── if (wanted('lightbox')) { section('Lightbox'); await applySettings({ 'theme.preset': SHOWCASE_THEME }); await withPage(browser, VIEWPORT, (page) => captureLightbox(page, gridPath)); } // ── Mobile viewport ── if (wanted('mobile')) { section('Mobile (375x812)'); await applySettings({ 'theme.preset': SHOWCASE_THEME }); await withPage(browser, MOBILE_VIEWPORT, async (page) => { await captureHome(page, 'mobile-home'); await captureGrid(page, gridPath, 'mobile-grid'); }); } // ── Admin panel ── if (wanted('admin')) { await applySettings({ 'theme.preset': SHOWCASE_THEME }); await captureAdmin(browser); } } finally { await browser?.close(); await stopDevServer(); restoreManagedFiles(originals); } await optimizePngs(); if (failedSections.length > 0) { console.log(`\n⚠️ Incomplete sections: ${failedSections.join(', ')}`); } console.log(`\n🎉 Done! Screenshots saved to ${OUTPUT_DIR}/`); for (const file of fs .readdirSync(OUTPUT_DIR) .filter((f) => f.endsWith('.png')) .sort()) { const kb = Math.round(fs.statSync(path.join(OUTPUT_DIR, file)).size / 1024); console.log(` ${file} (${kb} KB)`); } } /** * Re-encode the PNGs losslessly. Playwright optimises for capture speed, not * file size — a plain re-encode takes a full-page photo grid from ~2 MB to * ~700 KB with identical pixels. sharp ships with Next.js but is not a declared * dependency here, so a missing install just skips this step. */ async function optimizePngs(): Promise { let sharp: typeof import('sharp'); try { sharp = (await import('sharp')).default; } catch { console.log('\n⏭ sharp not available — screenshots left as captured.'); return; } console.log('\n🗜 Optimising PNGs...'); let before = 0; let after = 0; for (const file of fs.readdirSync(OUTPUT_DIR).filter((f) => f.endsWith('.png'))) { const filePath = path.join(OUTPUT_DIR, file); before += fs.statSync(filePath).size; const optimised = await sharp(filePath).png({ compressionLevel: 9, effort: 10 }).toBuffer(); // Never grow a file — some captures are already minimal. if (optimised.length < fs.statSync(filePath).size) fs.writeFileSync(filePath, optimised); after += fs.statSync(filePath).size; } console.log(` ${Math.round(before / 1024)} KB → ${Math.round(after / 1024)} KB`); } // ── Capture helpers ────────────────────────────────────────────── let currentSection = ''; /** Sections that threw, reported again at the end of the run. */ const failedSections: string[] = []; function section(title: string): void { currentSection = title; console.log(`\n📷 ${title}`); console.log('─'.repeat(50)); } /** * Run a capture block in a fresh browser context, so localStorage (light mode) * and cookies (admin session) never leak into the next block. * * A block is retried once — a wedged dev server is replaced in between — and * then reported and skipped rather than aborting the run: a full pass takes many * minutes, and losing the remaining thirty screenshots because one page * misbehaved is worse than a gap in the output. Re-running a block simply * overwrites the screenshots it had already taken. */ async function withPage( browser: Browser, viewport: { width: number; height: number }, fn: (page: Page) => Promise, initScript?: () => void, ): Promise { for (let attempt = 1; attempt <= 2; attempt++) { await ensureServerHealthy(); const context = await browser.newContext({ viewport }); // The dev server compiles routes on first hit, which can exceed Playwright's // 30s default right after a settings change. context.setDefaultNavigationTimeout(90000); context.setDefaultTimeout(30000); if (initScript) await context.addInitScript(initScript); const page = await context.newPage(); try { await fn(page); return; } catch (err) { const reason = String(err).split('\n')[0]; if (attempt === 1) { console.error(` ↻ ${currentSection} failed (${reason}) — retrying`); } else { failedSections.push(currentSection); console.error(` ⚠️ ${currentSection} failed: ${reason}`); } } finally { await context.close(); } } } async function captureHome(page: Page, name: string, urlPath = '/'): Promise { await page.goto(`${BASE_URL}${urlPath}`, { waitUntil: 'networkidle' }); await revealDeferredContent(page); await waitForImages(page); await capture(page, name); } async function captureGrid(page: Page, urlPath: string, name: string): Promise { await capturePage(page, urlPath, name, 3000); } /** Load a page, reveal everything that fades or lazy-loads in, then shoot it. */ async function capturePage( page: Page, urlPath: string, name: string, settleMs: number, ): Promise { await page.goto(`${BASE_URL}${urlPath}`, { waitUntil: 'networkidle' }); await revealDeferredContent(page); await waitForImages(page); // Extra settle time for late-loading images in lower columns await page.waitForTimeout(settleMs); await capture(page, name); } /** * Open the first still image of an album in the lightbox and expand the EXIF * panel. Videos are skipped — their tile opens a player, not a photo. */ async function captureLightbox(page: Page, gridPath: string): Promise { await page.goto(`${BASE_URL}${gridPath}`, { waitUntil: 'networkidle' }); await revealDeferredContent(page); await waitForImages(page); const tiles = page.locator('.photo-grid__item'); const count = await tiles.count(); for (let i = 0; i < count; i++) { const tile = tiles.nth(i); if ((await tile.locator('.photo-grid__item-play').count()) > 0) continue; await tile.click(); break; } await page.keyboard.press('i'); await page.locator('#exif-panel').waitFor({ state: 'visible', timeout: 5000 }); await waitForImages(page); await page.waitForTimeout(1500); await capture(page, 'lightbox-exif'); } /** * Admin panel: login screen, page builder, album picker and settings editor. * Skipped with a warning when ADMIN_PASSWORD is unset — the panel is optional * and a missing password should not fail the whole run. */ async function captureAdmin(browser: Browser): Promise { section('Admin panel'); if (!ADMIN_PASSWORD) { console.log(' ⏭ ADMIN_PASSWORD not set — skipping the admin screenshots.'); return; } await withPage(browser, VIEWPORT, async (page) => { await page.goto(`${BASE_URL}/admin`, { waitUntil: 'networkidle' }); await page.locator('.admin-login-card').waitFor({ timeout: 15000 }); await capture(page, 'admin-login'); await page.locator('.admin-login-card input[type="password"]').fill(ADMIN_PASSWORD); await page.locator('.admin-login-card button[type="submit"]').click(); await page.locator('.admin-tabs').waitFor({ timeout: 20000 }); await page.waitForTimeout(2500); await waitForImages(page); await capture(page, 'admin-page-builder'); await captureAlbumPicker(page); // Reload rather than trusting Escape to have closed the picker — a modal // still on screen swallows the click on the settings tab. await page.reload({ waitUntil: 'networkidle' }); await page.locator('.admin-tabs').waitFor({ timeout: 20000 }); await page.locator('.admin-tab', { hasText: 'Settings' }).first().click(); await page.waitForTimeout(1500); await capture(page, 'admin-settings'); }); } /** Best effort: the picker is a modal behind a button whose label may change. */ async function captureAlbumPicker(page: Page): Promise { const trigger = page.getByRole('button', { name: /add album/i }).first(); if ((await trigger.count()) === 0) { console.log(' ⏭ No "Add album" button found — skipping the album picker shot.'); return; } try { await trigger.click(); await page.locator('input[placeholder*="Search" i]').first().waitFor({ timeout: 10000 }); await page.waitForTimeout(2000); await waitForImages(page); await capture(page, 'admin-album-picker'); await page.keyboard.press('Escape'); await page.waitForTimeout(500); } catch { console.log(' ⏭ Album picker did not open in time — skipping that shot.'); } } async function capture(page: Page, name: string): Promise { await hideDevOverlays(page); await page.screenshot({ path: path.join(OUTPUT_DIR, `${name}.png`), fullPage: false }); console.log(` ✅ ${name}.png`); } /** * Reveal content that only appears on scroll or interaction: fade-in wrappers * stay transparent until their observer fires, and lazy images never load * below the fold. Both would otherwise be captured empty. */ async function revealDeferredContent(page: Page): Promise { await page.evaluate(() => { document.querySelectorAll('.fade-in').forEach((el) => el.classList.add('fade-in--visible')); document.querySelectorAll('img[loading="lazy"]').forEach((img) => { (img as HTMLImageElement).loading = 'eager'; }); }); } /** Next.js' dev indicator and the in-app dev toolbar are not part of the product. */ async function hideDevOverlays(page: Page): Promise { await page.evaluate(() => { document.querySelectorAll('nextjs-portal').forEach((el) => el.remove()); document.querySelectorAll('[aria-label="Dev Toolbar"]').forEach((el) => el.remove()); }); } // ── Environment ────────────────────────────────────────────────── /** * Minimal .env.local reader. Next loads the file for the dev server it spawns, * but this process needs ADMIN_PASSWORD too, and real environment variables * must keep winning over the file. */ function loadEnvLocal(): void { const envFile = path.join(process.cwd(), '.env.local'); if (!fs.existsSync(envFile)) return; for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) { const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); if (!match || line.trimStart().startsWith('#')) continue; const [, key, rawValue] = match; if (process.env[key] !== undefined) continue; process.env[key] = rawValue.trim().replace(/^(["'])(.*)\1$/, '$2'); } } // ── Content rewriting ──────────────────────────────────────────── /** * Parse settings.yaml, merge the demo identity plus `overrides` (dotted keys) * and dump it back. Comments are lost in the temporary file, which is fine — * the original is restored verbatim from its backup, never re-serialised. */ function settingsWith(original: string, overrides: Record): string { const doc = (yaml.load(original) ?? {}) as Record; for (const [key, value] of Object.entries({ ...DEMO_SETTINGS, ...overrides })) { setPath(doc, key, value); } return yaml.dump(doc, { lineWidth: 0 }); } function setPath(target: Record, dottedKey: string, value: unknown): void { const keys = dottedKey.split('.'); let node = target; for (const key of keys.slice(0, -1)) { if (typeof node[key] !== 'object' || node[key] === null) node[key] = {}; node = node[key] as Record; } node[keys[keys.length - 1]] = value; } /** Replace every `password:` value in gallery.yaml with a throwaway one. */ function withRedactedPasswords(original: string): string { const doc = yaml.load(original); const walk = (node: unknown): void => { if (Array.isArray(node)) return node.forEach(walk); if (typeof node !== 'object' || node === null) return; const record = node as Record; for (const [key, value] of Object.entries(record)) { if (key === 'password' && typeof value === 'string') record[key] = 'demo'; else walk(value); } }; walk(doc); return yaml.dump(doc, { lineWidth: 0 }); } /** * A demo about page. The portrait is taken from the gallery's hero images so the * layout is shown with a real photo without publishing the operator's face, and * without hardcoding an asset UUID into the repository. */ function demoAboutMarkdown(galleryYaml: string): string { const gallery = (yaml.load(galleryYaml) ?? {}) as { hero?: unknown }; const hero = Array.isArray(gallery.hero) ? gallery.hero : []; const portrait = typeof hero[0] === 'string' ? hero[0] : ''; const frontmatter = [ portrait ? `portrait: ${portrait}` : null, 'name: Alex Rivera', 'location: Kyoto, Japan', 'gear:', ' - Leica Q3', ' - Summilux 35mm f/1.4', ' - Summicron 50mm f/2', ].filter((line): line is string => line !== null); return [ '---', ...frontmatter, '---', '', 'Photographer and traveller.', 'Obsessed with light, geometry, and the quiet in-between.', '', ].join('\n'); } // ── Page discovery ─────────────────────────────────────────────── /** * Find an album detail page by walking the running site. * * Album slugs are derived from Immich album names, so no path can be hardcoded * without tying the script to one installation. Start at the homepage, follow * internal links, and take the first page that renders a photo grid — either a * standalone album, or an album reached through a subpage's cover grid. * Password-gated pages are skipped. */ async function discoverGridPath(page: Page): Promise { const seen = new Set(); const internalLinks = async (selector: string): Promise => ( await page.$$eval(selector, (els) => els.map((el) => el.getAttribute('href')).filter((h): h is string => !!h), ) ).filter((href) => href.startsWith('/') && !EXCLUDED_PATHS.has(href) && !seen.has(href)); /** A photo grid means we arrived at an album; a password field means we cannot. */ const pageKind = async (): Promise<'grid' | 'gated' | 'other'> => { if ((await page.locator('input[type="password"]').count()) > 0) return 'gated'; if ((await page.locator('.photo-grid, .essay').count()) > 0) return 'grid'; return 'other'; }; await page.goto(BASE_URL, { waitUntil: 'networkidle' }); const entryPoints = await internalLinks('a[href^="/"]'); for (const entry of entryPoints) { seen.add(entry); await page.goto(`${BASE_URL}${entry}`, { waitUntil: 'networkidle' }); const kind = await pageKind(); if (kind === 'gated') continue; if (kind === 'grid') return entry; // A subpage listing — descend into its album covers. for (const cover of await internalLinks('.subpage-grid__item, .album-card')) { seen.add(cover); await page.goto(`${BASE_URL}${cover}`, { waitUntil: 'networkidle' }); if ((await pageKind()) === 'grid') return cover; } } throw new Error( 'Could not find a public album page to screenshot. Every reachable page was ' + 'password-protected or empty. Point the script at a specific album with ' + 'SCREENSHOT_GRID_PATH=/my-subpage/my-album npx tsx scripts/screenshots.ts', ); } /** * The collection overview belonging to the album, i.e. `/japan` for * `/japan/koyasan-2023`. Standalone albums have no parent, so fall back to the * first navigation entry that renders a subpage grid; null if there is none. */ async function resolveSubpagePath(page: Page, gridPath: string): Promise { const segments = gridPath.split('/').filter(Boolean); if (segments.length > 1) return `/${segments[0]}`; const links = await page.$$eval('nav a[href^="/"]', (els) => els.map((el) => el.getAttribute('href')).filter((h): h is string => !!h), ); for (const href of links) { if (EXCLUDED_PATHS.has(href) || href === gridPath) continue; await page.goto(`${BASE_URL}${href}`, { waitUntil: 'networkidle' }); if ((await page.locator('.subpage-grid').count()) > 0) return href; } return null; } // ── Content restore ────────────────────────────────────────────── function restoreManagedFiles(originals: Map): void { for (const [file, content] of originals) { fs.writeFileSync(file, content); fs.rmSync(backupPath(file), { force: true }); } console.log('\n✅ Restored original content files'); } /** * Restore the content files when the process is killed. Without this, Ctrl+C in * the middle of a run leaves the site on whatever theme was being captured — * which matters because these files are the live configuration. */ function installRestoreHandlers(): void { let restored = false; const restore = () => { if (restored) return; restored = true; for (const file of MANAGED_FILES) { try { if (!fs.existsSync(backupPath(file))) continue; fs.writeFileSync(file, fs.readFileSync(backupPath(file), 'utf8')); fs.rmSync(backupPath(file), { force: true }); } catch { console.error( `\n⚠️ Could not restore ${file} automatically. Copy it back from ${backupPath(file)}`, ); } } console.log('\n✅ Restored original content files'); }; for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { process.on(signal, () => { restore(); process.exit(130); }); } } // ── Server lifecycle ───────────────────────────────────────────── /** * One dev server for the whole run. The YAML loader caches by mtime and the * pages are force-dynamic, so a rewritten settings.yaml is picked up by the next * request — restarting the server per theme would only cost startup time. * * It does not always survive a full pass though: after a few dozen recompiles * the dev server occasionally stops answering, which used to turn into a run of * navigation timeouts. ensureServerHealthy() replaces it when that happens. */ let devServer: ChildProcess | undefined; async function ensureServerHealthy(): Promise { if (devServer && (await serverResponds())) return; if (devServer) { console.log(' ♻️ Dev server stopped responding — restarting it'); await stopDevServer(); } devServer = startDevServer(); await waitForServer(BASE_URL, 90000); console.log(' ✅ Server ready'); } async function serverResponds(): Promise { try { const res = await fetch(`${BASE_URL}/api/health`, { signal: AbortSignal.timeout(20000) }); return res.ok; } catch { return false; } } function startDevServer(): ChildProcess { const server = spawn('npm', ['run', 'dev'], { cwd: process.cwd(), stdio: 'pipe', env: { ...process.env, PORT }, detached: true, }); // Suppress server output unless DEBUG is set if (process.env.DEBUG) { server.stdout?.pipe(process.stdout); server.stderr?.pipe(process.stderr); } return server; } async function stopDevServer(): Promise { const server = devServer; devServer = undefined; if (!server) return; if (server.pid) { try { process.kill(-server.pid, 'SIGTERM'); } catch { // Already gone } } await new Promise((resolve) => { const timeout = setTimeout(resolve, 3000); server.on('close', () => { clearTimeout(timeout); resolve(null); }); }); console.log('🛑 Server stopped'); } async function waitForServer(url: string, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { // /api/health does not touch Immich, so it answers as soon as the server // is up rather than waiting on the first album fetch. const res = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(10000) }); if (res.ok) return; } catch { // Server not ready yet } await sleep(500); } throw new Error(`Server did not start within ${timeoutMs}ms`); } /** * Wait for all elements to finish loading their full-resolution images. * This prevents capturing blurhash placeholders instead of real photos. */ async function waitForImages(page: Page): Promise { await page.waitForTimeout(1000); // Initial settle await page.evaluate((timeout) => { return new Promise((resolve) => { const images = Array.from(document.querySelectorAll('img')); let pending = images.filter((img) => !img.complete).length; if (pending === 0) { // All images already loaded, wait a bit for any CSS transitions setTimeout(resolve, 500); return; } // Every callback below is inline and anonymous on purpose: tsx compiles // this file with esbuild's keepNames, which rewrites *named* functions // into `__name(fn, '…')` — and that helper does not exist in the page. images.forEach((img) => { if (!img.complete) { img.addEventListener('load', () => { if (--pending <= 0) setTimeout(resolve, 500); }); img.addEventListener('error', () => { if (--pending <= 0) setTimeout(resolve, 500); }); } }); // Safety timeout setTimeout(resolve, timeout); }); }, IMAGE_LOAD_TIMEOUT); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // ── Run ────────────────────────────────────────────────────────── main().catch((err) => { console.error('❌ Screenshot generation failed:', err); // The finally block normally restores; this covers a throw before it is reached. for (const file of MANAGED_FILES) { try { if (!fs.existsSync(backupPath(file))) continue; fs.writeFileSync(file, fs.readFileSync(backupPath(file), 'utf8')); fs.rmSync(backupPath(file), { force: true }); console.log(`✅ Restored ${path.basename(file)}`); } catch { console.error(`⚠️ Restore failed — copy ${file} back from ${backupPath(file)}`); } } process.exit(1); });