feat(og): implement Open Graph image generation for photos

- Added OgModule with OgController and OgService to handle Open Graph image requests.
- Integrated Satori and Resvg for rendering images based on photo metadata.
- Created OgTemplate for structuring the Open Graph image layout.
- Enhanced error handling for photo retrieval and image generation processes.
- Updated package dependencies to include @resvg/resvg-js and satori for image processing.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-08 17:58:12 +08:00
parent 44f9cbb376
commit caf47a4a01
13 changed files with 1347 additions and 822 deletions

View File

@@ -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(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
background:
'linear-gradient(145deg, #0d0d0d 0%, #1c1c1c 20%, #121212 40%, #1a1a1a 60%, #0f0f0f 80%, #0a0a0a 100%)',
padding: '80px',
fontFamily: 'Geist, system-ui, -apple-system, sans-serif',
position: 'relative',
}}
>
{/* 摄影师风格的网格背景 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0.03,
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: '60px 60px',
}}
/>
{/* 主光源效果 - 左上角 */}
<div
style={{
position: 'absolute',
top: '0px',
left: '0px',
width: '240px',
height: '240px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(60,60,70,0.15) 0%, rgba(40,40,50,0.08) 40%, transparent 70%)',
}}
/>
{/* 副光源效果 - 右下角 */}
<div
style={{
position: 'absolute',
bottom: '0px',
right: '0px',
width: '300px',
height: '300px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(45,45,55,0.12) 0%, rgba(30,30,40,0.06) 50%, transparent 80%)',
}}
/>
{/* 摄影工作室的聚光灯效果 */}
<div
style={{
position: 'absolute',
top: '5%',
right: '25%',
width: '180px',
height: '480px',
background:
'linear-gradient(45deg, transparent 0%, rgba(255,255,255,0.02) 40%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 60%, transparent 100%)',
transform: 'rotate(15deg)',
}}
/>
{/* 胶片装饰元素 */}
<div
style={{
position: 'absolute',
top: '15%',
right: '5%',
width: '30px',
height: '180px',
background: 'linear-gradient(0deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '3px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{/* 胶片孔 */}
<div
style={{
marginTop: '9px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
<div
style={{
marginTop: '15px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
<div
style={{
marginTop: '15px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
<div
style={{
marginTop: '15px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
<div
style={{
marginTop: '15px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
<div
style={{
marginTop: '15px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
</div>
{/* 几何装饰线条 - 多个层次 */}
<div
style={{
position: 'absolute',
top: '30%',
right: '12%',
width: '120px',
height: '120px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '5px',
transform: 'rotate(12deg)',
}}
/>
<div
style={{
position: 'absolute',
top: '35%',
right: '15%',
width: '90px',
height: '90px',
border: '1px solid rgba(255,255,255,0.04)',
borderRadius: '3px',
transform: 'rotate(-8deg)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '25%',
left: '12%',
width: '72px',
height: '72px',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: '50%',
}}
/>
{/* 光圈装饰 */}
<div
style={{
position: 'absolute',
bottom: '40%',
right: '8%',
width: '48px',
height: '48px',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* 内圈 */}
<div
style={{
width: '30px',
height: '30px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: '15px',
height: '15px',
border: '1px solid rgba(255,255,255,0.04)',
borderRadius: '50%',
}}
/>
</div>
</div>
{/* 主要内容区域 */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
maxWidth: '58%',
}}
>
{/* 标题 */}
<h1
style={{
fontSize: '80px',
fontWeight: 'bold',
color: 'white',
margin: '0 0 16px 0',
lineHeight: '1.1',
letterSpacing: '1px',
display: 'flex',
}}
>
{photo.title || 'Untitled Photo'}
</h1>
{/* 描述 */}
<p
style={{
fontSize: '36px',
color: 'rgba(255,255,255,0.9)',
margin: '0 0 16px 0',
lineHeight: '1.3',
letterSpacing: '0.3px',
display: 'flex',
fontFamily: 'Geist, SF Pro Display',
}}
>
{photo.description || siteConfig.name || siteConfig.title}
</p>
{/* 标签 */}
{tags && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
margin: '0 0 32px 0',
}}
>
{photo.tags?.slice(0, 3).map((tag, index) => (
<div
key={index}
style={{
fontSize: '26px',
color: 'rgba(255,255,255,0.9)',
backgroundColor: 'rgba(255,255,255,0.15)',
padding: '12px 20px',
borderRadius: '24px',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
border: '1px solid rgba(255,255,255,0.2)',
backdropFilter: 'blur(8px)',
fontFamily: 'Geist, SF Pro Display',
}}
>
#{tag}
</div>
))}
</div>
)}
</div>
{/* 照片缩略图 - 胶片风格 */}
{photo.thumbnailUrl && (
<div
style={{
position: 'absolute',
top: '75px',
right: '45px',
width: `${frameWidth}px`,
height: `${frameHeight}px`,
background: 'linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%)',
borderRadius: '6px',
border: '1px solid #2a2a2a',
boxShadow: '0 12px 48px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.03)',
display: 'flex',
overflow: 'hidden',
}}
>
{/* 胶片左边的孔洞 */}
<div
style={{
position: 'absolute',
left: '0px',
top: '0px',
width: '30px',
height: '100%',
background: 'linear-gradient(90deg, #0a0a0a 0%, #111 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: '25px',
paddingBottom: '25px',
}}
>
{/* 胶片孔洞 - 更柔和的边缘 */}
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
</div>
{/* 胶片右边的孔洞 */}
<div
style={{
position: 'absolute',
right: '0px',
top: '0px',
width: '30px',
height: '100%',
background: 'linear-gradient(90deg, #111 0%, #0a0a0a 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: '25px',
paddingBottom: '25px',
}}
>
{/* 胶片孔洞 - 更柔和的边缘 */}
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
<div
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
</div>
{/* 胶片中间的照片区域 */}
<div
style={{
position: 'absolute',
left: '30px',
top: '30px',
width: `${imageAreaWidth}px`,
height: `${imageAreaHeight}px`,
background: '#000',
borderRadius: '2px',
border: '2px solid #1a1a1a',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'inset 0 0 8px rgba(0,0,0,0.5)',
}}
>
<div
style={{
position: 'relative',
width: `${displayWidth}px`,
height: `${displayHeight}px`,
overflow: 'hidden',
display: 'flex',
}}
>
<img
// @ts-expect-error
src={thumbnailBuffer}
style={{
width: `${displayWidth}px`,
height: `${displayHeight}px`,
objectFit: 'cover',
}}
/>
</div>
{/* 胶片光泽效果 - 更柔和 */}
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background:
'linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.06) 25%, transparent 45%, transparent 55%, rgba(255,255,255,0.03) 75%, transparent 100%)',
pointerEvents: 'none',
}}
/>
</div>
{/* 胶片顶部和底部的纹理 - 更细腻 */}
<div
style={{
position: 'absolute',
top: '0',
left: '30px',
width: `${imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '0',
left: '30px',
width: `${imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderTop: '1px solid rgba(255,255,255,0.05)',
}}
/>
{/* 胶片编号 - 更自然的位置 */}
<div
style={{
position: 'absolute',
bottom: '8px',
right: '38px',
fontSize: '14px',
color: '#555',
fontFamily: 'monospace',
letterSpacing: '0.5px',
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
}}
>
{photoId.slice(-4).toUpperCase()}
</div>
{/* 胶片质感的整体覆盖层 */}
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background: 'linear-gradient(45deg, transparent 0%, rgba(255,255,255,0.01) 50%, transparent 100%)',
pointerEvents: 'none',
}}
/>
</div>
)}
{/* 底部信息 */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '28px',
}}
>
{/* 拍摄时间 */}
{formattedDate && (
<div
style={{
fontSize: '28px',
color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
📸 {formattedDate}
</div>
)}
{/* 相机信息 */}
{exifInfo?.camera && (
<div
style={{
fontSize: '25px',
color: 'rgba(255,255,255,0.6)',
letterSpacing: '0.3px',
display: 'flex',
}}
>
📷 {exifInfo.camera}
</div>
)}
{/* EXIF 信息 */}
{exifInfo && (exifInfo.aperture || exifInfo.shutterSpeed || exifInfo.iso || exifInfo.focalLength) && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '18px',
fontSize: '25px',
color: 'rgba(255,255,255,0.8)',
}}
>
{exifInfo.aperture && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{exifInfo.aperture}
</div>
)}
{exifInfo.shutterSpeed && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{exifInfo.shutterSpeed}
</div>
)}
{exifInfo.iso && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
📊 ISO {exifInfo.iso}
</div>
)}
{exifInfo.focalLength && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
🔍 {exifInfo.focalLength}
</div>
)}
</div>
)}
</div>
</div>
),
{
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,
})
}

