feat: add ogImagePlugin to build and upload og images (#213)

This commit is contained in:
woolen-sheep
2026-01-10 22:49:39 +08:00
committed by GitHub
parent e795a998e4
commit 0261e06f8d
20 changed files with 816 additions and 12 deletions

View File

@@ -20,7 +20,11 @@ src/core/
│ └── exif.ts # EXIF 数据提取
├── photo/ # 照片处理
│ ├── info-extractor.ts # 照片信息提取
── processor.ts # 照片处理主逻辑
── processor.ts # 照片处理主逻辑
├── plugins/ # 插件系统
│ ├── og-image-storage/ # OG 图片存储插件
│ │ └── index.ts # 生成并上传 OG 图片
│ │ └── README.md # 使用说明与集成示例
│ └── geocoding.ts # 反向地理编码
├── manifest/ # Manifest 管理
│ └── manager.ts # Manifest 读写和管理

View File

@@ -13,6 +13,8 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@afilmory/og-renderer": "workspace:*",
"@resvg/resvg-js": "2.6.2",
"@vingle/bmp-js": "^0.2.5",
"blurhash": "2.0.5",
"c12": "^3.3.3",
@@ -23,6 +25,7 @@
"fast-xml-parser": "5.3.3",
"heic-convert": "2.1.0",
"heic-to": "1.3.0",
"satori": "catalog:",
"sharp": "0.34.5",
"thumbhash": "0.1.1"
},

View File

@@ -17,6 +17,8 @@ export type { GeocodingPluginOptions } from './plugins/geocoding.js'
export { default as geocodingPlugin } from './plugins/geocoding.js'
export type { GitHubRepoSyncPluginOptions } from './plugins/github-repo-sync.js'
export { createGitHubRepoSyncPlugin, default as githubRepoSyncPlugin } from './plugins/github-repo-sync.js'
export type { OgImagePluginOptions } from './plugins/og-image-storage/index.js'
export { default as ogImagePlugin } from './plugins/og-image-storage/index.js'
export type { B2StoragePluginOptions } from './plugins/storage/b2.js'
export { default as b2StoragePlugin } from './plugins/storage/b2.js'
export type { EagleStoragePluginOptions } from './plugins/storage/eagle.js'

View File

@@ -0,0 +1,109 @@
# OG Image Storage Plugin
This plugin renders Open Graph (OG) images for each processed photo and uploads them to remote storage.
## Examples
### Plugin usage
```ts
import { defineBuilderConfig, thumbnailStoragePlugin, ogImagePlugin } from '@afilmory/builder'
export default defineBuilderConfig(() => ({
storage: {
provider: 's3',
bucket: process.env.S3_BUCKET_NAME!,
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT, // Optional, defaults to AWS S3
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
prefix: process.env.S3_PREFIX || 'photos/',
customDomain: process.env.S3_CUSTOM_DOMAIN, // Optional CDN domain
excludeRegex: process.env.S3_EXCLUDE_REGEX,
downloadConcurrency: 16, // Adjust based on network
},
plugins: [
thumbnailStoragePlugin(),
ogImagePlugin({
siteName: "YOUR_SITE_NAME",
accentColor: '#fb7185',
}),
],
}))
```
### Example Cloudflare Pages Middleware
If you are using Cloudflare Pages to host your Afilmory, you can use the following minimal middleware to rewrite the OG image meta tags on photo pages to point to the generated OG images stored in your storage.
You only need to change the `SITE_ORIGIN` and `OG_PATH` constants to match your site, and put the file in your `functions/_middleware.js` path.
```js
// Minimal middleware to rewrite OG image meta tags for photo pages on Cloudflare Pages.
// Assumes OG images are stored at https://your.afilmory.site/.afilmory/og-images/{slug}.png
const SITE_ORIGIN = 'https://your.afilmory.site'
const OG_PATH = '/.afilmory/og-images'
const OG_PATTERN = /^https?:\/\/your\.afilmory\.site\/+og-image.*\.png$/i
const normalizeSlug = (slug) => {
try {
return encodeURIComponent(decodeURIComponent(slug))
} catch {
return encodeURIComponent(slug)
}
}
const buildOgUrl = (slug) => `${SITE_ORIGIN}${OG_PATH}/${normalizeSlug(slug)}.png`
const stripPhotosPrefix = (pathname) => pathname.replace(/^\/?photos\//, '').replace(/\/$/, '')
const isHtml = (response) => (response.headers.get('content-type') || '').includes('text/html')
const shouldRewrite = (content) => Boolean(content?.trim() && OG_PATTERN.test(content.trim()))
export const onRequest = async ({ request, next }) => {
const url = new URL(request.url)
if (!url.pathname.startsWith('/photos/')) return next()
const slug = stripPhotosPrefix(url.pathname)
if (!slug) return next()
const response = await next()
if (!isHtml(response)) return response
const ogUrl = buildOgUrl(slug)
const handler = {
element(element) {
const content = element.getAttribute('content')
if (shouldRewrite(content)) element.setAttribute('content', ogUrl)
},
}
return new HTMLRewriter()
.on('meta[property="og:image"]', handler)
.on('meta[property="twitter:image"]', handler)
.transform(response)
}
```
## How it works
- Hooks into the builder after each photo is processed.
- Loads site branding from `config.json` (or provided path) and falls back to simple defaults.
- Reuses the generated thumbnail (buffer or URL) as the image source, avoiding extra reads of the original file.
- Injects light EXIF/context (title, date, focal length, aperture, ISO, shutter speed, camera) into the card.
- Renders the card with `@afilmory/og-renderer` (Satori + resvg) using bundled fonts, then uploads the PNG to storage.
- Caches uploads and public URLs within a single run so repeated work is skipped.
## Configuration
- `enable` (boolean): turn the plugin on/off. Defaults to `true`.
- `directory` (string): remote path prefix. Defaults to `.afilmory/og-images`.
- `storageConfig` (storage config): optional override; otherwise uses the builder's current storage.
- `contentType` (string): MIME type for uploads. Defaults to `image/png`.
- `siteName` / `accentColor` (strings): optional overrides for branding.
- `siteConfigPath` (string): path to a site config JSON; defaults to `config.json` in `process.cwd()`.
## Dependencies
- Uses fonts from `be/apps/core/src/modules/content/og/assets` (falls back to other repo paths). If fonts are missing, the plugin skips rendering for that run.
- Relies on the thumbnail storage plugin to provide in-memory thumbnail data when available; otherwise it will read a local/public or remote thumbnail URL.
## Notes
- Unsupported storage provider: `eagle` (the plugin disables itself in this case).
- Remote keys are cached; forcing (`isForceMode` or `isForceManifest`) re-renders and re-uploads.
- OG URLs are attached to `item.ogImageUrl` on the manifest entries.

