178 lines
7.1 KiB
TypeScript
178 lines
7.1 KiB
TypeScript
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<string, string> = { "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<ImageSettings> : {};
|
||
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<ImageProject> {
|
||
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<ImageSlide[]> {
|
||
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<ImageProject> : {};
|
||
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 };
|
||
}
|