feat(og): enhance Open Graph image generation with responsive layouts and font integration

- Updated OgService to implement responsive layout strategies based on photo aspect ratios.
- Introduced new PhotoDimensions interface for simplified dimension handling.
- Enhanced OgTemplate to support dynamic site branding and improved information hierarchy.
- Integrated custom fonts for better visual presentation in generated images.
- Added README documentation for the OG image generation module, detailing design principles and usage.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-08 19:35:54 +08:00
parent caf47a4a01
commit c5883f3bdc
8 changed files with 788 additions and 619 deletions

1
.gitattributes vendored
View File

@@ -2,3 +2,4 @@ photos/*.HEIC filter=lfs diff=lfs merge=lfs -text
photos/*.MOV filter=lfs diff=lfs merge=lfs -text
photos/*.jpg filter=lfs diff=lfs merge=lfs -text
photos/**/*.{jpg,jpeg,png,gif,webp,svg,mp4,mov,avi,mkv,mp3,wav,flac} filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text

1
be/apps/core/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import 'vite/client'

View File

@@ -0,0 +1,296 @@
# OG 图片生成模块
## 设计理念
新的 OG 图片设计采用**极简主义、照片为中心、响应式布局**的设计方案,确保在社交媒体分享时照片本身是绝对的视觉焦点,并且针对不同照片比例自动选择最佳布局。
## 响应式布局策略
### 四种布局模式
根据照片的宽高比,自动选择最合适的布局模式:
#### 1. **竖版照片** (比例 < 0.85,如 3:4)
```
┌──────────────────────────────────────┐
│ [品牌] │
│ ┌──────┐ 标题文字 #tag1 │
│ │ │ 📷 相机 │
│ │ 照片 │ ⚫ f/2.8 | ⏱️ 1/250s │
│ │ │ ━━━ │
│ └──────┘ │
└──────────────────────────────────────┘
左右分栏布局 - 照片占满高度
```
#### 2. **方形照片** (比例 0.85~1.15,如 1:1)
```
┌──────────────────────────────────────┐
│ [品牌] │
│ ┌───────┐ 标题文字 #tag1 │
│ │ │ 📷 相机 │
│ │ 照片 │ ⚫ f/2.8 | ⏱️ 1/250s │
│ │ │ ━━━ │
│ └───────┘ │
└──────────────────────────────────────┘
左右分栏布局 - 均衡布局
```
#### 3. **普通横版照片** (比例 1.15~2.2,如 4:3, 3:2)
```
┌──────────────────────────────────────┐
│ [品牌] │
│ ┌──────────────┐ │
│ │ 照片 │ │
│ └──────────────┘ │
│ 标题文字 #tag1 │
│ 📷 相机 | ⚫ f/2.8 | ⏱️ 1/250s │
│ ━━━ │
└──────────────────────────────────────┘
上下堆叠布局 - 照片占满宽度
```
#### 4. **超宽横版照片** (比例 > 2.2,如 16:9, 21:9)
```
┌──────────────────────────────────────┐
│ [品牌] │
│ ┌────────────────────────┐ │
│ │ 照片 │ │
│ └────────────────────────┘ │
│ 标题 #tag1 │
│ 📷 相机 | ⚫ f/2.8 │
│ ━━━ │
└──────────────────────────────────────┘
紧凑堆叠布局 - 最大化照片展示
```
## 设计特点
### 1. 智能响应式布局
**自动布局选择**:
- ✅ 根据照片宽高比自动选择最佳布局
- ✅ 竖版/方形照片使用左右分栏,充分利用垂直空间
- ✅ 横版照片使用上下堆叠,充分展示宽度
- ✅ 超宽照片使用紧凑布局,避免过多留白
- ✅ 所有布局都保持照片原始比例,不变形裁剪
**空间利用率**:
- 竖版/方形: 照片高度占满 (510px),信息占据右侧剩余空间
- 普通横版: 照片宽度占满 (1080px),信息在底部
- 超宽横版: 照片占 70% 高度,信息栏紧凑排列
### 2. 核心改进
相比旧版设计,新版有以下改进:
#### ✅ 去除冗余装饰
- 移除了胶片孔装饰
- 移除了"FILM 400 | STUDIO CUT"等文字
- 移除了虚线框等多余视觉元素
- 移除了复杂的背景装饰图形
#### ✅ 照片为绝对主角
- 照片占据约 75% 的画布空间
- 照片保持原始比例,不强制裁剪
- 添加精致的阴影和高光效果,提升质感
- 圆角设计更现代
#### ✅ 信息层次优化
- **主要信息**: 照片标题 (42px, 粗体)
- **辅助信息**: EXIF 参数 (20px, 横排图标)
- **次要信息**: 日期 (18px, 半透明)
- **品牌元素**: 网站名称 (右上角,18px, 低透明度)
#### ✅ 智能响应式布局
- 四种布局模式自动切换(竖版分栏/方形分栏/横版堆叠/超宽紧凑)
- 根据照片比例选择最佳布局,最大化空间利用
- 所有模式都保持照片原始比例
- 信息栏根据布局自动调整尺寸和排列
- 消除不必要的留白
#### ✅ 品牌定制化
- 使用 `siteConfig.accentColor` 作为装饰条颜色
- 显示网站名称 `siteConfig.name`
- 可自定义强调色,增强品牌识别度
### 3. 技术优化
#### 简化数据结构
**旧版接口**:
```typescript
interface FrameDimensions {
frameWidth: number
frameHeight: number
imageAreaWidth: number
imageAreaHeight: number
displayWidth: number
displayHeight: number
}
```
**新版接口**:
```typescript
interface PhotoDimensions {
width: number
height: number
}
```
新版在模板内部计算显示尺寸,逻辑更清晰,service 层更简洁。
#### 模板参数优化
```typescript
interface OgTemplateProps {
photoTitle: string // 照片标题
siteName: string // 网站名称 (新增)
tags: string[] // 标签 (最多3个)
formattedDate?: string // 格式化日期
exifInfo?: ExifInfo // EXIF 信息
thumbnailSrc?: string // 缩略图 data URL
photoDimensions: PhotoDimensions // 照片原始尺寸
accentColor?: string // 品牌强调色 (新增)
}
```
### 4. 布局尺寸规范
#### 画布尺寸
- **固定**: 1200×630px (标准 OG 尺寸)
#### 竖版/方形分栏布局
| 元素 | 尺寸/值 |
| ---------- | ------------------- |
| 外边距 | 60px |
| 照片高度 | 510px (占满高度) |
| 照片宽度 | 根据比例计算 |
| 信息栏宽度 | 剩余空间 - 50px间距 |
| 标题字体 | 42px, 粗体 |
| EXIF 字体 | 18px |
| 标签字体 | 16px |
| 强调色条 | 80×3px |
#### 横版堆叠布局
| 元素 | 尺寸/值 |
| ---------- | ----------------------- |
| 外边距 | 60px |
| 照片宽度 | 1080px (占满宽度) |
| 照片高度 | 根据比例计算,最大 430px |
| 照片下边距 | 28px |
| 标题字体 | 42px, 粗体 |
| EXIF 字体 | 18px |
| 标签字体 | 16px |
| 强调色条 | 80×3px |
#### 超宽紧凑布局
| 元素 | 尺寸/值 |
| ---------- | ----------------------- |
| 外边距 | 50px |
| 照片宽度 | 1100px (占满宽度) |
| 照片高度 | 根据比例计算,最大 450px |
| 照片下边距 | 32px |
| 标题字体 | 32px, 粗体 (紧凑) |
| EXIF 字体 | 16px (紧凑) |
| 标签字体 | 14px (紧凑) |
| 强调色条 | 60×3px |
### 5. 配色方案
```css
/* 背景渐变 */
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)
/* 文字颜色 */
标题: #ffffff
相机信息: rgba(255,255,255,0.7)
EXIF 参数: rgba(255,255,255,0.8)
日期: rgba(255,255,255,0.5)
品牌标识: rgba(255,255,255,0.4)
/* 装饰元素 */
强调色条: siteConfig.accentColor (默认 #007bff)
照片阴影: 0 20px 60px rgba(0,0,0,0.5)
```
## 使用示例
OG 图片会自动为每张照片生成,访问路径:
```
GET /og/{photoId}
```
示例:
```
https://your-site.com/og/DSCF1234
```
## 性能特点
1. **缓存策略**:
- `Cache-Control: public, max-age=31536000`
- 永久缓存,适合静态内容
2. **字体加载**:
- Geist Medium (英文)
- PingFang SC (中文)
- 字体数据内联,无需网络请求
3. **缩略图处理**:
- 优先使用本地文件
- 支持多种路径候选
- 转换为 data URL 嵌入
## 布局决策逻辑
```typescript
= /
if ( < 0.85) {
(portrait-split)
,
示例: 3:4
} else if (0.85 <= <= 1.15) {
(square-split)
,
示例: 1:1 Instagram
} else if ( > 2.2) {
(wide-full)
,
示例: 16:9, 21:9
} else {
(landscape-stacked)
,
示例: 4:3, 3:2
}
```
## 设计原则总结
1. **Responsive First**: 没有一种布局适合所有照片,必须响应式适配
2. **Less is More**: 去除一切不必要的装饰元素
3. **Content First**: 照片永远是主角,最大化展示面积
4. **Information Hierarchy**: 清晰的信息层次,支持紧凑和常规两种模式
5. **No Wasted Space**: 消除留白,充分利用画布空间
6. **Brand Consistency**: 与网站设计风格一致
7. **Social Optimized**: 在各平台预览中都表现优秀