View File

@@ -0,0 +1,456 @@
import { readFile, stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { ExifInfo } from '@afilmory/og-renderer'
import { renderOgImage } from '@afilmory/og-renderer'
import type { SatoriOptions } from 'satori'
import type { Logger } from '../../logger/index.js'
import { workdir } from '../../path.js'
import { StorageManager } from '../../storage/index.js'
import type { S3CompatibleConfig, StorageConfig } from '../../storage/interfaces.js'
import type { PhotoManifestItem } from '../../types/photo.js'
import type { ThumbnailPluginData } from '../thumbnail-storage/shared.js'
import { THUMBNAIL_PLUGIN_DATA_KEY } from '../thumbnail-storage/shared.js'
import type { BuilderPlugin } from '../types.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '../../../../..')
const ogAssetsDir = path.join(repoRoot, 'be/apps/core/src/modules/content/og/assets')
const PLUGIN_NAME = 'afilmory:og-image'
const RUN_STATE_KEY = 'state'
const DEFAULT_DIRECTORY = '.afilmory/og-images'
const DEFAULT_CONTENT_TYPE = 'image/png'
const DEFAULT_SITE_NAME = 'Photo Gallery'
const DEFAULT_ACCENT_COLOR = '#007bff'
type UploadableStorageConfig = Exclude<StorageConfig, { provider: 'eagle' }>
interface OgImagePluginOptions {
enable?: boolean
directory?: string
storageConfig?: UploadableStorageConfig
contentType?: string
siteName?: string
accentColor?: string
siteConfigPath?: string
}
interface ResolvedPluginConfig {
directory: string
remotePrefix: string
contentType: string
useDefaultStorage: boolean
storageConfig: UploadableStorageConfig | null
enabled: boolean
}
interface SiteMeta {
siteName: string
accentColor?: string
}
interface PluginRunState {
uploaded: Set<string>
urlCache: Map<string, string>
fonts?: SatoriOptions['fonts'] | null
siteMeta?: SiteMeta
}
function normalizeDirectory(directory: string | undefined): string {
const value = directory?.trim() || DEFAULT_DIRECTORY
const normalized = value.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
return normalized || DEFAULT_DIRECTORY
}
function trimSlashes(value: string | undefined | null): string | null {
if (!value) return null
const normalized = value.replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, '')
return normalized.length > 0 ? normalized : null
}
function joinSegments(...segments: Array<string | null | undefined>): string {
const filtered = segments
.map((segment) => (segment ?? '').replaceAll('\\', '/').replaceAll(/^\/+|\/+$/g, ''))
.filter((segment) => segment.length > 0)
return filtered.join('/')
}
function resolveRemotePrefix(config: UploadableStorageConfig, directory: string): string {
switch (config.provider) {
case 's3':
case 'oss':
case 'cos': {
const base = trimSlashes((config as S3CompatibleConfig).prefix)
return joinSegments(base, directory)
}
default: {
return joinSegments(directory)
}
}
}
/**
* Get or initialize per-run caches to dedupe uploads and URL lookups.
*/
function getOrCreateRunState(container: Map<string, unknown>): PluginRunState {
let state = container.get(RUN_STATE_KEY) as PluginRunState | undefined
if (!state) {
state = {
uploaded: new Set<string>(),
urlCache: new Map<string, string>(),
fonts: null,
}
container.set(RUN_STATE_KEY, state)
}
return state
}
async function loadFontFile(fileName: string): Promise<Buffer | null> {
const candidates = [
path.join(ogAssetsDir, fileName),
path.join(repoRoot, 'apps/core/src/modules/content/og/assets', fileName),
path.join(repoRoot, 'core/src/modules/content/og/assets', fileName),
]
for (const candidate of candidates) {
const stats = await stat(candidate).catch(() => null)
if (stats?.isFile()) {
return await readFile(candidate)
}
}
return null
}
/**
* Load required fonts for Satori/resvg. Missing fonts cause the plugin to skip rendering.
*/
async function loadFonts(logger: Logger): Promise<SatoriOptions['fonts'] | null> {
const geist = await loadFontFile('Geist-Medium.ttf')
const harmony = await loadFontFile('HarmonyOS_Sans_SC_Medium.ttf')
if (!geist || !harmony) {
logger.main.warn('OG image plugin: fonts not found, skip rendering for this run.')
return null
}
return [
{
name: 'Geist',
data: geist,
style: 'normal',
weight: 400,
},
{
name: 'HarmonyOS Sans SC',
data: harmony,
style: 'normal',
weight: 400,
},
]
}
/**
* Resolve site branding from a JSON config file, with sane fallbacks when the file is absent.
*/
async function loadSiteMeta(options: OgImagePluginOptions, logger: Logger): Promise<SiteMeta> {
const fallback: SiteMeta = {
siteName: options.siteName?.trim() || DEFAULT_SITE_NAME,
accentColor: options.accentColor?.trim() || DEFAULT_ACCENT_COLOR,
}
const siteConfigPath = options.siteConfigPath
? path.resolve(process.cwd(), options.siteConfigPath)
: path.resolve(process.cwd(), 'config.json')
try {
const raw = await readFile(siteConfigPath, 'utf8')
const parsed = JSON.parse(raw) as Partial<{ name: string; title: string; accentColor: string }>
return {
siteName: parsed.name?.trim() || parsed.title?.trim() || fallback.siteName,
accentColor: parsed.accentColor?.trim() || fallback.accentColor,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.main.info(`OG image plugin: using fallback site meta (${siteConfigPath} not readable: ${message}).`)
return fallback
}
}
function bufferToDataUrl(buffer: Buffer, contentType: string): string {
const base64 = buffer.toString('base64')
return `data:${contentType};base64,${base64}`
}
function guessContentType(thumbnailUrl: string): string {
const lowered = thumbnailUrl.toLowerCase()
if (lowered.endsWith('.png')) return 'image/png'
if (lowered.endsWith('.webp')) return 'image/webp'
return 'image/jpeg'
}
async function resolveThumbnailDataUrl(
item: PhotoManifestItem,
pluginData: ThumbnailPluginData | undefined,
logger: Logger,
): Promise<string | null> {
// Prefer the in-memory thumbnail to avoid extra reads; fall back to URLs when needed.
if (pluginData?.buffer) {
return bufferToDataUrl(pluginData.buffer, 'image/jpeg')
}
const thumbnailUrl = pluginData?.localUrl || item.thumbnailUrl
if (!thumbnailUrl) return null
const contentType = guessContentType(thumbnailUrl)
if (/^https?:\/\//i.test(thumbnailUrl)) {
try {
const response = await fetch(thumbnailUrl)
if (response.ok) {
const arrayBuffer = await response.arrayBuffer()
return bufferToDataUrl(Buffer.from(arrayBuffer), response.headers.get('content-type') ?? contentType)
}
} catch (error) {
logger.thumbnail?.warn?.(`OG image plugin: failed to fetch remote thumbnail ${thumbnailUrl}`, error)
}
}
const normalized = thumbnailUrl.replace(/^\/+/, '')
const localPath = path.join(workdir, 'public', normalized)
try {
const localBuffer = await readFile(localPath)
return bufferToDataUrl(localBuffer, contentType)
} catch (error) {
logger.thumbnail?.debug?.(`OG image plugin: could not read local thumbnail ${localPath}`, error)
return null
}
}
function formatDate(input?: string | null): string | undefined {
if (!input) {
return undefined
}
const timestamp = Date.parse(input)
if (Number.isNaN(timestamp)) {
return undefined
}
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
/**
* Build a lightweight EXIF summary for display; returns null when nothing meaningful is present.
*/
function buildExifInfo(photo: PhotoManifestItem): ExifInfo | null {
const { exif } = photo
if (!exif) {
return null
}
const focalLength = exif.FocalLengthIn35mmFormat || exif.FocalLength
const aperture = exif.FNumber ? `f/${exif.FNumber}` : null
const iso = exif.ISO ?? null
const shutterSpeed = exif.ExposureTime ? `${exif.ExposureTime}s` : null
const camera =
exif.Make && exif.Model ? `${exif.Make.trim()} ${exif.Model.trim()}`.trim() : (exif.Model ?? exif.Make ?? null)
if (!focalLength && !aperture && !iso && !shutterSpeed && !camera) {
return null
}
return {
focalLength: focalLength ?? null,
aperture,
iso,
shutterSpeed,
camera,
}
}
function getPhotoDimensions(photo: PhotoManifestItem) {
return {
width: photo.width || 1,
height: photo.height || 1,
}
}
/**
* Render Open Graph images for processed photos and upload them to remote storage.
*
* The plugin reuses generated thumbnails as the image source, injects light EXIF
* metadata, and caches uploads/URLs during a single builder run to reduce storage
* churn.
*/
export default function ogImagePlugin(options: OgImagePluginOptions = {}): BuilderPlugin {
let resolved: ResolvedPluginConfig | null = null
let externalStorageManager: StorageManager | null = null
return {
name: PLUGIN_NAME,
hooks: {
onInit: ({ builder, config, logger }) => {
const enable = options.enable ?? true
const directory = normalizeDirectory(options.directory)
const contentType = options.contentType ?? DEFAULT_CONTENT_TYPE
if (!enable) {
resolved = {
directory,
remotePrefix: '',
contentType,
useDefaultStorage: true,
storageConfig: null,
enabled: false,
}
return
}
const fallbackStorage = config.user?.storage ?? builder.getStorageConfig()
const storageConfig = (options.storageConfig ?? fallbackStorage) as StorageConfig
if (storageConfig.provider === 'eagle') {
logger.main.warn('OG image plugin does not support Eagle storage provider; plugin disabled.')
resolved = {
directory,
remotePrefix: '',
contentType,
useDefaultStorage: !options.storageConfig,
storageConfig: null,
enabled: false,
}
return
}
const uploadableConfig = storageConfig as UploadableStorageConfig
const remotePrefix = resolveRemotePrefix(uploadableConfig, directory)
resolved = {
directory,
remotePrefix,
contentType,
useDefaultStorage: !options.storageConfig,
storageConfig: uploadableConfig,
enabled: true,
}
if (!options.storageConfig) {
builder.getStorageManager().addExcludePrefix(remotePrefix)
} else {
externalStorageManager = new StorageManager(uploadableConfig)
}
},
afterPhotoProcess: async ({ builder, payload, runShared, logger }) => {
if (!resolved || !resolved.enabled) {
return
}
const { item, type } = payload.result
if (!item) {
return
}
const storageManager = resolved.useDefaultStorage ? builder.getStorageManager() : externalStorageManager
if (!storageManager) {
logger.main.warn('OG image plugin could not resolve storage manager. Skipping upload.')
return
}
const state = getOrCreateRunState(runShared)
if (!state.siteMeta) {
state.siteMeta = await loadSiteMeta(options, logger)
}
if (!state.fonts) {
state.fonts = await loadFonts(logger)
}
const { fonts } = state
if (!fonts) {
return
}
const shouldRender = type !== 'skipped' || payload.options.isForceMode || payload.options.isForceManifest
const remoteKey = joinSegments(resolved.remotePrefix, `${item.id}.png`)
if (!shouldRender) {
try {
const remoteUrl = await storageManager.generatePublicUrl(remoteKey)
state.urlCache.set(remoteKey, remoteUrl)
item.ogImageUrl = remoteUrl
} catch (error) {
logger.main.info(`OG image plugin: skipped rendering and could not resolve URL for ${remoteKey}.`, error)
}
return
}
const thumbnailData = payload.context.pluginData[THUMBNAIL_PLUGIN_DATA_KEY] as ThumbnailPluginData | undefined
const thumbnailSrc = await resolveThumbnailDataUrl(item, thumbnailData, logger)
const exifInfo = buildExifInfo(item)
const formattedDate = formatDate(item.exif?.DateTimeOriginal ?? item.lastModified)
try {
const png = await renderOgImage({
template: {
photoTitle: item.title || item.id || 'Untitled Photo',
siteName: state.siteMeta.siteName || DEFAULT_SITE_NAME,
tags: (item.tags ?? []).slice(0, 3),
formattedDate,
exifInfo,
thumbnailSrc,
photoDimensions: getPhotoDimensions(item),
accentColor: state.siteMeta.accentColor ?? DEFAULT_ACCENT_COLOR,
},
fonts,
})
const stateForUpload = state
if (
!stateForUpload.uploaded.has(remoteKey) ||
payload.options.isForceMode ||
payload.options.isForceManifest
) {
try {
await storageManager.uploadFile(remoteKey, Buffer.from(png), {
contentType: resolved.contentType,
})
stateForUpload.uploaded.add(remoteKey)
} catch (error) {
logger.main.error(`OG image plugin: failed to upload ${remoteKey}`, error)
return
}
}
let remoteUrl = stateForUpload.urlCache.get(remoteKey)
if (!remoteUrl) {
try {
remoteUrl = await storageManager.generatePublicUrl(remoteKey)
stateForUpload.urlCache.set(remoteKey, remoteUrl)
} catch (error) {
logger.main.error(`OG image plugin: failed to generate URL for ${remoteKey}`, error)
return
}
}
item.ogImageUrl = remoteUrl
} catch (error) {
logger.main.error(`OG image plugin: failed to render OG image for ${item.id}`, error)
}
},
},
}
}
export type { OgImagePluginOptions }

View File

@@ -60,6 +60,7 @@ export interface PhotoManifestItem extends PhotoInfo {
originalUrl: string
format: string
thumbnailUrl: string
ogImageUrl?: string | null
thumbHash: string | null
width: number
height: number

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
@@ -15,12 +19,22 @@
"skipDefaultLibCheck": true,
"noImplicitAny": false,
"noEmit": true,
"jsx": "preserve",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"paths": {
"@afilmory/builder/*": ["./src/*"],
"@pkg": ["./package.json"],
"@env": ["../../env.ts"]
"@afilmory/builder/*": [
"./src/*"
],
"@pkg": [
"./package.json"
],
"@env": [
"../../env.ts"
],
}
},
"include": ["./src/**/*", "./scripts/**/*"]
"include": [
"./src/**/*",
"./scripts/**/*"
]
}

View File

@@ -0,0 +1,15 @@
{
"name": "@afilmory/og-renderer",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./renderer": "./src/og.renderer.tsx",
"./template": "./src/og.template.tsx"
},
"devDependencies": {
"@resvg/resvg-js": "2.6.2",
"hono": "4.11.1",
"satori": "catalog:"
}
}