View File

@@ -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',

View File

@@ -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<{
<ScrollArea
rootClassName="flex-1 min-h-0 overflow-auto lg:overflow-hidden"
viewportClassName="px-4 pb-4 [&_*]:select-text"
viewportClassName="px-4 pb-4 **:select-text"
>
<div className={`space-y-${isMobile ? '3' : '4'}`}>
{/* 基本信息和标签 - 合并到一个 section */}

View File

@@ -93,7 +93,7 @@ export const GalleryThumbnail: FC<{
return (
<m.div
className="pb-safe border-accent/20 z-10 shrink-0 border-t backdrop-blur-2xl"
className="pb-safe border-accent/20 bg-material-medium z-10 shrink-0 border-t backdrop-blur-2xl"
initial={{ y: 100, opacity: 0 }}
animate={{
y: visible ? 0 : 48,
@@ -103,8 +103,6 @@ export const GalleryThumbnail: FC<{
transition={Spring.presets.smooth}
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))',
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)',
}}

View File

@@ -26,6 +26,7 @@
"@afilmory/utils": "workspace:*",
"@aws-sdk/client-s3": "3.921.0",
"@hono/node-server": "^1.19.6",
"@resvg/resvg-js": "2.6.2",
"better-auth": "1.3.34",
"drizzle-orm": "^0.44.7",
"hono": "4.10.4",
@@ -34,6 +35,7 @@
"pg": "^8.16.3",
"picocolors": "1.1.1",
"reflect-metadata": "0.2.2",
"satori": "0.18.3",
"tsyringe": "4.10.0",
"zod": "^4.1.11"
},

