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>
This commit is contained in:
2026-01-26 22:05:25 +08:00
parent 663917f663
commit 140608845a
27 changed files with 4034 additions and 499 deletions

View File

@@ -0,0 +1,310 @@
---
name: texture-atlas-browser-upgrade
overview: 重构合图工具页面实现三栏布局左侧文件列表、中间Canvas实时预览、右侧参数配置支持文件夹拖拽上传使用 Web Worker 在浏览器端完成合图处理,并新增精灵动画预览弹窗功能。
design:
architecture:
framework: react
styleKeywords:
- Professional Tool
- Dark Theme
- Three-Column Layout
- Glassmorphism Panels
- Minimal Borders
fontSystem:
fontFamily: PingFang SC
heading:
size: 18px
weight: 600
subheading:
size: 14px
weight: 500
body:
size: 13px
weight: 400
colorSystem:
primary:
- "#3B82F6"
- "#2563EB"
- "#1D4ED8"
background:
- "#0A0A0B"
- "#18181B"
- "#27272A"
text:
- "#FAFAFA"
- "#A1A1AA"
- "#71717A"
functional:
- "#22C55E"
- "#EF4444"
- "#F59E0B"
todos:
- id: atlas-store
content: 创建atlasStore状态管理定义精灵列表、配置、结果等状态及actions
status: completed
- id: browser-packer
content: 使用 [skill:frontend-patterns] 迁移打包算法到浏览器端创建atlas-packer.ts实现MaxRects和Shelf算法
status: completed
- id: web-worker
content: 创建atlas-worker.ts实现Web Worker打包处理和消息通信协议
status: completed
dependencies:
- browser-packer
- id: file-list-panel
content: 使用 [skill:frontend-design] 开发左侧FileListPanel组件支持文件夹拖拽上传和文件列表展示
status: completed
dependencies:
- atlas-store
- id: canvas-preview
content: 使用 [skill:frontend-design] 开发中间CanvasPreview组件实现实时渲染、缩放平移功能
status: completed
dependencies:
- atlas-store
- web-worker
- id: animation-dialog
content: 使用 [skill:frontend-design] 开发AnimationPreviewDialog组件实现精灵动画帧播放和帧率控制
status: completed
dependencies:
- canvas-preview
- id: page-integration
content: 使用 [skill:vercel-react-best-practices] 重构texture-atlas/page.tsx整合三栏布局和所有功能组件
status: completed
dependencies:
- file-list-panel
- canvas-preview
- animation-dialog
---
## 产品概述
重构合图工具页面从传统的两栏布局升级为三栏专业工作台布局。左侧文件列表面板显示上传的精灵图文件中间Canvas实时预览合图结果右侧参数配置面板调整合图参数。核心目标是将服务端处理迁移到浏览器端使用 Web Worker 实现无阻塞的合图算法处理。
## 核心功能
1. **三栏工作台布局**:左侧文件树/列表、中间Canvas预览、右侧参数配置支持面板宽度调整
2. **文件夹拖拽上传**:支持 webkitdirectory 单层文件夹上传,图片平铺显示并按文件名排序
3. **实时Canvas预览**参数变更时自动触发合图计算Canvas实时渲染结果支持缩放和平移
4. **Web Worker浏览器端处理**:将 MaxRects 和 Shelf 打包算法迁移到 Web Worker实现非阻塞处理
5. **精灵动画预览弹窗**:支持选择精灵帧按顺序播放动画,可调节帧率(FPS),循环播放
6. **导出下载**:支持导出合图图片和元数据文件(Cocos2d plist、Cocos Creator JSON、通用JSON)
## 技术栈
- 框架Next.js 15 + React 19 + TypeScript
- 样式Tailwind CSS + CSS Variables
- UI组件Radix UI (Dialog已有) + Framer Motion
- 状态管理Zustand (扩展现有 uploadStore)
- 国际化:现有 i18n 系统 (en.json / zh.json)
- 图像处理Canvas API + Web Worker + OffscreenCanvas
## 技术架构
### 系统架构
```mermaid
graph TB
subgraph UI Layer
A[FileListPanel] --> B[AtlasStore]
C[CanvasPreview] --> B
D[ConfigPanel] --> B
E[AnimationDialog] --> B
end
subgraph Processing Layer
B --> F[Web Worker]
F --> G[MaxRects Algorithm]
F --> H[Shelf Algorithm]
F --> I[Canvas Composite]
end
subgraph Export Layer
B --> J[Plist Exporter]
B --> K[JSON Exporter]
B --> L[Image Download]
end
```
### 模块划分
- **UI模块**三栏布局组件、文件列表面板、Canvas预览组件、动画预览弹窗
- **状态模块**扩展现有Zustand store管理文件列表、合图配置、处理状态
- **Worker模块**:浏览器端打包算法实现,处理图片合成
- **导出模块**:复用现有导出格式逻辑,适配浏览器端
### 数据流
```mermaid
flowchart LR
A[文件夹拖拽上传] --> B[解析图片文件]
B --> C[加载为ImageBitmap]
C --> D[存入Store]
D --> E[触发Worker处理]
E --> F[执行打包算法]
F --> G[返回布局数据]
G --> H[Canvas渲染预览]
H --> I[用户调整参数]
I --> E
```
## 实现细节
### 核心目录结构
```
src/
├── app/(dashboard)/tools/texture-atlas/
│ └── page.tsx # 重构:三栏布局主页面
├── components/tools/
│ ├── atlas/
│ │ ├── FileListPanel.tsx # 新增:左侧文件列表面板
│ │ ├── CanvasPreview.tsx # 新增中间Canvas预览组件
│ │ ├── AtlasConfigPanel.tsx # 新增:右侧配置面板(基于ConfigPanel)
│ │ └── AnimationPreviewDialog.tsx # 新增:动画预览弹窗
│ └── FileUploader.tsx # 修改:支持文件夹上传
├── lib/
│ ├── atlas-worker.ts # 新增Web Worker主文件
│ ├── atlas-packer.ts # 新增:浏览器端打包算法(从texture-atlas.ts迁移)
│ └── atlas-exporter.ts # 新增:导出格式生成器
├── store/
│ └── atlasStore.ts # 新增:合图专用状态管理
└── types/
└── index.ts # 修改:添加浏览器端类型定义
```
### 关键代码结构
**浏览器端精灵类型定义**:用于存储加载的图片及其元信息,使用 ImageBitmap 代替 Buffer 以适配浏览器环境。
```typescript
// 浏览器端精灵类型
interface BrowserSprite {
id: string;
name: string;
width: number;
height: number;
image: ImageBitmap;
}
// 合图Store状态
interface AtlasState {
sprites: BrowserSprite[];
config: TextureAtlasConfig;
result: AtlasResult | null;
isProcessing: boolean;
previewScale: number;
}
```
**Web Worker消息协议**定义Worker与主线程之间的通信格式支持打包计算和进度回调。
```typescript
// Worker消息协议
interface WorkerMessage {
type: 'pack' | 'cancel';
sprites: { id: string; width: number; height: number }[];
config: TextureAtlasConfig;
}
interface WorkerResult {
type: 'result' | 'progress' | 'error';
placements?: Map<string, AtlasRect>;
progress?: number;
error?: string;
}
```
### 技术实现要点
1. **文件夹上传**
- 使用 `webkitdirectory` 属性支持文件夹选择
- 过滤非图片文件,按文件名自然排序
- 使用 `createImageBitmap` 异步加载图片
2. **Web Worker处理**
- 将 MaxRects 和 Shelf 算法迁移到 Worker
- 仅传递尺寸数据到 Worker图片保留在主线程
- Worker 返回布局坐标,主线程执行 Canvas 合成
3. **Canvas实时预览**
- 使用 Canvas 2D Context 渲染合图结果
- 支持鼠标滚轮缩放和拖拽平移
- 配置变更触发防抖重新计算
4. **动画预览**
- 基于现有 Dialog 组件实现弹窗
- 使用 requestAnimationFrame 控制帧率
- 从合图结果中裁剪精灵帧序列播放
### 性能优化
- 使用防抖处理配置变更,避免频繁重计算
- Worker 处理打包算法不阻塞UI线程
- ImageBitmap 高效图片加载和渲染
- Canvas缓存合图结果缩放时仅变换视图
## 设计风格
采用专业工具型界面设计,三栏工作台布局参考主流图形编辑软件(如TexturePacker、Figma)。整体风格延续项目现有的暗色主题设计,使用玻璃态面板分隔各功能区域。
## 页面规划
### 合图工作台页面 (单页面三栏布局)
**整体布局**
- 三栏横向布局,左中右比例约 240px : auto : 320px
- 面板之间使用微妙的边框分隔,支持视觉层次区分
- 中间预览区域占据主要空间深色背景突出Canvas
**左侧文件列表面板**
- 顶部:文件夹名称显示、文件数量统计、清空按钮
- 中部:文件列表区域,每项显示缩略图、文件名、尺寸信息
- 底部:拖拽上传提示区域,支持点击选择文件夹
- 支持列表项hover高亮点击选中状态
**中间Canvas预览区域**
- 顶部工具栏:缩放比例显示、适应窗口按钮、动画预览按钮
- 主体深色棋盘格背景Canvas居中显示合图结果
- 支持鼠标滚轮缩放(10%-400%)、拖拽平移
- 空状态:显示拖拽上传引导图标和文字
**右侧参数配置面板**
- 复用现有ConfigPanel组件样式
- 分组展示:尺寸设置、布局设置、输出设置
- 底部:生成按钮、下载按钮组
**动画预览弹窗**
- 居中弹窗,尺寸 640x480
- 顶部:标题、关闭按钮
- 中部Canvas播放区域深色背景
- 底部:播放/暂停按钮、帧率滑块(1-60fps)、当前帧/总帧数显示
## Agent Extensions
### Skill
- **frontend-design**
- 用途设计三栏工作台布局、Canvas预览区域、动画预览弹窗等UI界面
- 预期结果:生成专业、美观的合图工具界面,符合现有项目设计风格
- **frontend-patterns**
- 用途实现Web Worker通信、Canvas渲染、状态管理等前端模式
- 预期结果代码结构清晰性能优化到位符合React最佳实践
- **vercel-react-best-practices**
- 用途优化React组件性能处理大量图片文件时的渲染优化
- 预期结果:组件渲染高效,避免不必要的重渲染
### SubAgent
- **code-explorer**
- 用途:在实现过程中探索现有组件复用、类型定义、国际化键值等
- 预期结果:充分复用现有代码,保持项目一致性

View File

@@ -6,10 +6,10 @@ export default function DashboardLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="flex"> <div className="flex min-h-[calc(100vh-4rem)]">
<Sidebar /> <Sidebar />
<main className="flex-1 lg:ml-64"> <main className="flex-1 lg:ml-64">
<div className="min-h-[calc(100vh-4rem)]">{children}</div> {children}
</main> </main>
</div> </div>
); );

View File

@@ -110,7 +110,7 @@ export default function AudioCompressPage() {
[addFile] [addFile]
); );
const handleConfigChange = (id: string, value: any) => { const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
setConfig((prev) => ({ ...prev, [id]: value })); setConfig((prev) => ({ ...prev, [id]: value }));
}; };

View File

