mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -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
1
be/apps/core/src/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import 'vite/client'
|
||||
296
be/apps/core/src/modules/og/README.md
Normal file
296
be/apps/core/src/modules/og/README.md
Normal 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**: 在各平台预览中都表现优秀
|
||||
BIN
be/apps/core/src/modules/og/assets/Geist-Medium.ttf
LFS
Normal file
BIN
be/apps/core/src/modules/og/assets/Geist-Medium.ttf
LFS
Normal file
Binary file not shown.
BIN
be/apps/core/src/modules/og/assets/SweiFistLegCJKsc-Medium-2.ttf
LFS
Normal file
BIN
be/apps/core/src/modules/og/assets/SweiFistLegCJKsc-Medium-2.ttf
LFS
Normal file
Binary file not shown.
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user