mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
feat(static-web): implement static OG image generation service and add font support
- Updated Dockerfile to use Alpine variant for Node.js and added Perl installation. - Introduced StaticOgService for generating Open Graph images with customizable templates. - Added Geist-Medium font for rendering OG images. - Updated TypeScript configuration to include JSX support and adjusted paths. - Created type declarations for assets and Vercel OG module. - Added new static assets for OG image generation. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_VERSION=22.11.0
|
||||
ARG NODE_VERSION=lts
|
||||
ARG NODE_VARIANT=alpine
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS builder
|
||||
FROM node:${NODE_VERSION}-${NODE_VARIANT} AS builder
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
|
||||
@@ -27,10 +28,11 @@ RUN pnpm --filter core build
|
||||
RUN mkdir -p be/apps/core/dist/static/web && cp -r apps/web/dist/. be/apps/core/dist/static/web/
|
||||
RUN mkdir -p be/apps/core/dist/static/dashboard && cp -r be/apps/dashboard/dist/. be/apps/core/dist/static/dashboard/
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS runner
|
||||
FROM node:${NODE_VERSION}-${NODE_VARIANT} AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache perl
|
||||
COPY --from=builder /workspace/be/apps/core/dist ./dist
|
||||
COPY --from=builder /workspace/be/apps/core/drizzle ./drizzle
|
||||
|
||||
|
||||
BIN
be/apps/core/src/modules/static-web/Geist-Medium.ttf
Normal file
BIN
be/apps/core/src/modules/static-web/Geist-Medium.ttf
Normal file
Binary file not shown.
@@ -1,22 +1,21 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { createLogger } from '@afilmory/framework'
|
||||
import { ImageResponse } from '@vercel/og'
|
||||
import type { Context } from 'hono'
|
||||
import type { JSX } from 'react'
|
||||
import * as React from 'react'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import geistFont from '../../../../../../apps/ssr/src/app/og/[photoId]/Geist-Medium.ttf.ts'
|
||||
import sansFont from '../../../../../../apps/ssr/src/app/og/[photoId]/PingFangSC.ttf.ts'
|
||||
import { siteConfig } from '../../../../../../site.config'
|
||||
import geistFont from './Geist-Medium.ttf?url'
|
||||
import { StaticWebManifestService } from './static-web-manifest.service'
|
||||
|
||||
const OG_IMAGE_WIDTH = 1200
|
||||
const OG_IMAGE_HEIGHT = 628
|
||||
|
||||
type OgElement = {
|
||||
type: string
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ExifInfo {
|
||||
focalLength?: string | null
|
||||
aperture?: string | null
|
||||
@@ -46,7 +45,7 @@ interface OgTemplateContext {
|
||||
@injectable()
|
||||
export class StaticOgService {
|
||||
private readonly logger = createLogger('StaticOgService')
|
||||
private fontDataPromise?: Promise<{ geist: ArrayBuffer; sans: ArrayBuffer }>
|
||||
private geistFontPromise?: Promise<ArrayBuffer>
|
||||
|
||||
constructor(private readonly manifestService: StaticWebManifestService) {}
|
||||
|
||||
@@ -59,14 +58,11 @@ export class StaticOgService {
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ geist, sans }, template] = await Promise.all([
|
||||
this.loadFonts(),
|
||||
this.buildTemplateContext(context, photo),
|
||||
])
|
||||
const [geist, template] = await Promise.all([this.loadGeistFont(), this.buildTemplateContext(context, photo)])
|
||||
|
||||
const element = this.renderTemplate(template)
|
||||
|
||||
return new ImageResponse(element as unknown as OgElement, {
|
||||
return new ImageResponse(element, {
|
||||
width: OG_IMAGE_WIDTH,
|
||||
height: OG_IMAGE_HEIGHT,
|
||||
emoji: 'noto',
|
||||
@@ -77,12 +73,6 @@ export class StaticOgService {
|
||||
style: 'normal',
|
||||
weight: 400,
|
||||
},
|
||||
{
|
||||
name: 'SF Pro Display',
|
||||
data: sans,
|
||||
style: 'normal',
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=31536000, stale-while-revalidate=31536000',
|
||||
@@ -118,19 +108,79 @@ export class StaticOgService {
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFonts(): Promise<{ geist: ArrayBuffer; sans: ArrayBuffer }> {
|
||||
if (!this.fontDataPromise) {
|
||||
this.fontDataPromise = Promise.resolve({
|
||||
geist: this.bufferToArrayBuffer(geistFont),
|
||||
sans: this.bufferToArrayBuffer(sansFont),
|
||||
})
|
||||
private async loadGeistFont(): Promise<ArrayBuffer> {
|
||||
if (!this.geistFontPromise) {
|
||||
this.geistFontPromise = this.loadFontData(geistFont)
|
||||
}
|
||||
|
||||
return this.fontDataPromise
|
||||
return this.geistFontPromise
|
||||
}
|
||||
|
||||
private bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
|
||||
private async loadFontData(assetPath: string): Promise<ArrayBuffer> {
|
||||
if (/^https?:\/\//.test(assetPath)) {
|
||||
const response = await fetch(assetPath)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch font from ${assetPath}`)
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
return this.toArrayBuffer(arrayBuffer)
|
||||
}
|
||||
|
||||
if (assetPath.startsWith('data:')) {
|
||||
const base64 = assetPath.split(',')[1] ?? ''
|
||||
return this.toArrayBuffer(Buffer.from(base64, 'base64'))
|
||||
}
|
||||
|
||||
const url = assetPath.startsWith('file:') ? new URL(assetPath) : new URL(assetPath, import.meta.url)
|
||||
|
||||
const buffer = await readFile(url)
|
||||
return this.toArrayBuffer(buffer)
|
||||
}
|
||||
|
||||
private toArrayBuffer(data: ArrayBuffer | SharedArrayBuffer | Uint8Array): ArrayBuffer {
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data instanceof SharedArrayBuffer) {
|
||||
const copy = new Uint8Array(data.byteLength)
|
||||
copy.set(new Uint8Array(data))
|
||||
return copy.buffer
|
||||
}
|
||||
|
||||
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
||||
}
|
||||
|
||||
private arrayBufferToDataUri(buffer: ArrayBuffer, mimeType: string): string {
|
||||
const base64 = Buffer.from(buffer).toString('base64')
|
||||
return `data:${mimeType};base64,${base64}`
|
||||
}
|
||||
|
||||
private inferThumbnailMimeType(thumbnailUrl?: string): string {
|
||||
if (!thumbnailUrl) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
const normalized = thumbnailUrl.toLowerCase()
|
||||
|
||||
if (normalized.endsWith('.png')) {
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.webp')) {
|
||||
return 'image/webp'
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.gif')) {
|
||||
return 'image/gif'
|
||||
}
|
||||
|
||||
if (normalized.endsWith('.avif')) {
|
||||
return 'image/avif'
|
||||
}
|
||||
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
private formatDate(photo: PhotoManifestItem): string {
|
||||
@@ -252,30 +302,9 @@ export class StaticOgService {
|
||||
throw new Error('Unable to load thumbnail image')
|
||||
}
|
||||
|
||||
private renderTemplate(context: OgTemplateContext): OgElement {
|
||||
private renderTemplate(context: OgTemplateContext): JSX.Element {
|
||||
const { photo, formattedDate, tags, exifInfo, layout, thumbnailBuffer } = context
|
||||
|
||||
const h = (
|
||||
type: string,
|
||||
props: Record<string, unknown> | null,
|
||||
...children: Array<OgElement | string | null>
|
||||
): OgElement => {
|
||||
const filteredChildren = children
|
||||
.flat()
|
||||
.filter((child): child is OgElement | string => child !== null && child !== undefined && child !== false)
|
||||
|
||||
const normalizedProps: Record<string, unknown> = { ...props }
|
||||
|
||||
if (filteredChildren.length > 0) {
|
||||
normalizedProps.children = filteredChildren.length === 1 ? filteredChildren[0] : filteredChildren
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
props: normalizedProps,
|
||||
}
|
||||
}
|
||||
|
||||
const decorativeOverlays = [
|
||||
{
|
||||
position: 'absolute',
|
||||
@@ -316,25 +345,22 @@ export class StaticOgService {
|
||||
'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)',
|
||||
},
|
||||
].map((style) => h('div', { style }, null))
|
||||
].map((style, index) => <div key={`decorative-${index}`} style={style} />)
|
||||
|
||||
const filmHoles = Array.from({ length: 7 }, () =>
|
||||
h(
|
||||
'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)',
|
||||
},
|
||||
},
|
||||
null,
|
||||
),
|
||||
)
|
||||
const filmHoles = Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={`film-hole-${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)',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
|
||||
const filmBorderStyles = [
|
||||
const filmBorderElements = [
|
||||
{
|
||||
position: 'absolute',
|
||||
top: '30%',
|
||||
@@ -364,12 +390,11 @@ export class StaticOgService {
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
].map((style) => h('div', { style }, null))
|
||||
].map((style, index) => <div key={`film-border-${index}`} style={style} />)
|
||||
|
||||
const apertureDecor = h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
const apertureDecor = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '40%',
|
||||
right: '8%',
|
||||
@@ -380,12 +405,10 @@ export class StaticOgService {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
@@ -393,63 +416,191 @@ export class StaticOgService {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
},
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '15px',
|
||||
height: '15px',
|
||||
border: '1px solid rgba(255,255,255,0.04)',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
},
|
||||
null,
|
||||
),
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const tagElements =
|
||||
tags.length > 0
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
margin: '0 0 32px 0',
|
||||
},
|
||||
},
|
||||
...tags.map((tag) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
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}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null
|
||||
const tagElements = tags.slice(0, 3).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>
|
||||
))
|
||||
|
||||
const leftFilmColumn = h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
const exifBadges: JSX.Element[] = []
|
||||
if (exifInfo?.aperture) {
|
||||
exifBadges.push(
|
||||
<div
|
||||
key="aperture"
|
||||
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>,
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo?.shutterSpeed) {
|
||||
exifBadges.push(
|
||||
<div
|
||||
key="shutter"
|
||||
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>,
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo?.iso) {
|
||||
exifBadges.push(
|
||||
<div
|
||||
key="iso"
|
||||
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>,
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo?.focalLength) {
|
||||
exifBadges.push(
|
||||
<div
|
||||
key="focalLength"
|
||||
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>,
|
||||
)
|
||||
}
|
||||
|
||||
const footerItems: JSX.Element[] = []
|
||||
|
||||
if (formattedDate) {
|
||||
footerItems.push(
|
||||
<div
|
||||
key="date"
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{`📸 ${formattedDate}`}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo?.camera) {
|
||||
footerItems.push(
|
||||
<div
|
||||
key="camera"
|
||||
style={{
|
||||
fontSize: '25px',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{`📷 ${exifInfo.camera}`}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
if (exifBadges.length > 0) {
|
||||
footerItems.push(
|
||||
<div
|
||||
key="exif"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '18px',
|
||||
fontSize: '25px',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
}}
|
||||
>
|
||||
{exifBadges}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
const footer =
|
||||
footerItems.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '28px',
|
||||
}}
|
||||
>
|
||||
{footerItems}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const thumbnailSrc =
|
||||
thumbnailBuffer && this.arrayBufferToDataUri(thumbnailBuffer, this.inferThumbnailMimeType(photo.thumbnailUrl))
|
||||
|
||||
const leftFilmColumn = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
@@ -462,15 +613,15 @@ export class StaticOgService {
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: '25px',
|
||||
paddingBottom: '25px',
|
||||
},
|
||||
},
|
||||
...filmHoles,
|
||||
}}
|
||||
>
|
||||
{filmHoles}
|
||||
</div>
|
||||
)
|
||||
|
||||
const rightFilmColumn = h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
const rightFilmColumn = (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0px',
|
||||
top: '0px',
|
||||
@@ -483,284 +634,42 @@ export class StaticOgService {
|
||||
justifyContent: 'space-around',
|
||||
paddingTop: '25px',
|
||||
paddingBottom: '25px',
|
||||
},
|
||||
},
|
||||
...filmHoles,
|
||||
}}
|
||||
>
|
||||
{filmHoles}
|
||||
</div>
|
||||
)
|
||||
|
||||
const filmTexture = [
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '30px',
|
||||
width: `${layout.imageAreaWidth}px`,
|
||||
height: '30px',
|
||||
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
},
|
||||
},
|
||||
null,
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '30px',
|
||||
width: `${layout.imageAreaWidth}px`,
|
||||
height: '30px',
|
||||
background: 'linear-gradient(0deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
|
||||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
},
|
||||
},
|
||||
null,
|
||||
),
|
||||
<div
|
||||
key="film-texture-top"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '30px',
|
||||
width: `${layout.imageAreaWidth}px`,
|
||||
height: '30px',
|
||||
background: 'linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
/>,
|
||||
<div
|
||||
key="film-texture-bottom"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '30px',
|
||||
width: `${layout.imageAreaWidth}px`,
|
||||
height: '30px',
|
||||
background: 'linear-gradient(0deg, #1a1a1a 0%, #2a2a2a 30%, #1a1a1a 100%)',
|
||||
borderTop: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
/>,
|
||||
]
|
||||
|
||||
const photoFrame =
|
||||
photo.thumbnailUrl && thumbnailBuffer
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: '75px',
|
||||
right: '45px',
|
||||
width: `${layout.frameWidth}px`,
|
||||
height: `${layout.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',
|
||||
},
|
||||
},
|
||||
leftFilmColumn,
|
||||
rightFilmColumn,
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
left: '30px',
|
||||
top: '30px',
|
||||
width: `${layout.imageAreaWidth}px`,
|
||||
height: `${layout.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)',
|
||||
},
|
||||
},
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
position: 'relative',
|
||||
width: `${layout.displayWidth}px`,
|
||||
height: `${layout.displayHeight}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
h(
|
||||
'img',
|
||||
{
|
||||
src: thumbnailBuffer,
|
||||
style: {
|
||||
width: `${layout.displayWidth}px`,
|
||||
height: `${layout.displayHeight}px`,
|
||||
objectFit: 'cover',
|
||||
},
|
||||
},
|
||||
null,
|
||||
),
|
||||
),
|
||||
h(
|
||||
'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',
|
||||
},
|
||||
},
|
||||
null,
|
||||
),
|
||||
...filmTexture,
|
||||
),
|
||||
)
|
||||
: null
|
||||
|
||||
const footerItems: Array<OgElement | string> = []
|
||||
|
||||
if (formattedDate) {
|
||||
footerItems.push(
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontSize: '28px',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
},
|
||||
},
|
||||
`📸 ${formattedDate}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo?.camera) {
|
||||
footerItems.push(
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
fontSize: '25px',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
`📷 ${exifInfo.camera}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo && (exifInfo.aperture || exifInfo.shutterSpeed || exifInfo.iso || exifInfo.focalLength)) {
|
||||
const exifBadges: OgElement[] = []
|
||||
|
||||
if (exifInfo.aperture) {
|
||||
exifBadges.push(
|
||||
h(
|
||||
'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}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo.shutterSpeed) {
|
||||
exifBadges.push(
|
||||
h(
|
||||
'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}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo.iso) {
|
||||
exifBadges.push(
|
||||
h(
|
||||
'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}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (exifInfo.focalLength) {
|
||||
exifBadges.push(
|
||||
h(
|
||||
'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}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
footerItems.push(
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '18px',
|
||||
fontSize: '25px',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
},
|
||||
},
|
||||
...exifBadges,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const footer = h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '28px',
|
||||
},
|
||||
},
|
||||
...footerItems,
|
||||
)
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
@@ -772,25 +681,22 @@ export class StaticOgService {
|
||||
padding: '80px',
|
||||
fontFamily: 'Geist, system-ui, -apple-system, sans-serif',
|
||||
position: 'relative',
|
||||
},
|
||||
},
|
||||
...decorativeOverlays,
|
||||
...filmBorderStyles,
|
||||
apertureDecor,
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
}}
|
||||
>
|
||||
{decorativeOverlays}
|
||||
{filmBorderElements}
|
||||
{apertureDecor}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
maxWidth: '58%',
|
||||
},
|
||||
},
|
||||
h(
|
||||
'h1',
|
||||
{
|
||||
style: {
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '80px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
@@ -798,14 +704,13 @@ export class StaticOgService {
|
||||
lineHeight: '1.1',
|
||||
letterSpacing: '1px',
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
photo.title || 'Untitled Photo',
|
||||
),
|
||||
h(
|
||||
'p',
|
||||
{
|
||||
style: {
|
||||
}}
|
||||
>
|
||||
{photo.title || 'Untitled Photo'}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: '36px',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
margin: '0 0 16px 0',
|
||||
@@ -813,14 +718,101 @@ export class StaticOgService {
|
||||
letterSpacing: '0.3px',
|
||||
display: 'flex',
|
||||
fontFamily: 'Geist, SF Pro Display',
|
||||
},
|
||||
},
|
||||
photo.description || siteConfig.name || siteConfig.title,
|
||||
),
|
||||
tagElements,
|
||||
),
|
||||
photoFrame,
|
||||
footer,
|
||||
}}
|
||||
>
|
||||
{photo.description || siteConfig.name || siteConfig.title}
|
||||
</p>
|
||||
|
||||
{tagElements.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
margin: '0 0 32px 0',
|
||||
}}
|
||||
>
|
||||
{tagElements}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{thumbnailSrc && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '75px',
|
||||
right: '45px',
|
||||
width: `${layout.frameWidth}px`,
|
||||
height: `${layout.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',
|
||||
}}
|
||||
>
|
||||
{leftFilmColumn}
|
||||
{rightFilmColumn}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '30px',
|
||||
top: '30px',
|
||||
width: `${layout.imageAreaWidth}px`,
|
||||
height: `${layout.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: `${layout.displayWidth}px`,
|
||||
height: `${layout.displayHeight}px`,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={photo.title ?? 'Photo thumbnail'}
|
||||
style={{
|
||||
width: `${layout.displayWidth}px`,
|
||||
height: `${layout.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',
|
||||
}}
|
||||
/>
|
||||
|
||||
{filmTexture}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
4
be/apps/core/src/types/assets.d.ts
vendored
Normal file
4
be/apps/core/src/types/assets.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.ttf?url' {
|
||||
const url: string
|
||||
export default url
|
||||
}
|
||||
25
be/apps/core/src/types/vercel-og.d.ts
vendored
Normal file
25
be/apps/core/src/types/vercel-og.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
declare module '@vercel/og' {
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
export type EmojiStyle = 'twemoji' | 'apple' | 'blobmoji' | 'noto'
|
||||
|
||||
export interface FontConfig {
|
||||
name: string
|
||||
data: ArrayBuffer
|
||||
weight?: number
|
||||
style?: 'normal' | 'italic'
|
||||
}
|
||||
|
||||
export interface ImageResponseOptions {
|
||||
width?: number
|
||||
height?: number
|
||||
emoji?: EmojiStyle
|
||||
fonts?: FontConfig[]
|
||||
headers?: Record<string, string>
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export class ImageResponse extends Response {
|
||||
constructor(element: ReactElement, options?: ImageResponseOptions)
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,11 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "react",
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"core": ["./src"],
|
||||
"core/*": ["./src/*"],
|
||||
"@afilmory/db": ["../../packages/db/src"],
|
||||
"@afilmory/db/*": ["../../packages/db/src/*"],
|
||||
"@afilmory/be-utils": ["../../packages/utils/src"],
|
||||
"@afilmory/be-utils/*": ["../../packages/utils/src/*"],
|
||||
"@afilmory/websocket": ["../../packages/websocket/src"],
|
||||
"@afilmory/websocket/*": ["../../packages/websocket/src/*"]
|
||||
"core/*": ["./src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
|
||||
Reference in New Issue
Block a user