View File

@@ -0,0 +1,3 @@
export { renderHomepageOgImage, renderOgImage } from './og/og.renderer'
export type { ExifInfo, HomepageOgTemplateProps, OgTemplateProps, PhotoDimensions } from './og/og.template'
export { HomepageOgTemplate, OgTemplate } from './og/og.template'

View File

@@ -0,0 +1,61 @@
/** @jsxImportSource hono/jsx */
/** @jsxRuntime automatic */
import { Buffer } from 'node:buffer'
import { Resvg } from '@resvg/resvg-js'
import type { SatoriOptions } from 'satori'
import satori from 'satori'
import type { HomepageOgTemplateProps, OgTemplateProps } from './og.template'
import { HomepageOgTemplate, OgTemplate } from './og.template'
import { get_icon_code, load_emoji } from './tweemoji'
interface RenderOgImageOptions {
template: OgTemplateProps
fonts: SatoriOptions['fonts']
}
export async function renderOgImage({ template, fonts }: RenderOgImageOptions): Promise<Uint8Array> {
const svg = await satori(<OgTemplate {...template} />, {
width: 1200,
height: 628,
fonts,
embedFont: true,
})
const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg)
const renderer = new Resvg(svgInput, {
fitTo: { mode: 'width', value: 1200 },
background: 'rgba(0,0,0,0)',
})
return renderer.render().asPng()
}
interface RenderHomepageOgImageOptions {
template: HomepageOgTemplateProps
fonts: SatoriOptions['fonts']
}
export async function renderHomepageOgImage({ template, fonts }: RenderHomepageOgImageOptions): Promise<Uint8Array> {
const svg = await satori(<HomepageOgTemplate {...template} />, {
width: 1200,
height: 628,
fonts,
embedFont: true,
async loadAdditionalAsset(code, segment) {
if (code === 'emoji' && segment) {
return `data:image/svg+xml;base64,${btoa(await load_emoji(get_icon_code(segment)))}`
}
return ''
},
})
const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg)
const renderer = new Resvg(svgInput, {
fitTo: { mode: 'width', value: 1200 },
background: 'rgba(0,0,0,0)',
})
return renderer.render().asPng()
}

