mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
|
||
import sharp from 'sharp'
|
||
|
||
import { buildTimePhotoLoader } from './photo-loader.js'
|
||
import { renderSVGText, wrapSVGText } from './svg-text-renderer.js'
|
||
|
||
// 获取最新的照片
|
||
async function getLatestPhotos(count = 4) {
|
||
const photos = buildTimePhotoLoader.getPhotos()
|
||
|
||
// 按拍摄时间排序,获取最新的照片
|
||
const sortedPhotos = photos.sort((a, b) => {
|
||
if (!a?.exif?.DateTimeOriginal || !b?.exif?.DateTimeOriginal) {
|
||
return 0
|
||
}
|
||
|
||
const aDate =
|
||
(a.exif.DateTimeOriginal as unknown as string) || a.lastModified
|
||
const bDate =
|
||
(b.exif.DateTimeOriginal as unknown as string) || b.lastModified
|
||
return bDate.localeCompare(aDate)
|
||
})
|
||
|
||
return sortedPhotos.slice(0, count)
|
||
}
|
||
|
||
// 下载并处理照片缩略图
|
||
async function downloadAndProcessThumbnail(thumbnailUrl: string, size = 150) {
|
||
try {
|
||
// 如果是本地路径,直接读取
|
||
if (thumbnailUrl.startsWith('/')) {
|
||
const localPath = join(process.cwd(), 'public', thumbnailUrl)
|
||
if (existsSync(localPath)) {
|
||
return await sharp(localPath)
|
||
.resize(size, size, { fit: 'cover' })
|
||
.png()
|
||
.toBuffer()
|
||
}
|
||
}
|
||
|
||
// 如果是 URL,需要下载(这里先返回 null,后面可以添加网络下载功能)
|
||
console.warn(`Cannot download thumbnail from URL: ${thumbnailUrl}`)
|
||
return null
|
||
} catch (error) {
|
||
console.warn(`Failed to process thumbnail: ${thumbnailUrl}`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 创建带特效的照片(旋转、阴影、边框)
|
||
async function createPhotoWithEffects(
|
||
imageBuffer: Buffer,
|
||
size: number,
|
||
rotation: number,
|
||
) {
|
||
try {
|
||
// 计算旋转后需要的画布大小
|
||
const diagonal = Math.ceil(size * Math.sqrt(2))
|
||
const canvasSize = diagonal + 40 // 额外空间用于阴影
|
||
|
||
// 创建阴影效果的 SVG
|
||
const shadowSvg = `
|
||
<svg width="${canvasSize}" height="${canvasSize}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||
<feDropShadow dx="4" dy="8" stdDeviation="6" flood-color="rgba(0,0,0,0.4)"/>
|
||
</filter>
|
||
</defs>
|
||
<rect x="${(canvasSize - size - 12) / 2}" y="${(canvasSize - size - 12) / 2}"
|
||
width="${size + 12}" height="${size + 12}"
|
||
fill="#f0f0f0" filter="url(#shadow)"
|
||
transform="rotate(${rotation} ${canvasSize / 2} ${canvasSize / 2})"/>
|
||
</svg>
|
||
`
|
||
|
||
// 创建阴影层
|
||
const shadowBuffer = await sharp(Buffer.from(shadowSvg)).png().toBuffer()
|
||
|
||
// 处理原图片:添加浅灰色边框并旋转(适配黑色主题)
|
||
const photoWithBorder = await sharp(imageBuffer)
|
||
.extend({
|
||
top: 6,
|
||
bottom: 6,
|
||
left: 6,
|
||
right: 6,
|
||
background: { r: 240, g: 240, b: 240, alpha: 1 },
|
||
})
|
||
.png()
|
||
.toBuffer()
|
||
|
||
// 创建最终画布
|
||
const canvas = sharp({
|
||
create: {
|
||
width: canvasSize,
|
||
height: canvasSize,
|
||
channels: 4,
|
||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||
},
|
||
})
|
||
|
||
// 计算照片在画布中的位置
|
||
const photoX = (canvasSize - size - 12) / 2
|
||
const photoY = (canvasSize - size - 12) / 2
|
||
|
||
// 合成阴影和照片
|
||
const result = await canvas
|
||
.composite([
|
||
{ input: shadowBuffer, top: 0, left: 0 },
|
||
{
|
||
input: photoWithBorder,
|
||
top: Math.round(photoY),
|
||
left: Math.round(photoX),
|
||
},
|
||
])
|
||
.png()
|
||
.toBuffer()
|
||
|
||
// 旋转整个图像
|
||
return await sharp(result)
|
||
.rotate(rotation, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||
.png()
|
||
.toBuffer()
|
||
} catch (error) {
|
||
console.warn('Failed to create photo with effects:', error)
|
||
// 如果特效失败,返回简单的边框版本(适配黑色主题)
|
||
return await sharp(imageBuffer)
|
||
.extend({
|
||
top: 4,
|
||
bottom: 4,
|
||
left: 4,
|
||
right: 4,
|
||
background: { r: 240, g: 240, b: 240, alpha: 1 },
|
||
})
|
||
.png()
|
||
.toBuffer()
|
||
}
|
||
}
|
||
|
||
interface OGImageOptions {
|
||
title: string
|
||
description: string
|
||
width?: number
|
||
height?: number
|
||
outputPath: string
|
||
includePhotos?: boolean
|
||
photoCount?: number
|
||
}
|
||
|
||
export async function generateOGImage(options: OGImageOptions) {
|
||
const {
|
||
title,
|
||
description,
|
||
width = 1200,
|
||
height = 630,
|
||
outputPath,
|
||
includePhotos = true,
|
||
photoCount = 4,
|
||
} = options
|
||
|
||
// 确保输出目录存在
|
||
const outputDir = join(process.cwd(), 'public')
|
||
if (!existsSync(outputDir)) {
|
||
mkdirSync(outputDir, { recursive: true })
|
||
}
|
||
|
||
try {
|
||
let finalImage: sharp.Sharp
|
||
|
||
if (includePhotos) {
|
||
// 获取最新照片
|
||
const latestPhotos = await getLatestPhotos(photoCount)
|
||
console.info(`📸 Found ${latestPhotos.length} latest photos`)
|
||
|
||
// 创建基础画布 - 黑色主题
|
||
const canvas = sharp({
|
||
create: {
|
||
width,
|
||
height,
|
||
channels: 4,
|
||
background: { r: 0, g: 0, b: 0, alpha: 1 },
|
||
},
|
||
})
|
||
|
||
// 创建现代黑色主题渐变背景
|
||
const gradientSvg = `
|
||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#0f0f0f;stop-opacity:1" />
|
||
<stop offset="50%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#0a0a0a;stop-opacity:1" />
|
||
</linearGradient>
|
||
<radialGradient id="accent" cx="80%" cy="20%" r="60%">
|
||
<stop offset="0%" style="stop-color:#333333;stop-opacity:0.3" />
|
||
<stop offset="100%" style="stop-color:#000000;stop-opacity:0" />
|
||
</radialGradient>
|
||
<pattern id="grid" width="60" height="60" patternUnits="userSpaceOnUse">
|
||
<path d="M 60 0 L 0 0 0 60" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
||
</pattern>
|
||
</defs>
|
||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||
<rect width="100%" height="100%" fill="url(#accent)"/>
|
||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||
</svg>
|
||
`
|
||
|
||
const gradientBuffer = await sharp(Buffer.from(gradientSvg))
|
||
.png()
|
||
.toBuffer()
|
||
|
||
// 创建文字层 - 使用 SVG 路径绘制 Helvetica 风格字体
|
||
const wrappedTitle = wrapSVGText(title, width - 120, {
|
||
fontSize: 48,
|
||
fontWeight: 'bold',
|
||
})
|
||
const wrappedDescription = wrapSVGText(description, width - 120, {
|
||
fontSize: 24,
|
||
})
|
||
const footerText = `Latest Photos • Generated on ${new Date().toLocaleDateString()}`
|
||
|
||
const titleSVG = renderSVGText(wrappedTitle, 60, 72, {
|
||
fontSize: 48,
|
||
fontWeight: 'bold',
|
||
color: 'white',
|
||
letterSpacing: 2,
|
||
})
|
||
|
||
const descriptionSVG = renderSVGText(wrappedDescription, 60, 146, {
|
||
fontSize: 24,
|
||
color: 'rgba(255,255,255,0.9)',
|
||
letterSpacing: 1,
|
||
})
|
||
|
||
const footerSVG = renderSVGText(footerText, 60, 556, {
|
||
fontSize: 18,
|
||
color: 'rgba(255,255,255,0.7)',
|
||
letterSpacing: 0.5,
|
||
})
|
||
|
||
const textSvg = `
|
||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||
${titleSVG}
|
||
${descriptionSVG}
|
||
${footerSVG}
|
||
</svg>
|
||
`
|
||
|
||
const textBuffer = await sharp(Buffer.from(textSvg)).png().toBuffer()
|
||
|
||
// 准备合成图层
|
||
const composite: sharp.OverlayOptions[] = [
|
||
{ input: gradientBuffer, top: 0, left: 0 },
|
||
{ input: textBuffer, top: 0, left: 0 },
|
||
]
|
||
|
||
// 处理照片缩略图 - 创建倾斜叠加效果
|
||
const photoSize = 160
|
||
const baseX = 580
|
||
const baseY = 200 // 往下移动 50px
|
||
const rotations = [-12, 5, -8, 10] // 每张照片的旋转角度
|
||
const offsets = [
|
||
{ x: 0, y: 20 },
|
||
{ x: 90, y: 60 },
|
||
{ x: 180, y: -10 },
|
||
{ x: 270, y: 70 },
|
||
]
|
||
|
||
const length = Math.min(latestPhotos.length, photoCount)
|
||
for (let i = length - 1; i >= 0; i--) {
|
||
const photo = latestPhotos[i]
|
||
const thumbnailBuffer = await downloadAndProcessThumbnail(
|
||
photo.thumbnailUrl,
|
||
photoSize,
|
||
)
|
||
|
||
if (thumbnailBuffer) {
|
||
const rotation = rotations[i] || 0
|
||
const offset = offsets[i] || { x: i * 60, y: 0 }
|
||
const x = baseX + offset.x
|
||
const y = baseY + offset.y
|
||
|
||
// 创建带阴影和边框的照片
|
||
const photoWithEffects = await createPhotoWithEffects(
|
||
thumbnailBuffer,
|
||
photoSize,
|
||
rotation,
|
||
)
|
||
|
||
composite.push({
|
||
input: photoWithEffects,
|
||
top: y,
|
||
left: x,
|
||
})
|
||
|
||
console.info(
|
||
`📷 Added photo: ${photo.title} at position (${x}, ${y}) with rotation ${rotation}°`,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 合成最终图像
|
||
finalImage = canvas.composite(composite)
|
||
} else {
|
||
// 不包含照片的简单版本 - 黑色主题,使用 SVG 路径绘制字体
|
||
const simpleWrappedTitle = wrapSVGText(title, width - 120, {
|
||
fontSize: 72,
|
||
fontWeight: 'bold',
|
||
})
|
||
const simpleWrappedDescription = wrapSVGText(description, width - 120, {
|
||
fontSize: 32,
|
||
})
|
||
const simpleFooterText = `Generated on ${new Date().toLocaleDateString()}`
|
||
|
||
const simpleTitleSVG = renderSVGText(simpleWrappedTitle, 60, 152, {
|
||
fontSize: 72,
|
||
fontWeight: 'bold',
|
||
color: 'white',
|
||
letterSpacing: 3,
|
||
})
|
||
|
||
const simpleDescriptionSVG = renderSVGText(
|
||
simpleWrappedDescription,
|
||
60,
|
||
256,
|
||
{
|
||
fontSize: 32,
|
||
color: 'rgba(255,255,255,0.9)',
|
||
letterSpacing: 1.5,
|
||
},
|
||
)
|
||
|
||
const simpleFooterSVG = renderSVGText(simpleFooterText, 60, 526, {
|
||
fontSize: 24,
|
||
color: 'rgba(255,255,255,0.7)',
|
||
letterSpacing: 1,
|
||
})
|
||
|
||
const svgContent = `
|
||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#0f0f0f;stop-opacity:1" />
|
||
<stop offset="50%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#0a0a0a;stop-opacity:1" />
|
||
</linearGradient>
|
||
<radialGradient id="accent" cx="80%" cy="20%" r="60%">
|
||
<stop offset="0%" style="stop-color:#333333;stop-opacity:0.3" />
|
||
<stop offset="100%" style="stop-color:#000000;stop-opacity:0" />
|
||
</radialGradient>
|
||
<pattern id="grid" width="60" height="60" patternUnits="userSpaceOnUse">
|
||
<path d="M 60 0 L 0 0 0 60" fill="none" stroke="rgba(255,255,255,0.02)" stroke-width="1"/>
|
||
</pattern>
|
||
</defs>
|
||
|
||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||
<rect width="100%" height="100%" fill="url(#accent)"/>
|
||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||
|
||
${simpleTitleSVG}
|
||
${simpleDescriptionSVG}
|
||
${simpleFooterSVG}
|
||
|
||
<circle cx="1000" cy="150" r="80" fill="rgba(255,255,255,0.03)"/>
|
||
<circle cx="1050" cy="200" r="40" fill="rgba(255,255,255,0.02)"/>
|
||
<circle cx="950" cy="250" r="60" fill="rgba(255,255,255,0.025)"/>
|
||
</svg>
|
||
`
|
||
|
||
finalImage = sharp(Buffer.from(svgContent))
|
||
}
|
||
|
||
// 生成最终图片
|
||
const buffer = await finalImage.png().toBuffer()
|
||
|
||
// 写入文件
|
||
const fullOutputPath = join(outputDir, outputPath)
|
||
writeFileSync(fullOutputPath, buffer)
|
||
|
||
console.info(`✅ OG image generated: ${fullOutputPath}`)
|
||
return fullOutputPath
|
||
} catch (error) {
|
||
console.error('❌ Error generating OG image:', error)
|
||
throw error
|
||
}
|
||
}
|