From 7b7b07c946bca588f1d88c414d2ccdfb9ced717d Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:24:31 +0200 Subject: [PATCH] default open graph image --- README.md | 5 - package.json | 2 + pnpm-lock.yaml | 8 + scripts/README.md | 22 +- scripts/generate-og-image.ts | 301 ++++++++++++++++++ .../components/DefaultOpenGraphImage.svelte | 15 + src/routes/+error.svelte | 3 + src/routes/+page.svelte | 3 + src/routes/[kind]/[id]/edit/+page.svelte | 3 + .../[kind]/[id]/season/[season]/+page.svelte | 5 + src/routes/[kind]/[id]/videos/+page.svelte | 3 + src/routes/cast/[id]/+page.svelte | 5 + src/routes/profile/[actor]/+page.svelte | 3 + .../profile/[actor]/list/[rkey]/+page.svelte | 3 + src/routes/videos/+page.svelte | 3 + src/routes/videos/[actor]/[rkey]/+page.svelte | 5 + static/og.png | Bin 0 -> 964013 bytes 17 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 scripts/generate-og-image.ts create mode 100644 src/lib/components/DefaultOpenGraphImage.svelte create mode 100644 static/og.png diff --git a/README.md b/README.md index 297a9da..ba510e3 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,6 @@ review movies and tv shows with your atmosphere account, using popfeed.social's movie and show data by tmdb -## Video curation - -Bulk discovery, pi-agent review, and rate-limit-aware AT Protocol upload tools live in -[`scripts/`](scripts/README.md). - ## API Worker The public Contrail API is a separate Cloudflare Worker under [`api/`](api/README.md). Contrail's generated client supports both the deployed target and the local API: diff --git a/package.json b/package.json index f6719b8..cee7e83 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "prettier --write .", + "og:generate": "node --env-file-if-exists=.env scripts/generate-og-image.ts", "videos:find": "node --env-file-if-exists=.env scripts/find-videos.ts", "videos:upload": "node --env-file-if-exists=.env scripts/upload-videos.ts", "test:unit": "vitest", @@ -33,6 +34,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@fontsource/inter": "^5.3.0", "@playwright/test": "^1.60.0", "@sveltejs/adapter-cloudflare": "^7.2.9", "@sveltejs/kit": "^2.63.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35e2152..e779962 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.1(jiti@2.7.0)) + '@fontsource/inter': + specifier: ^5.3.0 + version: 5.3.0 '@playwright/test': specifier: ^1.60.0 version: 1.62.1 @@ -751,6 +754,9 @@ packages: '@fontsource-variable/geist@5.3.0': resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==} + '@fontsource/inter@5.3.0': + resolution: {integrity: sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==} + '@foxui/core@0.9.1': resolution: {integrity: sha512-GnMegqx5gz71U85FK85ayEEHuD3bvpNET8HeNsJU1Gj+OVkKKvUY9q/XqmCY3tsfEqY0Mv+RM1iQSnsZn5LZ6w==} peerDependencies: @@ -4091,6 +4097,8 @@ snapshots: '@fontsource-variable/geist@5.3.0': {} + '@fontsource/inter@5.3.0': {} + '@foxui/core@0.9.1(@internationalized/date@3.12.3)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(typescript@6.0.3)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0))(tailwindcss@4.3.3)': dependencies: '@fontsource-variable/geist': 5.3.0 diff --git a/scripts/README.md b/scripts/README.md index b48617a..8b15471 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,8 +1,22 @@ -# Video curation scripts +# Project scripts -These scripts discover and review YouTube videos, then submit the accepted records to AT Protocol repositories. Run them from the repository root with Node 22.19 or newer. +Run these scripts from the repository root with Node 22.19 or newer. -## 1. Find and review videos +## Open Graph image + +Generate `static/og.png` from the current weekly trending movies and shows on TMDB. The renderer uses CSS 3D transforms in Chromium and requires `TMDB_ACCESS_TOKEN` or `TMDB_API_KEY` in `.env`. + +```sh +pnpm og:generate +``` + +If Playwright's Chromium is unavailable, run `pnpm exec playwright install chromium` or point `OG_BROWSER_PATH` to a Chromium-based browser. + +## Video curation + +These scripts discover and review YouTube videos, then submit the accepted records to AT Protocol repositories. + +### 1. Find and review videos `find-videos.ts` resolves each target through TMDB and first gives its cast, characters, creators, keywords, seasons, and episodes to a pi SDK search-planning agent. That agent designs title-specific searches such as character best-scenes queries, season recaps, iconic episode clips, interviews, production material, and analysis. The script prints and saves this plan before running any YouTube searches. @@ -38,7 +52,7 @@ Useful controls include: Use TMDB IDs for unambiguous targets. Run `pnpm videos:find -- --help` for every option. -## 2. Upload submissions +### 2. Upload submissions Use an AT Protocol app password rather than a primary password. diff --git a/scripts/generate-og-image.ts b/scripts/generate-og-image.ts new file mode 100644 index 0000000..c235c05 --- /dev/null +++ b/scripts/generate-og-image.ts @@ -0,0 +1,301 @@ +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(); diff --git a/src/lib/components/DefaultOpenGraphImage.svelte b/src/lib/components/DefaultOpenGraphImage.svelte new file mode 100644 index 0000000..c896af4 --- /dev/null +++ b/src/lib/components/DefaultOpenGraphImage.svelte @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte index a1c68ba..277f7a8 100644 --- a/src/routes/+error.svelte +++ b/src/routes/+error.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import Container from '$lib/components/Container.svelte'; + import DefaultOpenGraphImage from '$lib/components/DefaultOpenGraphImage.svelte'; let title = $derived(page.status === 404 ? 'Page not found' : 'Something went wrong'); let message = $derived( @@ -16,6 +17,8 @@ {page.status} — {title} | atmo.watch + +
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2429e1d..dc201ac 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,7 @@ import { resolve } from '$app/paths'; import { tick } from 'svelte'; import Container from '$lib/components/Container.svelte'; + import DefaultOpenGraphImage from '$lib/components/DefaultOpenGraphImage.svelte'; import MediaCollection from '$lib/components/MediaCollection.svelte'; import Review from '$lib/components/Review.svelte'; import SearchCommand from '$lib/components/SearchCommand.svelte'; @@ -79,6 +80,8 @@ + +

Search movies and TV shows

diff --git a/src/routes/[kind]/[id]/edit/+page.svelte b/src/routes/[kind]/[id]/edit/+page.svelte index 9c492d1..2c97024 100644 --- a/src/routes/[kind]/[id]/edit/+page.svelte +++ b/src/routes/[kind]/[id]/edit/+page.svelte @@ -1,5 +1,6 @@