import { existsSync } from 'node:fs'; import { mkdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { chromium, type LaunchOptions } from 'playwright'; const WIDTH = 1200; const HEIGHT = 630; const POSTERS_PER_ROW = 10; const OUTPUT_PATH = path.resolve('static/og.png'); const TMDB_API = 'https://api.themoviedb.org/3'; const TMDB_IMAGES = 'https://image.tmdb.org/t/p/w342'; type MediaKind = 'movie' | 'tv'; type TrendingItem = { id: number; name?: string; poster_path: string | null; title?: string; }; type Poster = { dataUrl: string; kind: MediaKind; title: string; }; function escapeHtml(value: string) { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function tmdbCredential() { const credential = [process.env.TMDB_ACCESS_TOKEN, process.env.TMDB_API_KEY] .map((value) => value?.trim()) .find(Boolean); if (!credential) { throw new Error('Set TMDB_ACCESS_TOKEN or TMDB_API_KEY before generating the OG image.'); } return credential; } async function tmdb(pathname: string): Promise { const credential = tmdbCredential(); const url = new URL(`${TMDB_API}${pathname}`); url.searchParams.set('language', 'en-US'); const headers: Record = { accept: 'application/json' }; if (credential.startsWith('eyJ')) { headers.Authorization = `Bearer ${credential}`; } else { url.searchParams.set('api_key', credential); } const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`TMDB ${pathname} failed (${response.status}): ${await response.text()}`); } return response.json() as Promise; } async function trending(kind: MediaKind) { const data = await tmdb<{ results: TrendingItem[] }>(`/trending/${kind}/week`); return data.results .filter((item): item is TrendingItem & { poster_path: string } => Boolean(item.poster_path)) .map((item) => ({ kind, posterPath: item.poster_path, title: item.title ?? item.name ?? `${kind} ${item.id}` })); } async function downloadPoster(item: Awaited>[number]): Promise { const response = await fetch(`${TMDB_IMAGES}${item.posterPath}`); if (!response.ok) { throw new Error(`Could not download the poster for ${item.title} (${response.status}).`); } const mimeType = response.headers.get('content-type') ?? 'image/jpeg'; const bytes = Buffer.from(await response.arrayBuffer()); return { dataUrl: `data:${mimeType};base64,${bytes.toString('base64')}`, kind: item.kind, title: item.title }; } function interleave(left: T[], right: T[]) { return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => [ left[index], right[index] ]) .flat() .filter((item): item is T => item !== undefined); } function posterMarkup(posters: Poster[]) { const angles = Array.from( { length: POSTERS_PER_ROW }, (_, index) => -70 + index * (140 / (POSTERS_PER_ROW - 1)) ); const rowY = [-245, 0, 245]; const radius = 630; const depth = 290; const centerDepth = -120; return rowY .flatMap((y, row) => angles.map((angle, column) => { const poster = posters[row * POSTERS_PER_ROW + column]; const radians = (angle * Math.PI) / 180; const x = Math.sin(radians) * radius; const z = (1 - Math.cos(radians)) * depth + centerDepth; const brightness = 0.66 + Math.cos(radians) * 0.34; return `
${escapeHtml(poster.title)}
`; }) ) .join('\n'); } function template(posters: Poster[], font: string) { return `
${posterMarkup(posters)}
atmo.watch
`; } async function launchBrowser() { const configuredPath = process.env.OG_BROWSER_PATH?.trim(); const options: LaunchOptions = configuredPath ? { executablePath: configuredPath } : existsSync(chromium.executablePath()) ? {} : { channel: 'chrome' }; try { return await chromium.launch(options); } catch (cause) { throw new Error( 'Could not launch Chromium. Run `pnpm exec playwright install chromium` or set OG_BROWSER_PATH.', { cause } ); } } async function main() { const [movies, shows, interSemiBold] = await Promise.all([ trending('movie'), trending('tv'), readFile(path.resolve('node_modules/@fontsource/inter/files/inter-latin-600-normal.woff2')) ]); const selected = interleave(movies, shows).slice(0, POSTERS_PER_ROW * 3); if (selected.length < POSTERS_PER_ROW * 3) { throw new Error(`TMDB returned only ${selected.length} posters; 30 are required.`); } console.log(`Downloading ${selected.length} current trending posters…`); const posters = await Promise.all(selected.map(downloadPoster)); const font = `data:font/woff2;base64,${interSemiBold.toString('base64')}`; const browser = await launchBrowser(); try { const context = await browser.newContext({ colorScheme: 'dark', deviceScaleFactor: 1, viewport: { width: WIDTH, height: HEIGHT } }); const page = await context.newPage(); await page.setContent(template(posters, font), { waitUntil: 'load' }); await page.evaluate(async () => { await document.fonts.ready; await Promise.all(Array.from(document.images, (image) => image.decode())); }); await mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); await page.screenshot({ path: OUTPUT_PATH, type: 'png' }); await context.close(); } finally { await browser.close(); } console.log(`Wrote ${path.relative(process.cwd(), OUTPUT_PATH)}`); console.log(posters.map((poster) => poster.title).join(' · ')); } await main();