View File

@@ -12,6 +12,7 @@ import { CacheModule } from './cache/cache.module'
import { DashboardModule } from './dashboard/dashboard.module'
import { DataSyncModule } from './data-sync/data-sync.module'
import { FeedModule } from './feed/feed.module'
import { OgModule } from './og/og.module'
import { OnboardingModule } from './onboarding/onboarding.module'
import { PhotoModule } from './photo/photo.module'
import { ReactionModule } from './reaction/reaction.module'
@@ -51,6 +52,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
TenantModule,
DataSyncModule,
FeedModule,
OgModule,
// This must be last
StaticWebModule,

View File

@@ -0,0 +1,14 @@
import { ContextParam, Controller, Get, Param } from '@afilmory/framework'
import type { Context } from 'hono'
import { OgService } from './og.service'
@Controller({ prefix: '/og', bypassGlobalPrefix: true })
export class OgController {
constructor(private readonly ogService: OgService) {}
@Get('/:photoId')
async getOgImage(@ContextParam() context: Context, @Param('photoId') photoId: string) {
return await this.ogService.render(context, photoId)
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@afilmory/framework'
import { ManifestModule } from '../manifest/manifest.module'
import { SiteSettingModule } from '../site-setting/site-setting.module'
import { OgController } from './og.controller'
import { OgService } from './og.service'
@Module({
imports: [ManifestModule, SiteSettingModule],
controllers: [OgController],
providers: [OgService],
})
export class OgModule {}

View File

@@ -0,0 +1,31 @@
/** @jsxImportSource hono/jsx */
import { Buffer } from 'node:buffer'
import { Resvg } from '@resvg/resvg-js'
import type { SatoriOptions } from 'satori'
import satori from 'satori'
import type { OgTemplateProps } from './og.template'
import { OgTemplate } from './og.template'
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()
}

View File

@@ -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<Response> {
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<SatoriOptions['fonts']> {
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<string | null> {
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<ThumbnailCandidateResult | null> {
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<ThumbnailCandidateResult | null> {
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<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()
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')}`
}
}

View File

@@ -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 (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
background:
'linear-gradient(145deg, #0d0d0d 0%, #1c1c1c 20%, #121212 40%, #1a1a1a 60%, #0f0f0f 80%, #0a0a0a 100%)',
padding: '80px',
fontFamily: 'Geist, system-ui, -apple-system, sans-serif',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0.03,
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: '60px 60px',
}}
/>
<div
style={{
position: 'absolute',
top: '0px',
left: '0px',
width: '240px',
height: '240px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(60,60,70,0.15) 0%, rgba(40,40,50,0.08) 40%, transparent 70%)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '0px',
right: '0px',
width: '300px',
height: '300px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(45,45,55,0.12) 0%, rgba(30,30,40,0.06) 50%, transparent 80%)',
}}
/>
<div
style={{
position: 'absolute',
top: '5%',
right: '25%',
width: '180px',
height: '480px',
background:
'linear-gradient(45deg, transparent 0%, rgba(255,255,255,0.02) 40%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 60%, transparent 100%)',
transform: 'rotate(15deg)',
}}
/>
<div
style={{
position: 'absolute',
top: '15%',
right: '5%',
width: '30px',
height: '180px',
background: 'linear-gradient(0deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '3px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{Array.from({ length: 6 }).map((_value, index) => (
<div
key={index}
style={{
marginTop: index === 0 ? '9px' : '15px',
width: '9px',
height: '9px',
background: '#0a0a0a',
borderRadius: '50%',
}}
/>
))}
</div>
<div
style={{
position: 'absolute',
top: '30%',
right: '12%',
width: '120px',
height: '120px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '5px',
transform: 'rotate(12deg)',
}}
/>
<div
style={{
position: 'absolute',
top: '35%',
right: '15%',
width: '90px',
height: '90px',
border: '1px solid rgba(255,255,255,0.04)',
borderRadius: '3px',
transform: 'rotate(-8deg)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '25%',
left: '12%',
width: '72px',
height: '72px',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: '50%',
}}
/>
<div
style={{
position: 'absolute',
bottom: '40%',
right: '8%',
width: '48px',
height: '48px',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: '30px',
height: '30px',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: '15px',
height: '15px',
border: '1px solid rgba(255,255,255,0.04)',
borderRadius: '50%',
}}
/>
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
maxWidth: '58%',
}}
>
<h1
style={{
fontSize: '80px',
fontWeight: 'bold',
color: 'white',
margin: '0 0 16px 0',
lineHeight: '1.1',
letterSpacing: '1px',
display: 'flex',
}}
>
{photoTitle || 'Untitled Photo'}
</h1>
<p
style={{
fontSize: '36px',
color: 'rgba(255,255,255,0.9)',
margin: '0 0 16px 0',
lineHeight: '1.3',
letterSpacing: '0.3px',
display: 'flex',
fontFamily: 'Geist, SF Pro Display',
}}
>
{photoDescription}
</p>
{tags.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '16px',
margin: '0 0 32px 0',
}}
>
{tags.map((tag) => (
<div
key={tag}
style={{
fontSize: '26px',
color: 'rgba(255,255,255,0.9)',
backgroundColor: 'rgba(255,255,255,0.15)',
padding: '12px 20px',
borderRadius: '24px',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
border: '1px solid rgba(255,255,255,0.2)',
backdropFilter: 'blur(8px)',
fontFamily: 'Geist, SF Pro Display',
}}
>
#{tag}
</div>
))}
</div>
)}
</div>
{thumbnailSrc && (
<div
style={{
position: 'absolute',
top: '75px',
right: '45px',
width: `${frame.frameWidth}px`,
height: `${frame.frameHeight}px`,
background: 'linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%)',
borderRadius: '6px',
border: '1px solid #2a2a2a',
boxShadow: '0 12px 48px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.03)',
display: 'flex',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: '0px',
top: '0px',
width: '30px',
height: '100%',
background: 'linear-gradient(90deg, #0a0a0a 0%, #111 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: '25px',
paddingBottom: '25px',
}}
>
{Array.from({ length: 7 }).map((_value, index) => (
<div
key={index}
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
))}
</div>
<div
style={{
position: 'absolute',
right: '0px',
top: '0px',
width: '30px',
height: '100%',
background: 'linear-gradient(90deg, #111 0%, #0a0a0a 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
paddingTop: '25px',
paddingBottom: '25px',
}}
>
{Array.from({ length: 7 }).map((_value, index) => (
<div
key={index}
style={{
width: '10px',
height: '10px',
background: 'radial-gradient(circle, #000 40%, #222 70%, #333 100%)',
borderRadius: '50%',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.8)',
}}
/>
))}
</div>
<div
style={{
position: 'absolute',
left: '30px',
top: '30px',
width: `${frame.imageAreaWidth}px`,
height: `${frame.imageAreaHeight}px`,
background: '#000',
borderRadius: '2px',
border: '2px solid #1a1a1a',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'inset 0 0 8px rgba(0,0,0,0.5)',
}}
>
<div
style={{
position: 'relative',
width: `${frame.displayWidth}px`,
height: `${frame.displayHeight}px`,
overflow: 'hidden',
display: 'flex',
}}
>
<img
src={thumbnailSrc}
style={{
width: `${frame.displayWidth}px`,
height: `${frame.displayHeight}px`,
objectFit: 'cover',
}}
/>
</div>
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
background:
'linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.06) 25%, transparent 45%, transparent 55%, rgba(255,255,255,0.03) 75%, transparent 100%)',
pointerEvents: 'none',
}}
/>
</div>
<div
style={{
position: 'absolute',
top: '0',
left: '30px',
width: `${frame.imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '0',
left: '30px',
width: `${frame.imageAreaWidth}px`,
height: '30px',
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
borderTop: '1px solid rgba(255,255,255,0.05)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '8px',
right: '38px',
fontSize: '14px',
color: '#555',
fontFamily: 'monospace',
letterSpacing: '0.5px',
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
}}
>
{photoId}
</div>
<div
style={{
position: 'absolute',
top: '8px',
left: '42px',
fontSize: '14px',
color: '#555',
letterSpacing: '0.5px',
fontFamily: 'monospace',
background: 'rgba(0,0,0,0.4)',
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
FILM 400 | STUDIO CUT
</div>
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: '80%',
height: '80%',
border: '1px dashed rgba(255,255,255,0.05)',
transform: 'translate(-50%, -50%)',
opacity: 0.6,
pointerEvents: 'none',
}}
/>
</div>
)}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '28px',
}}
>
{formattedDate && (
<div
style={{
fontSize: '28px',
color: 'rgba(255,255,255,0.7)',
letterSpacing: '0.3px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
📸 {formattedDate}
</div>
)}
{exifInfo?.camera && (
<div
style={{
fontSize: '25px',
color: 'rgba(255,255,255,0.6)',
letterSpacing: '0.3px',
display: 'flex',
}}
>
📷 {exifInfo.camera}
</div>
)}
{exifInfo && (exifInfo.aperture || exifInfo.shutterSpeed || exifInfo.iso || exifInfo.focalLength) && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '18px',
fontSize: '25px',
color: 'rgba(255,255,255,0.8)',
}}
>
{exifInfo.aperture && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{exifInfo.aperture}
</div>
)}
{exifInfo.shutterSpeed && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
{exifInfo.shutterSpeed}
</div>
)}
{exifInfo.iso && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
📊 ISO {exifInfo.iso}
</div>
)}
{exifInfo.focalLength && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'rgba(255,255,255,0.1)',
padding: '12px 18px',
borderRadius: '12px',
backdropFilter: 'blur(8px)',
}}
>
🔍 {exifInfo.focalLength}
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -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": []

262
pnpm-lock.yaml generated
View File

@@ -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