@@ -75,13 +75,31 @@ async function uploadFile(file: File): Promise<{ fileId: string } | null> {
return { fileId: data.fileId }; return { fileId: data.fileId };
} }
interface ProcessResult {
success: boolean;
data?: {
fileUrl: string;
filename: string;
metadata: {
originalSize: number;
compressedSize: number;
compressionRatio: number;
format: string;
quality?: number;
width?: number;
height?: number;
};
};
error?: string;
}
/** /**
* Process image compression * Process image compression
*/ */
async function processImageCompression( async function processImageCompression(
fileId: string, fileId: string,
config: ImageCompressConfig config: ImageCompressConfig
): Promise<{ success: boolean; data?: any; error?: string }> { ): Promise<ProcessResult> {
const response = await fetch("/api/process/image-compress", { const response = await fetch("/api/process/image-compress", {
method: "POST", method: "POST",
headers: { headers: {
@@ -131,7 +149,7 @@ export default function ImageCompressPage() {
[addFile] [addFile]
); );
const handleConfigChange = (id: string, value: any) => { const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
setConfig((prev) => ({ ...prev, [id]: value })); setConfig((prev) => ({ ...prev, [id]: value }));
}; };
@@ -299,10 +317,7 @@ export default function ImageCompressPage() {
<ConfigPanel <ConfigPanel
title={getT("config.imageCompression.title")} title={getT("config.imageCompression.title")}
description={getT("config.imageCompression.description")} description={getT("config.imageCompression.description")}
options={configOptions.map((opt) => ({ options={configOptions}
...opt,
value: config[opt.id as keyof ImageCompressConfig],
}))}
onChange={handleConfigChange} onChange={handleConfigChange}
onReset={handleResetConfig} onReset={handleResetConfig}
/> />

View File

@@ -1,487 +1,103 @@
"use client"; "use client";
import { useState, useCallback, useEffect } from "react"; import { useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Layers as LayersIcon, Box, Download, Archive } from "lucide-react"; import { Layers as LayersIcon } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader"; import {
import { ProgressBar } from "@/components/tools/ProgressBar"; FileListPanel,
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel"; CanvasPreview,
import { Button } from "@/components/ui/button"; AtlasConfigPanel,
import { useUploadStore } from "@/store/uploadStore"; AnimationPreviewDialog
import { generateId } from "@/lib/utils"; } from "@/components/tools/atlas";
import { useTranslation, getServerTranslations } from "@/lib/i18n"; import { useAtlasStore } from "@/store/atlasStore";
import type { UploadedFile, TextureAtlasConfig } from "@/types"; import { useAtlasWorker } from "@/hooks/useAtlasWorker";
import { useSafeTranslation } from "@/lib/i18n";
const imageAccept = {
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
};
const defaultConfig: TextureAtlasConfig = {
maxWidth: 1024,
maxHeight: 1024,
padding: 2,
allowRotation: false,
pot: true,
format: "png",
quality: 80,
outputFormat: "cocos2d",
algorithm: "MaxRects",
};
function useConfigOptions(config: TextureAtlasConfig, getT: (key: string) => string): ConfigOption[] {
return [
{
id: "maxWidth",
type: "slider",
label: getT("config.textureAtlas.maxWidth"),
description: getT("config.textureAtlas.maxWidthDescription"),
value: config.maxWidth,
min: 256,
max: 4096,
step: 256,
suffix: "px",
icon: <Box className="h-4 w-4" />,
},
{
id: "maxHeight",
type: "slider",
label: getT("config.textureAtlas.maxHeight"),
description: getT("config.textureAtlas.maxHeightDescription"),
value: config.maxHeight,
min: 256,
max: 4096,
step: 256,
suffix: "px",
icon: <Box className="h-4 w-4" />,
},
{
id: "padding",
type: "slider",
label: getT("config.textureAtlas.padding"),
description: getT("config.textureAtlas.paddingDescription"),
value: config.padding,
min: 0,
max: 16,
step: 1,
suffix: "px",
},
{
id: "allowRotation",
type: "select",
label: getT("config.textureAtlas.allowRotation"),
description: getT("config.textureAtlas.allowRotationDescription"),
value: config.allowRotation,
options: [
{ label: getT("common.no"), value: false },
{ label: getT("common.yes"), value: true },
],
},
{
id: "pot",
type: "select",
label: getT("config.textureAtlas.pot"),
description: getT("config.textureAtlas.potDescription"),
value: config.pot,
options: [
{ label: getT("common.no"), value: false },
{ label: getT("common.yes"), value: true },
],
},
{
id: "format",
type: "select",
label: getT("config.textureAtlas.format"),
description: getT("config.textureAtlas.formatDescription"),
value: config.format,
options: [
{ label: getT("config.textureAtlas.formatPng"), value: "png" },
{ label: getT("config.textureAtlas.formatWebp"), value: "webp" },
],
},
{
id: "quality",
type: "slider",
label: getT("config.textureAtlas.quality"),
description: getT("config.textureAtlas.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
},
{
id: "outputFormat",
type: "select",
label: getT("config.textureAtlas.outputFormat"),
description: getT("config.textureAtlas.outputFormatDescription"),
value: config.outputFormat,
options: [
{ label: getT("config.textureAtlas.outputCocosCreator"), value: "cocos-creator" },
{ label: getT("config.textureAtlas.outputCocos2d"), value: "cocos2d" },
{ label: getT("config.textureAtlas.outputGeneric"), value: "generic-json" },
],
},
{
id: "algorithm",
type: "select",
label: getT("config.textureAtlas.algorithm"),
description: getT("config.textureAtlas.algorithmDescription"),
value: config.algorithm,
options: [
{ label: getT("config.textureAtlas.algorithmMaxRects"), value: "MaxRects" },
{ label: getT("config.textureAtlas.algorithmShelf"), value: "Shelf" },
],
},
];
}
/**
* Upload a file to the server
*/
async function uploadFile(file: File): Promise<{ fileId: string } | null> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Upload failed");
}
const data = await response.json();
return { fileId: data.fileId };
}
/**
* Process texture atlas creation
*/
async function processTextureAtlas(
fileIds: string[],
filenames: string[],
config: TextureAtlasConfig
): Promise<{ success: boolean; data?: any; error?: string }> {
const response = await fetch("/api/process/texture-atlas", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ fileIds, filenames, config }),
});
const data = await response.json();
if (!response.ok) {
return { success: false, error: data.error || "Processing failed" };
}
return { success: true, data };
}
interface AtlasResult {
id: string;
imageUrl: string;
metadataUrl: string;
zipUrl: string;
imageFilename: string;
metadataFilename: string;
zipFilename: string;
metadata: {
width: number;
height: number;
format: string;
frameCount: number;
outputFormat: string;
};
createdAt: Date;
}
export default function TextureAtlasPage() { export default function TextureAtlasPage() {
const [mounted, setMounted] = useState(false); const { t, mounted } = useSafeTranslation();
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string, params?: Record<string, string | number>) => { const { sprites, config } = useAtlasStore();
if (!mounted) return getServerTranslations("en").t(key, params); const { pack } = useAtlasWorker();
return t(key, params);
// Auto-pack when sprites or config changes (with debounce)
useEffect(() => {
if (sprites.length === 0) return;
const timer = setTimeout(() => {
pack();
}, 300);
return () => clearTimeout(timer);
}, [sprites, config, pack]);
// Cleanup on unmount
useEffect(() => {
return () => {
// Don't clear sprites on unmount - keep state for when user returns
};
}, []);
const getT = (key: string) => {
if (!mounted) return key.split(".").pop() || key;
return t(key);
}; };
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
const [config, setConfig] = useState<TextureAtlasConfig>(defaultConfig);
const [atlasResult, setAtlasResult] = useState<AtlasResult | null>(null);
const handleFilesDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
}));
newFiles.forEach((file) => addFile(file));
},
[addFile]
);
const handleConfigChange = (id: string, value: any) => {
setConfig((prev) => ({ ...prev, [id]: value }));
};
const handleResetConfig = () => {
setConfig(defaultConfig);
};
const handleProcess = async () => {
if (files.length === 0) return;
setProcessingStatus({
status: "uploading",
progress: 0,
message: getT("processing.uploadingSprites"),
});
const fileIds: string[] = [];
const filenames: string[] = [];
const errors: string[] = [];
try {
// Upload all files
for (let i = 0; i < files.length; i++) {
const file = files[i];
const uploadProgress = Math.round(((i + 0.5) / files.length) * 50);
setProcessingStatus({
status: "uploading",
progress: uploadProgress,
message: getT("processing.uploadProgress", { progress: uploadProgress }),
});
try {
const uploadResult = await uploadFile(file.file);
if (!uploadResult) {
throw new Error("Upload failed");
}
fileIds.push(uploadResult.fileId);
filenames.push(file.name);
} catch (error) {
errors.push(
`${file.name}: ${error instanceof Error ? error.message : "Upload failed"}`
);
}
}
if (fileIds.length === 0) {
throw new Error("No files were successfully uploaded");
}
// Create atlas
setProcessingStatus({
status: "processing",
progress: 75,
message: getT("processing.creatingAtlas"),
});
const result = await processTextureAtlas(fileIds, filenames, config);
if (result.success && result.data) {
setAtlasResult({
id: generateId(),
imageUrl: result.data.imageUrl,
metadataUrl: result.data.metadataUrl,
zipUrl: result.data.zipUrl,
imageFilename: result.data.imageFilename,
metadataFilename: result.data.metadataFilename,
zipFilename: result.data.zipFilename,
metadata: result.data.metadata,
createdAt: new Date(),
});
clearFiles();
setProcessingStatus({
status: "completed",
progress: 100,
message: getT("processing.atlasComplete"),
});
} else {
throw new Error(result.error || "Failed to create texture atlas");
}
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: getT("processing.processingFailed"),
error: error instanceof Error ? error.message : getT("processing.unknownError"),
});
}
};
const handleDownload = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const canProcess =
files.length > 0 && processingStatus.status !== "processing" && files.length <= 500;
const configOptions = useConfigOptions(config, getT);
return ( return (
<div className="p-6"> <div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}> {/* Header */}
<div className="mb-8"> <motion.div
<div className="flex items-center gap-3"> initial={{ opacity: 0, y: -10 }}
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"> animate={{ opacity: 1, y: 0 }}
<LayersIcon className="h-6 w-6 text-primary" /> className="shrink-0 border-b border-border/40 bg-background/80 backdrop-blur-sm"
</div> >
<div> <div className="flex items-center gap-3 px-5 py-3">
<h1 className="text-3xl font-bold">{getT("tools.textureAtlas.title")}</h1> <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
<p className="text-muted-foreground"> <LayersIcon className="h-5 w-5 text-primary" />
{getT("tools.textureAtlas.description")}
</p>
</div>
</div> </div>
</div> <div>
<h1 className="text-lg font-semibold">{getT("tools.textureAtlas.title")}</h1>
<div className="grid gap-6 lg:grid-cols-2"> <p className="text-xs text-muted-foreground">
<div className="space-y-6"> {getT("tools.textureAtlas.description")}
<FileUploader </p>
files={files}
onFilesDrop={handleFilesDrop}
onRemoveFile={removeFile}
accept={imageAccept}
maxSize={10 * 1024 * 1024} // 10MB per file
maxFiles={500}
disabled={processingStatus.status === "processing"}
/>
<ConfigPanel
title={getT("config.textureAtlas.title")}
description={getT("config.textureAtlas.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof TextureAtlasConfig],
}))}
onChange={handleConfigChange}
onReset={handleResetConfig}
/>
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<LayersIcon className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.createAtlas")}
</Button>
)}
</div>
<div className="space-y-6">
{processingStatus.status !== "idle" && (
<ProgressBar progress={processingStatus} />
)}
{atlasResult && (
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<Download className="h-5 w-5 text-primary" />
{getT("results.processingComplete")}
</h3>
<div className="mb-4 rounded-lg bg-background p-4">
<img
src={atlasResult.imageUrl}
alt="Texture Atlas"
className="h-auto w-full rounded border border-border/40"
/>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.dimensions")}:</span>
<p className="font-medium">
{atlasResult.metadata.width} x {atlasResult.metadata.height}
</p>
</div>
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.sprites")}:</span>
<p className="font-medium">{atlasResult.metadata.frameCount}</p>
</div>
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.imageFormat")}:</span>
<p className="font-medium capitalize">{atlasResult.metadata.format}</p>
</div>
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.dataFormat")}:</span>
<p className="font-medium capitalize">
{atlasResult.metadata.outputFormat === "cocos-creator"
? "Cocos Creator JSON"
: atlasResult.metadata.outputFormat === "cocos2d"
? "Cocos2d plist"
: "Generic JSON"}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<Button
onClick={() =>
handleDownload(atlasResult.zipUrl, atlasResult.zipFilename)
}
size="lg"
className="w-full"
>
<Archive className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.downloadAll")}
</Button>
<div className="flex gap-3">
<Button
onClick={() =>
handleDownload(atlasResult.imageUrl, atlasResult.imageFilename)
}
variant="outline"
className="flex-1"
>
<Download className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.downloadImage")}
</Button>
<Button
onClick={() =>
handleDownload(atlasResult.metadataUrl, atlasResult.metadataFilename)
}
variant="outline"
className="flex-1"
>
<Download className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.downloadData")}
</Button>
</div>
</div>
</div>
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{getT("tools.textureAtlas.features")}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{(getT("tools.textureAtlas.featureList") as unknown as string[]).map(
(feature, index) => (
<li key={index}> {feature}</li>
)
)}
</ul>
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>
{/* Three-column layout */}
<div className="flex flex-1 gap-4 overflow-hidden p-4">
{/* Left panel - File list */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="relative w-64 shrink-0"
>
<FileListPanel />
</motion.div>
{/* Center panel - Canvas preview */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="flex-1 min-w-0"
>
<CanvasPreview />
</motion.div>
{/* Right panel - Config */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
className="w-72 shrink-0"
>
<AtlasConfigPanel />
</motion.div>
</div>
{/* Animation preview dialog */}
<AnimationPreviewDialog />
</div> </div>
); );
} }

View File

@@ -97,7 +97,7 @@ export default function VideoFramesPage() {
[addFile] [addFile]
); );
const handleConfigChange = (id: string, value: any) => { const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
setConfig((prev) => ({ ...prev, [id]: value })); setConfig((prev) => ({ ...prev, [id]: value }));
}; };

View File

