Files
mini-game-ai/src/lib/file-storage.ts
richarjiang 140608845a refactor: 重构纹理图集工具,实现浏览器端实时处理
将合图处理从服务端迁移到浏览器端,使用 Web Worker 实现高性能打包算法,新增三栏布局界面和精灵动画预览功能

- 新增 atlasStore 状态管理,实现文件、配置、结果的统一管理
- 新增 atlas-packer 打包算法库(MaxRects/Shelf),支持浏览器端快速合图
- 新增 atlas-worker Web Worker,实现异步打包处理避免阻塞 UI
- 新增三栏布局组件:FileListPanel、CanvasPreview、AtlasConfigPanel
- 新增 AnimationPreviewDialog 支持精灵动画帧预览和帧率控制
- 优化所有工具页面的响应式布局和交互体验

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 22:05:25 +08:00

306 lines
7.4 KiB
TypeScript

import { promises as fs } from "fs";
import path from "path";
import { randomUUID } from "crypto";
/**
* File storage service for handling temporary files
* Files are automatically cleaned up after a specified TTL
*/
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
const DOWNLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "downloads");
const FILE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
// Allowed MIME types for image upload
const ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/gif",
"image/bmp",
"image/tiff",
] as const;
// Maximum file size (50MB)
const MAX_FILE_SIZE = 50 * 1024 * 1024;
// Track cleanup timeout
const cleanupTimeouts = new Map<string, NodeJS.Timeout>();
/**
* Initialize temporary directories
*/
export async function initFileStorage(): Promise<void> {
try {
await fs.mkdir(UPLOAD_DIR, { recursive: true });
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
} catch (error) {
console.error("Failed to initialize file storage:", error);
throw new Error("File storage initialization failed");
}
}
/**
* Validate image file type and size
*/
export function validateImageFile(
file: File | { name: string; size: number; type: string }
): { valid: boolean; error?: string } {
// Check file size
if (file.size > MAX_FILE_SIZE) {
return {
valid: false,
error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`,
};
}
// Check file size (minimum 100 bytes to avoid corrupt files)
if (file.size < 100) {
return {
valid: false,
error: "File is too small or corrupt",
};
}
// Check MIME type
if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as typeof ALLOWED_IMAGE_TYPES[number])) {
return {
valid: false,
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`,
};
}
// Check file extension matches MIME type
const ext = getFileExtension(file.name).toLowerCase();
const validExtensions = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"];
if (!validExtensions.includes(ext)) {
return {
valid: false,
error: `Invalid file extension. Allowed: ${validExtensions.join(", ")}`,
};
}
return { valid: true };
}
/**
* Get file extension from filename
*/
function getFileExtension(filename: string): string {
const parts = filename.split(".");
return parts.length > 1 ? parts[parts.length - 1] : "";
}
/**
* Sanitize filename to prevent path traversal
*/
export function sanitizeFilename(filename: string): string {
// Remove any directory separators and special characters
const sanitized = filename
.replace(/[\\/]/g, "") // Remove path separators
.replace(/\.{2,}/g, ".") // Remove double dots
.replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars
.substring(0, 255); // Limit length
return sanitized || "file";
}
/**
* Save uploaded file to temp storage
*/
export async function saveUploadedFile(
file: File | { name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }
): Promise<{ fileId: string; filePath: string; originalName: string; size: number; type: string }> {
await initFileStorage();
const fileId = randomUUID();
const sanitized = sanitizeFilename(file.name);
const ext = getFileExtension(sanitized);
const filename = `${fileId}.${ext}`;
const filePath = path.join(UPLOAD_DIR, filename);
// Save file
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(filePath, buffer);
// Schedule cleanup
scheduleCleanup(fileId, filePath);
return {
fileId,
filePath,
originalName: file.name,
size: buffer.length,
type: file.type,
};
}
/**
* Save processed file to download directory
*/
export async function saveProcessedFile(
_fileId: string,
buffer: Buffer,
format: string,
originalName: string
): Promise<{ fileUrl: string; filename: string; size: number }> {
await initFileStorage();
const downloadId = randomUUID();
const sanitized = sanitizeFilename(originalName);
const nameWithoutExt = sanitized.replace(/\.[^.]+$/, "");
const filename = `${nameWithoutExt}_compressed.${format}`;
const filePath = path.join(DOWNLOAD_DIR, `${downloadId}_${filename}`);
await fs.writeFile(filePath, buffer);
// Schedule cleanup
scheduleCleanup(downloadId, filePath);
return {
fileUrl: `/api/download/${downloadId}`,
filename,
size: buffer.length,
};
}
/**
* Get file from upload directory
*/
export async function getUploadedFile(fileId: string): Promise<Buffer | null> {
await initFileStorage();
try {
const files = await fs.readdir(UPLOAD_DIR);
const file = files.find((f) => f.startsWith(`${fileId}.`));
if (!file) {
return null;
}
const filePath = path.join(UPLOAD_DIR, file);
return await fs.readFile(filePath);
} catch {
return null;
}
}
/**
* Get processed file for download
*/
export async function getProcessedFile(downloadId: string): Promise<
| { buffer: Buffer; filename: string; contentType: string }[]
| null
> {
await initFileStorage();
try {
const files = await fs.readdir(DOWNLOAD_DIR);
const file = files.find((f) => f.startsWith(`${downloadId}_`));
if (!file) {
return null;
}
const filePath = path.join(DOWNLOAD_DIR, file);
const buffer = await fs.readFile(filePath);
// Extract filename
const filename = file.substring(downloadId.length + 1);
// Determine content type
const ext = getFileExtension(filename);
const contentTypes: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
tif: "image/tiff",
tiff: "image/tiff",
};
return [
{
buffer,
filename,
contentType: contentTypes[ext] || "application/octet-stream",
},
];
} catch {
return null;
}
}
/**
* Schedule file cleanup
*/
function scheduleCleanup(fileId: string, filePath: string): void {
// Clear existing timeout if any
const existing = cleanupTimeouts.get(fileId);
if (existing) {
clearTimeout(existing);
}
// Schedule new cleanup
const timeout = setTimeout(async () => {
try {
await fs.unlink(filePath);
cleanupTimeouts.delete(fileId);
} catch (error) {
console.error(`Failed to cleanup file ${fileId}:`, error);
}
}, FILE_TTL);
cleanupTimeouts.set(fileId, timeout);
}
/**
* Manually cleanup a file
*/
export async function cleanupFile(fileId: string): Promise<void> {
const timeout = cleanupTimeouts.get(fileId);
if (timeout) {
clearTimeout(timeout);
cleanupTimeouts.delete(fileId);
}
// Try to delete from both directories
try {
const files = await fs.readdir(UPLOAD_DIR);
const file = files.find((f) => f.startsWith(`${fileId}.`));
if (file) {
await fs.unlink(path.join(UPLOAD_DIR, file));
}
} catch {
// Ignore
}
try {
const files = await fs.readdir(DOWNLOAD_DIR);
const file = files.find((f) => f.startsWith(`${fileId}_`));
if (file) {
await fs.unlink(path.join(DOWNLOAD_DIR, file));
}
} catch {
// Ignore
}
}
/**
* Get allowed image types
*/
export function getAllowedImageTypes(): readonly string[] {
return ALLOWED_IMAGE_TYPES;
}
/**
* Get maximum file size
*/
export function getMaxFileSize(): number {
return MAX_FILE_SIZE;
}