View File

@@ -0,0 +1,888 @@
/** @jsxImportSource hono/jsx */
/** @jsxRuntime automatic */
export interface PhotoDimensions {
width: number
height: number
}
export interface ExifInfo {
focalLength?: string | null
aperture?: string | null
iso?: string | number | null
shutterSpeed?: string | null
camera?: string | null
}
export interface OgTemplateProps {
photoTitle: string
siteName: string
tags: string[]
formattedDate?: string
exifInfo?: ExifInfo | null
thumbnailSrc?: string | null
photoDimensions: PhotoDimensions
accentColor?: string
}
const CANVAS = { width: 1200, height: 628 }
type LayoutType = 'portrait' | 'square' | 'landscape' | 'wide'
interface LayoutConfig {
type: LayoutType
arrangement: 'split' | 'stack' | 'wide'
padding: number
gap: number
photoBox: { maxWidth: number; maxHeight: number }
infoCompact: boolean
photoFit: 'cover' | 'contain'
}
interface LayoutPieces {
gap: number
photo: any
info: any
photoWidth: number
}
export function OgTemplate({
photoTitle,
siteName,
tags,
formattedDate,
exifInfo,
thumbnailSrc,
photoDimensions,
accentColor = '#007bff',
}: OgTemplateProps) {
const width = Number.isFinite(photoDimensions.width) && photoDimensions.width > 0 ? photoDimensions.width : 1
const height = Number.isFinite(photoDimensions.height) && photoDimensions.height > 0 ? photoDimensions.height : 1
const photoAspect = width / height
const layout = determineLayout(photoAspect)
const photoSize = fitWithinBox(photoAspect, layout.photoBox)
const exifItems = buildExifItems(exifInfo)
const photo = (
<PhotoFrame width={photoSize.width} height={photoSize.height} fit={layout.photoFit} src={thumbnailSrc} />
)
const info = (
<InfoPanel
title={photoTitle || 'Untitled Photo'}
tags={tags}
exifItems={exifItems}
camera={exifInfo?.camera ?? null}
formattedDate={formattedDate}
accentColor={accentColor}
compact={layout.infoCompact}
/>
)
const layoutComponent =
layout.arrangement === 'wide' ? WideLayout : layout.arrangement === 'stack' ? StackLayout : SplitLayout
return (
<BaseCanvas padding={layout.padding} siteName={siteName}>
{layoutComponent({ gap: layout.gap, photo, info, photoWidth: photoSize.width })}
</BaseCanvas>
)
}
const ogAspect = CANVAS.width / CANVAS.height
function determineLayout(aspect: number): LayoutConfig {
let finalAspect = aspect
if (!Number.isFinite(finalAspect) || finalAspect <= 0) {
finalAspect = 1
}
if (finalAspect < 0.9) {
const padding = 60
return {
type: 'portrait',
arrangement: 'split',
padding,
gap: 44,
photoBox: {
maxWidth: CANVAS.width * 0.44,
maxHeight: CANVAS.height - padding * 2,
},
infoCompact: false,
photoFit: 'cover',
}
}
if (finalAspect <= 1.1) {
const padding = 60
return {
type: 'square',
arrangement: 'split',
padding,
gap: 44,
photoBox: {
maxWidth: CANVAS.width * 0.5,
maxHeight: CANVAS.height - padding * 2,
},
infoCompact: false,
photoFit: 'cover',
}
}
if (finalAspect >= 2.35) {
const padding = 50
return {
type: 'wide',
arrangement: 'wide',
padding,
gap: 28,
photoBox: {
maxWidth: CANVAS.width - padding * 2,
maxHeight: 340,
},
infoCompact: true,
photoFit: 'contain',
}
}
const padding = 54
const landscapeArrangement = finalAspect / ogAspect <= 0.82 ? 'split' : 'stack'
return {
type: 'landscape',
arrangement: landscapeArrangement,
padding,
gap: 26,
photoBox: {
maxWidth: CANVAS.width - padding * 2,
maxHeight: 410,
},
infoCompact: false,
photoFit: 'cover',
}
}
function fitWithinBox(aspect: number, { maxWidth, maxHeight }: LayoutConfig['photoBox']) {
let width = maxWidth
let height = width / aspect
if (height > maxHeight) {
height = maxHeight
width = height * aspect
}
return { width, height }
}
function buildExifItems(exifInfo?: ExifInfo | null) {
const items: Array<{ label: string; text: string }> = []
if (exifInfo?.aperture) items.push({ label: 'f', text: exifInfo.aperture })
if (exifInfo?.shutterSpeed) items.push({ label: 's', text: exifInfo.shutterSpeed })
if (exifInfo?.iso) items.push({ label: 'iso', text: `${exifInfo.iso}` })
if (exifInfo?.focalLength) items.push({ label: 'mm', text: exifInfo.focalLength })
return items
}
interface BaseCanvasProps {
padding: number
siteName: string
children: any
}
function BaseCanvas({ padding, siteName, children }: BaseCanvasProps) {
return (
<div
style={{
width: '100%',
height: '100%',
padding: `${padding}px`,
position: 'relative',
background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)',
fontFamily: 'Geist, HarmonyOS Sans SC, system-ui, -apple-system, sans-serif',
display: 'flex',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0.02,
background: `
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(0deg, rgba(255,255,255,0.1) 1px, transparent 1px)
`,
backgroundSize: '48px 48px',
display: 'flex',
}}
/>
<div
style={{
position: 'absolute',
bottom: `32px`,
right: `32px`,
fontSize: '20px',
fontWeight: '500',
color: 'rgba(255,255,255,0.68)',
letterSpacing: '0.5px',
display: 'flex',
}}
>
{siteName} · Afilmory
</div>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
}}
>
{children}
</div>
</div>
)
}
function SplitLayout({ gap, photo, info }: LayoutPieces) {
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: `${gap}px`,
width: '100%',
height: '100%',
}}
>
<div style={{ display: 'flex', flexShrink: 0 }}>{photo}</div>
<div
style={{
display: 'flex',
alignItems: 'center',
flex: 1,
height: '100%',
}}
>
{info}
</div>
</div>
)
}
function StackLayout({ gap, photo, info, photoWidth }: LayoutPieces) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: `${gap}px`,
width: '100%',
height: '100%',
justifyContent: 'flex-start',
alignItems: 'center',
}}
>
<div style={{ display: 'flex', justifyContent: 'center', flexShrink: 0 }}>{photo}</div>
<div
style={{
width: '100%',
maxWidth: `${photoWidth}px`,
display: 'flex',
flexShrink: 0,
}}
>
{info}
</div>
</div>
)
}
function WideLayout({ gap, photo, info, photoWidth }: LayoutPieces) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: `${gap}px`,
width: '100%',
height: '100%',
justifyContent: 'flex-start',
}}
>
<div style={{ display: 'flex', justifyContent: 'center', flexShrink: 0 }}>{photo}</div>
<div
style={{
width: '100%',
maxWidth: `${photoWidth}px`,
margin: '0 auto',
display: 'flex',
flexShrink: 0,
}}
>
{info}
</div>
</div>
)
}
interface PhotoFrameProps {
width: number
height: number
fit: 'cover' | 'contain'
src?: string | null
}
function PhotoFrame({ width, height, fit, src }: PhotoFrameProps) {
if (!src) {
return (
<div
style={{
width: `${width}px`,
height: `${height}px`,
borderRadius: '10px',
backgroundColor: '#050505',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
color: 'rgba(255,255,255,0.35)',
fontSize: '14px',
letterSpacing: '0.3px',
}}
>
No Preview
</span>
</div>
)
}
return (
<div
style={{
width: `${width}px`,
height: `${height}px`,
position: 'relative',
borderRadius: '10px',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05)',
backgroundColor: '#050505',
display: 'flex',
}}
>
<img
src={src}
style={{
width: '100%',
height: '100%',
objectFit: fit,
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%)',
display: 'flex',
}}
/>
</div>
)
}
interface InfoPanelProps {
title: string
tags: string[]
exifItems: Array<{ label: string; text: string }>
camera: string | null
formattedDate?: string
accentColor: string
compact: boolean
}
function InfoPanel({ title, tags, exifItems, camera, formattedDate, accentColor, compact }: InfoPanelProps) {
const tagLimit = compact ? 2 : 3
const fontScale = compact ? 0.8 : 1
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: compact ? '12px' : '16px',
color: '#ffffff',
}}
>
<h1
style={{
margin: 0,
fontSize: `${compact ? 28 : 40}px`,
fontWeight: 700,
letterSpacing: '-0.5px',
lineHeight: 1.25,
}}
>
{title}
</h1>
{tags.length > 0 && (
<div
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
{tags.slice(0, tagLimit).map((tag) => (
<div
key={tag}
style={{
display: 'flex',
alignItems: 'center',
fontSize: `${13 * fontScale}px`,
color: 'rgba(255,255,255,0.9)',
backgroundColor: 'rgba(255,255,255,0.12)',
padding: `${compact ? 4 : 6}px ${compact ? 12 : 14}px`,
borderRadius: '16px',
border: '1px solid rgba(255,255,255,0.15)',
letterSpacing: '0.2px',
}}
>
#{tag}
</div>
))}
</div>
)}
{camera && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'rgba(255,255,255,0.7)',
fontSize: `${15 * fontScale}px`,
}}
>
<span
style={{
fontSize: `${11 * fontScale}px`,
color: 'rgba(255,255,255,0.45)',
letterSpacing: '0.3px',
textTransform: 'uppercase',
}}
>
cam
</span>
<span>{camera}</span>
</div>
)}
{exifItems.length > 0 && (
<div
style={{
display: 'flex',
gap: `${compact ? 12 : 18}px`,
color: 'rgba(255,255,255,0.75)',
fontSize: `${14 * fontScale}px`,
flexWrap: 'wrap',
}}
>
{exifItems.map((item) => (
<div key={`${item.label}-${item.text}`} style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<span
style={{
fontSize: `${10 * fontScale}px`,
color: 'rgba(255,255,255,0.35)',
letterSpacing: '0.2px',
textTransform: 'uppercase',
}}
>
{item.label}
</span>
<span>{item.text}</span>
</div>
))}
</div>
)}
{formattedDate && (
<div
style={{
color: 'rgba(255,255,255,0.45)',
fontSize: `${13 * fontScale}px`,
marginTop: compact ? '2px' : '6px',
display: 'flex',
}}
>
{formattedDate}
</div>
)}
<div
style={{
width: compact ? '50px' : '80px',
height: '3px',
background: accentColor,
borderRadius: '2px',
}}
/>
</div>
)
}
// ==================== Homepage OG Template ====================
export interface HomepageOgTemplateProps {
siteName: string
siteDescription?: string | null
authorAvatar?: string | null
accentColor?: string
stats: {
totalPhotos: number
uniqueTags: number
uniqueCameras: number
totalSizeGB?: number | null
}
featuredPhotos?: Array<{ thumbnailSrc: string | null }> | null
}
export function HomepageOgTemplate({
siteName,
siteDescription,
authorAvatar,
accentColor = '#007bff',
stats,
featuredPhotos,
}: HomepageOgTemplateProps) {
const hasAvatar = !!authorAvatar
const hasFeaturedPhotos = featuredPhotos && featuredPhotos.length > 0
return (
<BaseCanvas padding={0} siteName={siteName}>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
width: '100%',
height: '100%',
position: 'relative',
}}
>
{/* Background photo grid - rendered first so content overlays it */}
{hasFeaturedPhotos && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
gap: 0,
opacity: 0.2,
}}
>
{/* First row */}
<div
style={{
display: 'flex',
flexDirection: 'row',
gap: 0,
flex: 1,
}}
>
{featuredPhotos.slice(0, 3).map((photo, index) => (
<div
key={index}
style={{
flex: 1,
borderRadius: 0,
overflow: 'hidden',
backgroundColor: '#050505',
position: 'relative',
display: 'flex',
}}
>
{photo.thumbnailSrc && (
<img
src={photo.thumbnailSrc}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(0,0,0,0.3) 0%, transparent 50%)',
}}
/>
</div>
))}
</div>
{/* Second row */}
<div
style={{
display: 'flex',
flexDirection: 'row',
gap: 0,
flex: 1,
}}
>
{featuredPhotos.slice(3, 6).map((photo, index) => (
<div
key={index + 3}
style={{
flex: 1,
borderRadius: 0,
overflow: 'hidden',
backgroundColor: '#050505',
position: 'relative',
display: 'flex',
}}
>
{photo.thumbnailSrc && (
<img
src={photo.thumbnailSrc}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(0,0,0,0.3) 0%, transparent 50%)',
}}
/>
</div>
))}
</div>
</div>
)}
{/* Dark overlay for text readability */}
{hasFeaturedPhotos && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(0,0,0,0.5) 0%, rgba(0,0,0,0.3) 50%, rgba(0,0,0,0.5) 100%)',
}}
/>
)}
{/* Content layer */}
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: hasAvatar ? '48px' : '0',
width: '100%',
height: '100%',
position: 'relative',
padding: '60px',
}}
>
{hasAvatar && (
<div
style={{
display: 'flex',
flexShrink: 0,
}}
>
<div
style={{
width: '180px',
height: '180px',
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05)',
backgroundColor: '#050505',
display: 'flex',
position: 'relative',
}}
>
<img
src={authorAvatar}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, transparent 50%)',
display: 'flex',
}}
/>
</div>
</div>
)}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '24px',
flex: 1,
height: '100%',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<h1
style={{
margin: 0,
fontSize: '56px',
fontWeight: 700,
letterSpacing: '-1px',
lineHeight: 1.2,
color: '#ffffff',
}}
>
{siteName}
</h1>
{siteDescription && (
<p
style={{
margin: 0,
fontSize: '22px',
fontWeight: 400,
lineHeight: 1.4,
color: 'rgba(255,255,255,0.75)',
maxWidth: '700px',
}}
>
{siteDescription}
</p>
)}
</div>
<div
style={{
display: 'flex',
gap: '32px',
flexWrap: 'wrap',
marginTop: '8px',
}}
>
<StatItem icon="📸" label="Photos" value={formatNumber(stats.totalPhotos)} />
<StatItem icon="🏷️" label="Tags" value={formatNumber(stats.uniqueTags)} />
<StatItem icon="📷" label="Cameras" value={formatNumber(stats.uniqueCameras)} />
{stats.totalSizeGB !== null && stats.totalSizeGB !== undefined && (
<StatItem icon="💾" label="Storage" value={`${stats.totalSizeGB.toFixed(1)} GB`} />
)}
</div>
<div
style={{
width: '120px',
height: '4px',
background: accentColor,
borderRadius: '2px',
marginTop: '12px',
}}
/>
</div>
</div>
</div>
</BaseCanvas>
)
}
interface StatItemProps {
icon: string
label: string
value: string
}
function StatItem({ icon, label, value }: StatItemProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'rgba(255,255,255,0.6)',
fontSize: '14px',
letterSpacing: '0.3px',
}}
>
<span>{icon}</span>
<span>{label}</span>
</div>
<div
style={{
fontSize: '28px',
fontWeight: 600,
color: '#ffffff',
letterSpacing: '-0.5px',
}}
>
{value}
</div>
</div>
)
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`
}
return num.toString()
}

View File

@@ -0,0 +1,47 @@
/* eslint-disable unicorn/prefer-code-point */
/**
* Modified version of https://github.com/vercel/satori/blob/main/playground/utils/twemoji.ts
*/
/**
* Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js.
*/
/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */
const U200D = String.fromCharCode(8205)
const UFE0Fg = /\uFE0F/g
export function get_icon_code(char: string) {
return to_code_point(!char.includes(U200D) ? char.replaceAll(UFE0Fg, '') : char)
}
function to_code_point(unicodeSurrogates: string) {
const r: string[] = []
let c = 0,
p = 0,
i = 0
while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++)
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16))
p = 0
} else if (55296 <= c && c <= 56319) {
p = c
} else {
r.push(c.toString(16))
}
}
return r.join('-')
}
const emoji_cache: Record<string, Promise<string>> = {}
export function load_emoji(code: string) {
const key = code
if (key in emoji_cache) return emoji_cache[key]
return (emoji_cache[key] = fetch(
`https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${code.toLowerCase()}.svg`,
).then((r) => r.text()))
}

View File

@@ -0,0 +1,44 @@
'use strict'
import { Buffer } from 'node:buffer'
import { Resvg } from '@resvg/resvg-js'
import { jsx } from 'hono/jsx/jsx-runtime'
import satori from 'satori'
import { HomepageOgTemplate, OgTemplate } from './og.template'
import { get_icon_code, load_emoji } from './tweemoji'
export async function renderOgImage({ template, fonts }) {
const svg = await satori(/* @__PURE__ */ jsx(OgTemplate, { ...template }), {
width: 1200,
height: 628,
fonts,
embedFont: true,
})
const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg)
const renderer = new Resvg(svgInput, {
fitTo: { mode: 'width', value: 1200 },
background: 'rgba(0,0,0,0)',
})
return renderer.render().asPng()
}
export async function renderHomepageOgImage({ template, fonts }) {
const svg = await satori(/* @__PURE__ */ jsx(HomepageOgTemplate, { ...template }), {
width: 1200,
height: 628,
fonts,
embedFont: true,
async loadAdditionalAsset(code, segment) {
if (code === 'emoji' && segment) {
return `data:image/svg+xml;base64,${btoa(await load_emoji(get_icon_code(segment)))}`
}
return ''
},
})
const svgInput = typeof svg === 'string' ? svg : Buffer.from(svg)
const renderer = new Resvg(svgInput, {
fitTo: { mode: 'width', value: 1200 },
background: 'rgba(0,0,0,0)',
})
return renderer.render().asPng()
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"skipDefaultLibCheck": true,
"noImplicitAny": false,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
"include": [
"src"
]
}