Binary file not shown.

View File

@@ -1,7 +1,8 @@
import { readFile, stat } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { PhotoManifestItem } from '@afilmory/builder'
import type { OnModuleDestroy } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import type { Context } from 'hono'
import type { SatoriOptions } from 'satori'
@@ -9,26 +10,12 @@ import { injectable } from 'tsyringe'
import { ManifestService } from '../manifest/manifest.service'
import { SiteSettingService } from '../site-setting/site-setting.service'
import GeistMedium from './assets/Geist-Medium.ttf.ts'
import PingFangSC from './assets/PingFangSC.ttf.ts'
import geistFontUrl from './assets/Geist-Medium.ttf?url'
import cjkFontUrl from './assets/SweiFistLegCJKsc-Medium-2.ttf?url'
import { renderOgImage } from './og.renderer'
import type { ExifInfo, FrameDimensions } from './og.template'
import type { ExifInfo, PhotoDimensions } from './og.template'
const CACHE_CONTROL = 'public, max-age=31536000, stale-while-revalidate=31536000'
const LOCAL_THUMBNAIL_ROOT_CANDIDATES = [
resolve(process.cwd(), 'dist/static/web'),
resolve(process.cwd(), '../dist/static/web'),
resolve(process.cwd(), '../../dist/static/web'),
resolve(process.cwd(), 'static/web'),
resolve(process.cwd(), '../static/web'),
resolve(process.cwd(), '../../static/web'),
resolve(process.cwd(), 'apps/web/dist'),
resolve(process.cwd(), '../apps/web/dist'),
resolve(process.cwd(), '../../apps/web/dist'),
resolve(process.cwd(), 'apps/web/public'),
resolve(process.cwd(), '../apps/web/public'),
resolve(process.cwd(), '../../apps/web/public'),
]
interface ThumbnailCandidateResult {
buffer: Buffer
@@ -36,15 +23,22 @@ interface ThumbnailCandidateResult {
}
@injectable()
export class OgService {
export class OgService implements OnModuleDestroy {
private fontConfig: SatoriOptions['fonts'] | null = null
private localThumbnailRoots: string[] | null = null
private fontCleanupTimer: NodeJS.Timeout | null = null
constructor(
private readonly manifestService: ManifestService,
private readonly siteSettingService: SiteSettingService,
) {}
onModuleDestroy(): void {
if (this.fontCleanupTimer) {
clearTimeout(this.fontCleanupTimer)
this.fontCleanupTimer = null
}
}
async render(context: Context, photoId: string): Promise<Response> {
const manifest = await this.manifestService.getManifest()
const photo = manifest.data.find((entry) => entry.id === photoId)
@@ -55,20 +49,20 @@ export class OgService {
const siteConfig = await this.siteSettingService.getSiteConfig()
const formattedDate = this.formatDate(photo.exif?.DateTimeOriginal ?? photo.lastModified)
const exifInfo = this.buildExifInfo(photo)
const frame = this.computeFrameDimensions(photo)
const photoDimensions = this.getPhotoDimensions(photo)
const tags = (photo.tags ?? []).slice(0, 3)
const thumbnailSrc = await this.resolveThumbnailSrc(context, photo)
const png = await renderOgImage({
template: {
photoTitle: photo.title || photo.id || 'Untitled Photo',
photoDescription: photo.description || siteConfig.name || siteConfig.title || '',
siteName: siteConfig.name || siteConfig.title || 'Photo Gallery',
tags,
formattedDate,
exifInfo,
thumbnailSrc,
frame,
photoId: photo.id,
photoDimensions,
accentColor: siteConfig.accentColor,
},
fonts: await this.getFontConfig(),
})
@@ -83,27 +77,50 @@ export class OgService {
return new Response(body, { status: 200, headers })
}
private async getFontConfig(): Promise<SatoriOptions['fonts']> {
if (this.fontConfig) {
return this.fontConfig
}
private cjkFontPromise: Promise<NonSharedBuffer> | null = null
private geistFontPromise: Promise<NonSharedBuffer> | null = null
this.fontConfig = [
loadFonts() {
if (!this.cjkFontPromise || !this.geistFontPromise) {
this.cjkFontPromise = readFile(resolve(process.cwd(), `./${cjkFontUrl}`))
this.geistFontPromise = readFile(resolve(process.cwd(), `./${geistFontUrl}`))
}
this.resetFontCleanupTimer()
}
private async getFontConfig(): Promise<SatoriOptions['fonts']> {
this.loadFonts()
return [
{
name: 'Geist',
data: this.toArrayBuffer(GeistMedium),
data: await this.geistFontPromise!,
style: 'normal',
weight: 400,
},
{
name: 'SF Pro Display',
data: this.toArrayBuffer(PingFangSC),
name: '狮尾咏腿黑体',
data: await this.cjkFontPromise!,
style: 'normal',
weight: 400,
},
]
}
return this.fontConfig
private resetFontCleanupTimer(): void {
if (this.fontCleanupTimer) {
clearTimeout(this.fontCleanupTimer)
}
// Clean font promises 10 minutes after last activity
this.fontCleanupTimer = setTimeout(
() => {
this.cjkFontPromise = null
this.geistFontPromise = null
this.fontConfig = null
this.fontCleanupTimer = null
},
10 * 60 * 1000,
)
}
private toArrayBuffer(source: ArrayBufferView): ArrayBuffer {
@@ -163,41 +180,10 @@ export class OgService {
}
}
private computeFrameDimensions(photo: PhotoManifestItem): FrameDimensions {
const imageWidth = photo.width || 1
const imageHeight = photo.height || 1
const aspectRatio = imageWidth / imageHeight
const maxFrameWidth = 500
const maxFrameHeight = 420
let frameWidth = maxFrameWidth
let frameHeight = maxFrameHeight
if (aspectRatio > maxFrameWidth / maxFrameHeight) {
frameHeight = maxFrameWidth / aspectRatio
} else {
frameWidth = maxFrameHeight * aspectRatio
}
const imageAreaWidth = frameWidth - 70
const imageAreaHeight = frameHeight - 70
let displayWidth = imageAreaWidth
let displayHeight = imageAreaHeight
if (aspectRatio > imageAreaWidth / imageAreaHeight) {
displayHeight = imageAreaWidth / aspectRatio
} else {
displayWidth = imageAreaHeight * aspectRatio
}
private getPhotoDimensions(photo: PhotoManifestItem): PhotoDimensions {
return {
frameWidth,
frameHeight,
imageAreaWidth,
imageAreaHeight,
displayWidth,
displayHeight,
width: photo.width || 1,
height: photo.height || 1,
}
}
@@ -236,14 +222,6 @@ export class OgService {
}
}
const local = await this.tryReadLocalThumbnail(thumbnailPath)
if (local) {
return {
buffer: local,
contentType: 'image/jpeg',
}
}
return null
}
@@ -264,53 +242,6 @@ export class OgService {
}
}
private async tryReadLocalThumbnail(thumbnailPath: string): Promise<Buffer | null> {
const roots = await this.getLocalThumbnailRoots()
if (roots.length === 0) {
return null
}
const normalizedPath = thumbnailPath.startsWith('/') ? thumbnailPath.slice(1) : thumbnailPath
const candidates = [normalizedPath]
if (!normalizedPath.startsWith('static/web/')) {
candidates.push(`static/web/${normalizedPath}`)
}
for (const root of roots) {
for (const candidate of candidates) {
try {
const absolute = resolve(root, candidate)
return await readFile(absolute)
} catch {
continue
}
}
}
return null
}
private async getLocalThumbnailRoots(): Promise<string[]> {
if (this.localThumbnailRoots) {
return this.localThumbnailRoots
}
const resolved: string[] = []
for (const candidate of LOCAL_THUMBNAIL_ROOT_CANDIDATES) {
try {
const stats = await stat(candidate)
if (stats.isDirectory()) {
resolved.push(candidate)
}
} catch {
continue
}
}
this.localThumbnailRoots = resolved
return resolved
}
private buildThumbnailUrlCandidates(context: Context, thumbnailPath: string): string[] {
const result: string[] = []
const externalOverride = process.env.OG_THUMBNAIL_ORIGIN?.trim()

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@ NODE_BUILT_IN_MODULES.push(...NODE_BUILT_IN_MODULES.map((m) => `node:${m}`))
const __dirname = dirname(fileURLToPath(import.meta.url))
const external = ['sharp', 'nodejs-snowflake', 'ioredis', 'heic-convert']
function generateExternalsPackageJson (externals: string[]) {
const external = ['sharp', 'nodejs-snowflake', 'ioredis', 'heic-convert', 'satori', '@resvg/resvg-js']
function generateExternalsPackageJson(externals: string[]) {
const req = createRequire(import.meta.url)
let outDirAbs = ''
const plugin: import('vite').Plugin = {