diff --git a/src/app/api/og/list/route.tsx b/src/app/api/og/list/route.tsx index 1c67647..4d52012 100644 --- a/src/app/api/og/list/route.tsx +++ b/src/app/api/og/list/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from '@vercel/og'; import { NextRequest } from 'next/server'; +import { fetchImageAsDataUrl } from '@/lib/og-image'; export const runtime = 'edge'; export const revalidate = 3600; // Cache for 1 hour @@ -86,53 +87,8 @@ export async function GET(request: NextRequest) { const creatorAvatarUrl = creatorData?.avatar || ''; const listAvatarUrl = listData?.avatar || ''; - // Fetch list avatar image and convert to base64 with timeout - let listAvatarDataUrl = ''; - if (listAvatarUrl) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - try { - const avatarResponse = await fetch(listAvatarUrl, { signal: controller.signal }); - if (avatarResponse.ok) { - const avatarBuffer = await avatarResponse.arrayBuffer(); - const base64 = Buffer.from(avatarBuffer).toString('base64'); - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; - listAvatarDataUrl = `data:${contentType};base64,${base64}`; - } - } finally { - clearTimeout(timeoutId); - } - } catch (error) { - console.error('Error fetching list avatar:', error); - // Continue without avatar - } - } - - // Fetch creator avatar image and convert to base64 with timeout - let creatorAvatarDataUrl = ''; - if (creatorAvatarUrl) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - try { - const avatarResponse = await fetch(creatorAvatarUrl, { signal: controller.signal }); - if (avatarResponse.ok) { - const avatarBuffer = await avatarResponse.arrayBuffer(); - const base64 = Buffer.from(avatarBuffer).toString('base64'); - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; - creatorAvatarDataUrl = `data:${contentType};base64,${base64}`; - } - } finally { - clearTimeout(timeoutId); - } - } catch (error) { - console.error('Error fetching creator avatar:', error); - // Continue without avatar - } - } + const listAvatarDataUrl = await fetchImageAsDataUrl(listAvatarUrl); + const creatorAvatarDataUrl = await fetchImageAsDataUrl(creatorAvatarUrl); // Load Crimson Pro font const allText = `${listName} ${truncatedDescription} ${creatorName} @${creatorHandle} aturi.to ATmosphere`; diff --git a/src/app/api/og/post/route.tsx b/src/app/api/og/post/route.tsx index 1bd727b..ccefbbb 100644 --- a/src/app/api/og/post/route.tsx +++ b/src/app/api/og/post/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from '@vercel/og'; import { NextRequest } from 'next/server'; +import { fetchImageAsDataUrl } from '@/lib/og-image'; export const runtime = 'edge'; export const revalidate = 3600; // Cache for 1 hour @@ -166,53 +167,8 @@ export async function GET(request: NextRequest) { } } - // Fetch avatar image and convert to base64 with timeout - let avatarDataUrl = ''; - if (avatarUrl) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - try { - const avatarResponse = await fetch(avatarUrl, { signal: controller.signal }); - if (avatarResponse.ok) { - const avatarBuffer = await avatarResponse.arrayBuffer(); - const base64 = Buffer.from(avatarBuffer).toString('base64'); - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; - avatarDataUrl = `data:${contentType};base64,${base64}`; - } - } finally { - clearTimeout(timeoutId); - } - } catch (error) { - console.error('Error fetching avatar:', error); - // Continue without avatar - } - } - - // Fetch embed image if available - let embedImageDataUrl = ''; - if (embedImageUrl) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - try { - const embedResponse = await fetch(embedImageUrl, { signal: controller.signal }); - if (embedResponse.ok) { - const embedBuffer = await embedResponse.arrayBuffer(); - const base64 = Buffer.from(embedBuffer).toString('base64'); - const contentType = embedResponse.headers.get('content-type') || 'image/jpeg'; - embedImageDataUrl = `data:${contentType};base64,${base64}`; - } - } finally { - clearTimeout(timeoutId); - } - } catch (error) { - console.error('Error fetching embed image:', error); - // Continue without embed image - } - } + const avatarDataUrl = await fetchImageAsDataUrl(avatarUrl); + const embedImageDataUrl = await fetchImageAsDataUrl(embedImageUrl); // Load Crimson Pro font console.log('[OG Post] Loading font...'); diff --git a/src/app/api/og/profile/route.tsx b/src/app/api/og/profile/route.tsx index 1f01963..b65f014 100644 --- a/src/app/api/og/profile/route.tsx +++ b/src/app/api/og/profile/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from '@vercel/og'; import { NextRequest } from 'next/server'; +import { fetchImageAsDataUrl } from '@/lib/og-image'; export const runtime = 'edge'; export const revalidate = 3600; // Cache for 1 hour @@ -75,29 +76,7 @@ export async function GET(request: NextRequest) { const bio = profileData?.description || ''; const avatarUrl = profileData?.avatar || ''; - // Fetch avatar image and convert to base64 with timeout - let avatarDataUrl = ''; - if (avatarUrl) { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout - - try { - const avatarResponse = await fetch(avatarUrl, { signal: controller.signal }); - if (avatarResponse.ok) { - const avatarBuffer = await avatarResponse.arrayBuffer(); - const base64 = Buffer.from(avatarBuffer).toString('base64'); - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; - avatarDataUrl = `data:${contentType};base64,${base64}`; - } - } finally { - clearTimeout(timeoutId); - } - } catch (error) { - console.error('Error fetching avatar:', error); - // Continue without avatar - } - } + const avatarDataUrl = await fetchImageAsDataUrl(avatarUrl); // Get first 3 lines or truncate at ~180 characters const bioLines = bio.split('\n'); diff --git a/src/lib/og-image.ts b/src/lib/og-image.ts new file mode 100644 index 0000000..f72ff46 --- /dev/null +++ b/src/lib/og-image.ts @@ -0,0 +1,80 @@ +/** + * Mime types that @vercel/og's image decoder can base64-decode from a data URI. + * webp and avif are NOT supported here — passing them causes + * "TypeError: l is not iterable" inside the edge runtime bundle. + */ +const OG_SUPPORTED_MIME_TYPES = new Set([ + 'image/png', + 'image/apng', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', +]); + +/** + * Rewrite Bluesky CDN image URLs so the CDN returns JPEG instead of WebP. + * The bsky CDN honours an `@jpeg` suffix on the blob CID. We only touch URLs + * where it's safe to append the suffix (no existing format suffix and no query). + */ +function normalizeBskyImageUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.hostname !== 'cdn.bsky.app') { + return url; + } + + // Path looks like: /img//plain//[@format] + const path = parsed.pathname; + if (path.includes('@')) { + return url; + } + + parsed.pathname = `${path}@jpeg`; + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Fetch a remote image and return a base64 data URI suitable for inside + * @vercel/og's ImageResponse. Returns an empty string when the image is missing, + * times out, or is in a format that @vercel/og cannot decode (e.g. webp/avif). + */ +export async function fetchImageAsDataUrl( + rawUrl: string | undefined | null, + { timeoutMs = 3000 }: { timeoutMs?: number } = {} +): Promise { + if (!rawUrl) return ''; + + const url = normalizeBskyImageUrl(rawUrl); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) return ''; + + const rawContentType = response.headers.get('content-type') || 'image/jpeg'; + const contentType = rawContentType.split(';')[0].trim().toLowerCase(); + + if (!OG_SUPPORTED_MIME_TYPES.has(contentType)) { + // Silently drop unsupported formats (webp/avif/etc.). Including them would + // blow up image generation with "l is not iterable" inside @vercel/og. + console.warn( + `[og-image] Skipping image with unsupported content-type "${contentType}" (${url})` + ); + return ''; + } + + const buffer = await response.arrayBuffer(); + const base64 = Buffer.from(buffer).toString('base64'); + return `data:${contentType};base64,${base64}`; + } catch (error) { + console.error('[og-image] Failed to fetch image:', url, error); + return ''; + } finally { + clearTimeout(timeoutId); + } +}