Compare commits
3 Commits
54009163b1
...
c26d6eaada
| Author | SHA1 | Date | |
|---|---|---|---|
| c26d6eaada | |||
| 140608845a | |||
| 663917f663 |
310
.codebuddy/plans/texture-atlas-browser-upgrade_c5a9c6ec.md
Normal file
310
.codebuddy/plans/texture-atlas-browser-upgrade_c5a9c6ec.md
Normal 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**
|
||||||
|
- 用途:在实现过程中探索现有组件复用、类型定义、国际化键值等
|
||||||
|
- 预期结果:充分复用现有代码,保持项目一致性
|
||||||
958
package-lock.json
generated
958
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,13 @@
|
|||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.62.11",
|
"@tanstack/react-query": "^5.62.11",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "^15.1.6",
|
"next": "^15.1.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const imageAccept = {
|
|||||||
|
|
||||||
const defaultConfig: ImageCompressConfig = {
|
const defaultConfig: ImageCompressConfig = {
|
||||||
quality: 80,
|
quality: 80,
|
||||||
format: "original",
|
format: "auto",
|
||||||
};
|
};
|
||||||
|
|
||||||
function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] {
|
function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] {
|
||||||
@@ -43,10 +43,12 @@ function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => st
|
|||||||
description: getT("config.imageCompression.formatDescription"),
|
description: getT("config.imageCompression.formatDescription"),
|
||||||
value: config.format,
|
value: config.format,
|
||||||
options: [
|
options: [
|
||||||
|
{ label: getT("config.imageCompression.formatAuto"), value: "auto" },
|
||||||
{ label: getT("config.imageCompression.formatOriginal"), value: "original" },
|
{ label: getT("config.imageCompression.formatOriginal"), value: "original" },
|
||||||
{ label: getT("config.imageCompression.formatJpeg"), value: "jpeg" },
|
{ label: getT("config.imageCompression.formatJpeg"), value: "jpeg" },
|
||||||
{ label: getT("config.imageCompression.formatPng"), value: "png" },
|
{ label: getT("config.imageCompression.formatPng"), value: "png" },
|
||||||
{ label: getT("config.imageCompression.formatWebp"), value: "webp" },
|
{ label: getT("config.imageCompression.formatWebp"), value: "webp" },
|
||||||
|
{ label: getT("config.imageCompression.formatAvif"), value: "avif" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -73,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: {
|
||||||
@@ -129,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 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
103
src/app/(dashboard)/tools/texture-atlas/page.tsx
Normal file
103
src/app/(dashboard)/tools/texture-atlas/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Layers as LayersIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
FileListPanel,
|
||||||
|
CanvasPreview,
|
||||||
|
AtlasConfigPanel,
|
||||||
|
AnimationPreviewDialog
|
||||||
|
} from "@/components/tools/atlas";
|
||||||
|
import { useAtlasStore } from "@/store/atlasStore";
|
||||||
|
import { useAtlasWorker } from "@/hooks/useAtlasWorker";
|
||||||
|
import { useSafeTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function TextureAtlasPage() {
|
||||||
|
const { t, mounted } = useSafeTranslation();
|
||||||
|
|
||||||
|
const { sprites, config } = useAtlasStore();
|
||||||
|
const { pack } = useAtlasWorker();
|
||||||
|
|
||||||
|
// Auto-pack when sprites or config changes (with debounce)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sprites.length === 0) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pack();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="shrink-0 border-b border-border/40 bg-background/80 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-5 py-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
|
||||||
|
<LayersIcon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">{getT("tools.textureAtlas.title")}</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{getT("tools.textureAtlas.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
281
src/app/api/process/texture-atlas/route.ts
Normal file
281
src/app/api/process/texture-atlas/route.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { readFile, readdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import type { TextureAtlasConfig, AtlasSprite } from "@/types";
|
||||||
|
import {
|
||||||
|
saveProcessedFile,
|
||||||
|
cleanupFile,
|
||||||
|
sanitizeFilename,
|
||||||
|
} from "@/lib/file-storage";
|
||||||
|
import {
|
||||||
|
createTextureAtlas,
|
||||||
|
exportToCocos2dPlist,
|
||||||
|
exportToCocosCreatorJson,
|
||||||
|
exportToGenericJson,
|
||||||
|
validateTextureAtlasConfig,
|
||||||
|
} from "@/lib/texture-atlas";
|
||||||
|
import { validateImageBuffer } from "@/lib/image-processor";
|
||||||
|
import archiver from "archiver";
|
||||||
|
import { PassThrough } from "stream";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ZIP buffer containing the atlas image and metadata
|
||||||
|
*/
|
||||||
|
async function createZipBuffer(
|
||||||
|
imageBuffer: Buffer,
|
||||||
|
imageFilename: string,
|
||||||
|
metadataContent: string,
|
||||||
|
metadataFilename: string
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const passthrough = new PassThrough();
|
||||||
|
|
||||||
|
passthrough.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
passthrough.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
passthrough.on("error", reject);
|
||||||
|
|
||||||
|
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||||
|
archive.on("error", reject);
|
||||||
|
archive.pipe(passthrough);
|
||||||
|
|
||||||
|
// Add image
|
||||||
|
archive.append(imageBuffer, { name: imageFilename });
|
||||||
|
|
||||||
|
// Add metadata
|
||||||
|
archive.append(metadataContent, { name: metadataFilename });
|
||||||
|
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessRequest {
|
||||||
|
fileIds: string[];
|
||||||
|
config: TextureAtlasConfig;
|
||||||
|
filenames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find uploaded files by IDs
|
||||||
|
*/
|
||||||
|
async function findUploadedFiles(fileIds: string[]): Promise<
|
||||||
|
Array<{ fileId: string; buffer: Buffer; name: string }>
|
||||||
|
> {
|
||||||
|
const files: Array<{ fileId: string; buffer: Buffer; name: string }> = [];
|
||||||
|
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
try {
|
||||||
|
const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, "");
|
||||||
|
if (sanitizedId !== fileId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileList = await readdir(UPLOAD_DIR);
|
||||||
|
const file = fileList.find((f) => f.startsWith(`${fileId}.`));
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(UPLOAD_DIR, file);
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
|
||||||
|
// Validate it's actually an image
|
||||||
|
const isValid = await validateImageBuffer(buffer);
|
||||||
|
if (!isValid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract original name from file (remove UUID prefix)
|
||||||
|
const originalName = file.substring(fileId.length + 1);
|
||||||
|
|
||||||
|
files.push({ fileId, buffer, name: originalName });
|
||||||
|
} catch {
|
||||||
|
// Skip invalid files
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body: ProcessRequest = await request.json();
|
||||||
|
const { fileIds, config, filenames } = body;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "At least one file ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileIds.length > 500) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "Maximum 500 sprites allowed per atlas" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate config
|
||||||
|
const configValidation = validateTextureAtlasConfig(config);
|
||||||
|
if (!configValidation.valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: configValidation.error },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize file IDs
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, "");
|
||||||
|
if (sanitizedId !== fileId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: `Invalid file ID: ${fileId}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all uploaded files
|
||||||
|
const uploadedFiles = await findUploadedFiles(fileIds);
|
||||||
|
|
||||||
|
if (uploadedFiles.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "No valid files found or files have expired" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedFiles.length !== fileIds.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: `${fileIds.length - uploadedFiles.length} file(s) not found or expired`,
|
||||||
|
},
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare sprites
|
||||||
|
const sprites: AtlasSprite[] = uploadedFiles.map((file, index) => ({
|
||||||
|
id: file.fileId,
|
||||||
|
name: filenames?.[index] || file.name,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
buffer: file.buffer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create texture atlas
|
||||||
|
let atlasResult;
|
||||||
|
try {
|
||||||
|
atlasResult = await createTextureAtlas(sprites, config);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Failed to create texture atlas",
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define filenames
|
||||||
|
const imageFilename = `atlas.${atlasResult.format}`;
|
||||||
|
let metadataFilename: string;
|
||||||
|
|
||||||
|
// Export metadata based on format
|
||||||
|
let metadataContent: string;
|
||||||
|
|
||||||
|
switch (config.outputFormat) {
|
||||||
|
case "cocos2d":
|
||||||
|
metadataContent = exportToCocos2dPlist(atlasResult, imageFilename);
|
||||||
|
metadataFilename = "atlas.plist";
|
||||||
|
break;
|
||||||
|
case "cocos-creator":
|
||||||
|
metadataContent = exportToCocosCreatorJson(atlasResult, imageFilename);
|
||||||
|
metadataFilename = "atlas.json";
|
||||||
|
break;
|
||||||
|
case "generic-json":
|
||||||
|
metadataContent = exportToGenericJson(atlasResult, imageFilename);
|
||||||
|
metadataFilename = "atlas.json";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
metadataContent = exportToCocosCreatorJson(atlasResult, imageFilename);
|
||||||
|
metadataFilename = "atlas.json";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ZIP file with both image and metadata
|
||||||
|
const zipBuffer = await createZipBuffer(
|
||||||
|
atlasResult.image,
|
||||||
|
imageFilename,
|
||||||
|
metadataContent,
|
||||||
|
metadataFilename
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save ZIP file
|
||||||
|
const zipInfo = await saveProcessedFile(
|
||||||
|
`atlas_${Date.now()}`,
|
||||||
|
zipBuffer,
|
||||||
|
"zip",
|
||||||
|
"atlas.zip"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also save individual files for preview
|
||||||
|
const imageInfo = await saveProcessedFile(
|
||||||
|
`atlas_img_${Date.now()}`,
|
||||||
|
atlasResult.image,
|
||||||
|
atlasResult.format,
|
||||||
|
imageFilename
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadataBuffer = Buffer.from(metadataContent, "utf-8");
|
||||||
|
const metadataInfo = await saveProcessedFile(
|
||||||
|
`atlas_meta_${Date.now()}`,
|
||||||
|
metadataBuffer,
|
||||||
|
config.outputFormat === "cocos2d" ? "plist" : "json",
|
||||||
|
metadataFilename
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup uploaded files
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
try {
|
||||||
|
await cleanupFile(fileId);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
imageUrl: imageInfo.fileUrl,
|
||||||
|
metadataUrl: metadataInfo.fileUrl,
|
||||||
|
zipUrl: zipInfo.fileUrl,
|
||||||
|
imageFilename: imageInfo.filename,
|
||||||
|
metadataFilename: metadataInfo.filename,
|
||||||
|
zipFilename: zipInfo.filename,
|
||||||
|
metadata: {
|
||||||
|
width: atlasResult.width,
|
||||||
|
height: atlasResult.height,
|
||||||
|
format: atlasResult.format,
|
||||||
|
frameCount: atlasResult.frames.length,
|
||||||
|
outputFormat: config.outputFormat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Texture atlas processing error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Texture atlas processing failed",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Music,
|
Music,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||||
@@ -36,6 +37,7 @@ function useSidebarNavItems() {
|
|||||||
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
|
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
|
||||||
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
|
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
|
||||||
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
|
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
|
||||||
|
{ name: getT("sidebar.textureAtlas"), href: "/tools/texture-atlas", icon: Layers },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
353
src/components/tools/atlas/AnimationPreviewDialog.tsx
Normal file
353
src/components/tools/atlas/AnimationPreviewDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
468
src/components/tools/atlas/AtlasConfigPanel.tsx
Normal file
468
src/components/tools/atlas/AtlasConfigPanel.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
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 getMetadataInfo = useCallback(() => {
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content, filename, mimeType };
|
||||||
|
}, [result, config.format, config.outputFormat]);
|
||||||
|
|
||||||
|
const downloadMetadata = useCallback(() => {
|
||||||
|
const info = getMetadataInfo();
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
const { content, filename, mimeType } = info;
|
||||||
|
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);
|
||||||
|
}, [getMetadataInfo]);
|
||||||
|
|
||||||
|
const downloadAll = useCallback(async () => {
|
||||||
|
if (!result?.imageDataUrl) return;
|
||||||
|
|
||||||
|
const info = getMetadataInfo();
|
||||||
|
if (!info) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zip = new JSZip();
|
||||||
|
const imageFilename = `atlas.${config.format}`;
|
||||||
|
|
||||||
|
// Add image to zip
|
||||||
|
const base64Data = result.imageDataUrl.split(",")[1];
|
||||||
|
zip.file(imageFilename, base64Data, { base64: true });
|
||||||
|
|
||||||
|
// Add metadata to zip
|
||||||
|
zip.file(info.filename, info.content);
|
||||||
|
|
||||||
|
// Generate and download zip
|
||||||
|
const blob = await zip.generateAsync({ type: "blob" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "texture-atlas.zip";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create zip:", error);
|
||||||
|
}
|
||||||
|
}, [result, config.format, getMetadataInfo]);
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
src/components/tools/atlas/CanvasPreview.tsx
Normal file
494
src/components/tools/atlas/CanvasPreview.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
"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 imageCacheRef = useRef<{ url: string; image: HTMLImageElement } | null>(null);
|
||||||
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
const [hasMoved, setHasMoved] = useState(false);
|
||||||
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const {
|
||||||
|
sprites,
|
||||||
|
result,
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
previewScale,
|
||||||
|
previewOffset,
|
||||||
|
selectedSpriteIds,
|
||||||
|
setPreviewScale,
|
||||||
|
setPreviewOffset,
|
||||||
|
selectSprite,
|
||||||
|
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) {
|
||||||
|
const drawImage = (img: HTMLImageElement) => {
|
||||||
|
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 selection highlight for all selected sprites
|
||||||
|
if (selectedSpriteIds.length > 0) {
|
||||||
|
result.placements.forEach(p => {
|
||||||
|
if (selectedSpriteIds.includes(p.id)) {
|
||||||
|
const pw = (p.rotated ? p.height : p.width) * previewScale;
|
||||||
|
const ph = (p.rotated ? p.width : p.height) * previewScale;
|
||||||
|
const px = centerX + p.x * previewScale;
|
||||||
|
const py = centerY + p.y * previewScale;
|
||||||
|
|
||||||
|
// Apple style highlight: outer glow and soft border
|
||||||
|
ctx.save();
|
||||||
|
|
||||||
|
// Shadow/Glow
|
||||||
|
ctx.shadowBlur = 15;
|
||||||
|
ctx.shadowColor = "rgba(59, 130, 246, 0.6)";
|
||||||
|
|
||||||
|
// Animated outer border
|
||||||
|
const time = Date.now() / 1000;
|
||||||
|
const pulse = Math.sin(time * 3) * 0.2 + 0.8;
|
||||||
|
|
||||||
|
ctx.strokeStyle = `rgba(59, 130, 246, ${pulse})`;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
// Rounded rect path
|
||||||
|
const radius = 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(px + radius, py);
|
||||||
|
ctx.lineTo(px + pw - radius, py);
|
||||||
|
ctx.quadraticCurveTo(px + pw, py, px + pw, py + radius);
|
||||||
|
ctx.lineTo(px + pw, py + ph - radius);
|
||||||
|
ctx.quadraticCurveTo(px + pw, py + ph, px + pw - radius, py + ph);
|
||||||
|
ctx.lineTo(px + radius, py + ph);
|
||||||
|
ctx.quadraticCurveTo(px, py + ph, px, py + ph - radius);
|
||||||
|
ctx.lineTo(px, py + radius);
|
||||||
|
ctx.quadraticCurveTo(px, py, px + radius, py);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Inner fill with very low opacity
|
||||||
|
ctx.fillStyle = "rgba(59, 130, 246, 0.1)";
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
|
||||||
|
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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use cached image if URL matches
|
||||||
|
if (imageCacheRef.current && imageCacheRef.current.url === result.imageDataUrl) {
|
||||||
|
drawImage(imageCacheRef.current.image);
|
||||||
|
} else {
|
||||||
|
// Load and draw the atlas image
|
||||||
|
const img = new Image();
|
||||||
|
img.src = result.imageDataUrl;
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
imageCacheRef.current = { url: result.imageDataUrl!, image: img };
|
||||||
|
drawImage(img);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} 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]);
|
||||||
|
|
||||||
|
// Render loop for animation (highlights)
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrame: number;
|
||||||
|
const render = () => {
|
||||||
|
// Re-trigger the main render useEffect by some means or just call a separate draw function
|
||||||
|
// For simplicity, we can just use a dummy state to force re-render if needed,
|
||||||
|
// but here we already have selectedSpriteIds in the dependency array of the main render effect.
|
||||||
|
// To get smooth pulsing, we can just request another frame.
|
||||||
|
if (selectedSpriteIds.length > 0) {
|
||||||
|
// This is a bit hacky but works for a canvas in React
|
||||||
|
// A better way would be to move drawing logic to a separate function
|
||||||
|
setContainerSize(s => ({ ...s }));
|
||||||
|
}
|
||||||
|
animationFrame = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrame = requestAnimationFrame(render);
|
||||||
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
|
}, [selectedSpriteIds]);
|
||||||
|
|
||||||
|
// 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 and picking
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.button === 0) {
|
||||||
|
setIsPanning(true);
|
||||||
|
setHasMoved(false);
|
||||||
|
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) {
|
||||||
|
const dx = Math.abs(e.clientX - (panStart.x + previewOffset.x));
|
||||||
|
const dy = Math.abs(e.clientY - (panStart.y + previewOffset.y));
|
||||||
|
if (dx > 2 || dy > 2) {
|
||||||
|
setHasMoved(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewOffset({
|
||||||
|
x: e.clientX - panStart.x,
|
||||||
|
y: e.clientY - panStart.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isPanning, panStart, previewOffset, setPreviewOffset]);
|
||||||
|
|
||||||
|
// Handle mouse up (Picking logic)
|
||||||
|
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (isPanning && !hasMoved && result && containerRef.current) {
|
||||||
|
// Pick sprite
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const { width: cw, height: ch } = containerSize;
|
||||||
|
const scaledWidth = atlasWidth * previewScale;
|
||||||
|
const scaledHeight = atlasHeight * previewScale;
|
||||||
|
const centerX = (cw - scaledWidth) / 2 + previewOffset.x;
|
||||||
|
const centerY = (ch - scaledHeight) / 2 + previewOffset.y;
|
||||||
|
|
||||||
|
// Transform mouse to atlas space
|
||||||
|
const atlasX = (mouseX - centerX) / previewScale;
|
||||||
|
const atlasY = (mouseY - centerY) / previewScale;
|
||||||
|
|
||||||
|
// Find sprite under cursor
|
||||||
|
const clickedSprite = result.placements.find(p => {
|
||||||
|
const pw = p.rotated ? p.height : p.width;
|
||||||
|
const ph = p.rotated ? p.width : p.height;
|
||||||
|
return atlasX >= p.x && atlasX <= p.x + pw &&
|
||||||
|
atlasY >= p.y && atlasY <= p.y + ph;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clickedSprite) {
|
||||||
|
selectSprite(clickedSprite.id, e.ctrlKey || e.metaKey);
|
||||||
|
} else {
|
||||||
|
selectSprite("", false); // Deselect if clicked empty area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsPanning(false);
|
||||||
|
}, [isPanning, hasMoved, result, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]);
|
||||||
|
|
||||||
|
// 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 backdrop-blur-[2px] ${
|
||||||
|
result ? "bg-black/20" : "bg-black/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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 shadow-sm">
|
||||||
|
{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 shadow-inner">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
520
src/components/tools/atlas/FileListPanel.tsx
Normal file
520
src/components/tools/atlas/FileListPanel.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState, useEffect } 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]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll selected item into view
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSpriteIds.length === 1) {
|
||||||
|
const selectedId = selectedSpriteIds[0];
|
||||||
|
const element = document.getElementById(`sprite-${selectedId}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedSpriteIds]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
id={`sprite-${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/tools/atlas/index.ts
Normal file
4
src/components/tools/atlas/index.ts
Normal 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
536
src/hooks/useAtlasWorker.ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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
676
src/lib/atlas-packer.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
};
|
||||||
|
|
||||||
|
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
404
src/lib/atlas-worker.ts
Normal 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 {};
|
||||||
@@ -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(", ")}`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import sharp from "sharp";
|
|||||||
import type { ImageCompressConfig } from "@/types";
|
import type { ImageCompressConfig } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Image processing service using Sharp
|
* World-class Image Compression Engine
|
||||||
* Handles compression, format conversion, and resizing
|
*
|
||||||
|
* 实现业界领先的图片压缩算法,核心策略:
|
||||||
|
* 1. 智能格式检测与自适应压缩
|
||||||
|
* 2. 多轮压缩迭代,确保最优结果
|
||||||
|
* 3. 压缩后不大于原图保证
|
||||||
|
* 4. 自动元数据剥离
|
||||||
|
* 5. 智能调色板降级 (PNG)
|
||||||
|
* 6. 基于内容的压缩策略
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ProcessedImageResult {
|
export interface ProcessedImageResult {
|
||||||
@@ -21,14 +28,19 @@ export interface ImageMetadata {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
size: number;
|
size: number;
|
||||||
|
hasAlpha: boolean;
|
||||||
|
isAnimated: boolean;
|
||||||
|
colorSpace?: string;
|
||||||
|
channels?: number;
|
||||||
|
depth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supported output formats for compression
|
// Supported output formats for compression
|
||||||
const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const;
|
const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "tif"] as const;
|
||||||
type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
|
type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image metadata without loading the full image
|
* Get detailed image metadata
|
||||||
*/
|
*/
|
||||||
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
||||||
const metadata = await sharp(buffer).metadata();
|
const metadata = await sharp(buffer).metadata();
|
||||||
@@ -38,12 +50,16 @@ export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
|||||||
width: metadata.width || 0,
|
width: metadata.width || 0,
|
||||||
height: metadata.height || 0,
|
height: metadata.height || 0,
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
|
hasAlpha: metadata.hasAlpha || false,
|
||||||
|
isAnimated: (metadata.pages || 1) > 1,
|
||||||
|
colorSpace: metadata.space,
|
||||||
|
channels: metadata.channels,
|
||||||
|
depth: metadata.depth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate image buffer using Sharp
|
* Validate image buffer using Sharp
|
||||||
* Checks if the buffer contains a valid image
|
|
||||||
*/
|
*/
|
||||||
export async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
|
export async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -68,7 +84,97 @@ function isSupportedFormat(format: string): format is SupportedFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compress and/or convert image
|
* 分析图片特征,选择最佳压缩策略
|
||||||
|
*/
|
||||||
|
async function analyzeImageCharacteristics(buffer: Buffer): Promise<{
|
||||||
|
isPhotographic: boolean;
|
||||||
|
hasGradients: boolean;
|
||||||
|
isSimpleGraphic: boolean;
|
||||||
|
uniqueColors: number;
|
||||||
|
dominantColorCount: number;
|
||||||
|
}> {
|
||||||
|
const image = sharp(buffer);
|
||||||
|
const stats = await image.stats();
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
|
// 分析颜色分布
|
||||||
|
const channels = stats.channels;
|
||||||
|
let totalStdDev = 0;
|
||||||
|
let colorVariance = 0;
|
||||||
|
|
||||||
|
channels.forEach((channel) => {
|
||||||
|
totalStdDev += channel.stdev;
|
||||||
|
colorVariance += Math.abs(channel.max - channel.min);
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgStdDev = totalStdDev / channels.length;
|
||||||
|
const avgVariance = colorVariance / channels.length;
|
||||||
|
|
||||||
|
// 摄影图片通常有较高的标准差和颜色变化
|
||||||
|
const isPhotographic = avgStdDev > 40 && avgVariance > 150;
|
||||||
|
|
||||||
|
// 简单图形通常颜色变化小
|
||||||
|
const isSimpleGraphic = avgStdDev < 30 && avgVariance < 100;
|
||||||
|
|
||||||
|
// 渐变检测:中等标准差但低对比度
|
||||||
|
const hasGradients = avgStdDev > 20 && avgStdDev < 60;
|
||||||
|
|
||||||
|
// 估算唯一颜色数(基于统计)
|
||||||
|
const estimatedUniqueColors = Math.min(
|
||||||
|
Math.pow(2, (metadata.depth === "uchar" ? 8 : 16) * (metadata.channels || 3)),
|
||||||
|
Math.round(avgVariance * avgStdDev * 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPhotographic,
|
||||||
|
hasGradients,
|
||||||
|
isSimpleGraphic,
|
||||||
|
uniqueColors: estimatedUniqueColors,
|
||||||
|
dominantColorCount: channels.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择最佳输出格式
|
||||||
|
*/
|
||||||
|
async function selectOptimalFormat(
|
||||||
|
buffer: Buffer,
|
||||||
|
requestedFormat: string,
|
||||||
|
metadata: ImageMetadata
|
||||||
|
): Promise<string> {
|
||||||
|
if (requestedFormat !== "original" && requestedFormat !== "auto") {
|
||||||
|
return requestedFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留动画格式
|
||||||
|
if (metadata.isAnimated) {
|
||||||
|
if (metadata.format === "gif") return "gif";
|
||||||
|
if (metadata.format === "webp") return "webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有透明通道
|
||||||
|
if (metadata.hasAlpha) {
|
||||||
|
// WebP 支持透明且压缩率更好
|
||||||
|
return "webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 照片类用 JPEG/WebP
|
||||||
|
const characteristics = await analyzeImageCharacteristics(buffer);
|
||||||
|
if (characteristics.isPhotographic) {
|
||||||
|
return "webp"; // WebP 在照片上表现最好
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单图形用 PNG
|
||||||
|
if (characteristics.isSimpleGraphic && characteristics.uniqueColors < 256) {
|
||||||
|
return "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回原格式或 WebP
|
||||||
|
return metadata.format === "png" ? "png" : "webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核心压缩函数 - 业界最佳实践实现
|
||||||
*/
|
*/
|
||||||
export async function compressImage(
|
export async function compressImage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
@@ -80,116 +186,320 @@ export async function compressImage(
|
|||||||
throw new Error("Invalid image data");
|
throw new Error("Invalid image data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get original metadata
|
const originalSize = buffer.length;
|
||||||
const originalMetadata = await getImageMetadata(buffer);
|
const originalMetadata = await getImageMetadata(buffer);
|
||||||
|
|
||||||
// Create Sharp instance
|
// 选择最佳输出格式
|
||||||
let pipeline = sharp(buffer, {
|
let outputFormat = await selectOptimalFormat(
|
||||||
// Limit input pixels to prevent DoS attacks
|
buffer,
|
||||||
limitInputPixels: 268402689, // ~16384x16384
|
config.format,
|
||||||
// Enforce memory limits
|
originalMetadata
|
||||||
unlimited: false,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Apply resizing if configured
|
// BMP 不支持输出,转为合适格式
|
||||||
if (config.resize) {
|
|
||||||
const { width, height, fit } = config.resize;
|
|
||||||
|
|
||||||
if (width || height) {
|
|
||||||
pipeline = pipeline.resize(width || null, height || null, {
|
|
||||||
fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill",
|
|
||||||
// Don't enlarge images
|
|
||||||
withoutEnlargement: fit !== "fill",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine output format
|
|
||||||
let outputFormat = config.format === "original" ? originalMetadata.format : config.format;
|
|
||||||
|
|
||||||
// For BMP input without format conversion, use JPEG as output
|
|
||||||
// since Sharp doesn't support BMP output
|
|
||||||
if (outputFormat === "bmp") {
|
if (outputFormat === "bmp") {
|
||||||
outputFormat = "jpeg";
|
outputFormat = originalMetadata.hasAlpha ? "png" : "jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate format is supported
|
|
||||||
if (!isSupportedFormat(outputFormat)) {
|
if (!isSupportedFormat(outputFormat)) {
|
||||||
outputFormat = "jpeg";
|
outputFormat = "jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply format-specific compression
|
// 尝试多种压缩策略,选择最优结果
|
||||||
|
const compressionResults = await Promise.all([
|
||||||
|
compressWithStrategy(buffer, config, outputFormat, "aggressive"),
|
||||||
|
compressWithStrategy(buffer, config, outputFormat, "balanced"),
|
||||||
|
compressWithStrategy(buffer, config, outputFormat, "quality"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 选择最小且不大于原图的结果
|
||||||
|
let bestResult = compressionResults.reduce((best, current) => {
|
||||||
|
// 优先选择比原图小的
|
||||||
|
if (current.length < originalSize && best.length >= originalSize) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
if (current.length >= originalSize && best.length < originalSize) {
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
// 都比原图小或都比原图大时,选择最小的
|
||||||
|
return current.length < best.length ? current : best;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果所有策略都导致变大,返回原图
|
||||||
|
if (bestResult.length >= originalSize) {
|
||||||
|
// 尝试仅剥离元数据
|
||||||
|
const strippedBuffer = await stripMetadataOnly(buffer, originalMetadata.format);
|
||||||
|
if (strippedBuffer.length < originalSize) {
|
||||||
|
bestResult = strippedBuffer;
|
||||||
|
outputFormat = originalMetadata.format;
|
||||||
|
} else {
|
||||||
|
// 返回原图
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
format: originalMetadata.format,
|
||||||
|
width: originalMetadata.width,
|
||||||
|
height: originalMetadata.height,
|
||||||
|
originalSize,
|
||||||
|
compressedSize: originalSize,
|
||||||
|
compressionRatio: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取输出元数据
|
||||||
|
const outputMetadata = await sharp(bestResult).metadata();
|
||||||
|
|
||||||
|
const compressionRatio = Math.round(
|
||||||
|
((originalSize - bestResult.length) / originalSize) * 100
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: bestResult,
|
||||||
|
format: outputFormat,
|
||||||
|
width: outputMetadata.width || originalMetadata.width,
|
||||||
|
height: outputMetadata.height || originalMetadata.height,
|
||||||
|
originalSize,
|
||||||
|
compressedSize: bestResult.length,
|
||||||
|
compressionRatio: Math.max(0, compressionRatio),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅剥离元数据,不重新编码
|
||||||
|
*/
|
||||||
|
async function stripMetadataOnly(buffer: Buffer, format: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const pipeline = sharp(buffer).rotate(); // rotate() 会移除 EXIF
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "jpeg":
|
||||||
|
case "jpg":
|
||||||
|
return await pipeline.jpeg({ quality: 100 }).toBuffer();
|
||||||
|
case "png":
|
||||||
|
return await pipeline.png({ compressionLevel: 9 }).toBuffer();
|
||||||
|
case "webp":
|
||||||
|
return await pipeline.webp({ quality: 100, lossless: true }).toBuffer();
|
||||||
|
default:
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用特定策略进行压缩
|
||||||
|
*/
|
||||||
|
async function compressWithStrategy(
|
||||||
|
buffer: Buffer,
|
||||||
|
config: ImageCompressConfig,
|
||||||
|
outputFormat: string,
|
||||||
|
strategy: "aggressive" | "balanced" | "quality"
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const metadata = await getImageMetadata(buffer);
|
||||||
|
const characteristics = await analyzeImageCharacteristics(buffer);
|
||||||
|
|
||||||
|
let pipeline = sharp(buffer, {
|
||||||
|
limitInputPixels: 268402689,
|
||||||
|
unlimited: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移除所有元数据以减小体积
|
||||||
|
pipeline = pipeline.rotate(); // 自动旋转并移除 orientation
|
||||||
|
|
||||||
|
// 应用尺寸调整
|
||||||
|
if (config.resize) {
|
||||||
|
const { width, height, fit } = config.resize;
|
||||||
|
if (width || height) {
|
||||||
|
pipeline = pipeline.resize(width || null, height || null, {
|
||||||
|
fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill",
|
||||||
|
withoutEnlargement: fit !== "fill",
|
||||||
|
kernel: "lanczos3", // 高质量缩放算法
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据策略调整质量
|
||||||
|
const qualityMultiplier = {
|
||||||
|
aggressive: 0.7,
|
||||||
|
balanced: 0.85,
|
||||||
|
quality: 1.0,
|
||||||
|
}[strategy];
|
||||||
|
|
||||||
|
const adjustedQuality = Math.round(config.quality * qualityMultiplier);
|
||||||
|
|
||||||
|
// 应用格式特定的压缩
|
||||||
switch (outputFormat) {
|
switch (outputFormat) {
|
||||||
case "jpeg":
|
case "jpeg":
|
||||||
case "jpg":
|
case "jpg":
|
||||||
pipeline = pipeline.jpeg({
|
pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
|
||||||
quality: config.quality,
|
|
||||||
mozjpeg: true, // Use MozJPEG for better compression
|
|
||||||
progressive: true, // Progressive loading
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "png":
|
case "png":
|
||||||
// PNG compression is lossless, quality affects compression level
|
pipeline = pipeline.png(getPngOptions(adjustedQuality, strategy, characteristics, metadata));
|
||||||
// Map 1-100 to 0-9 compression level (inverted)
|
|
||||||
const compressionLevel = Math.floor(((100 - config.quality) / 100) * 9);
|
|
||||||
pipeline = pipeline.png({
|
|
||||||
compressionLevel,
|
|
||||||
adaptiveFiltering: true,
|
|
||||||
palette: false, // Keep true color
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "webp":
|
case "webp":
|
||||||
pipeline = pipeline.webp({
|
pipeline = pipeline.webp(getWebpOptions(adjustedQuality, strategy, characteristics, metadata));
|
||||||
quality: config.quality,
|
break;
|
||||||
effort: 6, // Compression effort (0-6, 6 is highest)
|
|
||||||
});
|
case "avif":
|
||||||
|
pipeline = pipeline.avif(getAvifOptions(adjustedQuality, strategy));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "gif":
|
case "gif":
|
||||||
// GIF doesn't support quality parameter in the same way
|
pipeline = pipeline.gif(getGifOptions(strategy));
|
||||||
// We'll use near-lossless for better quality
|
|
||||||
pipeline = pipeline.gif({
|
|
||||||
dither: 1.0,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "tiff":
|
case "tiff":
|
||||||
case "tif":
|
case "tif":
|
||||||
pipeline = pipeline.tiff({
|
pipeline = pipeline.tiff(getTiffOptions(adjustedQuality, strategy));
|
||||||
quality: config.quality,
|
|
||||||
compression: "jpeg",
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Default to JPEG
|
pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
|
||||||
pipeline = pipeline.jpeg({
|
|
||||||
quality: config.quality,
|
|
||||||
mozjpeg: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get metadata before compression
|
return await pipeline.toBuffer();
|
||||||
const metadata = await pipeline.metadata();
|
}
|
||||||
|
|
||||||
// Process image
|
/**
|
||||||
const compressedBuffer = await pipeline.toBuffer();
|
* JPEG 压缩选项 - 使用 MozJPEG 最佳实践
|
||||||
|
*/
|
||||||
// Calculate compression ratio
|
function getJpegOptions(
|
||||||
const compressionRatio = Math.round(
|
quality: number,
|
||||||
((buffer.length - compressedBuffer.length) / buffer.length) * 100
|
strategy: string,
|
||||||
);
|
characteristics: Awaited<ReturnType<typeof analyzeImageCharacteristics>>
|
||||||
|
): sharp.JpegOptions {
|
||||||
|
const baseOptions: sharp.JpegOptions = {
|
||||||
|
quality: Math.max(1, Math.min(100, quality)),
|
||||||
|
mozjpeg: true, // MozJPEG 提供更好的压缩率
|
||||||
|
progressive: true, // 渐进式加载,提升用户体验
|
||||||
|
optimiseCoding: true, // 优化哈夫曼表
|
||||||
|
optimiseScans: true, // 优化扫描顺序
|
||||||
|
trellisQuantisation: true, // Trellis 量化,提升质量
|
||||||
|
overshootDeringing: true, // 减少振铃效应
|
||||||
|
quantisationTable: characteristics.isPhotographic ? 3 : 2, // 照片用更高质量表
|
||||||
|
};
|
||||||
|
|
||||||
|
if (strategy === "aggressive") {
|
||||||
return {
|
return {
|
||||||
buffer: compressedBuffer,
|
...baseOptions,
|
||||||
format: outputFormat,
|
quality: Math.max(1, quality - 10),
|
||||||
width: metadata.width || 0,
|
quantisationTable: 0, // 最激进的量化表
|
||||||
height: metadata.height || 0,
|
};
|
||||||
originalSize: buffer.length,
|
}
|
||||||
compressedSize: compressedBuffer.length,
|
|
||||||
compressionRatio,
|
return baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PNG 压缩选项 - 智能无损/有损选择
|
||||||
|
*/
|
||||||
|
function getPngOptions(
|
||||||
|
quality: number,
|
||||||
|
strategy: string,
|
||||||
|
characteristics: Awaited<ReturnType<typeof analyzeImageCharacteristics>>,
|
||||||
|
metadata: ImageMetadata
|
||||||
|
): sharp.PngOptions {
|
||||||
|
// PNG 是无损格式,quality 映射到 compressionLevel (0-9)
|
||||||
|
// 更高的 compressionLevel = 更慢但更小的文件
|
||||||
|
const compressionLevel = Math.min(9, Math.max(0, Math.floor((100 - quality) / 11)));
|
||||||
|
|
||||||
|
// 智能调色板决策
|
||||||
|
// 简单图形或低质量设置时使用调色板可大幅减小体积
|
||||||
|
const usePalette =
|
||||||
|
strategy === "aggressive" ||
|
||||||
|
(characteristics.isSimpleGraphic && quality < 90) ||
|
||||||
|
(!metadata.hasAlpha && characteristics.uniqueColors < 256) ||
|
||||||
|
quality < 50;
|
||||||
|
|
||||||
|
// 颜色数量限制
|
||||||
|
const colours = usePalette
|
||||||
|
? Math.min(256, Math.max(2, Math.round(256 * (quality / 100))))
|
||||||
|
: 256;
|
||||||
|
|
||||||
|
const baseOptions: sharp.PngOptions = {
|
||||||
|
compressionLevel: strategy === "aggressive" ? 9 : compressionLevel,
|
||||||
|
adaptiveFiltering: true, // 自适应过滤器选择
|
||||||
|
palette: usePalette,
|
||||||
|
colours: colours,
|
||||||
|
effort: strategy === "quality" ? 7 : 10, // 压缩努力程度 (1-10)
|
||||||
|
dither: usePalette ? 1.0 : 0, // 调色板模式下使用抖动
|
||||||
|
};
|
||||||
|
|
||||||
|
return baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebP 压缩选项 - 最佳现代格式
|
||||||
|
*/
|
||||||
|
function getWebpOptions(
|
||||||
|
quality: number,
|
||||||
|
strategy: string,
|
||||||
|
characteristics: Awaited<ReturnType<typeof analyzeImageCharacteristics>>,
|
||||||
|
metadata: ImageMetadata
|
||||||
|
): sharp.WebpOptions {
|
||||||
|
// 对于简单图形,无损 WebP 可能更小
|
||||||
|
const useLossless =
|
||||||
|
characteristics.isSimpleGraphic &&
|
||||||
|
characteristics.uniqueColors < 256 &&
|
||||||
|
strategy !== "aggressive";
|
||||||
|
|
||||||
|
const baseOptions: sharp.WebpOptions = {
|
||||||
|
quality: Math.max(1, Math.min(100, quality)),
|
||||||
|
effort: 6, // 压缩努力程度 (0-6)
|
||||||
|
lossless: useLossless,
|
||||||
|
nearLossless: !useLossless && quality > 85, // 近无损模式
|
||||||
|
smartSubsample: true, // 智能色度子采样
|
||||||
|
alphaQuality: metadata.hasAlpha ? Math.max(quality - 10, 50) : 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (strategy === "aggressive") {
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
quality: Math.max(1, quality - 15),
|
||||||
|
effort: 6,
|
||||||
|
lossless: false,
|
||||||
|
nearLossless: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AVIF 压缩选项 - 最先进的压缩格式
|
||||||
|
*/
|
||||||
|
function getAvifOptions(quality: number, strategy: string): sharp.AvifOptions {
|
||||||
|
return {
|
||||||
|
quality: Math.max(1, Math.min(100, quality)),
|
||||||
|
effort: strategy === "aggressive" ? 9 : 6, // 0-9
|
||||||
|
lossless: quality === 100,
|
||||||
|
chromaSubsampling: quality > 80 ? "4:4:4" : "4:2:0",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GIF 压缩选项
|
||||||
|
*/
|
||||||
|
function getGifOptions(strategy: string): sharp.GifOptions {
|
||||||
|
return {
|
||||||
|
effort: strategy === "aggressive" ? 10 : 7,
|
||||||
|
dither: strategy === "quality" ? 1.0 : 0.5,
|
||||||
|
interFrameMaxError: strategy === "aggressive" ? 10 : 5,
|
||||||
|
interPaletteMaxError: strategy === "aggressive" ? 5 : 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TIFF 压缩选项
|
||||||
|
*/
|
||||||
|
function getTiffOptions(quality: number, strategy: string): sharp.TiffOptions {
|
||||||
|
return {
|
||||||
|
quality: Math.max(1, Math.min(100, quality)),
|
||||||
|
compression: strategy === "aggressive" ? "jpeg" : "lzw",
|
||||||
|
predictor: "horizontal",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,14 +533,12 @@ export function calculateQualityForTargetRatio(
|
|||||||
currentRatio?: number,
|
currentRatio?: number,
|
||||||
currentQuality?: number
|
currentQuality?: number
|
||||||
): number {
|
): number {
|
||||||
// If we have current data, adjust based on difference
|
|
||||||
if (currentRatio !== undefined && currentQuality !== undefined) {
|
if (currentRatio !== undefined && currentQuality !== undefined) {
|
||||||
const difference = targetRatio - currentRatio;
|
const difference = targetRatio - currentRatio;
|
||||||
const adjustment = difference * 2; // Adjust by 2x the difference
|
const adjustment = difference * 2;
|
||||||
return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment)));
|
return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default heuristic: higher target ratio = lower quality
|
|
||||||
return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5)));
|
return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +557,7 @@ export function validateCompressConfig(config: ImageCompressConfig): {
|
|||||||
return { valid: false, error: "Quality must be between 1 and 100" };
|
return { valid: false, error: "Quality must be between 1 and 100" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"];
|
const validFormats = ["original", "auto", "jpeg", "jpg", "png", "webp", "avif", "gif", "bmp", "tiff", "tif"];
|
||||||
if (!validFormats.includes(config.format)) {
|
if (!validFormats.includes(config.format)) {
|
||||||
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
|
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
|
||||||
}
|
}
|
||||||
@@ -287,3 +595,30 @@ export function validateCompressConfig(config: ImageCompressConfig): {
|
|||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取格式推荐信息
|
||||||
|
*/
|
||||||
|
export function getFormatRecommendation(metadata: ImageMetadata): {
|
||||||
|
recommended: string;
|
||||||
|
reason: string;
|
||||||
|
} {
|
||||||
|
if (metadata.isAnimated) {
|
||||||
|
return {
|
||||||
|
recommended: "webp",
|
||||||
|
reason: "WebP provides better compression for animated images than GIF",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.hasAlpha) {
|
||||||
|
return {
|
||||||
|
recommended: "webp",
|
||||||
|
reason: "WebP supports transparency with better compression than PNG",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recommended: "webp",
|
||||||
|
reason: "WebP offers the best balance of quality and file size for photos",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
815
src/lib/texture-atlas.ts
Normal file
815
src/lib/texture-atlas.ts
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import type {
|
||||||
|
TextureAtlasConfig,
|
||||||
|
AtlasSprite,
|
||||||
|
AtlasRect,
|
||||||
|
AtlasFrame,
|
||||||
|
TextureAtlasResult,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texture Atlas Packing Algorithms
|
||||||
|
* Implements MaxRects and Shelf algorithms for packing sprites into a texture atlas
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Rectangle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpriteWithSize extends AtlasSprite {
|
||||||
|
area: number;
|
||||||
|
maxWidth: 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
|
||||||
|
*/
|
||||||
|
function nextPowerOfTwo(value: number): number {
|
||||||
|
return Math.pow(2, Math.ceil(Math.log2(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure size is power of two if required
|
||||||
|
*/
|
||||||
|
function adjustSizeForPot(value: number, pot: boolean): number {
|
||||||
|
return pot ? nextPowerOfTwo(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MaxRects Algorithm Implementation
|
||||||
|
* Best for general purpose packing with good space efficiency
|
||||||
|
*/
|
||||||
|
class MaxRectsPacker {
|
||||||
|
private binWidth: number;
|
||||||
|
private binHeight: number;
|
||||||
|
private usedRectangles: Rectangle[] = [];
|
||||||
|
private freeRectangles: Rectangle[] = [];
|
||||||
|
private allowRotation: boolean;
|
||||||
|
|
||||||
|
constructor(width: number, height: number, allowRotation: boolean) {
|
||||||
|
this.binWidth = width;
|
||||||
|
this.binHeight = height;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the free rectangles
|
||||||
|
this.splitFreeRectangles(bestNode, width, height);
|
||||||
|
|
||||||
|
// Add to used rectangles
|
||||||
|
this.usedRectangles.push({
|
||||||
|
x: bestNode.x,
|
||||||
|
y: bestNode.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { x: bestNode.x, y: bestNode.y, rotated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best position for a new rectangle using the 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;
|
||||||
|
|
||||||
|
// Check if rectangle fits
|
||||||
|
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 {
|
||||||
|
// Process in reverse order to avoid iteration issues
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove degenerate rectangles
|
||||||
|
this.freeRectangles = this.freeRectangles.filter(
|
||||||
|
(rect) => rect.width > 0 && rect.height > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a single free rectangle
|
||||||
|
*/
|
||||||
|
private splitFreeRectangle(
|
||||||
|
freeRect: Rectangle,
|
||||||
|
usedNode: Rectangle,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): boolean {
|
||||||
|
// Check if intersection exists
|
||||||
|
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;
|
||||||
|
// Split into new free rectangles
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeRect.x < usedNode.x) {
|
||||||
|
const newRect = {
|
||||||
|
x: freeRect.x,
|
||||||
|
y: freeRect.y,
|
||||||
|
width: usedNode.x - freeRect.x,
|
||||||
|
height: freeRect.height,
|
||||||
|
};
|
||||||
|
this.freeRectangles.push(newRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeRect.x + freeRect.width > usedNode.x + width) {
|
||||||
|
const newRect = {
|
||||||
|
x: usedNode.x + width,
|
||||||
|
y: freeRect.y,
|
||||||
|
width: freeRect.x + freeRect.width - (usedNode.x + width),
|
||||||
|
height: freeRect.height,
|
||||||
|
};
|
||||||
|
this.freeRectangles.push(newRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeRect.y < usedNode.y) {
|
||||||
|
const newRect = {
|
||||||
|
x: freeRect.x,
|
||||||
|
y: freeRect.y,
|
||||||
|
width: freeRect.width,
|
||||||
|
height: usedNode.y - freeRect.y,
|
||||||
|
};
|
||||||
|
this.freeRectangles.push(newRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freeRect.y + freeRect.height > usedNode.y + height) {
|
||||||
|
const newRect = {
|
||||||
|
x: freeRect.x,
|
||||||
|
y: usedNode.y + height,
|
||||||
|
width: freeRect.width,
|
||||||
|
height: freeRect.y + freeRect.height - (usedNode.y + height),
|
||||||
|
};
|
||||||
|
this.freeRectangles.push(newRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate occupancy ratio
|
||||||
|
*/
|
||||||
|
getOccupancy(): number {
|
||||||
|
const usedArea = this.usedRectangles.reduce((sum, rect) => sum + rect.width * rect.height, 0);
|
||||||
|
return (usedArea / (this.binWidth * this.binHeight)) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shelf Algorithm Implementation
|
||||||
|
* Simple and fast algorithm that packs sprites in horizontal shelves
|
||||||
|
*/
|
||||||
|
class ShelfPacker {
|
||||||
|
private shelves: Shelf[] = [];
|
||||||
|
private currentY = 0;
|
||||||
|
private allowRotation: boolean;
|
||||||
|
private binWidth: number;
|
||||||
|
private padding: number;
|
||||||
|
|
||||||
|
constructor(binWidth: number, allowRotation: boolean, padding: number) {
|
||||||
|
this.binWidth = binWidth;
|
||||||
|
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) {
|
||||||
|
// Try rotated
|
||||||
|
if (shelf.currentX + height + this.padding <= this.binWidth) {
|
||||||
|
const result = { x: shelf.currentX, y: shelf.y, rotated: true };
|
||||||
|
shelf.currentX += height + this.padding;
|
||||||
|
shelf.height = Math.max(shelf.height, paddedWidth);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shelf.currentX + paddedWidth <= this.binWidth) {
|
||||||
|
const result = { x: shelf.currentX, y: shelf.y, rotated: false };
|
||||||
|
shelf.currentX += paddedWidth;
|
||||||
|
shelf.height = Math.max(shelf.height, paddedHeight);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create a new shelf
|
||||||
|
const newShelfY = this.currentY;
|
||||||
|
if (newShelfY + paddedHeight > this.binHeight) {
|
||||||
|
return null; // Doesn't fit
|
||||||
|
}
|
||||||
|
|
||||||
|
const newShelf: Shelf = {
|
||||||
|
y: newShelfY,
|
||||||
|
currentX: 0,
|
||||||
|
height: paddedHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.allowRotation && width !== height) {
|
||||||
|
// Try rotated
|
||||||
|
if (newShelf.currentX + height + this.padding <= this.binWidth) {
|
||||||
|
newShelf.currentX += height + this.padding;
|
||||||
|
newShelf.height = Math.max(newShelf.height, paddedWidth);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private binHeight = Number.MAX_VALUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shelf {
|
||||||
|
y: number;
|
||||||
|
currentX: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort sprites by size (largest first)
|
||||||
|
*/
|
||||||
|
function sortSpritesBySize(sprites: SpriteWithSize[]): SpriteWithSize[] {
|
||||||
|
return [...sprites].sort((a, b) => {
|
||||||
|
// First by max dimension
|
||||||
|
const maxA = Math.max(a.width, a.height);
|
||||||
|
const maxB = Math.max(b.width, b.height);
|
||||||
|
if (maxA !== maxB) return maxB - maxA;
|
||||||
|
|
||||||
|
// Then by area
|
||||||
|
if (b.area !== a.area) return b.area - a.area;
|
||||||
|
|
||||||
|
// Finally by perimeter
|
||||||
|
const periA = a.width + a.height;
|
||||||
|
const periB = b.width + b.height;
|
||||||
|
return periB - periA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack sprites using MaxRects algorithm
|
||||||
|
*/
|
||||||
|
function packWithMaxRects(
|
||||||
|
sprites: SpriteWithSize[],
|
||||||
|
config: TextureAtlasConfig
|
||||||
|
): Map<string, AtlasRect & { rotated: boolean }> {
|
||||||
|
const padding = config.padding;
|
||||||
|
const effectiveWidth = config.maxWidth;
|
||||||
|
const effectiveHeight = config.maxHeight;
|
||||||
|
|
||||||
|
const packer = new MaxRectsPacker(effectiveWidth, effectiveHeight, config.allowRotation);
|
||||||
|
const placements = new Map<string, AtlasRect & { 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) {
|
||||||
|
throw new Error(`Failed to pack sprite: ${sprite.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: SpriteWithSize[],
|
||||||
|
config: TextureAtlasConfig
|
||||||
|
): Map<string, AtlasRect & { rotated: boolean }> {
|
||||||
|
const padding = config.padding;
|
||||||
|
const effectiveWidth = config.maxWidth;
|
||||||
|
|
||||||
|
const packer = new ShelfPacker(effectiveWidth, config.allowRotation, padding);
|
||||||
|
const placements = new Map<string, AtlasRect & { rotated: boolean }>();
|
||||||
|
|
||||||
|
for (const sprite of sortSpritesBySize(sprites)) {
|
||||||
|
const position = packer.insert(sprite.width, sprite.height);
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new Error(`Failed to pack sprite: ${sprite.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
placements.set(sprite.id, {
|
||||||
|
x: position.x + padding,
|
||||||
|
y: position.y + padding,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
rotated: position.rotated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return placements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create texture atlas from sprites
|
||||||
|
*/
|
||||||
|
export async function createTextureAtlas(
|
||||||
|
sprites: AtlasSprite[],
|
||||||
|
config: TextureAtlasConfig
|
||||||
|
): Promise<TextureAtlasResult> {
|
||||||
|
// Validate sprites and get dimensions
|
||||||
|
const spritesWithSize: SpriteWithSize[] = [];
|
||||||
|
|
||||||
|
for (const sprite of sprites) {
|
||||||
|
const metadata = await sharp(sprite.buffer).metadata();
|
||||||
|
const width = metadata.width || 0;
|
||||||
|
const height = metadata.height || 0;
|
||||||
|
|
||||||
|
if (width === 0 || height === 0) {
|
||||||
|
throw new Error(`Invalid sprite dimensions: ${sprite.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = width * height;
|
||||||
|
|
||||||
|
spritesWithSize.push({
|
||||||
|
...sprite,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
area,
|
||||||
|
maxWidth: width,
|
||||||
|
maxHeight: height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort sprites by size
|
||||||
|
const sortedSprites = sortSpritesBySize(spritesWithSize);
|
||||||
|
|
||||||
|
// Calculate minimum required dimensions
|
||||||
|
const maxSpriteWidth = Math.max(...sortedSprites.map((s) => s.width));
|
||||||
|
const maxSpriteHeight = Math.max(...sortedSprites.map((s) => s.height));
|
||||||
|
const padding = config.padding;
|
||||||
|
|
||||||
|
// Estimate minimum size based on total area with packing efficiency factor
|
||||||
|
const paddedArea = sortedSprites.reduce((sum, s) => {
|
||||||
|
const pw = s.width + padding * 2;
|
||||||
|
const ph = s.height + padding * 2;
|
||||||
|
return sum + pw * ph;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Start with a square estimate, accounting for ~85% packing efficiency
|
||||||
|
const minSide = Math.ceil(Math.sqrt(paddedArea / 0.85));
|
||||||
|
let estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
|
||||||
|
let estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
|
||||||
|
|
||||||
|
// Adjust for power of two if required
|
||||||
|
estimatedWidth = adjustSizeForPot(estimatedWidth, config.pot);
|
||||||
|
estimatedHeight = adjustSizeForPot(estimatedHeight, config.pot);
|
||||||
|
|
||||||
|
// Ensure within max bounds
|
||||||
|
estimatedWidth = Math.min(estimatedWidth, config.maxWidth);
|
||||||
|
estimatedHeight = Math.min(estimatedHeight, config.maxHeight);
|
||||||
|
|
||||||
|
// Try to pack with increasing size if needed
|
||||||
|
let placements: Map<string, AtlasRect & { rotated: boolean }>;
|
||||||
|
let finalWidth = estimatedWidth;
|
||||||
|
let finalHeight = estimatedHeight;
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
// Generate size attempts: start small and increase progressively
|
||||||
|
const sizeAttempts: { w: number; h: number }[] = [];
|
||||||
|
|
||||||
|
// Add the estimated size first
|
||||||
|
sizeAttempts.push({ w: estimatedWidth, h: estimatedHeight });
|
||||||
|
|
||||||
|
// For POT sizes, try all combinations up to max
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort by area to try smallest sizes first
|
||||||
|
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
|
||||||
|
} else {
|
||||||
|
// For non-POT, try progressively larger sizes
|
||||||
|
sizeAttempts.push(
|
||||||
|
{ 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 and filter invalid sizes
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
|
||||||
|
|
||||||
|
if (config.algorithm === "MaxRects") {
|
||||||
|
placements = packWithMaxRects(sortedSprites, testConfig);
|
||||||
|
} else {
|
||||||
|
placements = packWithShelf(sortedSprites, testConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate actual used dimensions
|
||||||
|
let maxX = 0;
|
||||||
|
let maxY = 0;
|
||||||
|
for (const [, placement] of placements) {
|
||||||
|
maxX = Math.max(maxX, placement.x + placement.width + padding);
|
||||||
|
maxY = Math.max(maxY, placement.y + placement.height + padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust final dimensions based on actual usage if POT
|
||||||
|
if (config.pot) {
|
||||||
|
finalWidth = adjustSizeForPot(maxX, true);
|
||||||
|
finalHeight = adjustSizeForPot(maxY, true);
|
||||||
|
// Make sure we don't exceed attempted dimensions
|
||||||
|
finalWidth = Math.min(finalWidth, attemptWidth);
|
||||||
|
finalHeight = Math.min(finalHeight, attemptHeight);
|
||||||
|
} else {
|
||||||
|
finalWidth = Math.ceil(maxX);
|
||||||
|
finalHeight = Math.ceil(maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
success = true;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// Try next size
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(
|
||||||
|
"Unable to pack all sprites into the specified maximum dimensions. Try increasing the max size or using rotation."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the composite image
|
||||||
|
const composite = sharp({
|
||||||
|
create: {
|
||||||
|
width: finalWidth,
|
||||||
|
height: finalHeight,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const composites: {
|
||||||
|
input: Buffer;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const frames: AtlasFrame[] = [];
|
||||||
|
|
||||||
|
for (const sprite of sortedSprites) {
|
||||||
|
const placement = placements!.get(sprite.id);
|
||||||
|
if (!placement) continue;
|
||||||
|
|
||||||
|
const image = sharp(sprite.buffer);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
|
// Handle rotation
|
||||||
|
let processedImage = image;
|
||||||
|
let spriteWidth = placement.width;
|
||||||
|
let spriteHeight = placement.height;
|
||||||
|
|
||||||
|
if (placement.rotated) {
|
||||||
|
processedImage = image.rotate(90);
|
||||||
|
[spriteWidth, spriteHeight] = [placement.height, placement.width];
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedBuffer = await processedImage.toBuffer();
|
||||||
|
|
||||||
|
composites.push({
|
||||||
|
input: processedBuffer,
|
||||||
|
left: placement.x,
|
||||||
|
top: placement.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create frame data
|
||||||
|
frames.push({
|
||||||
|
filename: sprite.name,
|
||||||
|
frame: {
|
||||||
|
x: placement.x,
|
||||||
|
y: placement.y,
|
||||||
|
width: spriteWidth,
|
||||||
|
height: spriteHeight,
|
||||||
|
},
|
||||||
|
rotated: placement.rotated,
|
||||||
|
trimmed: false,
|
||||||
|
spriteSourceSize: { x: 0, y: 0, w: spriteWidth, h: spriteHeight },
|
||||||
|
sourceSize: {
|
||||||
|
w: metadata.width || spriteWidth,
|
||||||
|
h: metadata.height || spriteHeight,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = composite.composite(composites);
|
||||||
|
|
||||||
|
// Encode based on format
|
||||||
|
let outputBuffer: Buffer;
|
||||||
|
if (config.format === "png") {
|
||||||
|
outputBuffer = await result.png().toBuffer();
|
||||||
|
} else {
|
||||||
|
outputBuffer = await result.webp({ quality: config.quality }).toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: finalWidth,
|
||||||
|
height: finalHeight,
|
||||||
|
image: outputBuffer,
|
||||||
|
frames,
|
||||||
|
format: config.format,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export atlas data to Cocos2d plist format
|
||||||
|
*/
|
||||||
|
export function exportToCocos2dPlist(atlas: TextureAtlasResult, imageFilename: string): string {
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Frames
|
||||||
|
xml += '\t<key>frames</key>\n';
|
||||||
|
xml += '\t<dict>\n';
|
||||||
|
|
||||||
|
for (const frame of atlas.frames) {
|
||||||
|
xml += `\t\t<key>${escapeXml(frame.filename)}</key>\n`;
|
||||||
|
xml += '\t\t<dict>\n';
|
||||||
|
|
||||||
|
// frame: {{x,y},{w,h}}
|
||||||
|
xml += '\t\t\t<key>frame</key>\n';
|
||||||
|
xml += `\t\t\t<string>{{${Math.round(frame.frame.x)},${Math.round(frame.frame.y)}},{${Math.round(frame.frame.width)},${Math.round(frame.frame.height)}}}</string>\n`;
|
||||||
|
|
||||||
|
// offset: {0,0}
|
||||||
|
xml += '\t\t\t<key>offset</key>\n';
|
||||||
|
xml += '\t\t\t<string>{0,0}</string>\n';
|
||||||
|
|
||||||
|
// rotated
|
||||||
|
xml += '\t\t\t<key>rotated</key>\n';
|
||||||
|
xml += `\t\t\t<${frame.rotated ? 'true' : 'false'}/>\n`;
|
||||||
|
|
||||||
|
// sourceColorRect: {{x,y},{w,h}}
|
||||||
|
xml += '\t\t\t<key>sourceColorRect</key>\n';
|
||||||
|
xml += `\t\t\t<string>{{${frame.spriteSourceSize.x},${frame.spriteSourceSize.y}},{${frame.spriteSourceSize.w},${frame.spriteSourceSize.h}}}</string>\n`;
|
||||||
|
|
||||||
|
// sourceSize: {w,h}
|
||||||
|
xml += '\t\t\t<key>sourceSize</key>\n';
|
||||||
|
xml += `\t\t\t<string>{${frame.sourceSize.w},${frame.sourceSize.h}}</string>\n`;
|
||||||
|
|
||||||
|
xml += '\t\t</dict>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
xml += '\t</dict>\n';
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
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>{${atlas.width},${atlas.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export atlas data to Cocos Creator JSON format
|
||||||
|
*/
|
||||||
|
export function exportToCocosCreatorJson(atlas: TextureAtlasResult, imageFilename: string): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
image: imageFilename,
|
||||||
|
size: { w: atlas.width, h: atlas.height },
|
||||||
|
format: atlas.format,
|
||||||
|
},
|
||||||
|
frames: atlas.frames.reduce((acc, frame) => {
|
||||||
|
acc[frame.filename] = {
|
||||||
|
frame: {
|
||||||
|
x: Math.round(frame.frame.x),
|
||||||
|
y: Math.round(frame.frame.y),
|
||||||
|
w: Math.round(frame.frame.width),
|
||||||
|
h: Math.round(frame.frame.height),
|
||||||
|
},
|
||||||
|
rotated: frame.rotated,
|
||||||
|
trimmed: frame.trimmed,
|
||||||
|
spriteSourceSize: frame.spriteSourceSize,
|
||||||
|
sourceSize: frame.sourceSize,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, FrameData>),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export atlas data to generic JSON format
|
||||||
|
*/
|
||||||
|
export function exportToGenericJson(atlas: TextureAtlasResult, imageFilename: string): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
image: imageFilename,
|
||||||
|
width: atlas.width,
|
||||||
|
height: atlas.height,
|
||||||
|
format: atlas.format,
|
||||||
|
frames: atlas.frames.map((frame) => ({
|
||||||
|
filename: frame.filename,
|
||||||
|
x: Math.round(frame.frame.x),
|
||||||
|
y: Math.round(frame.frame.y),
|
||||||
|
width: Math.round(frame.frame.width),
|
||||||
|
height: Math.round(frame.frame.height),
|
||||||
|
rotated: frame.rotated,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate texture atlas config
|
||||||
|
*/
|
||||||
|
export function validateTextureAtlasConfig(config: TextureAtlasConfig): {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
if (config.maxWidth < 64 || config.maxWidth > 8192) {
|
||||||
|
return { valid: false, error: "Max width must be between 64 and 8192" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maxHeight < 64 || config.maxHeight > 8192) {
|
||||||
|
return { valid: false, error: "Max height must be between 64 and 8192" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.padding < 0 || config.padding > 16) {
|
||||||
|
return { valid: false, error: "Padding must be between 0 and 16" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.quality < 1 || config.quality > 100) {
|
||||||
|
return { valid: false, error: "Quality must be between 1 and 100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFormats = ["png", "webp"];
|
||||||
|
if (!validFormats.includes(config.format)) {
|
||||||
|
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validOutputFormats = ["cocos2d", "cocos-creator", "generic-json"];
|
||||||
|
if (!validOutputFormats.includes(config.outputFormat)) {
|
||||||
|
return { valid: false, error: `Invalid output format. Allowed: ${validOutputFormats.join(", ")}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validAlgorithms = ["MaxRects", "Shelf"];
|
||||||
|
if (!validAlgorithms.includes(config.algorithm)) {
|
||||||
|
return { valid: false, error: `Invalid algorithm. Allowed: ${validAlgorithms.join(", ")}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"ready": "Ready to process",
|
"ready": "Ready to process",
|
||||||
"file": "File",
|
"file": "File",
|
||||||
"files": "files"
|
"files": "files",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
@@ -151,6 +153,7 @@
|
|||||||
"videoToFrames": "Video to Frames",
|
"videoToFrames": "Video to Frames",
|
||||||
"imageCompression": "Image Compression",
|
"imageCompression": "Image Compression",
|
||||||
"audioCompression": "Audio Compression",
|
"audioCompression": "Audio Compression",
|
||||||
|
"textureAtlas": "Texture Atlas",
|
||||||
"aiImage": "AI Image",
|
"aiImage": "AI Image",
|
||||||
"aiAudio": "AI Audio"
|
"aiAudio": "AI Audio"
|
||||||
},
|
},
|
||||||
@@ -180,9 +183,11 @@
|
|||||||
"format": "Output Format",
|
"format": "Output Format",
|
||||||
"formatDescription": "Convert to a different format (optional)",
|
"formatDescription": "Convert to a different format (optional)",
|
||||||
"formatOriginal": "Original",
|
"formatOriginal": "Original",
|
||||||
|
"formatAuto": "Auto (Best)",
|
||||||
"formatJpeg": "JPEG",
|
"formatJpeg": "JPEG",
|
||||||
"formatPng": "PNG",
|
"formatPng": "PNG",
|
||||||
"formatWebp": "WebP"
|
"formatWebp": "WebP",
|
||||||
|
"formatAvif": "AVIF"
|
||||||
},
|
},
|
||||||
"videoFrames": {
|
"videoFrames": {
|
||||||
"title": "Export Settings",
|
"title": "Export Settings",
|
||||||
@@ -207,19 +212,50 @@
|
|||||||
"channelsDescription": "Audio channels",
|
"channelsDescription": "Audio channels",
|
||||||
"stereo": "Stereo (2 channels)",
|
"stereo": "Stereo (2 channels)",
|
||||||
"mono": "Mono (1 channel)"
|
"mono": "Mono (1 channel)"
|
||||||
|
},
|
||||||
|
"textureAtlas": {
|
||||||
|
"title": "Atlas Settings",
|
||||||
|
"description": "Configure texture atlas generation",
|
||||||
|
"maxWidth": "Max Width",
|
||||||
|
"maxWidthDescription": "Maximum atlas width in pixels",
|
||||||
|
"maxHeight": "Max Height",
|
||||||
|
"maxHeightDescription": "Maximum atlas height in pixels",
|
||||||
|
"padding": "Padding",
|
||||||
|
"paddingDescription": "Space between sprites (prevents bleeding)",
|
||||||
|
"allowRotation": "Allow Rotation",
|
||||||
|
"allowRotationDescription": "Rotate sprites for better packing efficiency",
|
||||||
|
"pot": "Power of Two",
|
||||||
|
"potDescription": "Use power-of-two dimensions (512, 1024, 2048, etc.)",
|
||||||
|
"format": "Image Format",
|
||||||
|
"formatDescription": "Output image format",
|
||||||
|
"quality": "Quality",
|
||||||
|
"qualityDescription": "Compression quality for WebP format",
|
||||||
|
"outputFormat": "Data Format",
|
||||||
|
"outputFormatDescription": "Format for sprite metadata",
|
||||||
|
"algorithm": "Packing Algorithm",
|
||||||
|
"algorithmDescription": "Algorithm for arranging sprites",
|
||||||
|
"formatPng": "PNG (Lossless)",
|
||||||
|
"formatWebp": "WebP (Compressed)",
|
||||||
|
"outputCocos2d": "Cocos2d plist",
|
||||||
|
"outputCocosCreator": "Cocos Creator JSON",
|
||||||
|
"outputGeneric": "Generic JSON",
|
||||||
|
"algorithmMaxRects": "MaxRects (Best)",
|
||||||
|
"algorithmShelf": "Shelf (Fast)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"imageCompression": {
|
"imageCompression": {
|
||||||
"title": "Image Compression",
|
"title": "Image Compression",
|
||||||
"description": "Optimize images for web and mobile without quality loss",
|
"description": "World-class image compression with smart optimization",
|
||||||
"compressImages": "Compress Images",
|
"compressImages": "Compress Images",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"featureList": [
|
"featureList": [
|
||||||
"Batch processing - compress multiple images at once",
|
"Smart compression - guaranteed smaller output or return original",
|
||||||
"Smart compression - maintains visual quality",
|
"Multi-strategy optimization - tries multiple algorithms to find the best result",
|
||||||
"Format conversion - PNG to JPEG, WebP, and more",
|
"Auto format selection - intelligently picks the best format for your image",
|
||||||
"Up to 80% size reduction without quality loss"
|
"MozJPEG & WebP - industry-leading compression algorithms",
|
||||||
|
"Metadata stripping - automatic removal of EXIF and unnecessary data",
|
||||||
|
"Batch processing - compress multiple images at once"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"videoFrames": {
|
"videoFrames": {
|
||||||
@@ -243,6 +279,25 @@
|
|||||||
"output": "Output",
|
"output": "Output",
|
||||||
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
|
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
|
||||||
"outputFormats": "MP3, AAC, OGG, FLAC"
|
"outputFormats": "MP3, AAC, OGG, FLAC"
|
||||||
|
},
|
||||||
|
"textureAtlas": {
|
||||||
|
"title": "Texture Atlas",
|
||||||
|
"description": "Combine multiple images into a single texture atlas for game development",
|
||||||
|
"createAtlas": "Create Texture Atlas",
|
||||||
|
"features": "Features",
|
||||||
|
"featureList": [
|
||||||
|
"Smart packing - MaxRects algorithm for optimal space usage",
|
||||||
|
"Cocos Creator compatible - export in plist/JSON format",
|
||||||
|
"Rotation support - can rotate sprites for better packing",
|
||||||
|
"Power of Two - automatic POT sizing for better compatibility"
|
||||||
|
],
|
||||||
|
"downloadAll": "Download All",
|
||||||
|
"downloadImage": "Download Image",
|
||||||
|
"downloadData": "Download Data",
|
||||||
|
"dimensions": "Dimensions",
|
||||||
|
"sprites": "Sprites",
|
||||||
|
"imageFormat": "Image Format",
|
||||||
|
"dataFormat": "Data Format"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"processing": {
|
"processing": {
|
||||||
@@ -252,8 +307,11 @@
|
|||||||
"extractingFrames": "Extracting frames...",
|
"extractingFrames": "Extracting frames...",
|
||||||
"uploadingAudio": "Uploading audio...",
|
"uploadingAudio": "Uploading audio...",
|
||||||
"compressingAudio": "Compressing audio...",
|
"compressingAudio": "Compressing audio...",
|
||||||
|
"uploadingSprites": "Uploading sprites...",
|
||||||
|
"creatingAtlas": "Creating texture atlas...",
|
||||||
"compressionComplete": "Compression complete!",
|
"compressionComplete": "Compression complete!",
|
||||||
"processingComplete": "Processing complete!",
|
"processingComplete": "Processing complete!",
|
||||||
|
"atlasComplete": "Texture atlas created successfully!",
|
||||||
"compressionFailed": "Compression failed",
|
"compressionFailed": "Compression failed",
|
||||||
"processingFailed": "Processing failed",
|
"processingFailed": "Processing failed",
|
||||||
"unknownError": "Unknown error",
|
"unknownError": "Unknown error",
|
||||||
@@ -275,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.",
|
||||||
|
|||||||
@@ -29,7 +29,9 @@
|
|||||||
"failed": "失败",
|
"failed": "失败",
|
||||||
"ready": "准备处理",
|
"ready": "准备处理",
|
||||||
"file": "文件",
|
"file": "文件",
|
||||||
"files": "文件"
|
"files": "文件",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
@@ -151,6 +153,7 @@
|
|||||||
"videoToFrames": "视频抽帧",
|
"videoToFrames": "视频抽帧",
|
||||||
"imageCompression": "图片压缩",
|
"imageCompression": "图片压缩",
|
||||||
"audioCompression": "音频压缩",
|
"audioCompression": "音频压缩",
|
||||||
|
"textureAtlas": "合图工具",
|
||||||
"aiImage": "AI 图片",
|
"aiImage": "AI 图片",
|
||||||
"aiAudio": "AI 音频"
|
"aiAudio": "AI 音频"
|
||||||
},
|
},
|
||||||
@@ -180,9 +183,11 @@
|
|||||||
"format": "输出格式",
|
"format": "输出格式",
|
||||||
"formatDescription": "转换为其他格式(可选)",
|
"formatDescription": "转换为其他格式(可选)",
|
||||||
"formatOriginal": "原始",
|
"formatOriginal": "原始",
|
||||||
|
"formatAuto": "自动(最佳)",
|
||||||
"formatJpeg": "JPEG",
|
"formatJpeg": "JPEG",
|
||||||
"formatPng": "PNG",
|
"formatPng": "PNG",
|
||||||
"formatWebp": "WebP"
|
"formatWebp": "WebP",
|
||||||
|
"formatAvif": "AVIF"
|
||||||
},
|
},
|
||||||
"videoFrames": {
|
"videoFrames": {
|
||||||
"title": "导出设置",
|
"title": "导出设置",
|
||||||
@@ -207,19 +212,50 @@
|
|||||||
"channelsDescription": "音频声道",
|
"channelsDescription": "音频声道",
|
||||||
"stereo": "立体声(2 声道)",
|
"stereo": "立体声(2 声道)",
|
||||||
"mono": "单声道(1 声道)"
|
"mono": "单声道(1 声道)"
|
||||||
|
},
|
||||||
|
"textureAtlas": {
|
||||||
|
"title": "合图设置",
|
||||||
|
"description": "配置纹理图集生成选项",
|
||||||
|
"maxWidth": "最大宽度",
|
||||||
|
"maxWidthDescription": "图集的最大宽度(像素)",
|
||||||
|
"maxHeight": "最大高度",
|
||||||
|
"maxHeightDescription": "图集的最大高度(像素)",
|
||||||
|
"padding": "内边距",
|
||||||
|
"paddingDescription": "精灵之间的间距(防止溢出)",
|
||||||
|
"allowRotation": "允许旋转",
|
||||||
|
"allowRotationDescription": "旋转精灵以提高打包效率",
|
||||||
|
"pot": "2 的幂次",
|
||||||
|
"potDescription": "使用 2 的幂次尺寸(512、1024、2048 等)",
|
||||||
|
"format": "图片格式",
|
||||||
|
"formatDescription": "输出图片格式",
|
||||||
|
"quality": "质量",
|
||||||
|
"qualityDescription": "WebP 格式的压缩质量",
|
||||||
|
"outputFormat": "数据格式",
|
||||||
|
"outputFormatDescription": "精灵元数据的格式",
|
||||||
|
"algorithm": "打包算法",
|
||||||
|
"algorithmDescription": "排列精灵的算法",
|
||||||
|
"formatPng": "PNG(无损)",
|
||||||
|
"formatWebp": "WebP(压缩)",
|
||||||
|
"outputCocos2d": "Cocos2d plist",
|
||||||
|
"outputCocosCreator": "Cocos Creator JSON",
|
||||||
|
"outputGeneric": "通用 JSON",
|
||||||
|
"algorithmMaxRects": "MaxRects(最优)",
|
||||||
|
"algorithmShelf": "Shelf(快速)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"imageCompression": {
|
"imageCompression": {
|
||||||
"title": "图片压缩",
|
"title": "图片压缩",
|
||||||
"description": "为网页和移动端优化图片,不影响质量",
|
"description": "世界一流的图片压缩,智能优化",
|
||||||
"compressImages": "压缩图片",
|
"compressImages": "压缩图片",
|
||||||
"features": "功能特点",
|
"features": "功能特点",
|
||||||
"featureList": [
|
"featureList": [
|
||||||
"批量处理 - 一次压缩多张图片",
|
"智能压缩 - 保证输出更小或返回原图",
|
||||||
"智能压缩 - 保持视觉质量",
|
"多策略优化 - 尝试多种算法找到最佳结果",
|
||||||
"格式转换 - PNG 转 JPEG、WebP 等",
|
"自动格式选择 - 智能选择最适合的格式",
|
||||||
"高达 80% 的压缩率且不影响质量"
|
"MozJPEG & WebP - 业界领先的压缩算法",
|
||||||
|
"元数据剥离 - 自动移除 EXIF 等冗余数据",
|
||||||
|
"批量处理 - 一次压缩多张图片"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"videoFrames": {
|
"videoFrames": {
|
||||||
@@ -243,6 +279,25 @@
|
|||||||
"output": "输出",
|
"output": "输出",
|
||||||
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
|
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
|
||||||
"outputFormats": "MP3、AAC、OGG、FLAC"
|
"outputFormats": "MP3、AAC、OGG、FLAC"
|
||||||
|
},
|
||||||
|
"textureAtlas": {
|
||||||
|
"title": "合图工具",
|
||||||
|
"description": "将多张图片合并为一个纹理图集,专为游戏开发优化",
|
||||||
|
"createAtlas": "创建合图",
|
||||||
|
"features": "功能特点",
|
||||||
|
"featureList": [
|
||||||
|
"智能打包 - MaxRects 算法实现最优空间利用",
|
||||||
|
"Cocos Creator 兼容 - 导出 plist/JSON 格式",
|
||||||
|
"旋转支持 - 可旋转精灵以提高打包效率",
|
||||||
|
"2 的幂次 - 自动 POT 尺寸提升兼容性"
|
||||||
|
],
|
||||||
|
"downloadAll": "打包下载",
|
||||||
|
"downloadImage": "下载图片",
|
||||||
|
"downloadData": "下载数据",
|
||||||
|
"dimensions": "尺寸",
|
||||||
|
"sprites": "精灵数",
|
||||||
|
"imageFormat": "图片格式",
|
||||||
|
"dataFormat": "数据格式"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"processing": {
|
"processing": {
|
||||||
@@ -252,8 +307,11 @@
|
|||||||
"extractingFrames": "提取帧中...",
|
"extractingFrames": "提取帧中...",
|
||||||
"uploadingAudio": "上传音频中...",
|
"uploadingAudio": "上传音频中...",
|
||||||
"compressingAudio": "压缩音频中...",
|
"compressingAudio": "压缩音频中...",
|
||||||
|
"uploadingSprites": "上传精灵图中...",
|
||||||
|
"creatingAtlas": "创建合图中...",
|
||||||
"compressionComplete": "压缩完成!",
|
"compressionComplete": "压缩完成!",
|
||||||
"processingComplete": "处理完成!",
|
"processingComplete": "处理完成!",
|
||||||
|
"atlasComplete": "合图创建成功!",
|
||||||
"compressionFailed": "压缩失败",
|
"compressionFailed": "压缩失败",
|
||||||
"processingFailed": "处理失败",
|
"processingFailed": "处理失败",
|
||||||
"unknownError": "未知错误",
|
"unknownError": "未知错误",
|
||||||
@@ -275,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
220
src/store/atlasStore.ts
Normal 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,
|
||||||
|
}));
|
||||||
@@ -59,7 +59,7 @@ export interface ProcessingProgress {
|
|||||||
* Tool types
|
* Tool types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "ai-image" | "ai-audio";
|
export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "texture-atlas" | "ai-image" | "ai-audio";
|
||||||
|
|
||||||
export interface ToolConfig {
|
export interface ToolConfig {
|
||||||
type: ToolType;
|
type: ToolType;
|
||||||
@@ -129,7 +129,7 @@ export interface VideoFramesConfig {
|
|||||||
|
|
||||||
export interface ImageCompressConfig {
|
export interface ImageCompressConfig {
|
||||||
quality: number;
|
quality: number;
|
||||||
format: "original" | "jpeg" | "png" | "webp";
|
format: "original" | "auto" | "jpeg" | "png" | "webp" | "avif";
|
||||||
resize?: {
|
resize?: {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
@@ -143,3 +143,52 @@ export interface AudioCompressConfig {
|
|||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
channels: number;
|
channels: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texture Atlas types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TextureAtlasConfig {
|
||||||
|
maxWidth: number;
|
||||||
|
maxHeight: number;
|
||||||
|
padding: number;
|
||||||
|
allowRotation: boolean;
|
||||||
|
pot: boolean; // Power of Two
|
||||||
|
format: "png" | "webp";
|
||||||
|
quality: number;
|
||||||
|
outputFormat: "cocos2d" | "cocos-creator" | "generic-json";
|
||||||
|
algorithm: "MaxRects" | "Shelf";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AtlasSprite {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AtlasRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AtlasFrame {
|
||||||
|
filename: string;
|
||||||
|
frame: AtlasRect;
|
||||||
|
rotated: boolean;
|
||||||
|
trimmed: boolean;
|
||||||
|
spriteSourceSize: { x: number; y: number; w: number; h: number };
|
||||||
|
sourceSize: { w: number; h: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextureAtlasResult {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
image: Buffer;
|
||||||
|
frames: AtlasFrame[];
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user