Files
hexin-codex-bot 276a8ae473
Verify and deploy production / verify-and-deploy (push) Successful in 2m34s
feat: add image project dashboard and canvas editor
2026-10-05 15:20:43 +08:00

178 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}