@@ -1,11 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation, getServerTranslations } from "@/lib/i18n"; import { useTranslation, getServerTranslations } from "@/lib/i18n";
import { cn } from "@/lib/utils";
export function Footer() { export function Footer() {
const pathname = usePathname();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -26,8 +29,14 @@ export function Footer() {
[mounted] [mounted]
); );
// Check if we're in the dashboard area (tools pages)
const isDashboard = pathname?.startsWith("/tools");
return ( return (
<footer className="border-t border-white/5 bg-background/50"> <footer className={cn(
"border-t border-white/5 bg-background/50",
isDashboard && "lg:ml-64"
)}>
<div className="container py-12"> <div className="container py-12">
<div className="grid gap-10 md:grid-cols-[1.5fr_1fr]"> <div className="grid gap-10 md:grid-cols-[1.5fr_1fr]">
<div> <div>

View File

@@ -8,13 +8,15 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSafeTranslation } from "@/lib/i18n"; import { useSafeTranslation } from "@/lib/i18n";
export type ConfigValue = string | number | boolean | undefined;
export interface ConfigOption { export interface ConfigOption {
id: string; id: string;
type: "slider" | "select" | "toggle" | "radio"; type: "slider" | "select" | "toggle" | "radio";
label: string; label: string;
description?: string; description?: string;
value: any; value: ConfigValue;
options?: { label: string; value: any }[]; options?: { label: string; value: ConfigValue }[];
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
@@ -26,7 +28,7 @@ interface ConfigPanelProps {
title: string; title: string;
description?: string; description?: string;
options: ConfigOption[]; options: ConfigOption[];
onChange: (id: string, value: any) => void; onChange: (id: string, value: ConfigValue) => void;
onReset?: () => void; onReset?: () => void;
className?: string; className?: string;
} }
@@ -86,7 +88,7 @@ export function ConfigPanel({
min={option.min ?? 0} min={option.min ?? 0}
max={option.max ?? 100} max={option.max ?? 100}
step={option.step ?? 1} step={option.step ?? 1}
value={[option.value]} value={[typeof option.value === "number" ? option.value : 0]}
onValueChange={(values: number[]) => onChange(option.id, values[0])} onValueChange={(values: number[]) => onChange(option.id, values[0])}
className="mt-2" className="mt-2"
/> />
@@ -96,7 +98,7 @@ export function ConfigPanel({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{option.options.map((opt) => ( {option.options.map((opt) => (
<Button <Button
key={opt.value} key={String(opt.value)}
variant={option.value === opt.value ? "default" : "outline"} variant={option.value === opt.value ? "default" : "outline"}
size="sm" size="sm"
onClick={() => onChange(option.id, opt.value)} onClick={() => onChange(option.id, opt.value)}
@@ -111,7 +113,7 @@ export function ConfigPanel({
<div className="space-y-2"> <div className="space-y-2">
{option.options.map((opt) => ( {option.options.map((opt) => (
<label <label
key={opt.value} key={String(opt.value)}
className={cn( className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-accent", "flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-accent",
option.value === opt.value && "border-primary bg-primary/10" option.value === opt.value && "border-primary bg-primary/10"
@@ -120,7 +122,7 @@ export function ConfigPanel({
<input <input
type="radio" type="radio"
name={option.id} name={option.id}
value={opt.value} value={String(opt.value)}
checked={option.value === opt.value} checked={option.value === opt.value}
onChange={() => onChange(option.id, opt.value)} onChange={() => onChange(option.id, opt.value)}
className="h-4 w-4" className="h-4 w-4"

View File

@@ -107,6 +107,7 @@ export function ImageCompareSlider({
> >
{/* Original Image (Background - Left Side) */} {/* Original Image (Background - Left Side) */}
<div className="relative w-full"> <div className="relative w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={originalSrc} src={originalSrc}
alt={texts.original} alt={texts.original}
@@ -120,6 +121,7 @@ export function ImageCompareSlider({
className="absolute inset-0 overflow-hidden pointer-events-none" className="absolute inset-0 overflow-hidden pointer-events-none"
style={{ clipPath: `inset(0 0 0 ${sliderPosition}%)` }} style={{ clipPath: `inset(0 0 0 ${sliderPosition}%)` }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={compressedSrc} src={compressedSrc}
alt={texts.compressed} alt={texts.compressed}

View File

@@ -146,6 +146,7 @@ export function ResultPreview({
onClick={() => handlePreview(result)} onClick={() => handlePreview(result)}
className="relative shrink-0 h-12 w-12 rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all cursor-zoom-in" className="relative shrink-0 h-12 w-12 rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all cursor-zoom-in"
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={result.processedUrl} src={result.processedUrl}
alt={result.originalFile.name} alt={result.originalFile.name}

View File

@@ -0,0 +1,353 @@
"use client";
import { useRef, useEffect, useState, useCallback } from "react";
import {
Play,
Pause,
SkipBack,
SkipForward,
Repeat,
Film
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { useAtlasStore } from "@/store/atlasStore";
import { useSafeTranslation } from "@/lib/i18n";
/**
* Draw checkerboard pattern for transparency
*/
function drawCheckerboard(ctx: CanvasRenderingContext2D, width: number, height: number) {
const tileSize = 8;
const lightColor = "#2a2a2e";
const darkColor = "#1e1e22";
for (let y = 0; y < height; y += tileSize) {
for (let x = 0; x < width; x += tileSize) {
const isLight = ((x / tileSize) + (y / tileSize)) % 2 === 0;
ctx.fillStyle = isLight ? lightColor : darkColor;
ctx.fillRect(x, y, tileSize, tileSize);
}
}
}
export function AnimationPreviewDialog() {
const { t } = useSafeTranslation();
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number>(0);
const [isPlaying, setIsPlaying] = useState(true);
const [currentFrame, setCurrentFrame] = useState(0);
const [loop, setLoop] = useState(true);
const {
sprites,
isAnimationDialogOpen,
closeAnimationDialog,
animationFps,
setAnimationFps,
} = useAtlasStore();
const frameCount = sprites.length;
const frameInterval = 1000 / animationFps;
// Get max sprite dimensions for canvas sizing
const maxWidth = sprites.length > 0 ? Math.max(...sprites.map((s) => s.width)) : 200;
const maxHeight = sprites.length > 0 ? Math.max(...sprites.map((s) => s.height)) : 200;
// Canvas size (with some padding)
const canvasWidth = Math.min(maxWidth + 40, 600);
const canvasHeight = Math.min(maxHeight + 40, 400);
// Draw current frame
const drawFrame = useCallback((frameIndex: number) => {
const canvas = canvasRef.current;
if (!canvas || sprites.length === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const sprite = sprites[frameIndex];
if (!sprite) return;
// Clear and draw background
ctx.fillStyle = "#0f0f11";
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Calculate centered position
const scale = Math.min(
(canvasWidth - 40) / sprite.width,
(canvasHeight - 40) / sprite.height,
2 // Max 2x scale
);
const scaledWidth = sprite.width * scale;
const scaledHeight = sprite.height * scale;
const x = (canvasWidth - scaledWidth) / 2;
const y = (canvasHeight - scaledHeight) / 2;
// Draw checkerboard behind sprite
ctx.save();
ctx.beginPath();
ctx.rect(x, y, scaledWidth, scaledHeight);
ctx.clip();
ctx.translate(x, y);
ctx.scale(scale, scale);
drawCheckerboard(ctx, sprite.width, sprite.height);
ctx.restore();
// Draw sprite
ctx.drawImage(sprite.image, x, y, scaledWidth, scaledHeight);
// Draw border
ctx.strokeStyle = "rgba(59, 130, 246, 0.3)";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, scaledWidth, scaledHeight);
}, [sprites, canvasWidth, canvasHeight]);
// Animation loop
useEffect(() => {
if (!isAnimationDialogOpen || !isPlaying || frameCount < 2) {
return;
}
const animate = (timestamp: number) => {
if (timestamp - lastFrameTimeRef.current >= frameInterval) {
lastFrameTimeRef.current = timestamp;
setCurrentFrame((prev) => {
const next = prev + 1;
if (next >= frameCount) {
if (loop) {
return 0;
} else {
setIsPlaying(false);
return prev;
}
}
return next;
});
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isAnimationDialogOpen, isPlaying, frameCount, frameInterval, loop]);
// Draw frame when currentFrame changes
useEffect(() => {
drawFrame(currentFrame);
}, [currentFrame, drawFrame]);
// Reset when dialog opens
useEffect(() => {
if (isAnimationDialogOpen) {
setCurrentFrame(0);
setIsPlaying(true);
lastFrameTimeRef.current = 0;
}
}, [isAnimationDialogOpen]);
// Control handlers
const togglePlay = useCallback(() => {
setIsPlaying((prev) => !prev);
}, []);
const goToStart = useCallback(() => {
setCurrentFrame(0);
}, []);
const goToEnd = useCallback(() => {
setCurrentFrame(frameCount - 1);
}, [frameCount]);
const nextFrame = useCallback(() => {
setCurrentFrame((prev) => Math.min(prev + 1, frameCount - 1));
setIsPlaying(false);
}, [frameCount]);
const prevFrame = useCallback(() => {
setCurrentFrame((prev) => Math.max(prev - 1, 0));
setIsPlaying(false);
}, []);
const handleFrameSliderChange = useCallback((values: number[]) => {
setCurrentFrame(values[0]);
setIsPlaying(false);
}, []);
const handleFpsChange = useCallback((values: number[]) => {
setAnimationFps(values[0]);
}, [setAnimationFps]);
const toggleLoop = useCallback(() => {
setLoop((prev) => !prev);
}, []);
return (
<Dialog open={isAnimationDialogOpen} onOpenChange={(open) => !open && closeAnimationDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Film className="h-5 w-5 text-primary" />
{t("atlas.animationPreview") || "动画预览"}
</DialogTitle>
<DialogDescription>
{t("atlas.animationDescription") || "预览精灵序列帧动画效果"}
</DialogDescription>
</DialogHeader>
{/* Canvas */}
<div className="relative mx-auto overflow-hidden rounded-lg bg-[#0f0f11]">
<canvas
ref={canvasRef}
width={canvasWidth}
height={canvasHeight}
className="block"
/>
{/* Frame counter overlay */}
<div className="absolute bottom-2 right-2 rounded bg-black/60 px-2 py-1 text-xs text-white">
{currentFrame + 1} / {frameCount}
</div>
{/* Sprite name overlay */}
{sprites[currentFrame] && (
<div className="absolute top-2 left-2 rounded bg-black/60 px-2 py-1 text-xs text-white truncate max-w-[200px]">
{sprites[currentFrame].name}
</div>
)}
</div>
{/* Frame timeline */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t("atlas.frame") || "帧"}
</Label>
<Badge variant="secondary" className="text-xs">
{currentFrame + 1} / {frameCount}
</Badge>
</div>
<Slider
min={0}
max={Math.max(0, frameCount - 1)}
step={1}
value={[currentFrame]}
onValueChange={handleFrameSliderChange}
/>
</div>
{/* Controls */}
<div className="flex items-center justify-between">
{/* Playback controls */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={goToStart}
>
<SkipBack className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={prevFrame}
>
<SkipBack className="h-3 w-3" />
</Button>
<Button
variant="default"
size="icon"
className="h-10 w-10"
onClick={togglePlay}
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 ml-0.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={nextFrame}
>
<SkipForward className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={goToEnd}
>
<SkipForward className="h-4 w-4" />
</Button>
<Button
variant={loop ? "secondary" : "ghost"}
size="icon"
className="h-8 w-8"
onClick={toggleLoop}
>
<Repeat className="h-4 w-4" />
</Button>
</div>
{/* FPS control */}
<div className="flex items-center gap-3">
<Label className="text-xs text-muted-foreground whitespace-nowrap">
{t("atlas.fps") || "帧率"}
</Label>
<div className="w-24">
<Slider
min={1}
max={60}
step={1}
value={[animationFps]}
onValueChange={handleFpsChange}
/>
</div>
<Badge variant="outline" className="text-xs min-w-[52px] justify-center">
{animationFps} FPS
</Badge>
</div>
</div>
{/* Sprite info */}
{sprites[currentFrame] && (
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground">
<span>
{t("atlas.spriteSize") || "精灵尺寸"}: {sprites[currentFrame].width} × {sprites[currentFrame].height}
</span>
<span>
{t("atlas.totalFrames") || "总帧数"}: {frameCount}
</span>
<span>
{t("atlas.duration") || "时长"}: {((frameCount / animationFps) * 1000).toFixed(0)}ms
</span>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,439 @@
"use client";
import { useCallback } from "react";
import {
Settings2,
Box,
LayoutGrid,
FileOutput,
Download,
Archive,
Play,
RefreshCw,
Layers
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAtlasStore } from "@/store/atlasStore";
import { useAtlasWorker } from "@/hooks/useAtlasWorker";
import {
exportToCocos2dPlist,
exportToCocosCreatorJson,
exportToGenericJson
} from "@/lib/atlas-packer";
import { useSafeTranslation } from "@/lib/i18n";
import type { TextureAtlasConfig } from "@/types";
/**
* Config section component
*/
function ConfigSection({
icon: Icon,
title,
children
}: {
icon: React.ElementType;
title: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Icon className="h-4 w-4" />
{title}
</div>
<div className="space-y-4">
{children}
</div>
</div>
);
}
/**
* Slider option component
*/
function SliderOption({
id,
label,
value,
min,
max,
step,
suffix,
onChange,
}: {
id: string;
label: string;
value: number;
min: number;
max: number;
step: number;
suffix?: string;
onChange: (value: number) => void;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={id} className="text-xs">
{label}
</Label>
<Badge variant="secondary" className="text-xs">
{value}{suffix}
</Badge>
</div>
<Slider
id={id}
min={min}
max={max}
step={step}
value={[value]}
onValueChange={(values) => onChange(values[0])}
/>
</div>
);
}
/**
* Select option component
*/
function SelectOption<T extends string | number | boolean>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { label: string; value: T }[];
onChange: (value: T) => void;
}) {
return (
<div className="space-y-2">
<Label className="text-xs">{label}</Label>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => (
<Button
key={String(opt.value)}
variant={value === opt.value ? "default" : "outline"}
size="sm"
className="h-7 text-xs"
onClick={() => onChange(opt.value)}
>
{opt.label}
</Button>
))}
</div>
</div>
);
}
export function AtlasConfigPanel() {
const { t } = useSafeTranslation();
const { pack } = useAtlasWorker();
const {
sprites,
config,
result,
status,
updateConfig,
resetConfig,
openAnimationDialog,
} = useAtlasStore();
// Config handlers
const handleConfigChange = useCallback(
<K extends keyof TextureAtlasConfig>(key: K, value: TextureAtlasConfig[K]) => {
updateConfig({ [key]: value });
},
[updateConfig]
);
// Generate atlas
const handleGenerate = useCallback(() => {
if (sprites.length > 0) {
pack();
}
}, [sprites.length, pack]);
// Download functions
const downloadImage = useCallback(() => {
if (!result?.imageDataUrl) return;
const link = document.createElement("a");
link.href = result.imageDataUrl;
link.download = `atlas.${config.format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [result, config.format]);
const downloadMetadata = useCallback(() => {
if (!result) return;
let content: string;
let filename: string;
let mimeType: string;
const imageFilename = `atlas.${config.format}`;
if (config.outputFormat === "cocos2d") {
content = exportToCocos2dPlist(result.placements, result.width, result.height, imageFilename);
filename = "atlas.plist";
mimeType = "application/xml";
} else if (config.outputFormat === "cocos-creator") {
content = exportToCocosCreatorJson(result.placements, result.width, result.height, imageFilename, config.format);
filename = "atlas.json";
mimeType = "application/json";
} else {
content = exportToGenericJson(result.placements, result.width, result.height, imageFilename, config.format);
filename = "atlas.json";
mimeType = "application/json";
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [result, config.format, config.outputFormat]);
const downloadAll = useCallback(async () => {
if (!result?.imageDataUrl) return;
// Download image
downloadImage();
// Small delay then download metadata
setTimeout(() => {
downloadMetadata();
}, 100);
}, [result, downloadImage, downloadMetadata]);
// Check if can process
const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering";
const hasResult = !!result;
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">
{t("config.textureAtlas.title") || "合图设置"}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={resetConfig}
>
{t("common.reset") || "重置"}
</Button>
</div>
{/* Config content */}
<div className="flex-1 overflow-y-auto p-3 space-y-6">
{/* Size settings */}
<ConfigSection icon={Box} title={t("atlas.sizeSettings") || "尺寸设置"}>
<SliderOption
id="maxWidth"
label={t("config.textureAtlas.maxWidth") || "最大宽度"}
value={config.maxWidth}
min={256}
max={4096}
step={256}
suffix="px"
onChange={(v) => handleConfigChange("maxWidth", v)}
/>
<SliderOption
id="maxHeight"
label={t("config.textureAtlas.maxHeight") || "最大高度"}
value={config.maxHeight}
min={256}
max={4096}
step={256}
suffix="px"
onChange={(v) => handleConfigChange("maxHeight", v)}
/>
<SliderOption
id="padding"
label={t("config.textureAtlas.padding") || "内边距"}
value={config.padding}
min={0}
max={16}
step={1}
suffix="px"
onChange={(v) => handleConfigChange("padding", v)}
/>
</ConfigSection>
{/* Layout settings */}
<ConfigSection icon={LayoutGrid} title={t("atlas.layoutSettings") || "布局设置"}>
<SelectOption
label={t("config.textureAtlas.algorithm") || "打包算法"}
value={config.algorithm}
options={[
{ label: "MaxRects", value: "MaxRects" as const },
{ label: "Shelf", value: "Shelf" as const },
]}
onChange={(v) => handleConfigChange("algorithm", v)}
/>
<SelectOption
label={t("config.textureAtlas.allowRotation") || "允许旋转"}
value={config.allowRotation}
options={[
{ label: t("common.no") || "否", value: false },
{ label: t("common.yes") || "是", value: true },
]}
onChange={(v) => handleConfigChange("allowRotation", v)}
/>
<SelectOption
label={t("config.textureAtlas.pot") || "2的幂次"}
value={config.pot}
options={[
{ label: t("common.no") || "否", value: false },
{ label: t("common.yes") || "是", value: true },
]}
onChange={(v) => handleConfigChange("pot", v)}
/>
</ConfigSection>
{/* Output settings */}
<ConfigSection icon={FileOutput} title={t("atlas.outputSettings") || "输出设置"}>
<SelectOption
label={t("config.textureAtlas.format") || "图片格式"}
value={config.format}
options={[
{ label: "PNG", value: "png" as const },
{ label: "WebP", value: "webp" as const },
]}
onChange={(v) => handleConfigChange("format", v)}
/>
{config.format === "webp" && (
<SliderOption
id="quality"
label={t("config.textureAtlas.quality") || "质量"}
value={config.quality}
min={1}
max={100}
step={1}
suffix="%"
onChange={(v) => handleConfigChange("quality", v)}
/>
)}
<SelectOption
label={t("config.textureAtlas.outputFormat") || "数据格式"}
value={config.outputFormat}
options={[
{ label: "Cocos2d plist", value: "cocos2d" as const },
{ label: "Cocos Creator", value: "cocos-creator" as const },
{ label: "JSON", value: "generic-json" as const },
]}
onChange={(v) => handleConfigChange("outputFormat", v)}
/>
</ConfigSection>
{/* Result info */}
{result && (
<Card className="bg-primary/5 border-primary/20">
<CardHeader className="pb-2 pt-3 px-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Layers className="h-4 w-4 text-primary" />
{t("atlas.resultInfo") || "合图信息"}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions") || "尺寸"}:</span>
<p className="font-medium">{result.width} × {result.height}</p>
</div>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites") || "精灵数"}:</span>
<p className="font-medium">{result.placements.length}</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Actions */}
<div className="border-t border-white/[0.06] p-4 space-y-2.5">
{/* Generate button */}
<Button
className="w-full rounded-xl"
size="lg"
onClick={handleGenerate}
disabled={!canProcess}
>
{status === "packing" || status === "rendering" ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
{t("common.processing") || "处理中..."}
</>
) : (
<>
<Layers className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.createAtlas") || "生成合图"}
</>
)}
</Button>
{/* Animation preview */}
<Button
variant="outline"
className="w-full rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={openAnimationDialog}
disabled={sprites.length < 2}
>
<Play className="mr-2 h-4 w-4" />
{t("atlas.previewAnimation") || "预览动画"}
</Button>
{/* Download buttons */}
{hasResult && (
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={downloadImage}
>
<Download className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.downloadImage") || "图片"}
</Button>
<Button
variant="outline"
className="flex-1 rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={downloadMetadata}
>
<Download className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.downloadData") || "数据"}
</Button>
</div>
)}
{hasResult && (
<Button
variant="secondary"
className="w-full rounded-xl"
onClick={downloadAll}
>
<Archive className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.downloadAll") || "打包下载"}
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,374 @@
"use client";
import { useRef, useEffect, useCallback, useState } from "react";
import { motion } from "framer-motion";
import {
ZoomIn,
ZoomOut,
Maximize2,
Play,
Layers,
Move,
Download
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useAtlasStore } from "@/store/atlasStore";
import { useSafeTranslation } from "@/lib/i18n";
/**
* Draw checkerboard pattern for transparency
*/
function drawCheckerboard(ctx: CanvasRenderingContext2D, width: number, height: number) {
const tileSize = 8;
const lightColor = "#2a2a2e";
const darkColor = "#1e1e22";
for (let y = 0; y < height; y += tileSize) {
for (let x = 0; x < width; x += tileSize) {
const isLight = ((x / tileSize) + (y / tileSize)) % 2 === 0;
ctx.fillStyle = isLight ? lightColor : darkColor;
ctx.fillRect(x, y, tileSize, tileSize);
}
}
}
export function CanvasPreview() {
const { t } = useSafeTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const {
sprites,
result,
status,
progress,
previewScale,
previewOffset,
setPreviewScale,
setPreviewOffset,
openAnimationDialog,
} = useAtlasStore();
// Calculate dimensions
const atlasWidth = result?.width || 0;
const atlasHeight = result?.height || 0;
// Update container size on resize
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
// Render canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const { width: cw, height: ch } = containerSize;
if (cw === 0 || ch === 0) return;
// Set canvas size
const dpr = window.devicePixelRatio || 1;
canvas.width = cw * dpr;
canvas.height = ch * dpr;
canvas.style.width = `${cw}px`;
canvas.style.height = `${ch}px`;
ctx.scale(dpr, dpr);
// Clear and draw background
ctx.fillStyle = "#0f0f11";
ctx.fillRect(0, 0, cw, ch);
if (result && result.imageDataUrl) {
// Load and draw the atlas image
const img = new Image();
img.src = result.imageDataUrl;
img.onload = () => {
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = "#0f0f11";
ctx.fillRect(0, 0, cw, ch);
// Calculate centered position
const scaledWidth = atlasWidth * previewScale;
const scaledHeight = atlasHeight * previewScale;
const centerX = (cw - scaledWidth) / 2 + previewOffset.x;
const centerY = (ch - scaledHeight) / 2 + previewOffset.y;
// Draw checkerboard background for atlas area
ctx.save();
ctx.translate(centerX, centerY);
ctx.beginPath();
ctx.rect(0, 0, scaledWidth, scaledHeight);
ctx.clip();
// Scale checkerboard
ctx.scale(previewScale, previewScale);
drawCheckerboard(ctx, atlasWidth, atlasHeight);
ctx.restore();
// Draw atlas image
ctx.drawImage(img, centerX, centerY, scaledWidth, scaledHeight);
// Draw border
ctx.strokeStyle = "rgba(59, 130, 246, 0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(centerX, centerY, scaledWidth, scaledHeight);
// Draw dimensions label
ctx.fillStyle = "rgba(59, 130, 246, 0.8)";
ctx.font = "11px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText(
`${atlasWidth} × ${atlasHeight}`,
centerX + scaledWidth / 2,
centerY - 8
);
};
} else if (sprites.length === 0) {
// Empty state
ctx.fillStyle = "#71717a";
ctx.font = "14px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
t("atlas.emptyPreview") || "上传精灵图后预览合图效果",
cw / 2,
ch / 2
);
}
}, [containerSize, result, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, t]);
// Handle wheel zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setPreviewScale(previewScale + delta);
}, [previewScale, setPreviewScale]);
// Handle mouse down for panning
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 0) {
setIsPanning(true);
setPanStart({ x: e.clientX - previewOffset.x, y: e.clientY - previewOffset.y });
}
}, [previewOffset]);
// Handle mouse move for panning
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
setPreviewOffset({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
});
}
}, [isPanning, panStart, setPreviewOffset]);
// Handle mouse up
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// Fit to view
const fitToView = useCallback(() => {
if (!result || containerSize.width === 0) return;
const padding = 40;
const availableWidth = containerSize.width - padding * 2;
const availableHeight = containerSize.height - padding * 2;
const scaleX = availableWidth / atlasWidth;
const scaleY = availableHeight / atlasHeight;
const newScale = Math.min(scaleX, scaleY, 1);
setPreviewScale(newScale);
setPreviewOffset({ x: 0, y: 0 });
}, [result, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]);
// Zoom controls
const zoomIn = useCallback(() => setPreviewScale(previewScale + 0.1), [previewScale, setPreviewScale]);
const zoomOut = useCallback(() => setPreviewScale(previewScale - 0.1), [previewScale, setPreviewScale]);
// Download image
const downloadImage = useCallback(() => {
if (!result?.imageDataUrl) return;
const link = document.createElement("a");
link.href = result.imageDataUrl;
link.download = `atlas_${atlasWidth}x${atlasHeight}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [result, atlasWidth, atlasHeight]);
// Scale percentage
const scalePercent = Math.round(previewScale * 100);
return (
<div className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl">
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-white/[0.06] bg-black/20 px-4 py-2.5">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t("atlas.preview") || "预览"}
</span>
{result && (
<Badge variant="secondary" className="text-xs">
{atlasWidth} × {atlasHeight}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{/* Zoom controls */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={zoomOut}
disabled={previewScale <= 0.1}
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
<div className="w-12 text-center text-xs text-muted-foreground">
{scalePercent}%
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={zoomIn}
disabled={previewScale >= 4}
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-4 w-px bg-border/40" />
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={fitToView}
disabled={!result}
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-4 w-px bg-border/40" />
{/* Animation preview */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={openAnimationDialog}
disabled={sprites.length < 2}
title={t("atlas.previewAnimation") || "预览动画"}
>
<Play className="h-3.5 w-3.5" />
</Button>
{/* Download */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={downloadImage}
disabled={!result?.imageDataUrl}
title={t("common.download") || "下载"}
>
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Canvas container */}
<div
ref={containerRef}
className="relative flex-1 cursor-grab overflow-hidden bg-[#0a0a0b] active:cursor-grabbing"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<canvas
ref={canvasRef}
className="absolute inset-0"
/>
{/* Loading overlay */}
{(status === "packing" || status === "rendering" || status === "loading") && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div className="mb-4 h-10 w-10 animate-spin rounded-full border-3 border-primary border-t-transparent" />
<p className="mb-2 text-sm font-medium">
{status === "packing" && (t("atlas.packing") || "打包中...")}
{status === "rendering" && (t("atlas.rendering") || "渲染中...")}
{status === "loading" && (t("common.loading") || "加载中...")}
</p>
{progress > 0 && (
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-white/10">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
className="h-full bg-primary"
/>
</div>
)}
</motion.div>
)}
{/* Empty state overlay */}
{sprites.length === 0 && status === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0a0b]">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.04]">
<Layers className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="mb-1 text-sm font-medium text-muted-foreground">
{t("atlas.emptyPreview") || "上传精灵图后预览合图效果"}
</p>
<p className="text-xs text-muted-foreground/50">
{t("atlas.dragHint") || "拖拽文件或文件夹到左侧面板"}
</p>
</div>
)}
{/* Pan hint */}
{result && !isPanning && (
<div className="absolute bottom-4 left-4 flex items-center gap-1.5 rounded-lg bg-black/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-sm">
<Move className="h-3 w-3" />
{t("atlas.panHint") || "拖拽平移,滚轮缩放"}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,506 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Folder,
FolderOpen,
Image as ImageIcon,
Trash2,
Upload,
X,
ChevronDown,
ChevronRight
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAtlasStore, type BrowserSprite } from "@/store/atlasStore";
import { useSafeTranslation } from "@/lib/i18n";
/**
* Generate unique ID
*/
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Natural sort comparator for filenames
*/
function naturalSort(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}
/**
* Check if file is an image
*/
function isImageFile(file: File): boolean {
const imageTypes = ["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif", "image/bmp"];
return imageTypes.includes(file.type) || /\.(png|jpe?g|webp|gif|bmp)$/i.test(file.name);
}
/**
* Load image file as BrowserSprite
*/
async function loadImageAsSprite(file: File): Promise<BrowserSprite> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async () => {
try {
const blob = new Blob([reader.result as ArrayBuffer], { type: file.type });
const imageBitmap = await createImageBitmap(blob);
resolve({
id: generateId(),
name: file.name,
width: imageBitmap.width,
height: imageBitmap.height,
image: imageBitmap,
file,
});
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsArrayBuffer(file);
});
}
export function FileListPanel() {
const { t } = useSafeTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isExpanded, setIsExpanded] = useState(true);
const {
sprites,
folderName,
addSprites,
removeSprite,
clearSprites,
setFolderName,
selectedSpriteIds,
selectSprite,
setStatus,
} = useAtlasStore();
/**
* Process uploaded files
*/
const processFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files);
const imageFiles = fileArray.filter(isImageFile);
if (imageFiles.length === 0) return;
setIsLoading(true);
setStatus("loading");
try {
// Sort by filename
imageFiles.sort((a, b) => naturalSort(a.name, b.name));
// Extract folder name from first file's path
const firstFile = imageFiles[0];
if (firstFile.webkitRelativePath) {
const pathParts = firstFile.webkitRelativePath.split("/");
if (pathParts.length > 1) {
setFolderName(pathParts[0]);
}
}
// Load all images
const loadPromises = imageFiles.map((file) => loadImageAsSprite(file));
const loadedSprites = await Promise.all(loadPromises);
addSprites(loadedSprites);
setStatus("idle");
} catch (error) {
console.error("Failed to load images:", error);
setStatus("error");
} finally {
setIsLoading(false);
}
}, [addSprites, setFolderName, setStatus]);
/**
* Handle drag events
*/
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if we're leaving the drop zone
if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const items = e.dataTransfer.items;
const dataTransferFiles = e.dataTransfer.files;
const files: File[] = [];
// Read all entries from a directory (handles batched readEntries)
const readAllEntries = async (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> => {
const allEntries: FileSystemEntry[] = [];
const readBatch = (): Promise<FileSystemEntry[]> => {
return new Promise((resolve) => {
reader.readEntries((entries) => resolve(entries));
});
};
// readEntries may return results in batches, keep reading until empty
let batch = await readBatch();
while (batch.length > 0) {
allEntries.push(...batch);
batch = await readBatch();
}
return allEntries;
};
// Handle folder drop
const processEntry = async (entry: FileSystemEntry): Promise<void> => {
if (entry.isFile) {
const fileEntry = entry as FileSystemFileEntry;
return new Promise((resolve) => {
fileEntry.file((file) => {
if (isImageFile(file)) {
// Add webkitRelativePath-like info
Object.defineProperty(file, "webkitRelativePath", {
value: entry.fullPath.substring(1), // Remove leading slash
writable: false,
});
files.push(file);
}
resolve();
});
});
} else if (entry.isDirectory) {
const dirEntry = entry as FileSystemDirectoryEntry;
const reader = dirEntry.createReader();
const entries = await readAllEntries(reader);
for (const childEntry of entries) {
await processEntry(childEntry);
}
}
};
// Check if webkitGetAsEntry is supported
let hasEntrySupport = false;
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === "function") {
hasEntrySupport = true;
}
if (hasEntrySupport) {
// Process using FileSystem API (supports folders)
const entryPromises: Promise<void>[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const entry = item.webkitGetAsEntry?.();
if (entry) {
entryPromises.push(processEntry(entry));
}
}
await Promise.all(entryPromises);
} else {
// Fallback: use dataTransfer.files directly (no folder support)
for (let i = 0; i < dataTransferFiles.length; i++) {
const file = dataTransferFiles[i];
if (file && isImageFile(file)) {
files.push(file);
}
}
}
if (files.length > 0) {
// Extract folder name from first file
if (files[0].webkitRelativePath) {
const pathParts = files[0].webkitRelativePath.split("/");
if (pathParts.length > 1) {
setFolderName(pathParts[0]);
}
}
await processFiles(files);
}
}, [processFiles, setFolderName]);
/**
* Handle file input change
*/
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
processFiles(e.target.files);
}
// Reset input
e.target.value = "";
}, [processFiles]);
/**
* Handle sprite click
*/
const handleSpriteClick = useCallback((id: string, e: React.MouseEvent) => {
selectSprite(id, e.ctrlKey || e.metaKey);
}, [selectSprite]);
/**
* Handle clear all
*/
const handleClear = useCallback(() => {
clearSprites();
setFolderName("");
}, [clearSprites, setFolderName]);
return (
<div
ref={dropZoneRef}
className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{folderName ? (
<FolderOpen className="h-4 w-4 text-primary" />
) : (
<Folder className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium truncate max-w-[120px]">
{folderName || t("tools.textureAtlas.sprites") || "精灵"}
</span>
{sprites.length > 0 && (
<span className="text-xs text-muted-foreground">
({sprites.length})
</span>
)}
</div>
{sprites.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-white/[0.06]"
onClick={handleClear}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{/* File list */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0 }}
animate={{ height: "auto" }}
exit={{ height: 0 }}
className="flex-1 overflow-hidden"
>
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{sprites.length > 0 ? (
<div className="space-y-1 p-2">
{sprites.map((sprite, index) => (
<motion.div
key={sprite.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ delay: index * 0.02 }}
onClick={(e) => handleSpriteClick(sprite.id, e)}
className={`
group flex items-center gap-2.5 rounded-xl px-2.5 py-2 cursor-pointer
transition-all duration-150 hover:bg-white/[0.06]
${selectedSpriteIds.includes(sprite.id) ? "bg-primary/15 ring-1 ring-primary/30" : ""}
`}
>
{/* Thumbnail */}
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-lg border border-white/[0.08] bg-black/30">
<canvas
ref={(canvas) => {
if (canvas && sprite.image) {
const ctx = canvas.getContext("2d");
if (ctx) {
canvas.width = 36;
canvas.height = 36;
// Calculate aspect ratio fit
const scale = Math.min(36 / sprite.width, 36 / sprite.height);
const w = sprite.width * scale;
const h = sprite.height * scale;
const x = (36 - w) / 2;
const y = (36 - h) / 2;
ctx.clearRect(0, 0, 36, 36);
ctx.drawImage(sprite.image, x, y, w, h);
}
}
}}
className="h-full w-full"
/>
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{sprite.name}</p>
<p className="text-[10px] text-muted-foreground/70">
{sprite.width} × {sprite.height}
</p>
</div>
{/* Remove button */}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:bg-white/[0.08] transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeSprite(sprite.id);
}}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
) : (
/* Empty state / Drop zone */
<div className="flex h-full min-h-[200px] flex-col items-center justify-center p-4 text-center">
{isDragging ? (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center"
>
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/20">
<Upload className="h-7 w-7 text-primary" />
</div>
<p className="text-sm font-medium text-primary">
{t("uploader.dropActive") || "释放文件即可上传"}
</p>
</motion.div>
) : isLoading ? (
<div className="flex flex-col items-center">
<div className="mb-3 h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-xs text-muted-foreground">
{t("common.loading") || "加载中..."}
</p>
</div>
) : (
<>
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-white/[0.04]">
<ImageIcon className="h-7 w-7 text-muted-foreground/60" />
</div>
<p className="mb-1 text-sm font-medium">
{t("atlas.dropSprites") || "拖拽精灵图到这里"}
</p>
<p className="mb-4 text-xs text-muted-foreground/60">
{t("atlas.supportFolder") || "支持拖拽文件夹上传"}
</p>
<div className="flex flex-col gap-2 w-full px-2">
<Button
variant="outline"
size="sm"
className="w-full rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="mr-1.5 h-3.5 w-3.5" />
{t("atlas.selectFiles") || "选择文件"}
</Button>
<Button
variant="outline"
size="sm"
className="w-full rounded-xl border-white/[0.08] hover:bg-white/[0.06]"
onClick={() => folderInputRef.current?.click()}
>
<Folder className="mr-1.5 h-3.5 w-3.5" />
{t("atlas.selectFolder") || "选择文件夹"}
</Button>
</div>
</>
)}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
className="hidden"
onChange={handleFileChange}
/>
<input
ref={folderInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/webp,image/gif,image/bmp"
// @ts-expect-error - webkitdirectory is not in the type definition
webkitdirectory="true"
className="hidden"
onChange={handleFileChange}
/>
{/* Drag overlay */}
<AnimatePresence>
{isDragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-50 flex items-center justify-center bg-primary/10 backdrop-blur-sm"
>
<div className="rounded-lg border-2 border-dashed border-primary bg-background/80 px-8 py-6 text-center">
<Upload className="mx-auto mb-2 h-8 w-8 text-primary" />
<p className="font-medium text-primary">
{t("uploader.dropActive") || "释放文件即可上传"}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { FileListPanel } from "./FileListPanel";
export { CanvasPreview } from "./CanvasPreview";
export { AtlasConfigPanel } from "./AtlasConfigPanel";
export { AnimationPreviewDialog } from "./AnimationPreviewDialog";

536
src/hooks/useAtlasWorker.ts Normal file
View File

@@ -0,0 +1,536 @@
/**
* Hook for managing Atlas Worker communication
*/
import { useRef, useCallback, useEffect } from "react";
import { useAtlasStore, type BrowserSprite, type AtlasResult } from "@/store/atlasStore";
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
import type { PackerPlacement } from "@/lib/atlas-packer";
interface WorkerInputMessage {
type: "pack";
sprites: { id: string; name: string; width: number; height: number }[];
config: TextureAtlasConfig;
}
interface WorkerOutputMessage {
type: "result" | "progress" | "error";
result?: {
width: number;
height: number;
placements: PackerPlacement[];
};
progress?: number;
error?: string;
}
/**
* Render sprites to canvas and get data URL
*/
async function renderAtlasToCanvas(
sprites: BrowserSprite[],
placements: PackerPlacement[],
width: number,
height: number
): Promise<string> {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
// Clear canvas (transparent background)
ctx.clearRect(0, 0, width, height);
// Create sprite lookup
const spriteMap = new Map(sprites.map((s) => [s.id, s]));
// Draw each sprite
for (const placement of placements) {
const sprite = spriteMap.get(placement.id);
if (!sprite) continue;
ctx.save();
if (placement.rotated) {
// Rotate 90 degrees clockwise
ctx.translate(placement.x + placement.height, placement.y);
ctx.rotate(Math.PI / 2);
ctx.drawImage(sprite.image, 0, 0);
} else {
ctx.drawImage(sprite.image, placement.x, placement.y);
}
ctx.restore();
}
return canvas.toDataURL("image/png");
}
/**
* Build AtlasFrame array from placements
*/
function buildFrames(placements: PackerPlacement[]): AtlasFrame[] {
return placements.map((p) => {
const w = p.rotated ? p.height : p.width;
const h = p.rotated ? p.width : p.height;
return {
filename: p.name,
frame: { x: p.x, y: p.y, width: w, height: h },
rotated: p.rotated,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: p.width, h: p.height },
sourceSize: { w: p.width, h: p.height },
};
});
}
export function useAtlasWorker() {
const workerRef = useRef<Worker | null>(null);
const { sprites, config, setStatus, setProgress, setError, setResult } = useAtlasStore();
// Initialize worker
useEffect(() => {
// Create worker from blob to avoid bundler issues
const workerCode = `
// Worker code is loaded inline
${getWorkerCode()}
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const workerUrl = URL.createObjectURL(blob);
workerRef.current = new Worker(workerUrl);
workerRef.current.onmessage = async (event: MessageEvent<WorkerOutputMessage>) => {
const { type, result, progress, error } = event.data;
if (type === "progress" && progress !== undefined) {
setProgress(progress * 0.5); // Packing is 50% of total progress
} else if (type === "result" && result) {
setStatus("rendering");
setProgress(50);
try {
// Render to canvas
const currentSprites = useAtlasStore.getState().sprites;
const imageDataUrl = await renderAtlasToCanvas(
currentSprites,
result.placements,
result.width,
result.height
);
const frames = buildFrames(result.placements);
const atlasResult: AtlasResult = {
width: result.width,
height: result.height,
placements: result.placements,
frames,
imageDataUrl,
};
setResult(atlasResult);
setProgress(100);
} catch (renderError) {
setError(renderError instanceof Error ? renderError.message : "Render failed");
}
} else if (type === "error" && error) {
setError(error);
}
};
workerRef.current.onerror = (error) => {
console.error("Worker error:", error);
setError("Worker error: " + error.message);
};
return () => {
if (workerRef.current) {
workerRef.current.terminate();
URL.revokeObjectURL(workerUrl);
}
};
}, [setStatus, setProgress, setError, setResult]);
// Pack function
const pack = useCallback(() => {
if (!workerRef.current || sprites.length === 0) {
return;
}
setStatus("packing");
setProgress(0);
setError(null);
const message: WorkerInputMessage = {
type: "pack",
sprites: sprites.map((s) => ({
id: s.id,
name: s.name,
width: s.width,
height: s.height,
})),
config,
};
workerRef.current.postMessage(message);
}, [sprites, config, setStatus, setProgress, setError]);
return { pack };
}
/**
* Get worker code as string
* This is necessary because Next.js doesn't support worker imports easily
*/
function getWorkerCode(): string {
return `
function nextPowerOfTwo(value) {
return Math.pow(2, Math.ceil(Math.log2(value)));
}
function adjustSizeForPot(value, pot) {
return pot ? nextPowerOfTwo(value) : value;
}
function sortSpritesBySize(sprites) {
return [...sprites].sort((a, b) => {
const maxA = Math.max(a.width, a.height);
const maxB = Math.max(b.width, b.height);
if (maxA !== maxB) return maxB - maxA;
const areaA = a.width * a.height;
const areaB = b.width * b.height;
if (areaB !== areaA) return areaB - areaA;
return (b.width + b.height) - (a.width + a.height);
});
}
class MaxRectsPacker {
constructor(width, height, allowRotation) {
this.binWidth = width;
this.binHeight = height;
this.allowRotation = allowRotation;
this.usedRectangles = [];
this.freeRectangles = [{ x: 0, y: 0, width, height }];
}
insert(width, height) {
let bestNode = this.findPositionBestAreaFit(width, height);
let rotated = false;
if (this.allowRotation && width !== height) {
const rotatedNode = this.findPositionBestAreaFit(height, width);
if (!bestNode || (rotatedNode && rotatedNode.height * rotatedNode.width < bestNode.height * bestNode.width)) {
bestNode = rotatedNode;
rotated = true;
[width, height] = [height, width];
}
}
if (!bestNode) return null;
this.splitFreeRectangles(bestNode, width, height);
this.usedRectangles.push({ x: bestNode.x, y: bestNode.y, width, height });
return { x: bestNode.x, y: bestNode.y, rotated };
}
findPositionBestAreaFit(width, height) {
let bestNode = null;
let bestAreaFit = Number.MAX_VALUE;
let bestShortSideFit = Number.MAX_VALUE;
for (const rect of this.freeRectangles) {
const areaFit = rect.width * rect.height - width * height;
if (rect.width >= width && rect.height >= height) {
const leftoverHoriz = Math.abs(rect.width - width);
const leftoverVert = Math.abs(rect.height - height);
const shortSideFit = Math.min(leftoverHoriz, leftoverVert);
if (areaFit < bestAreaFit || (areaFit === bestAreaFit && shortSideFit < bestShortSideFit)) {
bestNode = { x: rect.x, y: rect.y, width, height };
bestShortSideFit = shortSideFit;
bestAreaFit = areaFit;
}
}
}
return bestNode;
}
splitFreeRectangles(node, width, height) {
for (let i = this.freeRectangles.length - 1; i >= 0; i--) {
const freeRect = this.freeRectangles[i];
if (this.splitFreeRectangle(freeRect, node, width, height)) {
this.freeRectangles.splice(i, 1);
}
}
this.freeRectangles = this.freeRectangles.filter(r => r.width > 0 && r.height > 0);
this.pruneFreeRectangles();
}
splitFreeRectangle(freeRect, usedNode, width, height) {
if (freeRect.x >= usedNode.x + width || freeRect.x + freeRect.width <= usedNode.x ||
freeRect.y >= usedNode.y + height || freeRect.y + freeRect.height <= usedNode.y) {
return false;
}
if (freeRect.x < usedNode.x) {
this.freeRectangles.push({ x: freeRect.x, y: freeRect.y, width: usedNode.x - freeRect.x, height: freeRect.height });
}
if (freeRect.x + freeRect.width > usedNode.x + width) {
this.freeRectangles.push({ x: usedNode.x + width, y: freeRect.y, width: freeRect.x + freeRect.width - (usedNode.x + width), height: freeRect.height });
}
if (freeRect.y < usedNode.y) {
this.freeRectangles.push({ x: freeRect.x, y: freeRect.y, width: freeRect.width, height: usedNode.y - freeRect.y });
}
if (freeRect.y + freeRect.height > usedNode.y + height) {
this.freeRectangles.push({ x: freeRect.x, y: usedNode.y + height, width: freeRect.width, height: freeRect.y + freeRect.height - (usedNode.y + height) });
}
return true;
}
pruneFreeRectangles() {
for (let i = 0; i < this.freeRectangles.length; i++) {
for (let j = i + 1; j < this.freeRectangles.length; j++) {
if (this.isContainedIn(this.freeRectangles[i], this.freeRectangles[j])) {
this.freeRectangles.splice(i, 1);
i--;
break;
}
if (this.isContainedIn(this.freeRectangles[j], this.freeRectangles[i])) {
this.freeRectangles.splice(j, 1);
j--;
}
}
}
}
isContainedIn(a, b) {
return a.x >= b.x && a.y >= b.y && a.x + a.width <= b.x + b.width && a.y + a.height <= b.y + b.height;
}
}
class ShelfPacker {
constructor(binWidth, binHeight, allowRotation, padding) {
this.binWidth = binWidth;
this.binHeight = binHeight;
this.allowRotation = allowRotation;
this.padding = padding;
this.shelves = [];
this.currentY = 0;
}
insert(width, height) {
const paddedWidth = width + this.padding;
const paddedHeight = height + this.padding;
for (const shelf of this.shelves) {
if (this.allowRotation && width !== height) {
if (shelf.currentX + height + this.padding <= this.binWidth && shelf.y + width <= this.binHeight) {
const result = { x: shelf.currentX, y: shelf.y, rotated: true };
shelf.currentX += height + this.padding;
shelf.height = Math.max(shelf.height, width + this.padding);
return result;
}
}
if (shelf.currentX + paddedWidth <= this.binWidth && shelf.y + height <= this.binHeight) {
const result = { x: shelf.currentX, y: shelf.y, rotated: false };
shelf.currentX += paddedWidth;
shelf.height = Math.max(shelf.height, paddedHeight);
return result;
}
}
const newShelfY = this.currentY;
if (newShelfY + paddedHeight > this.binHeight) return null;
const newShelf = { y: newShelfY, currentX: 0, height: paddedHeight };
if (this.allowRotation && width !== height) {
if (newShelf.currentX + height + this.padding <= this.binWidth) {
newShelf.currentX = height + this.padding;
newShelf.height = Math.max(newShelf.height, width + this.padding);
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: true };
}
}
if (newShelf.currentX + paddedWidth <= this.binWidth) {
newShelf.currentX = paddedWidth;
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: false };
}
return null;
}
}
function packWithMaxRects(sprites, config, postProgress) {
const padding = config.padding;
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
const placements = new Map();
const sorted = sortSpritesBySize(sprites);
for (let i = 0; i < sorted.length; i++) {
const sprite = sorted[i];
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
const position = packer.insert(paddedWidth, paddedHeight);
if (!position) return null;
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
postProgress(((i + 1) / sorted.length) * 100);
}
return placements;
}
function packWithShelf(sprites, config, postProgress) {
const padding = config.padding;
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
const placements = new Map();
const sorted = sortSpritesBySize(sprites);
for (let i = 0; i < sorted.length; i++) {
const sprite = sorted[i];
const position = packer.insert(sprite.width, sprite.height);
if (!position) return null;
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
postProgress(((i + 1) / sorted.length) * 100);
}
return placements;
}
function packSprites(sprites, config, postProgress) {
if (sprites.length === 0) return null;
const padding = config.padding;
const maxSpriteWidth = Math.max(...sprites.map(s => s.width));
const maxSpriteHeight = Math.max(...sprites.map(s => s.height));
const totalArea = sprites.reduce((sum, s) => sum + (s.width + padding * 2) * (s.height + padding * 2), 0);
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
const sizeAttempts = [];
if (config.pot) {
const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(s => s <= config.maxWidth || s <= config.maxHeight);
for (const w of potSizes) {
for (const h of potSizes) {
if (w <= config.maxWidth && h <= config.maxHeight && w >= maxSpriteWidth + padding * 2 && h >= maxSpriteHeight + padding * 2) {
sizeAttempts.push({ w, h });
}
}
}
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
} else {
sizeAttempts.push(
{ w: estimatedWidth, h: estimatedHeight },
{ w: estimatedWidth * 1.5, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 2, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 2 },
{ w: estimatedWidth * 2, h: estimatedHeight * 2 },
{ w: config.maxWidth, h: config.maxHeight }
);
}
const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => {
const w = Math.min(Math.ceil(attempt.w), config.maxWidth);
const h = Math.min(Math.ceil(attempt.h), config.maxHeight);
return self.findIndex(a => Math.min(Math.ceil(a.w), config.maxWidth) === w && Math.min(Math.ceil(a.h), config.maxHeight) === h) === index;
});
for (const attempt of uniqueAttempts) {
const attemptWidth = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w), config.maxWidth);
const attemptHeight = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h), config.maxHeight);
if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) continue;
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
let placements;
if (config.algorithm === "MaxRects") {
placements = packWithMaxRects(sprites, testConfig, postProgress);
} else {
placements = packWithShelf(sprites, testConfig, postProgress);
}
if (placements) {
let maxX = 0, maxY = 0;
for (const p of placements.values()) {
const effectiveWidth = p.rotated ? p.height : p.width;
const effectiveHeight = p.rotated ? p.width : p.height;
maxX = Math.max(maxX, p.x + effectiveWidth + padding);
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
}
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
const resultPlacements = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
}
}
return { width: finalWidth, height: finalHeight, placements: resultPlacements };
}
}
return null;
}
self.onmessage = function(event) {
const { type, sprites, config } = event.data;
if (type === "pack") {
try {
const postProgress = (progress) => {
self.postMessage({ type: "progress", progress });
};
const result = packSprites(sprites, config, postProgress);
if (result) {
self.postMessage({ type: "result", result });
} else {
self.postMessage({ type: "error", error: "Failed to pack all sprites. Try increasing max size or enabling rotation." });
}
} catch (error) {
self.postMessage({ type: "error", error: error.message || "Unknown error" });
}
}
};
`;
}

View File

@@ -1,4 +1,10 @@
import { type ApiResponse, type UploadResponse } from "@/types"; import {
type ApiResponse,
type UploadResponse,
type VideoFramesConfig,
type ImageCompressConfig,
type AudioCompressConfig
} from "@/types";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || ""; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
@@ -72,7 +78,7 @@ export async function uploadFile(file: File, _onProgress?: (progress: number) =>
/** /**
* Process video to frames * Process video to frames
*/ */
export async function processVideoFrames(fileId: string, config: any) { export async function processVideoFrames(fileId: string, config: VideoFramesConfig) {
return apiClient("/process/video-frames", { return apiClient("/process/video-frames", {
method: "POST", method: "POST",
body: JSON.stringify({ fileId, config }), body: JSON.stringify({ fileId, config }),
@@ -82,7 +88,7 @@ export async function processVideoFrames(fileId: string, config: any) {
/** /**
* Process image compression * Process image compression
*/ */
export async function processImageCompression(fileId: string, config: any) { export async function processImageCompression(fileId: string, config: ImageCompressConfig) {
return apiClient("/process/image-compress", { return apiClient("/process/image-compress", {
method: "POST", method: "POST",
body: JSON.stringify({ fileId, config }), body: JSON.stringify({ fileId, config }),
@@ -92,7 +98,7 @@ export async function processImageCompression(fileId: string, config: any) {
/** /**
* Process audio compression * Process audio compression
*/ */
export async function processAudioCompression(fileId: string, config: any) { export async function processAudioCompression(fileId: string, config: AudioCompressConfig) {
return apiClient("/process/audio-compress", { return apiClient("/process/audio-compress", {
method: "POST", method: "POST",
body: JSON.stringify({ fileId, config }), body: JSON.stringify({ fileId, config }),

676
src/lib/atlas-packer.ts Normal file
View File

@@ -0,0 +1,676 @@
/**
* Browser-side Texture Atlas Packing Algorithms
* Implements MaxRects and Shelf algorithms for packing sprites
*/
import type { TextureAtlasConfig } from "@/types";
/**
* Rectangle definition
*/
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
/**
* Sprite input for packing
*/
export interface PackerSprite {
id: string;
name: string;
width: number;
height: number;
}
/**
* Packing result for a single sprite
*/
export interface PackerPlacement {
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
rotated: boolean;
}
/**
* Complete packing result
*/
export interface PackerResult {
width: number;
height: number;
placements: PackerPlacement[];
}
/**
* Calculate the next power of two
*/
function nextPowerOfTwo(value: number): number {
return Math.pow(2, Math.ceil(Math.log2(value)));
}
/**
* Adjust size for power of two if required
*/
function adjustSizeForPot(value: number, pot: boolean): number {
return pot ? nextPowerOfTwo(value) : value;
}
/**
* Sort sprites by size (largest first)
*/
function sortSpritesBySize<T extends { width: number; height: number }>(sprites: T[]): T[] {
return [...sprites].sort((a, b) => {
const maxA = Math.max(a.width, a.height);
const maxB = Math.max(b.width, b.height);
if (maxA !== maxB) return maxB - maxA;
const areaA = a.width * a.height;
const areaB = b.width * b.height;
if (areaB !== areaA) return areaB - areaA;
return (b.width + b.height) - (a.width + a.height);
});
}
/**
* MaxRects Packing Algorithm
* Best for general purpose packing with good space efficiency
*/
class MaxRectsPacker {
private usedRectangles: Rectangle[] = [];
private freeRectangles: Rectangle[] = [];
private allowRotation: boolean;
constructor(width: number, height: number, allowRotation: boolean) {
this.allowRotation = allowRotation;
this.freeRectangles.push({ x: 0, y: 0, width, height });
}
/**
* Insert a rectangle and return its position
*/
insert(width: number, height: number): { x: number; y: number; rotated: boolean } | null {
let bestNode = this.findPositionForNewNodeBestAreaFit(width, height);
let rotated = false;
// Try rotated version if allowed
if (this.allowRotation && width !== height) {
const rotatedNode = this.findPositionForNewNodeBestAreaFit(height, width);
if (
!bestNode ||
(rotatedNode && rotatedNode.height * rotatedNode.width < bestNode.height * bestNode.width)
) {
bestNode = rotatedNode;
rotated = true;
[width, height] = [height, width];
}
}
if (!bestNode) {
return null;
}
this.splitFreeRectangles(bestNode, width, height);
this.usedRectangles.push({
x: bestNode.x,
y: bestNode.y,
width,
height,
});
return { x: bestNode.x, y: bestNode.y, rotated };
}
/**
* Find the best position using Best Area Fit heuristic
*/
private findPositionForNewNodeBestAreaFit(
width: number,
height: number
): Rectangle | null {
let bestNode: Rectangle | null = null;
let bestAreaFit = Number.MAX_VALUE;
let bestShortSideFit = Number.MAX_VALUE;
for (const rect of this.freeRectangles) {
const areaFit = rect.width * rect.height - width * height;
if (rect.width >= width && rect.height >= height) {
const leftoverHoriz = Math.abs(rect.width - width);
const leftoverVert = Math.abs(rect.height - height);
const shortSideFit = Math.min(leftoverHoriz, leftoverVert);
if (areaFit < bestAreaFit || (areaFit === bestAreaFit && shortSideFit < bestShortSideFit)) {
bestNode = { x: rect.x, y: rect.y, width, height };
bestShortSideFit = shortSideFit;
bestAreaFit = areaFit;
}
}
}
return bestNode;
}
/**
* Split free rectangles after placing a rectangle
*/
private splitFreeRectangles(node: Rectangle, width: number, height: number): void {
for (let i = this.freeRectangles.length - 1; i >= 0; i--) {
const freeRect = this.freeRectangles[i];
if (this.splitFreeRectangle(freeRect, node, width, height)) {
this.freeRectangles.splice(i, 1);
}
}
this.freeRectangles = this.freeRectangles.filter(
(rect) => rect.width > 0 && rect.height > 0
);
this.pruneFreeRectangles();
}
/**
* Split a single free rectangle
*/
private splitFreeRectangle(
freeRect: Rectangle,
usedNode: Rectangle,
width: number,
height: number
): boolean {
if (
freeRect.x >= usedNode.x + width ||
freeRect.x + freeRect.width <= usedNode.x ||
freeRect.y >= usedNode.y + height ||
freeRect.y + freeRect.height <= usedNode.y
) {
return false;
}
if (freeRect.x < usedNode.x) {
this.freeRectangles.push({
x: freeRect.x,
y: freeRect.y,
width: usedNode.x - freeRect.x,
height: freeRect.height,
});
}
if (freeRect.x + freeRect.width > usedNode.x + width) {
this.freeRectangles.push({
x: usedNode.x + width,
y: freeRect.y,
width: freeRect.x + freeRect.width - (usedNode.x + width),
height: freeRect.height,
});
}
if (freeRect.y < usedNode.y) {
this.freeRectangles.push({
x: freeRect.x,
y: freeRect.y,
width: freeRect.width,
height: usedNode.y - freeRect.y,
});
}
if (freeRect.y + freeRect.height > usedNode.y + height) {
this.freeRectangles.push({
x: freeRect.x,
y: usedNode.y + height,
width: freeRect.width,
height: freeRect.y + freeRect.height - (usedNode.y + height),
});
}
return true;
}
/**
* Remove redundant free rectangles
*/
private pruneFreeRectangles(): void {
for (let i = 0; i < this.freeRectangles.length; i++) {
for (let j = i + 1; j < this.freeRectangles.length; j++) {
if (this.isContainedIn(this.freeRectangles[i], this.freeRectangles[j])) {
this.freeRectangles.splice(i, 1);
i--;
break;
}
if (this.isContainedIn(this.freeRectangles[j], this.freeRectangles[i])) {
this.freeRectangles.splice(j, 1);
j--;
}
}
}
}
/**
* Check if rectangle a is contained within rectangle b
*/
private isContainedIn(a: Rectangle, b: Rectangle): boolean {
return (
a.x >= b.x &&
a.y >= b.y &&
a.x + a.width <= b.x + b.width &&
a.y + a.height <= b.y + b.height
);
}
}
/**
* Shelf Packing Algorithm
* Simple and fast algorithm that packs sprites in horizontal shelves
*/
class ShelfPacker {
private shelves: { y: number; currentX: number; height: number }[] = [];
private currentY = 0;
private allowRotation: boolean;
private binWidth: number;
private binHeight: number;
private padding: number;
constructor(binWidth: number, binHeight: number, allowRotation: boolean, padding: number) {
this.binWidth = binWidth;
this.binHeight = binHeight;
this.allowRotation = allowRotation;
this.padding = padding;
}
/**
* Insert a rectangle
*/
insert(width: number, height: number): { x: number; y: number; rotated: boolean } | null {
const paddedWidth = width + this.padding;
const paddedHeight = height + this.padding;
// Try to fit in existing shelves
for (const shelf of this.shelves) {
if (this.allowRotation && width !== height) {
if (shelf.currentX + height + this.padding <= this.binWidth &&
shelf.y + width <= this.binHeight) {
const result = { x: shelf.currentX, y: shelf.y, rotated: true };
shelf.currentX += height + this.padding;
shelf.height = Math.max(shelf.height, width + this.padding);
return result;
}
}
if (shelf.currentX + paddedWidth <= this.binWidth &&
shelf.y + height <= this.binHeight) {
const result = { x: shelf.currentX, y: shelf.y, rotated: false };
shelf.currentX += paddedWidth;
shelf.height = Math.max(shelf.height, paddedHeight);
return result;
}
}
// Create a new shelf
const newShelfY = this.currentY;
if (newShelfY + paddedHeight > this.binHeight) {
return null;
}
const newShelf = {
y: newShelfY,
currentX: 0,
height: paddedHeight,
};
if (this.allowRotation && width !== height) {
if (newShelf.currentX + height + this.padding <= this.binWidth) {
newShelf.currentX = height + this.padding;
newShelf.height = Math.max(newShelf.height, width + this.padding);
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: true };
}
}
if (newShelf.currentX + paddedWidth <= this.binWidth) {
newShelf.currentX = paddedWidth;
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: false };
}
return null;
}
}
/**
* Pack sprites using MaxRects algorithm
*/
function packWithMaxRects(
sprites: PackerSprite[],
config: TextureAtlasConfig
): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
const padding = config.padding;
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
for (const sprite of sortSpritesBySize(sprites)) {
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
const position = packer.insert(paddedWidth, paddedHeight);
if (!position) {
return null; // Failed to pack
}
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
}
return placements;
}
/**
* Pack sprites using Shelf algorithm
*/
function packWithShelf(
sprites: PackerSprite[],
config: TextureAtlasConfig
): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
const padding = config.padding;
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
for (const sprite of sortSpritesBySize(sprites)) {
const position = packer.insert(sprite.width, sprite.height);
if (!position) {
return null;
}
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
}
return placements;
}
/**
* Main packing function
*/
export function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig): PackerResult | null {
if (sprites.length === 0) {
return null;
}
const padding = config.padding;
const maxSpriteWidth = Math.max(...sprites.map((s) => s.width));
const maxSpriteHeight = Math.max(...sprites.map((s) => s.height));
// Calculate total area for estimation
const totalArea = sprites.reduce((sum, s) => {
const pw = s.width + padding * 2;
const ph = s.height + padding * 2;
return sum + pw * ph;
}, 0);
// Start with estimated size
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
// Build size attempts
const sizeAttempts: { w: number; h: number }[] = [];
if (config.pot) {
const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(
(s) => s <= config.maxWidth || s <= config.maxHeight
);
for (const w of potSizes) {
for (const h of potSizes) {
if (
w <= config.maxWidth &&
h <= config.maxHeight &&
w >= maxSpriteWidth + padding * 2 &&
h >= maxSpriteHeight + padding * 2
) {
sizeAttempts.push({ w, h });
}
}
}
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
} else {
sizeAttempts.push(
{ w: estimatedWidth, h: estimatedHeight },
{ w: estimatedWidth * 1.5, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 2, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 2 },
{ w: estimatedWidth * 2, h: estimatedHeight * 2 },
{ w: config.maxWidth, h: config.maxHeight }
);
}
// Remove duplicates
const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => {
const w = Math.min(Math.ceil(attempt.w), config.maxWidth);
const h = Math.min(Math.ceil(attempt.h), config.maxHeight);
return (
self.findIndex(
(a) =>
Math.min(Math.ceil(a.w), config.maxWidth) === w &&
Math.min(Math.ceil(a.h), config.maxHeight) === h
) === index
);
});
// Try each size
for (const attempt of uniqueAttempts) {
const attemptWidth = Math.min(
config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w),
config.maxWidth
);
const attemptHeight = Math.min(
config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h),
config.maxHeight
);
if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) {
continue;
}
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
let placements: Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null;
if (config.algorithm === "MaxRects") {
placements = packWithMaxRects(sprites, testConfig);
} else {
placements = packWithShelf(sprites, testConfig);
}
if (placements) {
// Calculate actual dimensions
let maxX = 0;
let maxY = 0;
for (const placement of placements.values()) {
const effectiveWidth = placement.rotated ? placement.height : placement.width;
const effectiveHeight = placement.rotated ? placement.width : placement.height;
maxX = Math.max(maxX, placement.x + effectiveWidth + padding);
maxY = Math.max(maxY, placement.y + effectiveHeight + padding);
}
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
// Build result
const resultPlacements: PackerPlacement[] = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({
id: sprite.id,
name: sprite.name,
...placement,
});
}
}
return {
width: finalWidth,
height: finalHeight,
placements: resultPlacements,
};
}
}
return null;
}
/**
* Export to Cocos2d plist format
*/
export function exportToCocos2dPlist(
placements: PackerPlacement[],
width: number,
height: number,
imageFilename: string
): string {
const escapeXml = (str: string): string => {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
};
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n';
xml += '<plist version="1.0">\n';
xml += '<dict>\n';
xml += '\t<key>frames</key>\n';
xml += '\t<dict>\n';
for (const p of placements) {
const w = p.rotated ? p.height : p.width;
const h = p.rotated ? p.width : p.height;
xml += `\t\t<key>${escapeXml(p.name)}</key>\n`;
xml += '\t\t<dict>\n';
xml += '\t\t\t<key>frame</key>\n';
xml += `\t\t\t<string>{{${Math.round(p.x)},${Math.round(p.y)}},{${Math.round(w)},${Math.round(h)}}}</string>\n`;
xml += '\t\t\t<key>offset</key>\n';
xml += '\t\t\t<string>{0,0}</string>\n';
xml += '\t\t\t<key>rotated</key>\n';
xml += `\t\t\t<${p.rotated ? 'true' : 'false'}/>\n`;
xml += '\t\t\t<key>sourceColorRect</key>\n';
xml += `\t\t\t<string>{{0,0},{${Math.round(p.width)},${Math.round(p.height)}}}</string>\n`;
xml += '\t\t\t<key>sourceSize</key>\n';
xml += `\t\t\t<string>{${Math.round(p.width)},${Math.round(p.height)}}</string>\n`;
xml += '\t\t</dict>\n';
}
xml += '\t</dict>\n';
xml += '\t<key>metadata</key>\n';
xml += '\t<dict>\n';
xml += '\t\t<key>format</key>\n';
xml += '\t\t<integer>2</integer>\n';
xml += '\t\t<key>realTextureFileName</key>\n';
xml += `\t\t<string>${escapeXml(imageFilename)}</string>\n`;
xml += '\t\t<key>size</key>\n';
xml += `\t\t<string>{${width},${height}}</string>\n`;
xml += '\t\t<key>textureFileName</key>\n';
xml += `\t\t<string>${escapeXml(imageFilename)}</string>\n`;
xml += '\t</dict>\n';
xml += '</dict>\n';
xml += '</plist>\n';
return xml;
}
/**
* Export to Cocos Creator JSON format
*/
export function exportToCocosCreatorJson(
placements: PackerPlacement[],
width: number,
height: number,
imageFilename: string,
format: string
): string {
const frames: Record<string, object> = {};
for (const p of placements) {
const w = p.rotated ? p.height : p.width;
const h = p.rotated ? p.width : p.height;
frames[p.name] = {
frame: { x: Math.round(p.x), y: Math.round(p.y), w: Math.round(w), h: Math.round(h) },
rotated: p.rotated,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: Math.round(p.width), h: Math.round(p.height) },
sourceSize: { w: Math.round(p.width), h: Math.round(p.height) },
};
}
return JSON.stringify(
{
meta: {
image: imageFilename,
size: { w: width, h: height },
format,
},
frames,
},
null,
2
);
}
/**
* Export to generic JSON format
*/
export function exportToGenericJson(
placements: PackerPlacement[],
width: number,
height: number,
imageFilename: string,
format: string
): string {
return JSON.stringify(
{
image: imageFilename,
width,
height,
format,
frames: placements.map((p) => ({
filename: p.name,
x: Math.round(p.x),
y: Math.round(p.y),
width: Math.round(p.rotated ? p.height : p.width),
height: Math.round(p.rotated ? p.width : p.height),
rotated: p.rotated,
})),
},
null,
2
);
}

404
src/lib/atlas-worker.ts Normal file
View File

@@ -0,0 +1,404 @@
/**
* Web Worker for Texture Atlas Packing
* Handles CPU-intensive packing calculations off the main thread
*/
import type { TextureAtlasConfig } from "@/types";
// Re-implement packing logic in worker context (workers can't import from main bundle easily)
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
interface PackerSprite {
id: string;
name: string;
width: number;
height: number;
}
interface PackerPlacement {
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
rotated: boolean;
}
interface PackerResult {
width: number;
height: number;
placements: PackerPlacement[];
}
// Worker message types
interface WorkerInputMessage {
type: "pack";
sprites: PackerSprite[];
config: TextureAtlasConfig;
}
interface WorkerOutputMessage {
type: "result" | "progress" | "error";
result?: PackerResult;
progress?: number;
error?: string;
}
function nextPowerOfTwo(value: number): number {
return Math.pow(2, Math.ceil(Math.log2(value)));
}
function adjustSizeForPot(value: number, pot: boolean): number {
return pot ? nextPowerOfTwo(value) : value;
}
function sortSpritesBySize<T extends { width: number; height: number }>(sprites: T[]): T[] {
return [...sprites].sort((a, b) => {
const maxA = Math.max(a.width, a.height);
const maxB = Math.max(b.width, b.height);
if (maxA !== maxB) return maxB - maxA;
const areaA = a.width * a.height;
const areaB = b.width * b.height;
if (areaB !== areaA) return areaB - areaA;
return (b.width + b.height) - (a.width + a.height);
});
}
class MaxRectsPacker {
private usedRectangles: Rectangle[] = [];
private freeRectangles: Rectangle[] = [];
private allowRotation: boolean;
constructor(width: number, height: number, allowRotation: boolean) {
this.allowRotation = allowRotation;
this.freeRectangles.push({ x: 0, y: 0, width, height });
}
insert(width: number, height: number): { x: number; y: number; rotated: boolean } | null {
let bestNode = this.findPositionBestAreaFit(width, height);
let rotated = false;
if (this.allowRotation && width !== height) {
const rotatedNode = this.findPositionBestAreaFit(height, width);
if (!bestNode || (rotatedNode && rotatedNode.height * rotatedNode.width < bestNode.height * bestNode.width)) {
bestNode = rotatedNode;
rotated = true;
[width, height] = [height, width];
}
}
if (!bestNode) return null;
this.splitFreeRectangles(bestNode, width, height);
this.usedRectangles.push({ x: bestNode.x, y: bestNode.y, width, height });
return { x: bestNode.x, y: bestNode.y, rotated };
}
private findPositionBestAreaFit(width: number, height: number): Rectangle | null {
let bestNode: Rectangle | null = null;
let bestAreaFit = Number.MAX_VALUE;
let bestShortSideFit = Number.MAX_VALUE;
for (const rect of this.freeRectangles) {
const areaFit = rect.width * rect.height - width * height;
if (rect.width >= width && rect.height >= height) {
const leftoverHoriz = Math.abs(rect.width - width);
const leftoverVert = Math.abs(rect.height - height);
const shortSideFit = Math.min(leftoverHoriz, leftoverVert);
if (areaFit < bestAreaFit || (areaFit === bestAreaFit && shortSideFit < bestShortSideFit)) {
bestNode = { x: rect.x, y: rect.y, width, height };
bestShortSideFit = shortSideFit;
bestAreaFit = areaFit;
}
}
}
return bestNode;
}
private splitFreeRectangles(node: Rectangle, width: number, height: number): void {
for (let i = this.freeRectangles.length - 1; i >= 0; i--) {
const freeRect = this.freeRectangles[i];
if (this.splitFreeRectangle(freeRect, node, width, height)) {
this.freeRectangles.splice(i, 1);
}
}
this.freeRectangles = this.freeRectangles.filter(r => r.width > 0 && r.height > 0);
this.pruneFreeRectangles();
}
private splitFreeRectangle(freeRect: Rectangle, usedNode: Rectangle, width: number, height: number): boolean {
if (freeRect.x >= usedNode.x + width || freeRect.x + freeRect.width <= usedNode.x ||
freeRect.y >= usedNode.y + height || freeRect.y + freeRect.height <= usedNode.y) {
return false;
}
if (freeRect.x < usedNode.x) {
this.freeRectangles.push({ x: freeRect.x, y: freeRect.y, width: usedNode.x - freeRect.x, height: freeRect.height });
}
if (freeRect.x + freeRect.width > usedNode.x + width) {
this.freeRectangles.push({ x: usedNode.x + width, y: freeRect.y, width: freeRect.x + freeRect.width - (usedNode.x + width), height: freeRect.height });
}
if (freeRect.y < usedNode.y) {
this.freeRectangles.push({ x: freeRect.x, y: freeRect.y, width: freeRect.width, height: usedNode.y - freeRect.y });
}
if (freeRect.y + freeRect.height > usedNode.y + height) {
this.freeRectangles.push({ x: freeRect.x, y: usedNode.y + height, width: freeRect.width, height: freeRect.y + freeRect.height - (usedNode.y + height) });
}
return true;
}
private pruneFreeRectangles(): void {
for (let i = 0; i < this.freeRectangles.length; i++) {
for (let j = i + 1; j < this.freeRectangles.length; j++) {
if (this.isContainedIn(this.freeRectangles[i], this.freeRectangles[j])) {
this.freeRectangles.splice(i, 1);
i--;
break;
}
if (this.isContainedIn(this.freeRectangles[j], this.freeRectangles[i])) {
this.freeRectangles.splice(j, 1);
j--;
}
}
}
}
private isContainedIn(a: Rectangle, b: Rectangle): boolean {
return a.x >= b.x && a.y >= b.y && a.x + a.width <= b.x + b.width && a.y + a.height <= b.y + b.height;
}
}
class ShelfPacker {
private shelves: { y: number; currentX: number; height: number }[] = [];
private currentY = 0;
private allowRotation: boolean;
private binWidth: number;
private binHeight: number;
private padding: number;
constructor(binWidth: number, binHeight: number, allowRotation: boolean, padding: number) {
this.binWidth = binWidth;
this.binHeight = binHeight;
this.allowRotation = allowRotation;
this.padding = padding;
}
insert(width: number, height: number): { x: number; y: number; rotated: boolean } | null {
const paddedWidth = width + this.padding;
const paddedHeight = height + this.padding;
for (const shelf of this.shelves) {
if (this.allowRotation && width !== height) {
if (shelf.currentX + height + this.padding <= this.binWidth && shelf.y + width <= this.binHeight) {
const result = { x: shelf.currentX, y: shelf.y, rotated: true };
shelf.currentX += height + this.padding;
shelf.height = Math.max(shelf.height, width + this.padding);
return result;
}
}
if (shelf.currentX + paddedWidth <= this.binWidth && shelf.y + height <= this.binHeight) {
const result = { x: shelf.currentX, y: shelf.y, rotated: false };
shelf.currentX += paddedWidth;
shelf.height = Math.max(shelf.height, paddedHeight);
return result;
}
}
const newShelfY = this.currentY;
if (newShelfY + paddedHeight > this.binHeight) return null;
const newShelf = { y: newShelfY, currentX: 0, height: paddedHeight };
if (this.allowRotation && width !== height) {
if (newShelf.currentX + height + this.padding <= this.binWidth) {
newShelf.currentX = height + this.padding;
newShelf.height = Math.max(newShelf.height, width + this.padding);
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: true };
}
}
if (newShelf.currentX + paddedWidth <= this.binWidth) {
newShelf.currentX = paddedWidth;
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: false };
}
return null;
}
}
function packWithMaxRects(sprites: PackerSprite[], config: TextureAtlasConfig, postProgress: (p: number) => void): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
const padding = config.padding;
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
const sorted = sortSpritesBySize(sprites);
for (let i = 0; i < sorted.length; i++) {
const sprite = sorted[i];
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
const position = packer.insert(paddedWidth, paddedHeight);
if (!position) return null;
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
postProgress(((i + 1) / sorted.length) * 100);
}
return placements;
}
function packWithShelf(sprites: PackerSprite[], config: TextureAtlasConfig, postProgress: (p: number) => void): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
const padding = config.padding;
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
const sorted = sortSpritesBySize(sprites);
for (let i = 0; i < sorted.length; i++) {
const sprite = sorted[i];
const position = packer.insert(sprite.width, sprite.height);
if (!position) return null;
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
postProgress(((i + 1) / sorted.length) * 100);
}
return placements;
}
function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig, postProgress: (p: number) => void): PackerResult | null {
if (sprites.length === 0) return null;
const padding = config.padding;
const maxSpriteWidth = Math.max(...sprites.map(s => s.width));
const maxSpriteHeight = Math.max(...sprites.map(s => s.height));
const totalArea = sprites.reduce((sum, s) => sum + (s.width + padding * 2) * (s.height + padding * 2), 0);
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
const sizeAttempts: { w: number; h: number }[] = [];
if (config.pot) {
const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(s => s <= config.maxWidth || s <= config.maxHeight);
for (const w of potSizes) {
for (const h of potSizes) {
if (w <= config.maxWidth && h <= config.maxHeight && w >= maxSpriteWidth + padding * 2 && h >= maxSpriteHeight + padding * 2) {
sizeAttempts.push({ w, h });
}
}
}
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
} else {
sizeAttempts.push(
{ w: estimatedWidth, h: estimatedHeight },
{ w: estimatedWidth * 1.5, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 2, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 2 },
{ w: estimatedWidth * 2, h: estimatedHeight * 2 },
{ w: config.maxWidth, h: config.maxHeight }
);
}
const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => {
const w = Math.min(Math.ceil(attempt.w), config.maxWidth);
const h = Math.min(Math.ceil(attempt.h), config.maxHeight);
return self.findIndex(a => Math.min(Math.ceil(a.w), config.maxWidth) === w && Math.min(Math.ceil(a.h), config.maxHeight) === h) === index;
});
for (const attempt of uniqueAttempts) {
const attemptWidth = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w), config.maxWidth);
const attemptHeight = Math.min(config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h), config.maxHeight);
if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) continue;
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
let placements: Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null;
if (config.algorithm === "MaxRects") {
placements = packWithMaxRects(sprites, testConfig, postProgress);
} else {
placements = packWithShelf(sprites, testConfig, postProgress);
}
if (placements) {
let maxX = 0, maxY = 0;
for (const p of placements.values()) {
const effectiveWidth = p.rotated ? p.height : p.width;
const effectiveHeight = p.rotated ? p.width : p.height;
maxX = Math.max(maxX, p.x + effectiveWidth + padding);
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
}
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
const resultPlacements: PackerPlacement[] = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
}
}
return { width: finalWidth, height: finalHeight, placements: resultPlacements };
}
}
return null;
}
// Worker message handler
self.onmessage = (event: MessageEvent<WorkerInputMessage>) => {
const { type, sprites, config } = event.data;
if (type === "pack") {
try {
const postProgress = (progress: number) => {
self.postMessage({ type: "progress", progress } as WorkerOutputMessage);
};
const result = packSprites(sprites, config, postProgress);
if (result) {
self.postMessage({ type: "result", result } as WorkerOutputMessage);
} else {
self.postMessage({ type: "error", error: "Failed to pack all sprites. Try increasing max size or enabling rotation." } as WorkerOutputMessage);
}
} catch (error) {
self.postMessage({ type: "error", error: error instanceof Error ? error.message : "Unknown error" } as WorkerOutputMessage);
}
}
};
export {};

View File

@@ -64,7 +64,7 @@ export function validateImageFile(
} }
// Check MIME type // Check MIME type
if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as any)) { if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as typeof ALLOWED_IMAGE_TYPES[number])) {
return { return {
valid: false, valid: false,
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`, error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`,

View File

@@ -20,8 +20,13 @@ interface I18nState {
t: (key: string, params?: Record<string, string | number>) => string; t: (key: string, params?: Record<string, string | number>) => string;
} }
function getNestedValue(obj: any, path: string): string | unknown { function getNestedValue(obj: Record<string, unknown>, path: string): string | unknown {
return path.split(".").reduce((acc, part) => acc?.[part], obj) || path; return path.split(".").reduce((acc: unknown, part) => {
if (acc && typeof acc === "object" && part in (acc as Record<string, unknown>)) {
return (acc as Record<string, unknown>)[part];
}
return undefined;
}, obj) || path;
} }
function interpolate(template: string, params: Record<string, string | number | unknown> = {}): string { function interpolate(template: string, params: Record<string, string | number | unknown> = {}): string {

View File

@@ -269,7 +269,7 @@ export async function compressImage(
*/ */
async function stripMetadataOnly(buffer: Buffer, format: string): Promise<Buffer> { async function stripMetadataOnly(buffer: Buffer, format: string): Promise<Buffer> {
try { try {
let pipeline = sharp(buffer).rotate(); // rotate() 会移除 EXIF const pipeline = sharp(buffer).rotate(); // rotate() 会移除 EXIF
switch (format) { switch (format) {
case "jpeg": case "jpeg":

View File

@@ -25,6 +25,15 @@ interface SpriteWithSize extends AtlasSprite {
maxHeight: number; maxHeight: number;
} }
/** Frame data for Cocos Creator JSON export */
interface FrameData {
frame: { x: number; y: number; w: number; h: number };
rotated: boolean;
trimmed: boolean;
spriteSourceSize: { x: number; y: number; w: number; h: number };
sourceSize: { w: number; h: number };
}
/** /**
* Calculate the next power of two size * Calculate the next power of two size
*/ */
@@ -733,7 +742,7 @@ export function exportToCocosCreatorJson(atlas: TextureAtlasResult, imageFilenam
sourceSize: frame.sourceSize, sourceSize: frame.sourceSize,
}; };
return acc; return acc;
}, {} as Record<string, any>), }, {} as Record<string, FrameData>),
}, },
null, null,
2 2

View File

@@ -76,7 +76,7 @@ export function generateId(): string {
/** /**
* Debounce function * Debounce function
*/ */
export function debounce<T extends (...args: any[]) => any>( export function debounce<T extends (...args: unknown[]) => unknown>(
func: T, func: T,
wait: number wait: number
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {

View File

@@ -333,6 +333,30 @@
"dragHint": "Drag slider or click to compare", "dragHint": "Drag slider or click to compare",
"filename": "Filename" "filename": "Filename"
}, },
"atlas": {
"dropSprites": "Drop sprites here",
"supportFolder": "Supports folder drag and drop",
"selectFiles": "Select Files",
"selectFolder": "Select Folder",
"preview": "Preview",
"emptyPreview": "Upload sprites to preview atlas",
"dragHint": "Drag files or folder to left panel",
"panHint": "Drag to pan, scroll to zoom",
"packing": "Packing...",
"rendering": "Rendering...",
"previewAnimation": "Preview Animation",
"sizeSettings": "Size Settings",
"layoutSettings": "Layout Settings",
"outputSettings": "Output Settings",
"resultInfo": "Atlas Info",
"animationPreview": "Animation Preview",
"animationDescription": "Preview sprite sequence animation",
"frame": "Frame",
"fps": "FPS",
"spriteSize": "Sprite Size",
"totalFrames": "Total Frames",
"duration": "Duration"
},
"footer": { "footer": {
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.", "tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",
"note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.", "note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.",

View File

@@ -333,6 +333,30 @@
"dragHint": "拖动滑块或点击来对比", "dragHint": "拖动滑块或点击来对比",
"filename": "文件名" "filename": "文件名"
}, },
"atlas": {
"dropSprites": "拖拽精灵图到这里",
"supportFolder": "支持拖拽文件夹上传",
"selectFiles": "选择文件",
"selectFolder": "选择文件夹",
"preview": "预览",
"emptyPreview": "上传精灵图后预览合图效果",
"dragHint": "拖拽文件或文件夹到左侧面板",
"panHint": "拖拽平移,滚轮缩放",
"packing": "打包中...",
"rendering": "渲染中...",
"previewAnimation": "预览动画",
"sizeSettings": "尺寸设置",
"layoutSettings": "布局设置",
"outputSettings": "输出设置",
"resultInfo": "合图信息",
"animationPreview": "动画预览",
"animationDescription": "预览精灵序列帧动画效果",
"frame": "帧",
"fps": "帧率",
"spriteSize": "精灵尺寸",
"totalFrames": "总帧数",
"duration": "时长"
},
"footer": { "footer": {
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。", "tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。", "note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",

220
src/store/atlasStore.ts Normal file
View File

@@ -0,0 +1,220 @@
import { create } from "zustand";
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
import type { PackerPlacement } from "@/lib/atlas-packer";
/**
* Browser-side sprite with ImageBitmap
*/
export interface BrowserSprite {
id: string;
name: string;
width: number;
height: number;
image: ImageBitmap;
file: File;
}
/**
* Complete atlas result
*/
export interface AtlasResult {
width: number;
height: number;
placements: PackerPlacement[];
frames: AtlasFrame[];
imageDataUrl: string | null;
}
/**
* Processing status
*/
export type AtlasProcessStatus = "idle" | "loading" | "packing" | "rendering" | "completed" | "error";
/**
* Atlas Store State
*/
interface AtlasState {
// Sprite data
sprites: BrowserSprite[];
folderName: string;
// Configuration
config: TextureAtlasConfig;
// Processing state
status: AtlasProcessStatus;
progress: number;
errorMessage: string | null;
// Result
result: AtlasResult | null;
// Preview state
previewScale: number;
previewOffset: { x: number; y: number };
selectedSpriteIds: string[];
// Animation preview
isAnimationDialogOpen: boolean;
animationFps: number;
// Actions
addSprites: (sprites: BrowserSprite[]) => void;
removeSprite: (id: string) => void;
clearSprites: () => void;
setFolderName: (name: string) => void;
updateConfig: (config: Partial<TextureAtlasConfig>) => void;
resetConfig: () => void;
setStatus: (status: AtlasProcessStatus) => void;
setProgress: (progress: number) => void;
setError: (message: string | null) => void;
setResult: (result: AtlasResult | null) => void;
setPreviewScale: (scale: number) => void;
setPreviewOffset: (offset: { x: number; y: number }) => void;
selectSprite: (id: string, multi?: boolean) => void;
deselectAllSprites: () => void;
openAnimationDialog: () => void;
closeAnimationDialog: () => void;
setAnimationFps: (fps: number) => void;
}
/**
* Default texture atlas configuration
*/
const defaultConfig: TextureAtlasConfig = {
maxWidth: 1024,
maxHeight: 1024,
padding: 2,
allowRotation: false,
pot: true,
format: "png",
quality: 90,
outputFormat: "cocos2d",
algorithm: "MaxRects",
};
/**
* Atlas Store
*/
export const useAtlasStore = create<AtlasState>((set, get) => ({
// Initial state
sprites: [],
folderName: "",
config: { ...defaultConfig },
status: "idle",
progress: 0,
errorMessage: null,
result: null,
previewScale: 1,
previewOffset: { x: 0, y: 0 },
selectedSpriteIds: [],
isAnimationDialogOpen: false,
animationFps: 12,
// Sprite actions
addSprites: (newSprites) => {
set((state) => {
// Filter out duplicates by name
const existingNames = new Set(state.sprites.map((s) => s.name));
const uniqueSprites = newSprites.filter((s) => !existingNames.has(s.name));
// Sort by name (natural sort for frame sequences)
const allSprites = [...state.sprites, ...uniqueSprites].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
);
return { sprites: allSprites, result: null };
});
},
removeSprite: (id) => {
set((state) => ({
sprites: state.sprites.filter((s) => s.id !== id),
selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id),
result: null,
}));
},
clearSprites: () => {
// Release ImageBitmap resources
const { sprites } = get();
sprites.forEach((s) => s.image.close());
set({
sprites: [],
folderName: "",
result: null,
selectedSpriteIds: [],
status: "idle",
progress: 0,
errorMessage: null,
});
},
setFolderName: (name) => set({ folderName: name }),
// Config actions
updateConfig: (partialConfig) => {
set((state) => ({
config: { ...state.config, ...partialConfig },
result: null, // Clear result when config changes
}));
},
resetConfig: () => set({ config: { ...defaultConfig }, result: null }),
// Status actions
setStatus: (status) => set({ status }),
setProgress: (progress) => set({ progress }),
setError: (message) => set({ errorMessage: message, status: message ? "error" : "idle" }),
// Result actions
setResult: (result) => set({ result, status: result ? "completed" : "idle" }),
// Preview actions
setPreviewScale: (scale) => set({ previewScale: Math.max(0.1, Math.min(4, scale)) }),
setPreviewOffset: (offset) => set({ previewOffset: offset }),
selectSprite: (id, multi = false) => {
set((state) => {
if (multi) {
const isSelected = state.selectedSpriteIds.includes(id);
return {
selectedSpriteIds: isSelected
? state.selectedSpriteIds.filter((sid) => sid !== id)
: [...state.selectedSpriteIds, id],
};
}
return { selectedSpriteIds: [id] };
});
},
deselectAllSprites: () => set({ selectedSpriteIds: [] }),
// Animation dialog actions
openAnimationDialog: () => set({ isAnimationDialogOpen: true }),
closeAnimationDialog: () => set({ isAnimationDialogOpen: false }),
setAnimationFps: (fps) => set({ animationFps: Math.max(1, Math.min(60, fps)) }),
}));
/**
* Selector hooks for optimized re-renders
*/
export const useAtlasSprites = () => useAtlasStore((state) => state.sprites);
export const useAtlasConfig = () => useAtlasStore((state) => state.config);
export const useAtlasResult = () => useAtlasStore((state) => state.result);
export const useAtlasStatus = () => useAtlasStore((state) => ({
status: state.status,
progress: state.progress,
errorMessage: state.errorMessage,
}));
export const useAtlasPreview = () => useAtlasStore((state) => ({
scale: state.previewScale,
offset: state.previewOffset,
}));