import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { IMAGE_PROJECTS_ROOT } from "./config"; export type ImageSettings = { fit: "contain" | "cover"; scale: number; offsetX: number; offsetY: number; rotation: number; brightness: number; contrast: number; saturation: number; borderRadius: number; }; export type ImageSlide = { id: string; fileName: string; originalName: string; mimeType: string; settings: ImageSettings; }; export type ImageProject = { id: string; name: string; canvas: { width: number; height: number; background: string }; slides: ImageSlide[]; createdAt: string; updatedAt: string; }; const PROJECT_ID = /^[0-9a-f-]{36}$/; const FILE_ID = /^[0-9a-f-]{36}\.(?:png|jpe?g|webp)$/; const MIME_EXTENSIONS: Record = { "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp" }; const DEFAULT_SETTINGS: ImageSettings = { fit: "cover", scale: 100, offsetX: 0, offsetY: 0, rotation: 0, brightness: 100, contrast: 100, saturation: 100, borderRadius: 0 }; function projectRoot(id: string) { if (!PROJECT_ID.test(id)) throw new Error("Invalid image project id"); return path.join(IMAGE_PROJECTS_ROOT, id); } function projectFile(id: string) { return path.join(projectRoot(id), "project.json"); } function clamp(value: unknown, min: number, max: number, fallback: number) { const number = Number(value); return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; } function normalizeSettings(value: unknown, previous = DEFAULT_SETTINGS): ImageSettings { const input = value && typeof value === "object" ? value as Partial : {}; return { fit: input.fit === "contain" ? "contain" : input.fit === "cover" ? "cover" : previous.fit, scale: clamp(input.scale, 20, 300, previous.scale), offsetX: clamp(input.offsetX, -100, 100, previous.offsetX), offsetY: clamp(input.offsetY, -100, 100, previous.offsetY), rotation: clamp(input.rotation, -180, 180, previous.rotation), brightness: clamp(input.brightness, 0, 200, previous.brightness), contrast: clamp(input.contrast, 0, 200, previous.contrast), saturation: clamp(input.saturation, 0, 200, previous.saturation), borderRadius: clamp(input.borderRadius, 0, 200, previous.borderRadius), }; } function cleanName(value: unknown, fallback: string) { return typeof value === "string" && value.trim() ? value.trim().slice(0, 80) : fallback; } async function readProject(id: string): Promise { return JSON.parse(await fs.readFile(projectFile(id), "utf8")); } async function writeProject(project: ImageProject) { await fs.writeFile(projectFile(project.id), JSON.stringify(project, null, 2), "utf8"); } function validateFiles(files: File[]) { if (files.length < 1 || files.length > 20) throw new Error("请选择 1 到 20 张图片"); let total = 0; for (const file of files) { if (!MIME_EXTENSIONS[file.type]) throw new Error(`不支持的图片格式:${file.name}`); if (file.size > 25 * 1024 * 1024) throw new Error(`单张图片不能超过 25MB:${file.name}`); total += file.size; } if (total > 150 * 1024 * 1024) throw new Error("图片总大小不能超过 150MB"); } async function storeFiles(root: string, files: File[]): Promise { const imagesRoot = path.join(root, "images"); await fs.mkdir(imagesRoot, { recursive: true }); const slides: ImageSlide[] = []; for (const file of files) { const id = crypto.randomUUID(); const fileName = `${id}.${MIME_EXTENSIONS[file.type]}`; await fs.writeFile(path.join(imagesRoot, fileName), Buffer.from(await file.arrayBuffer())); slides.push({ id, fileName, originalName: cleanName(file.name, "图片"), mimeType: file.type, settings: { ...DEFAULT_SETTINGS } }); } return slides; } export async function listImageProjects() { await fs.mkdir(IMAGE_PROJECTS_ROOT, { recursive: true }); const entries = await fs.readdir(IMAGE_PROJECTS_ROOT, { withFileTypes: true }); const projects = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => { try { return await readProject(entry.name); } catch { return null; } })); return projects.filter((project): project is ImageProject => Boolean(project)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } export async function createImageProject(files: File[], requestedName?: unknown) { validateFiles(files); await fs.mkdir(IMAGE_PROJECTS_ROOT, { recursive: true }); const id = crypto.randomUUID(); const root = projectRoot(id); await fs.mkdir(root, { recursive: true }); try { const slides = await storeFiles(root, files); const now = new Date().toISOString(); const baseName = path.parse(files[0].name).name || "图文项目"; const project: ImageProject = { id, name: cleanName(requestedName, files.length > 1 ? `${baseName} 等 ${files.length} 张` : baseName), canvas: { width: 1080, height: 1440, background: "#ffffff" }, slides, createdAt: now, updatedAt: now, }; await writeProject(project); return project; } catch (error) { await fs.rm(root, { recursive: true, force: true }); throw error; } } export async function getImageProject(id: string) { return readProject(id); } export async function addImagesToProject(id: string, files: File[]) { validateFiles(files); const project = await readProject(id); if (project.slides.length + files.length > 20) throw new Error("每个项目最多包含 20 张图片"); const slides = await storeFiles(projectRoot(id), files); project.slides.push(...slides); project.updatedAt = new Date().toISOString(); await writeProject(project); return project; } export async function updateImageProject(id: string, input: unknown) { const project = await readProject(id); const value = input && typeof input === "object" ? input as Partial : {}; project.name = cleanName(value.name, project.name); if (value.canvas && typeof value.canvas === "object") { project.canvas = { width: clamp(value.canvas.width, 320, 4096, project.canvas.width), height: clamp(value.canvas.height, 320, 4096, project.canvas.height), background: typeof value.canvas.background === "string" && /^#[0-9a-f]{6}$/i.test(value.canvas.background) ? value.canvas.background : project.canvas.background, }; } if (Array.isArray(value.slides)) { const settingsById = new Map(value.slides.map((slide) => [slide.id, slide.settings])); project.slides = project.slides.map((slide) => ({ ...slide, settings: normalizeSettings(settingsById.get(slide.id), slide.settings) })); } project.updatedAt = new Date().toISOString(); await writeProject(project); return project; } export async function readImageProjectFile(id: string, fileName: string) { if (!FILE_ID.test(fileName)) throw new Error("Invalid image file"); const project = await readProject(id); const slide = project.slides.find((item) => item.fileName === fileName); if (!slide) throw new Error("Image not found"); return { data: await fs.readFile(path.join(projectRoot(id), "images", fileName)), mimeType: slide.mimeType }; }