diff --git a/apps/ssr/src/app/og/[photoId]/route.tsx b/apps/ssr/src/app/og/[photoId]/route.tsx
index 9bbf9ba7..2ca03fb8 100644
--- a/apps/ssr/src/app/og/[photoId]/route.tsx
+++ b/apps/ssr/src/app/og/[photoId]/route.tsx
@@ -1,826 +1,68 @@
-import { siteConfig } from '@config'
-import { ImageResponse } from 'next/og'
import type { NextRequest } from 'next/server'
-import { photoLoader } from '~/lib/photo-loader'
+const CORE_API_BASE =
+ process.env.CORE_API_URL ??
+ process.env.NEXT_PUBLIC_CORE_API_URL ??
+ process.env.API_BASE_URL ??
+ 'http://localhost:3000'
-import geistFont from './Geist-Medium.ttf'
-import Sans from './PingFangSC.ttf'
+const FORWARDED_HEADER_KEYS = [
+ 'cookie',
+ 'authorization',
+ 'x-tenant-id',
+ 'x-tenant-slug',
+ 'x-forwarded-host',
+ 'x-forwarded-proto',
+ 'host',
+]
+
+function buildBackendUrl(photoId: string): string {
+ const base = CORE_API_BASE.endsWith('/') ? CORE_API_BASE.slice(0, -1) : CORE_API_BASE
+ return `${base}/og/${encodeURIComponent(photoId)}`
+}
+
+function buildForwardHeaders(request: NextRequest): Headers {
+ const headers = new Headers()
+
+ for (const key of FORWARDED_HEADER_KEYS) {
+ const value = request.headers.get(key)
+ if (value) {
+ headers.set(key, value)
+ }
+ }
+
+ const hostHeader = request.headers.get('host')
+ if (!headers.has('x-forwarded-host') && hostHeader) {
+ headers.set('x-forwarded-host', hostHeader)
+ }
+
+ if (!headers.has('x-forwarded-proto')) {
+ headers.set('x-forwarded-proto', request.nextUrl.protocol.replace(':', ''))
+ }
+
+ headers.set('accept', 'image/png,image/*;q=0.9,*/*;q=0.8')
+ return headers
+}
+
+export const revalidate = 0
export const GET = async (request: NextRequest, { params }: { params: Promise<{ photoId: string }> }) => {
const { photoId } = await params
+ const targetUrl = buildBackendUrl(photoId)
- const photo = photoLoader.getPhoto(photoId)
- if (!photo) {
- return new Response('Photo not found', { status: 404 })
- }
+ const response = await fetch(targetUrl, {
+ headers: buildForwardHeaders(request),
+ })
- try {
- // 格式化拍摄时间
- const dateTaken = photo.exif?.DateTimeOriginal || photo.lastModified
- const formattedDate = dateTaken ? new Date(dateTaken).toLocaleDateString('en-US') : ''
-
- // 处理标签
- const tags = photo.tags?.slice(0, 3).join(' • ') || ''
- // Format EXIF information
- const formatExifInfo = () => {
- if (!photo.exif) return null
-
- const info = {
- focalLength: photo.exif.FocalLengthIn35mmFormat || photo.exif.FocalLength,
- aperture: photo.exif.FNumber ? `f/${photo.exif.FNumber}` : null,
- iso: photo.exif.ISO || null,
- shutterSpeed: `${photo.exif.ExposureTime}s`,
- camera: photo.exif.Make && photo.exif.Model ? `${photo.exif.Make} ${photo.exif.Model}` : null,
- }
-
- return info
- }
-
- const exifInfo = formatExifInfo()
- const thumbnailBuffer = await Promise.any([
- fetch(`http://localhost:13333${photo.thumbnailUrl.replace('.webp', '.jpg')}`).then((res) => res.arrayBuffer()),
- process.env.NEXT_PUBLIC_APP_URL
- ? fetch(`http://${process.env.NEXT_PUBLIC_APP_URL}${photo.thumbnailUrl.replace('.webp', '.jpg')}`).then((res) =>
- res.arrayBuffer(),
- )
- : Promise.reject(),
- fetch(`http://${request.nextUrl.host}${photo.thumbnailUrl.replace('.webp', '.jpg')}`).then((res) =>
- res.arrayBuffer(),
- ),
- ])
-
- // 计算图片显示尺寸以保持原始比例
- 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
- }
-
- return new ImageResponse(
- (
-
- {/* 摄影师风格的网格背景 */}
-
-
- {/* 主光源效果 - 左上角 */}
-
-
- {/* 副光源效果 - 右下角 */}
-
-
- {/* 摄影工作室的聚光灯效果 */}
-
-
- {/* 胶片装饰元素 */}
-
- {/* 胶片孔 */}
-
-
-
-
-
-
-
-
- {/* 几何装饰线条 - 多个层次 */}
-
-
-
-
-
-
- {/* 光圈装饰 */}
-
-
- {/* 主要内容区域 */}
-
- {/* 标题 */}
-
- {photo.title || 'Untitled Photo'}
-
-
- {/* 描述 */}
-
- {photo.description || siteConfig.name || siteConfig.title}
-
-
- {/* 标签 */}
- {tags && (
-
- {photo.tags?.slice(0, 3).map((tag, index) => (
-
- #{tag}
-
- ))}
-
- )}
-
-
- {/* 照片缩略图 - 胶片风格 */}
- {photo.thumbnailUrl && (
-
- {/* 胶片左边的孔洞 */}
-
- {/* 胶片孔洞 - 更柔和的边缘 */}
-
-
-
-
-
-
-
-
-
- {/* 胶片右边的孔洞 */}
-
- {/* 胶片孔洞 - 更柔和的边缘 */}
-
-
-
-
-
-
-
-
-
- {/* 胶片中间的照片区域 */}
-
-
-

-
-
- {/* 胶片光泽效果 - 更柔和 */}
-
-
-
- {/* 胶片顶部和底部的纹理 - 更细腻 */}
-
-
-
- {/* 胶片编号 - 更自然的位置 */}
-
- {photoId.slice(-4).toUpperCase()}
-
-
- {/* 胶片质感的整体覆盖层 */}
-
-
- )}
-
- {/* 底部信息 */}
-
- {/* 拍摄时间 */}
- {formattedDate && (
-
- 📸 {formattedDate}
-
- )}
- {/* 相机信息 */}
- {exifInfo?.camera && (
-
- 📷 {exifInfo.camera}
-
- )}
- {/* EXIF 信息 */}
- {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}
-
- )}
-
- )}
-
-
- ),
- {
- width: 1200,
- height: 628,
- emoji: 'noto',
- fonts: [
- {
- name: 'Geist',
- data: geistFont,
- style: 'normal',
- weight: 400,
- },
- {
- name: 'SF Pro Display',
- data: Sans,
- style: 'normal',
- weight: 400,
- },
- ],
- headers: {
- // Cache 1 years
- 'Cache-Control': 'public, max-age=31536000, stale-while-revalidate=31536000',
- 'Cloudflare-CDN-Cache-Control': 'public, max-age=31536000, stale-while-revalidate=31536000',
- },
- },
- )
- } catch (error) {
- console.error('Failed to generate OG image:', error)
- return new Response(`Failed to generate image, ${error.message}`, {
- status: 500,
+ if (!response.ok) {
+ return new Response(await response.text(), {
+ status: response.status,
+ headers: response.headers,
})
}
+
+ return new Response(response.body, {
+ status: response.status,
+ headers: response.headers,
+ })
}
diff --git a/apps/web/plugins/vite/feed-sitemap.ts b/apps/web/plugins/vite/feed-sitemap.ts
index 892db709..706b6be3 100644
--- a/apps/web/plugins/vite/feed-sitemap.ts
+++ b/apps/web/plugins/vite/feed-sitemap.ts
@@ -1,12 +1,14 @@
import { readFileSync } from 'node:fs'
import type { PhotoManifestItem } from '@afilmory/builder'
-import { generateRSSFeed } from '@afilmory/utils'
+import { tsImport } from 'tsx/esm/api'
import type { Plugin } from 'vite'
import type { SiteConfig } from '../../../../site.config'
import { MANIFEST_PATH } from './__internal__/constants'
+const { generateRSSFeed } = await tsImport('@afilmory/utils', import.meta.url)
+
export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
return {
name: 'feed-sitemap-generator',
diff --git a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
index bffccdb2..5174edc6 100644
--- a/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
+++ b/apps/web/src/components/ui/photo-viewer/ExifPanel.tsx
@@ -72,7 +72,7 @@ export const ExifPanel: FC<{
style={{
pointerEvents: visible ? 'auto' : 'none',
backgroundImage:
- 'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
+ 'linear-gradient(to bottom right, rgba(var(--color-materialMedium)), rgba(var(--color-materialThick)), transparent)',
boxShadow:
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.1)',
}}
@@ -101,7 +101,7 @@ export const ExifPanel: FC<{
{/* 基本信息和标签 - 合并到一个 section */}
diff --git a/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx b/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx
index e34dfa37..e3533571 100644
--- a/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx
+++ b/apps/web/src/components/ui/photo-viewer/GalleryThumbnail.tsx
@@ -93,7 +93,7 @@ export const GalleryThumbnail: FC<{
return (
{
+ const svg = await satori(, {
+ 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()
+}
diff --git a/be/apps/core/src/modules/og/og.service.ts b/be/apps/core/src/modules/og/og.service.ts
new file mode 100644
index 00000000..9b474ba6
--- /dev/null
+++ b/be/apps/core/src/modules/og/og.service.ts
@@ -0,0 +1,362 @@
+import { readFile, stat } from 'node:fs/promises'
+import { resolve } from 'node:path'
+
+import type { PhotoManifestItem } from '@afilmory/builder'
+import { BizException, ErrorCode } from 'core/errors'
+import type { Context } from 'hono'
+import type { SatoriOptions } from 'satori'
+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 { renderOgImage } from './og.renderer'
+import type { ExifInfo, FrameDimensions } 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
+ contentType: string
+}
+
+@injectable()
+export class OgService {
+ private fontConfig: SatoriOptions['fonts'] | null = null
+ private localThumbnailRoots: string[] | null = null
+
+ constructor(
+ private readonly manifestService: ManifestService,
+ private readonly siteSettingService: SiteSettingService,
+ ) {}
+
+ async render(context: Context, photoId: string): Promise {
+ const manifest = await this.manifestService.getManifest()
+ const photo = manifest.data.find((entry) => entry.id === photoId)
+ if (!photo) {
+ throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: 'Photo not found' })
+ }
+
+ 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 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 || '',
+ tags,
+ formattedDate,
+ exifInfo,
+ thumbnailSrc,
+ frame,
+ photoId: photo.id,
+ },
+ fonts: await this.getFontConfig(),
+ })
+ const headers = new Headers({
+ 'content-type': 'image/png',
+ 'cache-control': CACHE_CONTROL,
+ 'cloudflare-cdn-cache-control': CACHE_CONTROL,
+ })
+
+ const body = this.toArrayBuffer(png)
+
+ return new Response(body, { status: 200, headers })
+ }
+
+ private async getFontConfig(): Promise {
+ if (this.fontConfig) {
+ return this.fontConfig
+ }
+
+ this.fontConfig = [
+ {
+ name: 'Geist',
+ data: this.toArrayBuffer(GeistMedium),
+ style: 'normal',
+ weight: 400,
+ },
+ {
+ name: 'SF Pro Display',
+ data: this.toArrayBuffer(PingFangSC),
+ style: 'normal',
+ weight: 400,
+ },
+ ]
+
+ return this.fontConfig
+ }
+
+ private toArrayBuffer(source: ArrayBufferView): ArrayBuffer {
+ const { buffer, byteOffset, byteLength } = source
+
+ if (buffer instanceof ArrayBuffer) {
+ return buffer.slice(byteOffset, byteOffset + byteLength)
+ }
+
+ const copy = new ArrayBuffer(byteLength)
+ const view = new Uint8Array(buffer, byteOffset, byteLength)
+ new Uint8Array(copy).set(view)
+
+ return copy
+ }
+
+ private 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',
+ })
+ }
+
+ private 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,
+ }
+ }
+
+ 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
+ }
+
+ return {
+ frameWidth,
+ frameHeight,
+ imageAreaWidth,
+ imageAreaHeight,
+ displayWidth,
+ displayHeight,
+ }
+ }
+
+ private async resolveThumbnailSrc(context: Context, photo: PhotoManifestItem): Promise {
+ const normalized = this.normalizeThumbnailPath(photo.thumbnailUrl)
+ if (!normalized) {
+ return null
+ }
+
+ const fetched = await this.fetchThumbnailBuffer(context, normalized)
+ if (!fetched) {
+ return null
+ }
+
+ return this.bufferToDataUrl(fetched.buffer, fetched.contentType)
+ }
+
+ private normalizeThumbnailPath(value?: string | null): string | null {
+ if (!value) {
+ return null
+ }
+
+ const replaced = value.replace(/\.webp$/i, '.jpg')
+ return replaced
+ }
+
+ private async fetchThumbnailBuffer(
+ context: Context,
+ thumbnailPath: string,
+ ): Promise {
+ const requests = this.buildThumbnailUrlCandidates(context, thumbnailPath)
+ for (const candidate of requests) {
+ const fetched = await this.tryFetchUrl(candidate)
+ if (fetched) {
+ return fetched
+ }
+ }
+
+ const local = await this.tryReadLocalThumbnail(thumbnailPath)
+ if (local) {
+ return {
+ buffer: local,
+ contentType: 'image/jpeg',
+ }
+ }
+
+ return null
+ }
+
+ private async tryFetchUrl(url: string): Promise {
+ try {
+ const response = await fetch(url)
+ if (!response.ok) {
+ return null
+ }
+ const arrayBuffer = await response.arrayBuffer()
+ const contentType = response.headers.get('content-type') ?? 'image/jpeg'
+ return {
+ buffer: Buffer.from(arrayBuffer),
+ contentType,
+ }
+ } catch {
+ return null
+ }
+ }
+
+ 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()
+ const normalizedPath = thumbnailPath.startsWith('/') ? thumbnailPath : `/${thumbnailPath}`
+
+ if (thumbnailPath.startsWith('http://') || thumbnailPath.startsWith('https://')) {
+ result.push(thumbnailPath)
+ } else {
+ const base = this.resolveBaseUrl(context)
+ if (base) {
+ result.push(new URL(normalizedPath, base).toString())
+ if (!normalizedPath.startsWith('/static/web/')) {
+ result.push(new URL(`/static/web${normalizedPath}`, base).toString())
+ }
+ }
+
+ if (externalOverride) {
+ result.push(`${externalOverride.replace(/\/+$/, '')}${normalizedPath}`)
+ }
+ }
+
+ return Array.from(new Set(result))
+ }
+
+ private resolveBaseUrl(context: Context): URL | null {
+ const forwardedHost = context.req.header('x-forwarded-host')
+ const forwardedProto = context.req.header('x-forwarded-proto')
+ const host = forwardedHost ?? context.req.header('host')
+
+ if (host) {
+ const protocol = forwardedProto ?? (host.includes('localhost') ? 'http' : 'https')
+ try {
+ return new URL(`${protocol}://${host}`)
+ } catch {
+ return null
+ }
+ }
+
+ try {
+ return new URL(context.req.url)
+ } catch {
+ return null
+ }
+ }
+
+ private bufferToDataUrl(buffer: Buffer, contentType: string): string {
+ return `data:${contentType};base64,${buffer.toString('base64')}`
+ }
+}
diff --git a/be/apps/core/src/modules/og/og.template.tsx b/be/apps/core/src/modules/og/og.template.tsx
new file mode 100644
index 00000000..799168ad
--- /dev/null
+++ b/be/apps/core/src/modules/og/og.template.tsx
@@ -0,0 +1,595 @@
+/** @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 ExifInfo {
+ focalLength?: string | null
+ aperture?: string | null
+ iso?: string | number | null
+ shutterSpeed?: string | null
+ camera?: string | null
+}
+
+export interface OgTemplateProps {
+ photoTitle: string
+ photoDescription: string
+ tags: string[]
+ formattedDate?: string
+ exifInfo?: ExifInfo | null
+ thumbnailSrc?: string | null
+ frame: FrameDimensions
+ photoId: string
+}
+
+export function OgTemplate({
+ photoTitle,
+ photoDescription,
+ tags,
+ formattedDate,
+ exifInfo,
+ thumbnailSrc,
+ frame,
+ photoId,
+}: OgTemplateProps): JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_value, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {photoTitle || 'Untitled Photo'}
+
+
+
+ {photoDescription}
+
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+
+ {thumbnailSrc && (
+
+
+ {Array.from({ length: 7 }).map((_value, index) => (
+
+ ))}
+
+
+
+ {Array.from({ length: 7 }).map((_value, index) => (
+
+ ))}
+
+
+
+
+

+
+
+
+
+
+
+
+
+
+ {photoId}
+
+
+
+ FILM 400 | STUDIO CUT
+
+
+
+
+ )}
+
+
+ {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/tsconfig.json b/be/apps/core/tsconfig.json
index 97e362c1..f54db48e 100644
--- a/be/apps/core/tsconfig.json
+++ b/be/apps/core/tsconfig.json
@@ -29,7 +29,9 @@
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
- "skipLibCheck": true
+ "skipLibCheck": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
},
"include": ["src/**/*", "*.d.ts"],
"exclude": []
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3af5601a..ceb23fb8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -604,6 +604,9 @@ importers:
'@hono/node-server':
specifier: ^1.19.6
version: 1.19.6(hono@4.10.4)
+ '@resvg/resvg-js':
+ specifier: 2.6.2
+ version: 2.6.2
better-auth:
specifier: 1.3.34
version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -628,6 +631,9 @@ importers:
reflect-metadata:
specifier: 0.2.2
version: 0.2.2
+ satori:
+ specifier: 0.18.3
+ version: 0.18.3
tsyringe:
specifier: 4.10.0
version: 4.10.0
@@ -1320,6 +1326,9 @@ importers:
packages/utils:
dependencies:
+ '@afilmory/builder':
+ specifier: workspace:*
+ version: link:../builder
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -4400,6 +4409,82 @@ packages:
peerDependencies:
react: '>=18.2.0'
+ '@resvg/resvg-js-android-arm-eabi@2.6.2':
+ resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [android]
+
+ '@resvg/resvg-js-android-arm64@2.6.2':
+ resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@resvg/resvg-js-darwin-arm64@2.6.2':
+ resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@resvg/resvg-js-darwin-x64@2.6.2':
+ resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
+ resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@resvg/resvg-js-linux-arm64-gnu@2.6.2':
+ resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@resvg/resvg-js-linux-arm64-musl@2.6.2':
+ resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@resvg/resvg-js-linux-x64-gnu@2.6.2':
+ resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@resvg/resvg-js-linux-x64-musl@2.6.2':
+ resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@resvg/resvg-js-win32-arm64-msvc@2.6.2':
+ resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@resvg/resvg-js-win32-ia32-msvc@2.6.2':
+ resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@resvg/resvg-js-win32-x64-msvc@2.6.2':
+ resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@resvg/resvg-js@2.6.2':
+ resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==}
+ engines: {node: '>= 10'}
+
'@reteps/dockerfmt@0.3.6':
resolution: {integrity: sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==}
engines: {node: ^v12.20.0 || ^14.13.0 || >=16.0.0}
@@ -4714,6 +4799,11 @@ packages:
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
+ '@shuding/opentype.js@1.4.0-beta.0':
+ resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==}
+ engines: {node: '>= 8.0.0'}
+ hasBin: true
+
'@simplewebauthn/browser@13.2.2':
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
@@ -6076,6 +6166,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ base64-js@0.0.8:
+ resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
+ engines: {node: '>= 0.4'}
+
baseline-browser-mapping@2.8.18:
resolution: {integrity: sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==}
hasBin: true
@@ -6207,6 +6301,9 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
+ camelize@1.0.1:
+ resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
+
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
@@ -6504,6 +6601,20 @@ packages:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
+ css-background-parser@0.1.0:
+ resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
+
+ css-box-shadow@1.0.0-3:
+ resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
+
+ css-color-keywords@1.0.0:
+ resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
+ engines: {node: '>=4'}
+
+ css-gradient-parser@0.0.17:
+ resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==}
+ engines: {node: '>=16'}
+
css-in-js-utils@3.1.0:
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
@@ -6513,6 +6624,9 @@ packages:
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+ css-to-react-native@3.2.0:
+ resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
+
css-tree@1.1.3:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
@@ -6975,6 +7089,10 @@ packages:
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
+ emoji-regex-xs@2.0.1:
+ resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==}
+ engines: {node: '>=10.0.0'}
+
emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -7076,6 +7194,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-html@1.0.3:
+ resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
@@ -7457,6 +7578,9 @@ packages:
picomatch:
optional: true
+ fflate@0.7.4:
+ resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==}
+
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -7821,6 +7945,10 @@ packages:
hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
+ hex-rgb@4.3.0:
+ resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
+ engines: {node: '>=6'}
+
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -8407,6 +8535,9 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
+ linebreak@1.1.0:
+ resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -9027,6 +9158,9 @@ packages:
package-manager-detector@1.5.0:
resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==}
+ pako@0.2.9:
+ resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
+
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
@@ -9041,6 +9175,9 @@ packages:
resolution: {integrity: sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw==}
engines: {node: '>=0.10.0'}
+ parse-css-color@0.2.1:
+ resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -10213,6 +10350,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ satori@0.18.3:
+ resolution: {integrity: sha512-T3DzWNmnrfVmk2gCIlAxLRLbGkfp3K7TyRva+Byyojqu83BNvnMeqVeYRdmUw4TKCsyH4RiQ/KuF/I4yEzgR5A==}
+ engines: {node: '>=16'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -10956,6 +11097,9 @@ packages:
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
engines: {node: '>=4'}
+ unicode-trie@2.0.0:
+ resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
+
unicorn-magic@0.1.0:
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
engines: {node: '>=18'}
@@ -11507,6 +11651,9 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
+ yoga-layout@3.2.1:
+ resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
zod-validation-error@3.5.3:
resolution: {integrity: sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==}
engines: {node: '>=18.0.0'}
@@ -15464,6 +15611,57 @@ snapshots:
dependencies:
react: 19.2.0
+ '@resvg/resvg-js-android-arm-eabi@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-android-arm64@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-darwin-arm64@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-darwin-x64@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-linux-arm64-gnu@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-linux-arm64-musl@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-linux-x64-gnu@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-linux-x64-musl@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-win32-arm64-msvc@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-win32-ia32-msvc@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js-win32-x64-msvc@2.6.2':
+ optional: true
+
+ '@resvg/resvg-js@2.6.2':
+ optionalDependencies:
+ '@resvg/resvg-js-android-arm-eabi': 2.6.2
+ '@resvg/resvg-js-android-arm64': 2.6.2
+ '@resvg/resvg-js-darwin-arm64': 2.6.2
+ '@resvg/resvg-js-darwin-x64': 2.6.2
+ '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2
+ '@resvg/resvg-js-linux-arm64-gnu': 2.6.2
+ '@resvg/resvg-js-linux-arm64-musl': 2.6.2
+ '@resvg/resvg-js-linux-x64-gnu': 2.6.2
+ '@resvg/resvg-js-linux-x64-musl': 2.6.2
+ '@resvg/resvg-js-win32-arm64-msvc': 2.6.2
+ '@resvg/resvg-js-win32-ia32-msvc': 2.6.2
+ '@resvg/resvg-js-win32-x64-msvc': 2.6.2
+
'@reteps/dockerfmt@0.3.6': {}
'@rolldown/binding-android-arm64@1.0.0-beta.45':
@@ -15742,6 +15940,11 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {}
+ '@shuding/opentype.js@1.4.0-beta.0':
+ dependencies:
+ fflate: 0.7.4
+ string.prototype.codepointat: 0.2.1
+
'@simplewebauthn/browser@13.2.2': {}
'@simplewebauthn/server@13.2.2':
@@ -17394,6 +17597,8 @@ snapshots:
balanced-match@1.0.2: {}
+ base64-js@0.0.8: {}
+
baseline-browser-mapping@2.8.18: {}
batch-cluster@15.0.1: {}
@@ -17530,6 +17735,8 @@ snapshots:
camelcase-css@2.0.1: {}
+ camelize@1.0.1: {}
+
caniuse-lite@1.0.30001751: {}
caniuse-lite@1.0.30001752: {}
@@ -17829,6 +18036,14 @@ snapshots:
crypto-random-string@2.0.0: {}
+ css-background-parser@0.1.0: {}
+
+ css-box-shadow@1.0.0-3: {}
+
+ css-color-keywords@1.0.0: {}
+
+ css-gradient-parser@0.0.17: {}
+
css-in-js-utils@3.1.0:
dependencies:
hyphenate-style-name: 1.1.0
@@ -17849,6 +18064,12 @@ snapshots:
domutils: 3.2.2
nth-check: 2.1.1
+ css-to-react-native@3.2.0:
+ dependencies:
+ camelize: 1.0.1
+ css-color-keywords: 1.0.0
+ postcss-value-parser: 4.2.0
+
css-tree@1.1.3:
dependencies:
mdn-data: 2.0.14
@@ -18232,6 +18453,8 @@ snapshots:
emoji-mart@5.6.0: {}
+ emoji-regex-xs@2.0.1: {}
+
emoji-regex@10.4.0: {}
emoji-regex@10.5.0: {}
@@ -18451,6 +18674,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
@@ -19048,6 +19273,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fflate@0.7.4: {}
+
fflate@0.8.2: {}
figures@6.1.0:
@@ -19511,6 +19738,8 @@ snapshots:
dependencies:
hermes-estree: 0.25.1
+ hex-rgb@4.3.0: {}
+
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@@ -20036,6 +20265,11 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
+ linebreak@1.1.0:
+ dependencies:
+ base64-js: 0.0.8
+ unicode-trie: 2.0.0
+
lines-and-columns@1.2.4: {}
linkedom@0.18.12:
@@ -21054,6 +21288,8 @@ snapshots:
package-manager-detector@1.5.0: {}
+ pako@0.2.9: {}
+
pako@2.1.0: {}
param-case@3.0.4:
@@ -21069,6 +21305,11 @@ snapshots:
dependencies:
author-regex: 1.0.0
+ parse-css-color@0.2.1:
+ dependencies:
+ color-name: 1.1.4
+ hex-rgb: 4.3.0
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -22498,6 +22739,20 @@ snapshots:
safer-buffer@2.1.2: {}
+ satori@0.18.3:
+ dependencies:
+ '@shuding/opentype.js': 1.4.0-beta.0
+ css-background-parser: 0.1.0
+ css-box-shadow: 1.0.0-3
+ css-gradient-parser: 0.0.17
+ css-to-react-native: 3.2.0
+ emoji-regex-xs: 2.0.1
+ escape-html: 1.0.3
+ linebreak: 1.1.0
+ parse-css-color: 0.2.1
+ postcss-value-parser: 4.2.0
+ yoga-layout: 3.2.1
+
scheduler@0.27.0: {}
screenfull@5.2.0: {}
@@ -23252,6 +23507,11 @@ snapshots:
unicode-property-aliases-ecmascript@2.2.0: {}
+ unicode-trie@2.0.0:
+ dependencies:
+ pako: 0.2.9
+ tiny-inflate: 1.0.3
+
unicorn-magic@0.1.0: {}
unicorn-magic@0.3.0: {}
@@ -23896,6 +24156,8 @@ snapshots:
yoctocolors@2.1.1: {}
+ yoga-layout@3.2.1: {}
+
zod-validation-error@3.5.3(zod@3.25.76):
dependencies:
zod: 3.25.76