mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)',
|
||||
}}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
be/apps/core/src/modules/og/og.controller.ts
Normal file
14
be/apps/core/src/modules/og/og.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
13
be/apps/core/src/modules/og/og.module.ts
Normal file
13
be/apps/core/src/modules/og/og.module.ts
Normal 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 {}
|
||||
31
be/apps/core/src/modules/og/og.renderer.tsx
Normal file
31
be/apps/core/src/modules/og/og.renderer.tsx
Normal 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()
|
||||
}
|
||||
362
be/apps/core/src/modules/og/og.service.ts
Normal file
362
be/apps/core/src/modules/og/og.service.ts
Normal 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')}`
|
||||
}
|
||||
}
|
||||
595
be/apps/core/src/modules/og/og.template.tsx
Normal file
595
be/apps/core/src/modules/og/og.template.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
262
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user