diff --git a/be/apps/core/Dockerfile b/be/apps/core/Dockerfile index c67a6ff6..1e99bbcc 100644 --- a/be/apps/core/Dockerfile +++ b/be/apps/core/Dockerfile @@ -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 diff --git a/be/apps/core/src/modules/static-web/Geist-Medium.ttf b/be/apps/core/src/modules/static-web/Geist-Medium.ttf new file mode 100644 index 00000000..97712e55 Binary files /dev/null and b/be/apps/core/src/modules/static-web/Geist-Medium.ttf differ diff --git a/be/apps/core/src/modules/static-web/static-og.service.ts b/be/apps/core/src/modules/static-web/static-og.service.tsx similarity index 52% rename from be/apps/core/src/modules/static-web/static-og.service.ts rename to be/apps/core/src/modules/static-web/static-og.service.tsx index c60c7558..b8139640 100644 --- a/be/apps/core/src/modules/static-web/static-og.service.ts +++ b/be/apps/core/src/modules/static-web/static-og.service.tsx @@ -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 -} - 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 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 { + 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 { + 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 | null, - ...children: Array - ): OgElement => { - const filteredChildren = children - .flat() - .filter((child): child is OgElement | string => child !== null && child !== undefined && child !== false) - - const normalizedProps: Record = { ...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) =>
) - 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) => ( +
+ )) - 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) =>
) - const apertureDecor = h( - 'div', - { - style: { + const apertureDecor = ( +
+
+
+
+
) - 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) => ( +
+ #{tag} +
+ )) - const leftFilmColumn = h( - 'div', - { - style: { + const exifBadges: JSX.Element[] = [] + if (exifInfo?.aperture) { + exifBadges.push( +
+ {`⚫ ${exifInfo.aperture}`} +
, + ) + } + + if (exifInfo?.shutterSpeed) { + exifBadges.push( +
+ {`⏱️ ${exifInfo.shutterSpeed}`} +
, + ) + } + + if (exifInfo?.iso) { + exifBadges.push( +
+ {`📊 ISO ${exifInfo.iso}`} +
, + ) + } + + if (exifInfo?.focalLength) { + exifBadges.push( +
+ {`🔍 ${exifInfo.focalLength}`} +
, + ) + } + + const footerItems: JSX.Element[] = [] + + if (formattedDate) { + footerItems.push( +
+ {`📸 ${formattedDate}`} +
, + ) + } + + if (exifInfo?.camera) { + footerItems.push( +
+ {`📷 ${exifInfo.camera}`} +
, + ) + } + + if (exifBadges.length > 0) { + footerItems.push( +
+ {exifBadges} +
, + ) + } + + const footer = + footerItems.length > 0 ? ( +
+ {footerItems} +
+ ) : null + + const thumbnailSrc = + thumbnailBuffer && this.arrayBufferToDataUri(thumbnailBuffer, this.inferThumbnailMimeType(photo.thumbnailUrl)) + + const leftFilmColumn = ( +
+ {filmHoles} +
) - const rightFilmColumn = h( - 'div', - { - style: { + const rightFilmColumn = ( +
+ {filmHoles} +
) 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, - ), +
, +
, ] - 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 = [] - - 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 ( +
+ {decorativeOverlays} + {filmBorderElements} + {apertureDecor} + +
+

+ {photo.title || 'Untitled Photo'} +

+ +

+ {photo.description || siteConfig.name || siteConfig.title} +

+ + {tagElements.length > 0 && ( +
+ {tagElements} +
+ )} +
+ + {thumbnailSrc && ( +
+ {leftFilmColumn} + {rightFilmColumn} + +
+
+ {photo.title +
+ +
+ + {filmTexture} +
+
+ )} + + {footer} +
) } } diff --git a/be/apps/core/src/types/assets.d.ts b/be/apps/core/src/types/assets.d.ts new file mode 100644 index 00000000..3afc367c --- /dev/null +++ b/be/apps/core/src/types/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.ttf?url' { + const url: string + export default url +} diff --git a/be/apps/core/src/types/vercel-og.d.ts b/be/apps/core/src/types/vercel-og.d.ts new file mode 100644 index 00000000..e66aa348 --- /dev/null +++ b/be/apps/core/src/types/vercel-og.d.ts @@ -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 + debug?: boolean + } + + export class ImageResponse extends Response { + constructor(element: ReactElement, options?: ImageResponseOptions) + } +} diff --git a/be/apps/core/tsconfig.json b/be/apps/core/tsconfig.json index 97e362c1..25039a8e 100644 --- a/be/apps/core/tsconfig.json +++ b/be/apps/core/tsconfig.json @@ -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,