diff --git a/.gitattributes b/.gitattributes index 4378751c..17d1f328 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/be/apps/core/src/global.d.ts b/be/apps/core/src/global.d.ts new file mode 100644 index 00000000..5597b4fb --- /dev/null +++ b/be/apps/core/src/global.d.ts @@ -0,0 +1 @@ +import 'vite/client' diff --git a/be/apps/core/src/modules/og/README.md b/be/apps/core/src/modules/og/README.md new file mode 100644 index 00000000..0bd977c7 --- /dev/null +++ b/be/apps/core/src/modules/og/README.md @@ -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**: 在各平台预览中都表现优秀 diff --git a/be/apps/core/src/modules/og/assets/Geist-Medium.ttf b/be/apps/core/src/modules/og/assets/Geist-Medium.ttf new file mode 100644 index 00000000..a787d166 --- /dev/null +++ b/be/apps/core/src/modules/og/assets/Geist-Medium.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62d7975c788e6442421623b8a5ab6bfc91174358ea515813d9261fbb9f344060 +size 126744 diff --git a/be/apps/core/src/modules/og/assets/SweiFistLegCJKsc-Medium-2.ttf b/be/apps/core/src/modules/og/assets/SweiFistLegCJKsc-Medium-2.ttf new file mode 100644 index 00000000..cd88c686 --- /dev/null +++ b/be/apps/core/src/modules/og/assets/SweiFistLegCJKsc-Medium-2.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e72c835fe739bfc3cf48f3add941e78aab40ce4171b63cf6008b067a2891f48 +size 18157980 diff --git a/be/apps/core/src/modules/og/og.service.ts b/be/apps/core/src/modules/og/og.service.ts index 9b474ba6..6663fb7f 100644 --- a/be/apps/core/src/modules/og/og.service.ts +++ b/be/apps/core/src/modules/og/og.service.ts @@ -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 { 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 { - if (this.fontConfig) { - return this.fontConfig - } + private cjkFontPromise: Promise | null = null + private geistFontPromise: Promise | 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 { + 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 { - 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 { - 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() diff --git a/be/apps/core/src/modules/og/og.template.tsx b/be/apps/core/src/modules/og/og.template.tsx index 799168ad..86de8b87 100644 --- a/be/apps/core/src/modules/og/og.template.tsx +++ b/be/apps/core/src/modules/og/og.template.tsx @@ -1,13 +1,8 @@ /** @jsxImportSource hono/jsx */ -import type { JSX } from 'hono/jsx' -export interface FrameDimensions { - frameWidth: number - frameHeight: number - imageAreaWidth: number - imageAreaHeight: number - displayWidth: number - displayHeight: number +export interface PhotoDimensions { + width: number + height: number } export interface ExifInfo { @@ -20,39 +15,179 @@ export interface ExifInfo { export interface OgTemplateProps { photoTitle: string - photoDescription: string + siteName: string tags: string[] formattedDate?: string exifInfo?: ExifInfo | null thumbnailSrc?: string | null - frame: FrameDimensions - photoId: string + photoDimensions: PhotoDimensions + accentColor?: string +} + +const CANVAS = { width: 1200, height: 628 } + +type LayoutType = 'portrait' | 'square' | 'landscape' | 'wide' + +interface LayoutConfig { + type: LayoutType + 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, - photoDescription, + siteName, tags, formattedDate, exifInfo, thumbnailSrc, - frame, - photoId, -}: OgTemplateProps): JSX.Element { + 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 = ( + + ) + + const info = ( + + ) + + const layoutComponent = layout.type === 'wide' ? WideLayout : layout.type === 'landscape' ? StackLayout : SplitLayout + + return ( + + {layoutComponent({ gap: layout.gap, photo, info, photoWidth: photoSize.width })} + + ) +} + +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', + 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', + 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', + padding, + gap: 28, + photoBox: { + maxWidth: CANVAS.width - padding * 2, + maxHeight: 340, + }, + infoCompact: true, + photoFit: 'contain', + } + } + + const padding = 54 + return { + type: 'landscape', + 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 (
- -
- -
- -
- -
+ +
- {Array.from({ length: 6 }).map((_value, index) => ( -
- ))} + {siteName}
+ position: 'relative', + width: '100%', + height: '100%', + display: 'flex', + }} + > + {children} +
+
+ ) +} + +function SplitLayout({ gap, photo, info }: LayoutPieces) { + return ( +
+
{photo}
+ > + {info} +
+
+ ) +} +function StackLayout({ gap, photo, info, photoWidth }: LayoutPieces) { + return ( +
+
{photo}
+ > + {info} +
+
+ ) +} +function WideLayout({ gap, photo, info, photoWidth }: LayoutPieces) { + return ( +
+
{photo}
+ {info} +
+
+ ) +} + +interface PhotoFrameProps { + width: number + height: number + fit: 'cover' | 'contain' + src?: string | null +} + +function PhotoFrame({ width, height, fit, src }: PhotoFrameProps) { + if (!src) { + return ( +
-
-
-
+ No Preview +
+ ) + } + return ( +
+
+
+ ) +} + +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 ( +
+

-

- {photoTitle || 'Untitled Photo'} -

+ {title} + -

- {photoDescription} -

- - {tags.length > 0 && ( -
- {tags.map((tag) => ( -
- #{tag} -
- ))} -
- )} -
- - {thumbnailSrc && ( + {tags.length > 0 && (
-
- {Array.from({ length: 7 }).map((_value, index) => ( -
- ))} -
- -
- {Array.from({ length: 7 }).map((_value, index) => ( -
- ))} -
- -
+ {tags.slice(0, tagLimit).map((tag) => (
- + #{tag}
+ ))} +
+ )} -
-
- -
+ -
- -
- {photoId} -
+ cam + + {camera} +
+ )} -
- FILM 400 | STUDIO CUT -
+ {exifItems.length > 0 && ( +
+ {exifItems.map((item) => ( +
+ + {item.label} + + {item.text} +
+ ))} +
+ )} -
+ {formattedDate && ( +
+ {formattedDate}
)}
- {formattedDate && ( -
- 📸 {formattedDate} -
- )} - - {exifInfo?.camera && ( -
- 📷 {exifInfo.camera} -
- )} - - {exifInfo && (exifInfo.aperture || exifInfo.shutterSpeed || exifInfo.iso || exifInfo.focalLength) && ( -
- {exifInfo.aperture && ( -
- ⚫ {exifInfo.aperture} -
- )} - - {exifInfo.shutterSpeed && ( -
- ⏱️ {exifInfo.shutterSpeed} -
- )} - - {exifInfo.iso && ( -
- 📊 ISO {exifInfo.iso} -
- )} - - {exifInfo.focalLength && ( -
- 🔍 {exifInfo.focalLength} -
- )} -
- )} -
+ />
) } diff --git a/be/apps/core/vite.config.ts b/be/apps/core/vite.config.ts index 9599c732..33403274 100644 --- a/be/apps/core/vite.config.ts +++ b/be/apps/core/vite.config.ts @@ -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 = {