Compare commits
9 Commits
77c048b6a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b29de1dd80 | ||
| c26d6eaada | |||
| 140608845a | |||
| 663917f663 | |||
| 54009163b1 | |||
| 081e2058bf | |||
| fc29ec880c | |||
| b7402edf6a | |||
| e2280b12e2 |
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**
|
||||
- 用途:在实现过程中探索现有组件复用、类型定义、国际化键值等
|
||||
- 预期结果:充分复用现有代码,保持项目一致性
|
||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Start dev server with Turbopack |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run start` | Run production server |
|
||||
| `npm run lint` | ESLint check |
|
||||
| `npm run type-check` | TypeScript type checking without emit |
|
||||
| `npm run format` | Format code with Prettier |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Next.js 15 with App Router, React 19
|
||||
- **State**: Zustand for client state
|
||||
- **Styling**: Tailwind CSS with HSL CSS variables (dark mode by default)
|
||||
- **UI Components**: Custom components built on Radix UI primitives
|
||||
- **Animations**: Framer Motion
|
||||
- **Form Validation**: Zod
|
||||
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
|
||||
|
||||
### Route Groups
|
||||
- `(auth)` - Authentication routes (login, register)
|
||||
- `(dashboard)` - Dashboard routes with shared Sidebar layout
|
||||
|
||||
### Tool Pages Pattern
|
||||
Each tool (image-compress, video-frames, audio-compress) follows a consistent pattern:
|
||||
1. `FileUploader` - Drag-drop file input using react-dropzone
|
||||
2. `ConfigPanel` - Tool-specific configuration options
|
||||
3. `ProgressBar` - Processing status indicator
|
||||
4. `ResultPreview` - Display processed files
|
||||
5. State managed via `useUploadStore` Zustand store
|
||||
|
||||
### State Management
|
||||
- `store/uploadStore.ts` - File list, processing status, progress tracking
|
||||
- `store/authStore.ts` - User authentication state
|
||||
|
||||
### Styling Convention
|
||||
Use `cn()` utility from `lib/utils.ts` for Tailwind class merging. Theme colors are CSS variables in `app/globals.css` accessed via HSL: `hsl(var(--primary))`.
|
||||
|
||||
### Type Definitions
|
||||
All shared types in `types/index.ts` including file types, processing configs, API responses.
|
||||
|
||||
### Development Phases
|
||||
- Current (Phase 1-4): Basic tools with mock API implementations
|
||||
- Phase 5: AI services integration (Replicate, OpenAI)
|
||||
- Phase 6: Authentication, database (PostgreSQL), payment processing (Stripe), cloud storage (Cloudflare R2)
|
||||
70
CODEBUDDY.md
Normal file
70
CODEBUDDY.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# CODEBUDDY.md This file provides guidance to CodeBuddy when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Start dev server with Turbopack (fast refresh) |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run start` | Run production server |
|
||||
| `npm run lint` | ESLint check |
|
||||
| `npm run type-check` | TypeScript type checking without emit |
|
||||
| `npm run format` | Format code with Prettier |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Next.js 15 with App Router, React 19
|
||||
- **State**: Zustand for client state
|
||||
- **Styling**: Tailwind CSS with HSL CSS variables (dark mode by default)
|
||||
- **UI Components**: Custom components built on Radix UI primitives
|
||||
- **Animations**: Framer Motion
|
||||
- **Form Validation**: Zod
|
||||
- **Media Processing**: Sharp (images), FFmpeg (video/audio)
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (auth)/ # Auth routes group (login, register)
|
||||
│ ├── (dashboard)/ # Dashboard routes with Sidebar layout
|
||||
│ │ ├── tools/ # Tool pages (image-compress, video-frames, audio-compress)
|
||||
│ │ └── layout.tsx # Dashboard layout with Sidebar
|
||||
│ ├── api/ # API routes
|
||||
│ │ ├── upload/ # File upload endpoint
|
||||
│ │ └── process/ # Processing endpoints per tool type
|
||||
│ ├── globals.css # Global styles with CSS variables
|
||||
│ └── layout.tsx # Root layout (Header + Footer)
|
||||
├── components/
|
||||
│ ├── ui/ # Base UI primitives (button, card, input, etc.)
|
||||
│ ├── tools/ # Tool-specific components (FileUploader, ConfigPanel, ProgressBar, ResultPreview)
|
||||
│ └── layout/ # Layout components (Header, Footer, Sidebar)
|
||||
├── lib/
|
||||
│ ├── api.ts # API client functions
|
||||
│ └── utils.ts # Utility functions (cn, formatFileSize, etc.)
|
||||
├── store/
|
||||
│ ├── authStore.ts # Auth state
|
||||
│ └── uploadStore.ts # File upload and processing state
|
||||
└── types/
|
||||
└── index.ts # TypeScript types (UploadedFile, ProcessedFile, configs, etc.)
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Route Groups**: Uses `(auth)` and `(dashboard)` route groups. Dashboard routes share a layout with `Sidebar` component.
|
||||
|
||||
**Tool Pages Pattern**: Each tool (image-compress, video-frames, audio-compress) follows the same pattern:
|
||||
1. Uses `FileUploader` for drag-drop file input
|
||||
2. Uses `ConfigPanel` for tool-specific configuration options
|
||||
3. Uses `ProgressBar` to show processing status
|
||||
4. Uses `ResultPreview` to display processed files
|
||||
5. State managed via `useUploadStore` Zustand store
|
||||
|
||||
**API Routes**: API routes under `app/api/` use Node.js runtime. Each processing endpoint validates input and returns JSON responses. Currently mock implementations - production would use Sharp/FFmpeg and cloud storage.
|
||||
|
||||
**State Management**: Zustand stores in `store/` directory. `uploadStore` manages file list, processing status and progress. `authStore` manages user authentication state.
|
||||
|
||||
**Styling**: Uses `cn()` utility from `lib/utils.ts` for Tailwind class merging. Theme colors defined as CSS variables in `globals.css`. Component styling uses HSL color functions like `hsl(var(--primary))`.
|
||||
|
||||
**Type Definitions**: All shared types in `types/index.ts`. Includes file types, processing configs, API responses, and user types.
|
||||
1897
package-lock.json
generated
1897
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -8,19 +8,25 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"deploy": "bash scripts/deploy.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.11",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.1.6",
|
||||
"react": "^19.0.0",
|
||||
@@ -28,11 +34,13 @@
|
||||
"react-dropzone": "^14.3.5",
|
||||
"sharp": "^0.33.5",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"upng-js": "^2.1.0",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
112
scripts/deploy.sh
Executable file
112
scripts/deploy.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 部署配置
|
||||
SERVER_USER="root"
|
||||
SERVER_HOST="129.204.155.94"
|
||||
SERVER_PATH="/usr/local/web/mini-game-ai"
|
||||
PM2_APP_NAME="mini-game-ai"
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
echo_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# 检查 SSH 连接
|
||||
echo_info "检查服务器连接..."
|
||||
if ! ssh -o ConnectTimeout=5 ${SERVER_USER}@${SERVER_HOST} "echo '连接成功'" > /dev/null 2>&1; then
|
||||
echo_error "无法连接到服务器 ${SERVER_HOST}"
|
||||
echo_warn "请确保:"
|
||||
echo_warn " 1. 服务器地址正确"
|
||||
echo_warn " 2. 已配置 SSH 密钥认证或密码"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 本地构建
|
||||
echo_info "开始本地构建..."
|
||||
npm run build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo_error "构建失败,部署终止"
|
||||
exit 1
|
||||
fi
|
||||
echo_info "构建完成"
|
||||
|
||||
# 创建服务器目录
|
||||
echo_info "准备服务器目录..."
|
||||
ssh ${SERVER_USER}@${SERVER_HOST} "mkdir -p ${SERVER_PATH}"
|
||||
|
||||
# 同步文件到服务器
|
||||
echo_info "上传文件到服务器 ${SERVER_HOST}:${SERVER_PATH}"
|
||||
rsync -avz --delete \
|
||||
--exclude 'node_modules' \
|
||||
--exclude '.git' \
|
||||
--exclude 'public/uploads' \
|
||||
--exclude '__pycache__' \
|
||||
--exclude '*.pyc' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.log' \
|
||||
./ ${SERVER_USER}@${SERVER_HOST}:${SERVER_PATH}/
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo_error "文件上传失败"
|
||||
exit 1
|
||||
fi
|
||||
echo_info "文件上传完成"
|
||||
|
||||
# 在服务器上安装依赖并重启服务
|
||||
echo_info "服务器部署操作..."
|
||||
ssh ${SERVER_USER}@${SERVER_HOST} "bash -l" << 'ENDSSH'
|
||||
export PATH="$HOME/.nvm/versions/node/v22.17.1/bin:$PATH"
|
||||
cd /usr/local/web/mini-game-ai
|
||||
|
||||
# 安装依赖
|
||||
echo "安装依赖..."
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
npm install --production --no-audit --no-fund
|
||||
|
||||
# 检查 PM2 是否安装
|
||||
if ! command -v pm2 &> /dev/null; then
|
||||
echo "安装 PM2..."
|
||||
npm install -g pm2
|
||||
fi
|
||||
|
||||
# 停止旧服务(如果存在)
|
||||
if pm2 describe mini-game-ai &> /dev/null; then
|
||||
echo "停止旧服务..."
|
||||
pm2 stop mini-game-ai
|
||||
pm2 delete mini-game-ai
|
||||
fi
|
||||
|
||||
# 启动新服务
|
||||
echo "启动服务(端口 3003)..."
|
||||
PORT=3003 pm2 start npm --name mini-game-ai -- start
|
||||
|
||||
# 保存 PM2 配置
|
||||
pm2 save --force
|
||||
|
||||
# 等待服务启动并检查状态
|
||||
sleep 3
|
||||
echo "服务状态:"
|
||||
pm2 status mini-game-ai
|
||||
|
||||
# 检查服务是否在线
|
||||
if pm2 describe mini-game-ai | grep -q "online"; then
|
||||
echo "✓ 服务启动成功!"
|
||||
else
|
||||
echo "✗ 服务启动失败,查看日志:"
|
||||
pm2 logs mini-game-ai --lines 20 --nostream
|
||||
exit 1
|
||||
fi
|
||||
ENDSSH
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo_info "部署成功!"
|
||||
echo_info "应用地址: http://${SERVER_HOST}:3003"
|
||||
else
|
||||
echo_error "部署失败"
|
||||
exit 1
|
||||
fi
|
||||
@@ -6,10 +6,10 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="flex min-h-[calc(100vh-4rem)]">
|
||||
<Sidebar />
|
||||
<main className="flex-1 lg:ml-64">
|
||||
<div className="min-h-[calc(100vh-4rem)]">{children}</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
39
src/app/(dashboard)/tools/audio-compress/layout.tsx
Normal file
39
src/app/(dashboard)/tools/audio-compress/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||
|
||||
const titles = {
|
||||
en: "Audio Compression - Compress & Convert Audio Files",
|
||||
zh: "音频压缩 - 压缩并转换音频文件",
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
en: "Compress and convert audio files to various formats including MP3, AAC, OGG, FLAC. Adjust bitrate and sample rate for optimal quality.",
|
||||
zh: "压缩并转换音频文件为多种格式,包括 MP3、AAC、OGG、FLAC。调整比特率和采样率以获得最佳质量。",
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
twitter: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function AudioCompressLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Music, Volume2 } from "lucide-react";
|
||||
import { FileUploader } from "@/components/tools/FileUploader";
|
||||
@@ -10,6 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUploadStore } from "@/store/uploadStore";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
import type { UploadedFile, ProcessedFile, AudioCompressConfig } from "@/types";
|
||||
|
||||
const audioAccept = {
|
||||
@@ -23,13 +24,14 @@ const defaultConfig: AudioCompressConfig = {
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
const configOptions: ConfigOption[] = [
|
||||
function useConfigOptions(config: AudioCompressConfig, getT: (key: string) => string): ConfigOption[] {
|
||||
return [
|
||||
{
|
||||
id: "bitrate",
|
||||
type: "select",
|
||||
label: "Bitrate",
|
||||
description: "Higher bitrate = better quality, larger file",
|
||||
value: defaultConfig.bitrate,
|
||||
label: getT("config.audioCompression.bitrate"),
|
||||
description: getT("config.audioCompression.bitrateDescription"),
|
||||
value: config.bitrate,
|
||||
options: [
|
||||
{ label: "64 kbps", value: 64 },
|
||||
{ label: "128 kbps", value: 128 },
|
||||
@@ -41,9 +43,9 @@ const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: "Output Format",
|
||||
description: "Target audio format",
|
||||
value: defaultConfig.format,
|
||||
label: getT("config.audioCompression.format"),
|
||||
description: getT("config.audioCompression.formatDescription"),
|
||||
value: config.format,
|
||||
options: [
|
||||
{ label: "MP3", value: "mp3" },
|
||||
{ label: "AAC", value: "aac" },
|
||||
@@ -54,9 +56,9 @@ const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "sampleRate",
|
||||
type: "select",
|
||||
label: "Sample Rate",
|
||||
description: "Audio sample rate in Hz",
|
||||
value: defaultConfig.sampleRate,
|
||||
label: getT("config.audioCompression.sampleRate"),
|
||||
description: getT("config.audioCompression.sampleRateDescription"),
|
||||
value: config.sampleRate,
|
||||
options: [
|
||||
{ label: "44.1 kHz", value: 44100 },
|
||||
{ label: "48 kHz", value: 48000 },
|
||||
@@ -65,17 +67,27 @@ const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "channels",
|
||||
type: "radio",
|
||||
label: "Channels",
|
||||
description: "Audio channels",
|
||||
value: defaultConfig.channels,
|
||||
label: getT("config.audioCompression.channels"),
|
||||
description: getT("config.audioCompression.channelsDescription"),
|
||||
value: config.channels,
|
||||
options: [
|
||||
{ label: "Stereo (2 channels)", value: 2 },
|
||||
{ label: "Mono (1 channel)", value: 1 },
|
||||
{ label: getT("config.audioCompression.stereo"), value: 2 },
|
||||
{ label: getT("config.audioCompression.mono"), value: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function AudioCompressPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getT = (key: string, params?: Record<string, string | number>) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
|
||||
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
|
||||
useUploadStore();
|
||||
|
||||
@@ -98,7 +110,7 @@ export default function AudioCompressPage() {
|
||||
[addFile]
|
||||
);
|
||||
|
||||
const handleConfigChange = (id: string, value: any) => {
|
||||
const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
|
||||
setConfig((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
@@ -112,7 +124,7 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
message: "Uploading audio...",
|
||||
message: getT("processing.uploadingAudio"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -122,14 +134,14 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: i,
|
||||
message: `Uploading... ${i}%`,
|
||||
message: getT("processing.uploadProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: "Compressing audio...",
|
||||
message: getT("processing.compressingAudio"),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
@@ -138,7 +150,7 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: `Compressing... ${i}%`,
|
||||
message: getT("processing.compressProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,14 +174,14 @@ export default function AudioCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
message: "Compression complete!",
|
||||
message: getT("processing.compressionComplete"),
|
||||
});
|
||||
} catch (error) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: "Compression failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: getT("processing.compressionFailed"),
|
||||
error: error instanceof Error ? error.message : getT("processing.unknownError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -179,6 +191,7 @@ export default function AudioCompressPage() {
|
||||
};
|
||||
|
||||
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||
const configOptions = useConfigOptions(config, getT);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -192,9 +205,9 @@ export default function AudioCompressPage() {
|
||||
<Music className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Audio Compression</h1>
|
||||
<h1 className="text-3xl font-bold">{getT("tools.audioCompression.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Compress and convert audio files with quality control
|
||||
{getT("tools.audioCompression.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,8 +226,8 @@ export default function AudioCompressPage() {
|
||||
/>
|
||||
|
||||
<ConfigPanel
|
||||
title="Audio Settings"
|
||||
description="Configure compression parameters"
|
||||
title={getT("config.audioCompression.title")}
|
||||
description={getT("config.audioCompression.description")}
|
||||
options={configOptions.map((opt) => ({
|
||||
...opt,
|
||||
value: config[opt.id as keyof AudioCompressConfig],
|
||||
@@ -226,7 +239,7 @@ export default function AudioCompressPage() {
|
||||
{canProcess && (
|
||||
<Button onClick={handleProcess} size="lg" className="w-full">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
Compress Audio
|
||||
{getT("tools.audioCompression.compressAudio")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -241,15 +254,15 @@ export default function AudioCompressPage() {
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||
<h3 className="mb-3 font-semibold">Supported Formats</h3>
|
||||
<h3 className="mb-3 font-semibold">{getT("tools.audioCompression.supportedFormats")}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Input</p>
|
||||
<p>MP3, WAV, OGG, AAC, FLAC, M4A</p>
|
||||
<p className="font-medium text-foreground">{getT("tools.audioCompression.input")}</p>
|
||||
<p>{getT("tools.audioCompression.inputFormats")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Output</p>
|
||||
<p>MP3, AAC, OGG, FLAC</p>
|
||||
<p className="font-medium text-foreground">{getT("tools.audioCompression.output")}</p>
|
||||
<p>{getT("tools.audioCompression.outputFormats")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
39
src/app/(dashboard)/tools/image-compress/layout.tsx
Normal file
39
src/app/(dashboard)/tools/image-compress/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||
|
||||
const titles = {
|
||||
en: "Image Compression - Optimize Images for Web & Mobile",
|
||||
zh: "图片压缩 - 为网页和移动端优化图片",
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
en: "Optimize images for web and mobile without quality loss. Support for batch processing and format conversion including PNG, JPEG, WebP.",
|
||||
zh: "为网页和移动端优化图片,不影响质量。支持批量处理和格式转换,包括 PNG、JPEG、WebP。",
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
twitter: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function ImageCompressLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Image as ImageIcon, Zap } from "lucide-react";
|
||||
import { FileUploader } from "@/components/tools/FileUploader";
|
||||
@@ -10,24 +10,26 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUploadStore } from "@/store/uploadStore";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
|
||||
|
||||
const imageAccept = {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"],
|
||||
};
|
||||
|
||||
const defaultConfig: ImageCompressConfig = {
|
||||
quality: 80,
|
||||
format: "original",
|
||||
format: "auto",
|
||||
};
|
||||
|
||||
const configOptions: ConfigOption[] = [
|
||||
function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] {
|
||||
return [
|
||||
{
|
||||
id: "quality",
|
||||
type: "slider",
|
||||
label: "Compression Quality",
|
||||
description: "Lower quality = smaller file size",
|
||||
value: defaultConfig.quality,
|
||||
label: getT("config.imageCompression.quality"),
|
||||
description: getT("config.imageCompression.qualityDescription"),
|
||||
value: config.quality,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
@@ -37,19 +39,94 @@ const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: "Output Format",
|
||||
description: "Convert to a different format (optional)",
|
||||
value: defaultConfig.format,
|
||||
label: getT("config.imageCompression.format"),
|
||||
description: getT("config.imageCompression.formatDescription"),
|
||||
value: config.format,
|
||||
options: [
|
||||
{ label: "Original", value: "original" },
|
||||
{ label: "JPEG", value: "jpeg" },
|
||||
{ label: "PNG", value: "png" },
|
||||
{ label: "WebP", value: "webp" },
|
||||
{ label: getT("config.imageCompression.formatAuto"), value: "auto" },
|
||||
{ label: getT("config.imageCompression.formatOriginal"), value: "original" },
|
||||
{ label: getT("config.imageCompression.formatJpeg"), value: "jpeg" },
|
||||
{ label: getT("config.imageCompression.formatPng"), value: "png" },
|
||||
{ label: getT("config.imageCompression.formatWebp"), value: "webp" },
|
||||
{ label: getT("config.imageCompression.formatAvif"), value: "avif" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the server
|
||||
*/
|
||||
async function uploadFile(file: File): Promise<{ fileId: string } | null> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || "Upload failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { fileId: data.fileId };
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
async function processImageCompression(
|
||||
fileId: string,
|
||||
config: ImageCompressConfig
|
||||
): Promise<ProcessResult> {
|
||||
const response = await fetch("/api/process/image-compress", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ fileId, config }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.error || "Processing failed" };
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
export default function ImageCompressPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getT = (key: string, params?: Record<string, string | number>) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
|
||||
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
|
||||
useUploadStore();
|
||||
|
||||
@@ -72,7 +149,7 @@ export default function ImageCompressPage() {
|
||||
[addFile]
|
||||
);
|
||||
|
||||
const handleConfigChange = (id: string, value: any) => {
|
||||
const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
|
||||
setConfig((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
@@ -86,88 +163,140 @@ export default function ImageCompressPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
message: "Uploading images...",
|
||||
message: getT("processing.uploadingImages"),
|
||||
});
|
||||
|
||||
const results: ProcessedFile[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Simulate upload
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
// Process each file
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
|
||||
// Update progress
|
||||
const uploadProgress = Math.round(((i + 0.5) / files.length) * 50);
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: i,
|
||||
message: `Uploading... ${i}%`,
|
||||
progress: uploadProgress,
|
||||
message: getT("processing.uploadProgress", { progress: uploadProgress }),
|
||||
});
|
||||
|
||||
// Upload file
|
||||
let fileId: string;
|
||||
try {
|
||||
const uploadResult = await uploadFile(file.file);
|
||||
if (!uploadResult) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
fileId = uploadResult.fileId;
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: ${error instanceof Error ? error.message : "Upload failed"}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update progress to processing
|
||||
const processProgress = 50 + Math.round(((i + 0.5) / files.length) * 50);
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: "Compressing images...",
|
||||
progress: processProgress,
|
||||
message: getT("processing.compressProgress", { progress: processProgress }),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
for (let i = 0; i <= 100; i += 5) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: `Compressing... ${i}%`,
|
||||
});
|
||||
}
|
||||
// Process image
|
||||
try {
|
||||
const result = await processImageCompression(fileId, config);
|
||||
|
||||
// Simulate completion
|
||||
const results: ProcessedFile[] = files.map((file) => ({
|
||||
if (result.success && result.data) {
|
||||
results.push({
|
||||
id: generateId(),
|
||||
originalFile: file,
|
||||
processedUrl: "#",
|
||||
processedUrl: result.data.fileUrl,
|
||||
metadata: {
|
||||
format: config.format === "original" ? file.file.type.split("/")[1] : config.format,
|
||||
quality: config.quality,
|
||||
compressionRatio: Math.floor(Math.random() * 30) + 40, // Simulated 40-70%
|
||||
format: result.data.metadata.format,
|
||||
quality: result.data.metadata.quality,
|
||||
compressionRatio: result.data.metadata.compressionRatio,
|
||||
resolution: `${result.data.metadata.width}x${result.data.metadata.height}`,
|
||||
originalSize: result.data.metadata.originalSize,
|
||||
compressedSize: result.data.metadata.compressedSize,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
errors.push(
|
||||
`${file.name}: ${result.error || getT("processing.unknownError")}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
`${file.name}: ${error instanceof Error ? error.message : "Processing failed"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedFiles(results);
|
||||
// Clear uploaded files
|
||||
clearFiles();
|
||||
|
||||
// Set final status
|
||||
if (results.length > 0) {
|
||||
setProcessedFiles(results);
|
||||
setProcessingStatus({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
message: "Compression complete!",
|
||||
message: getT("processing.compressionComplete"),
|
||||
});
|
||||
} else if (errors.length > 0) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: errors[0],
|
||||
error: errors.join("; "),
|
||||
});
|
||||
} else {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: getT("processing.compressionFailed"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: "Compression failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: getT("processing.compressionFailed"),
|
||||
error: error instanceof Error ? error.message : getT("processing.unknownError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (fileId: string) => {
|
||||
console.log("Downloading file:", fileId);
|
||||
const file = processedFiles.find((f) => f.id === fileId);
|
||||
if (file) {
|
||||
// Create a temporary link to trigger download
|
||||
const link = document.createElement("a");
|
||||
link.href = file.processedUrl;
|
||||
link.download = file.metadata.filename || `compressed-${file.originalFile.name}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||
const configOptions = useConfigOptions(config, getT);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ImageIcon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Image Compression</h1>
|
||||
<h1 className="text-3xl font-bold">{getT("tools.imageCompression.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Optimize images for web and mobile without quality loss
|
||||
{getT("tools.imageCompression.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,12 +315,9 @@ export default function ImageCompressPage() {
|
||||
/>
|
||||
|
||||
<ConfigPanel
|
||||
title="Compression Settings"
|
||||
description="Configure compression options"
|
||||
options={configOptions.map((opt) => ({
|
||||
...opt,
|
||||
value: config[opt.id as keyof ImageCompressConfig],
|
||||
}))}
|
||||
title={getT("config.imageCompression.title")}
|
||||
description={getT("config.imageCompression.description")}
|
||||
options={configOptions}
|
||||
onChange={handleConfigChange}
|
||||
onReset={handleResetConfig}
|
||||
/>
|
||||
@@ -199,7 +325,7 @@ export default function ImageCompressPage() {
|
||||
{canProcess && (
|
||||
<Button onClick={handleProcess} size="lg" className="w-full">
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Compress Images
|
||||
{getT("tools.imageCompression.compressImages")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,12 +340,13 @@ export default function ImageCompressPage() {
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||
<h3 className="mb-3 font-semibold">Features</h3>
|
||||
<h3 className="mb-3 font-semibold">{getT("tools.imageCompression.features")}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>• Batch processing - compress multiple images at once</li>
|
||||
<li>• Smart compression - maintains visual quality</li>
|
||||
<li>• Format conversion - PNG to JPEG, WebP, and more</li>
|
||||
<li>• Up to 80% size reduction without quality loss</li>
|
||||
{(getT("tools.imageCompression.featureList") as unknown as string[]).map(
|
||||
(feature, index) => (
|
||||
<li key={index}>• {feature}</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
39
src/app/(dashboard)/tools/video-frames/layout.tsx
Normal file
39
src/app/(dashboard)/tools/video-frames/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const headersList = await headers();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const lang = acceptLanguage.includes("zh") ? "zh" : "en";
|
||||
|
||||
const titles = {
|
||||
en: "Video to Frames - Extract Frames from Videos",
|
||||
zh: "视频抽帧 - 从视频中提取帧",
|
||||
};
|
||||
|
||||
const descriptions = {
|
||||
en: "Extract frames from videos with customizable frame rates. Perfect for sprite animations and game asset preparation. Supports MP4, MOV, AVI, WebM.",
|
||||
zh: "从视频中提取帧,可自定义帧率。非常适合精灵动画制作和游戏素材准备。支持 MP4、MOV、AVI、WebM。",
|
||||
};
|
||||
|
||||
return {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
openGraph: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
twitter: {
|
||||
title: titles[lang],
|
||||
description: descriptions[lang],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function VideoFramesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Video, Settings } from "lucide-react";
|
||||
import { FileUploader } from "@/components/tools/FileUploader";
|
||||
@@ -10,6 +10,7 @@ import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUploadStore } from "@/store/uploadStore";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
import type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
|
||||
|
||||
const videoAccept = {
|
||||
@@ -24,13 +25,14 @@ const defaultConfig: VideoFramesConfig = {
|
||||
height: undefined,
|
||||
};
|
||||
|
||||
const configOptions: ConfigOption[] = [
|
||||
function useConfigOptions(config: VideoFramesConfig, getT: (key: string) => string): ConfigOption[] {
|
||||
return [
|
||||
{
|
||||
id: "fps",
|
||||
type: "slider",
|
||||
label: "Frame Rate",
|
||||
description: "Number of frames to extract per second",
|
||||
value: defaultConfig.fps,
|
||||
label: getT("config.videoFrames.fps"),
|
||||
description: getT("config.videoFrames.fpsDescription"),
|
||||
value: config.fps,
|
||||
min: 1,
|
||||
max: 60,
|
||||
step: 1,
|
||||
@@ -40,9 +42,9 @@ const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "format",
|
||||
type: "select",
|
||||
label: "Output Format",
|
||||
description: "Image format for the extracted frames",
|
||||
value: defaultConfig.format,
|
||||
label: getT("config.videoFrames.format"),
|
||||
description: getT("config.videoFrames.formatDescription"),
|
||||
value: config.format,
|
||||
options: [
|
||||
{ label: "PNG", value: "png" },
|
||||
{ label: "JPEG", value: "jpeg" },
|
||||
@@ -52,17 +54,27 @@ const configOptions: ConfigOption[] = [
|
||||
{
|
||||
id: "quality",
|
||||
type: "slider",
|
||||
label: "Quality",
|
||||
description: "Image quality (for JPEG and WebP)",
|
||||
value: defaultConfig.quality,
|
||||
label: getT("config.videoFrames.quality"),
|
||||
description: getT("config.videoFrames.qualityDescription"),
|
||||
value: config.quality,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
suffix: "%",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function VideoFramesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getT = (key: string, params?: Record<string, string | number>) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
|
||||
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
|
||||
useUploadStore();
|
||||
|
||||
@@ -85,7 +97,7 @@ export default function VideoFramesPage() {
|
||||
[addFile]
|
||||
);
|
||||
|
||||
const handleConfigChange = (id: string, value: any) => {
|
||||
const handleConfigChange = (id: string, value: string | number | boolean | undefined) => {
|
||||
setConfig((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
@@ -99,7 +111,7 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
message: "Uploading video...",
|
||||
message: getT("processing.uploadingVideo"),
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -109,14 +121,14 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "uploading",
|
||||
progress: i,
|
||||
message: `Uploading... ${i}%`,
|
||||
message: getT("processing.uploadProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: 0,
|
||||
message: "Extracting frames...",
|
||||
message: getT("processing.extractingFrames"),
|
||||
});
|
||||
|
||||
// Simulate processing
|
||||
@@ -125,7 +137,7 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "processing",
|
||||
progress: i,
|
||||
message: `Processing... ${i}%`,
|
||||
message: getT("processing.processProgress", { progress: i }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,24 +161,24 @@ export default function VideoFramesPage() {
|
||||
setProcessingStatus({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
message: "Processing complete!",
|
||||
message: getT("processing.processingComplete"),
|
||||
});
|
||||
} catch (error) {
|
||||
setProcessingStatus({
|
||||
status: "failed",
|
||||
progress: 0,
|
||||
message: "Processing failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: getT("processing.processingFailed"),
|
||||
error: error instanceof Error ? error.message : getT("processing.unknownError"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (fileId: string) => {
|
||||
console.log("Downloading file:", fileId);
|
||||
// Implement download logic
|
||||
};
|
||||
|
||||
const canProcess = files.length > 0 && processingStatus.status !== "processing";
|
||||
const configOptions = useConfigOptions(config, getT);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -181,9 +193,9 @@ export default function VideoFramesPage() {
|
||||
<Video className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Video to Frames</h1>
|
||||
<h1 className="text-3xl font-bold">{getT("tools.videoFrames.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Extract frames from videos with customizable settings
|
||||
{getT("tools.videoFrames.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,8 +215,8 @@ export default function VideoFramesPage() {
|
||||
/>
|
||||
|
||||
<ConfigPanel
|
||||
title="Export Settings"
|
||||
description="Configure how frames are extracted"
|
||||
title={getT("config.videoFrames.title")}
|
||||
description={getT("config.videoFrames.description")}
|
||||
options={configOptions.map((opt) => ({
|
||||
...opt,
|
||||
value: config[opt.id as keyof VideoFramesConfig],
|
||||
@@ -216,7 +228,7 @@ export default function VideoFramesPage() {
|
||||
{canProcess && (
|
||||
<Button onClick={handleProcess} size="lg" className="w-full">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Process Video
|
||||
{getT("tools.videoFrames.processVideo")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,12 +245,11 @@ export default function VideoFramesPage() {
|
||||
|
||||
{/* Info Card */}
|
||||
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
|
||||
<h3 className="mb-3 font-semibold">How it works</h3>
|
||||
<h3 className="mb-3 font-semibold">{getT("tools.videoFrames.howItWorks")}</h3>
|
||||
<ol className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>1. Upload your video file (MP4, MOV, AVI, etc.)</li>
|
||||
<li>2. Configure frame rate, format, and quality</li>
|
||||
<li>3. Click "Process Video" to start extraction</li>
|
||||
<li>4. Download the ZIP file with all frames</li>
|
||||
{(getT("tools.videoFrames.steps") as unknown as string[]).map((step, index) => (
|
||||
<li key={index}>{index + 1}. {step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
72
src/app/api/download/[id]/route.ts
Normal file
72
src/app/api/download/[id]/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sanitizeFilename } from "@/lib/file-storage";
|
||||
import { getProcessedFile } from "@/lib/file-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
// Validate ID format (UUID-like)
|
||||
if (!id || typeof id !== "string") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid download ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize ID to prevent path traversal
|
||||
const sanitizedId = sanitizeFilename(id);
|
||||
if (sanitizedId !== id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid download ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Basic UUID format validation
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(id)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid download ID format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get processed file
|
||||
const fileData = await getProcessedFile(id);
|
||||
|
||||
if (!fileData || fileData.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "File not found or expired" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { buffer, filename, contentType } = fileData[0];
|
||||
|
||||
// Create response with file
|
||||
const response = new NextResponse(buffer as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
|
||||
"Content-Length": buffer.length.toString(),
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Download error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Download failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,137 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFile, readdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import type { ImageCompressConfig } from "@/types";
|
||||
import {
|
||||
saveProcessedFile,
|
||||
cleanupFile,
|
||||
sanitizeFilename,
|
||||
} from "@/lib/file-storage";
|
||||
import {
|
||||
compressImage,
|
||||
getImageMetadata,
|
||||
validateImageBuffer,
|
||||
validateCompressConfig,
|
||||
} from "@/lib/image-processor";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
|
||||
|
||||
interface ProcessRequest {
|
||||
fileId: string;
|
||||
config: ImageCompressConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find file by ID in upload directory
|
||||
*/
|
||||
async function findUploadedFile(fileId: string): Promise<{ buffer: Buffer; name: string } | null> {
|
||||
try {
|
||||
const files = await readdir(UPLOAD_DIR);
|
||||
const file = files.find((f) => f.startsWith(`${fileId}.`));
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract original name from file (remove UUID prefix)
|
||||
const originalName = file.substring(fileId.length + 1);
|
||||
|
||||
return { buffer, name: originalName };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: ProcessRequest = await request.json();
|
||||
const { fileId, config } = body;
|
||||
|
||||
if (!fileId) {
|
||||
// Validate request
|
||||
if (!fileId || typeof fileId !== "string") {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No file ID provided" },
|
||||
{ success: false, error: "Valid file ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize file ID to prevent path traversal
|
||||
const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, "");
|
||||
if (sanitizedId !== fileId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid file ID" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate config
|
||||
if (!config.quality || config.quality < 1 || config.quality > 100) {
|
||||
const configValidation = validateCompressConfig(config);
|
||||
if (!configValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid quality value" },
|
||||
{ success: false, error: configValidation.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// In production, you would:
|
||||
// 1. Retrieve the file from storage
|
||||
// 2. Use Sharp to compress the image
|
||||
// 3. Apply format conversion if needed
|
||||
// 4. Upload to R2/S3
|
||||
// 5. Return download URL
|
||||
// Find uploaded file
|
||||
const uploadedFile = await findUploadedFile(fileId);
|
||||
if (!uploadedFile) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "File not found or expired" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Mock processing for now
|
||||
const resultFileId = `processed-${Date.now()}`;
|
||||
// Get original metadata
|
||||
const originalMetadata = await getImageMetadata(uploadedFile.buffer);
|
||||
|
||||
// Process image
|
||||
const result = await compressImage(uploadedFile.buffer, config);
|
||||
|
||||
// Save processed file
|
||||
const outputFormat = config.format === "original" ? originalMetadata.format : config.format;
|
||||
const downloadInfo = await saveProcessedFile(
|
||||
fileId, // Original file ID for tracking
|
||||
result.buffer,
|
||||
outputFormat,
|
||||
uploadedFile.name
|
||||
);
|
||||
|
||||
// Cleanup original file
|
||||
await cleanupFile(fileId);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fileUrl: `/api/download/${resultFileId}`,
|
||||
filename: `compressed-${resultFileId}`,
|
||||
fileUrl: downloadInfo.fileUrl,
|
||||
filename: downloadInfo.filename,
|
||||
metadata: {
|
||||
format: config.format,
|
||||
format: result.format,
|
||||
quality: config.quality,
|
||||
compressionRatio: Math.floor(Math.random() * 30) + 40, // Mock 40-70%
|
||||
compressionRatio: result.compressionRatio,
|
||||
originalSize: result.originalSize,
|
||||
compressedSize: result.compressedSize,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Processing error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Processing failed" },
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Processing failed",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
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,4 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
saveUploadedFile,
|
||||
validateImageFile,
|
||||
getAllowedImageTypes,
|
||||
getMaxFileSize,
|
||||
} from "@/lib/file-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -14,31 +20,26 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const maxSize = parseInt(process.env.MAX_FILE_SIZE || "52428800"); // 50MB default
|
||||
if (file.size > maxSize) {
|
||||
// Validate file
|
||||
const validation = validateImageFile(file);
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `File size exceeds ${maxSize / 1024 / 1024}MB limit` },
|
||||
{ success: false, error: validation.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate file ID
|
||||
const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// In production, you would:
|
||||
// 1. Save to Cloudflare R2 or S3
|
||||
// 2. Return the actual URL
|
||||
// For now, we'll return a mock response
|
||||
// Save file to temp storage
|
||||
const result = await saveUploadedFile(file);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fileId,
|
||||
fileUrl: `/uploads/${fileId}`,
|
||||
fileId: result.fileId,
|
||||
fileUrl: `/api/file/${result.fileId}`,
|
||||
metadata: {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
name: result.originalName,
|
||||
size: result.size,
|
||||
type: result.type,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -49,3 +50,11 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return allowed file types and max size for client
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
allowedTypes: getAllowedImageTypes(),
|
||||
maxSize: getMaxFileSize(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,8 +106,21 @@
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
|
||||
/* Subtle film grain (Apple-ish texture) */
|
||||
.noise-overlay {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='.35'/%3E%3C/svg%3E");
|
||||
background-size: 180px 180px;
|
||||
}
|
||||
|
||||
/* Gentle top fade used by hero backgrounds */
|
||||
.mask-fade-y {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 18%, black 82%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 18%, black 82%, transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mini Game AI - AI-Powered Tools for Game Developers",
|
||||
description: "Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, and more.",
|
||||
description:
|
||||
"Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, and more.",
|
||||
keywords: ["game development", "AI tools", "video processing", "image compression", "audio processing"],
|
||||
};
|
||||
|
||||
@@ -20,7 +18,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={cn("min-h-screen bg-background font-sans antialiased", inter.variable)}>
|
||||
<body className={cn("min-h-screen bg-background font-sans antialiased")}>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
776
src/app/page.tsx
776
src/app/page.tsx
@@ -1,374 +1,459 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import {
|
||||
ArrowRight,
|
||||
Video,
|
||||
Image,
|
||||
ChevronDown,
|
||||
Image as ImageIcon,
|
||||
Layers,
|
||||
Music,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Video,
|
||||
Zap,
|
||||
Shield,
|
||||
Users,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
|
||||
const features = [
|
||||
type TFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
function useStableT(): TFn {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return useMemo(() => {
|
||||
const serverT = getServerTranslations("en").t;
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
if (!mounted) return serverT(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
}, [mounted, t]);
|
||||
}
|
||||
|
||||
function SectionHeader({ kicker, title, description }: { kicker?: string; title: string; description?: string }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
{kicker ? (
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs font-medium tracking-wide text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>{kicker}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundAuras({ reduceMotion }: { reduceMotion: boolean }) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,hsl(var(--primary)/0.25),transparent_45%),radial-gradient(circle_at_80%_0%,hsl(var(--accent)/0.16),transparent_40%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.07),transparent_55%)]" />
|
||||
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="absolute -top-24 left-1/2 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle_at_40%_30%,rgba(255,255,255,0.18),transparent_55%)] blur-3xl"
|
||||
animate={reduceMotion ? undefined : { y: [0, 26, 0], scale: [1, 1.03, 1] }}
|
||||
transition={reduceMotion ? undefined : { duration: 12, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-40 left-[-10%] h-[520px] w-[520px] rounded-full bg-[radial-gradient(circle_at_50%_50%,hsl(var(--primary)/0.18),transparent_60%)] blur-3xl"
|
||||
animate={reduceMotion ? undefined : { x: [0, 80, 0], y: [0, -30, 0] }}
|
||||
transition={reduceMotion ? undefined : { duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 noise-overlay opacity-[0.09] mix-blend-overlay mask-fade-y" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
<BackgroundAuras reduceMotion={reduceMotion} />
|
||||
|
||||
<div className="container py-20 sm:py-24 md:py-28">
|
||||
<motion.div
|
||||
initial={reduceMotion ? undefined : { opacity: 0, y: 16 }}
|
||||
animate={reduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="mx-auto max-w-4xl text-center"
|
||||
>
|
||||
<div className="mb-4 text-xs font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{t("home.hero.kicker")}
|
||||
</div>
|
||||
|
||||
<h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl md:text-6xl">
|
||||
{t("home.hero.title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
{t("home.hero.description")}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg" className="rounded-full px-7">
|
||||
<Link href="/tools/image-compress">
|
||||
{t("home.hero.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild size="lg" variant="outline" className="rounded-full border-white/10 bg-white/[0.02] px-7 hover:bg-white/[0.04]">
|
||||
<a href="#tools">
|
||||
{t("home.hero.secondaryCta")} <ChevronDown className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs text-muted-foreground">{t("home.hero.note")}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={reduceMotion ? undefined : { opacity: 0, y: 20 }}
|
||||
animate={reduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="mx-auto mt-14 max-w-6xl"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-3xl border border-white/10 bg-white/[0.03] shadow-[0_1px_0_0_rgba(255,255,255,0.06)_inset]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_30%,rgba(255,255,255,0.08),transparent_55%),radial-gradient(circle_at_80%_70%,hsl(var(--primary)/0.14),transparent_55%)]" />
|
||||
<div className="relative p-6 sm:p-8">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-red-500/70" />
|
||||
<span className="h-2 w-2 rounded-full bg-yellow-500/70" />
|
||||
<span className="h-2 w-2 rounded-full bg-green-500/70" />
|
||||
<span className="ml-3">{t("home.hero.previewTitle")}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{
|
||||
icon: Video,
|
||||
title: "Video to Frames",
|
||||
description: "Extract frames from videos with customizable frame rates and formats. Perfect for sprite animations.",
|
||||
title: t("home.tools.videoToFrames.title"),
|
||||
description: t("home.tools.videoToFrames.description"),
|
||||
href: "/tools/video-frames",
|
||||
tint: "from-sky-500/20",
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: "Image Compression",
|
||||
description: "Optimize images for web and mobile without quality loss. Support for batch processing.",
|
||||
icon: ImageIcon,
|
||||
title: t("home.tools.imageCompression.title"),
|
||||
description: t("home.tools.imageCompression.description"),
|
||||
href: "/tools/image-compress",
|
||||
tint: "from-violet-500/20",
|
||||
},
|
||||
{
|
||||
icon: Music,
|
||||
title: "Audio Compression",
|
||||
description: "Compress and convert audio files to various formats. Adjust bitrate and sample rate.",
|
||||
title: t("home.tools.audioCompression.title"),
|
||||
description: t("home.tools.audioCompression.description"),
|
||||
href: "/tools/audio-compress",
|
||||
tint: "from-emerald-500/20",
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
title: t("home.tools.textureAtlas.title"),
|
||||
description: t("home.tools.textureAtlas.description"),
|
||||
href: "/tools/texture-atlas",
|
||||
tint: "from-orange-500/20",
|
||||
},
|
||||
].map((tool) => (
|
||||
<motion.div
|
||||
key={tool.href}
|
||||
whileHover={reduceMotion ? undefined : { y: -6 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 18 }}
|
||||
>
|
||||
<Link
|
||||
href={tool.href}
|
||||
className="group block h-full rounded-2xl border border-white/10 bg-black/20 p-5 backdrop-blur-xl transition-colors hover:bg-black/25"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -inset-5 rounded-3xl bg-gradient-to-br opacity-0 blur-2xl transition-opacity duration-500 group-hover:opacity-100",
|
||||
tool.tint
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex items-start gap-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-white/[0.06] text-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
|
||||
<tool.icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold tracking-tight">{tool.title}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{t("common.tryNow")}</span>
|
||||
<ArrowRight className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-3 gap-6 text-center">
|
||||
{[
|
||||
{ value: "10K+", label: t("home.hero.stats.developers") },
|
||||
{ value: "1M+", label: t("home.hero.stats.filesProcessed") },
|
||||
{ value: "99.9%", label: t("home.hero.stats.uptime") },
|
||||
].map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="text-xl font-semibold tracking-tight sm:text-2xl">{item.value}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsShowcase({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
||||
const items = [
|
||||
{
|
||||
icon: Video,
|
||||
title: t("home.tools.videoToFrames.title"),
|
||||
description: t("home.tools.videoToFrames.description"),
|
||||
href: "/tools/video-frames",
|
||||
gradient: "from-sky-500/20 via-white/[0.03] to-transparent",
|
||||
},
|
||||
{
|
||||
icon: ImageIcon,
|
||||
title: t("home.tools.imageCompression.title"),
|
||||
description: t("home.tools.imageCompression.description"),
|
||||
href: "/tools/image-compress",
|
||||
gradient: "from-violet-500/20 via-white/[0.03] to-transparent",
|
||||
},
|
||||
{
|
||||
icon: Music,
|
||||
title: t("home.tools.audioCompression.title"),
|
||||
description: t("home.tools.audioCompression.description"),
|
||||
href: "/tools/audio-compress",
|
||||
gradient: "from-emerald-500/20 via-white/[0.03] to-transparent",
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
title: t("home.tools.textureAtlas.title"),
|
||||
description: t("home.tools.textureAtlas.description"),
|
||||
href: "/tools/texture-atlas",
|
||||
gradient: "from-orange-500/20 via-white/[0.03] to-transparent",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="tools" className="border-t border-white/5 py-20 sm:py-24">
|
||||
<div className="container">
|
||||
<SectionHeader
|
||||
kicker={t("home.showcase.kicker")}
|
||||
title={t("home.showcase.title")}
|
||||
description={t("home.showcase.description")}
|
||||
/>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
initial={reduceMotion ? undefined : { opacity: 0, y: 18 }}
|
||||
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-120px" }}
|
||||
transition={{ duration: 0.6, delay: index * 0.08, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Link href={item.href} className="group block h-full">
|
||||
<Card className="relative h-full overflow-hidden rounded-3xl border-white/10 bg-white/[0.02] p-6 transition-colors hover:bg-white/[0.03]">
|
||||
<div className={cn("absolute inset-0 bg-gradient-to-b", item.gradient)} />
|
||||
<div className="absolute inset-0 noise-overlay opacity-[0.06] mix-blend-overlay" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-2xl bg-white/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
|
||||
<item.icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-muted-foreground">
|
||||
{t("common.tryNow")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-6 text-lg font-semibold tracking-tight">{item.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.description}</p>
|
||||
|
||||
<div className="mt-6 inline-flex items-center text-sm font-medium">
|
||||
{t("home.showcase.cta")}
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform duration-300 group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Workflow({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
||||
const steps = [
|
||||
{
|
||||
k: "01",
|
||||
title: t("home.workflow.steps.step1.title"),
|
||||
description: t("home.workflow.steps.step1.description"),
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
k: "02",
|
||||
title: t("home.workflow.steps.step2.title"),
|
||||
description: t("home.workflow.steps.step2.description"),
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
k: "03",
|
||||
title: t("home.workflow.steps.step3.title"),
|
||||
description: t("home.workflow.steps.step3.description"),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-20 sm:py-24">
|
||||
<div className="container">
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:items-start">
|
||||
<div>
|
||||
<SectionHeader title={t("home.workflow.title")} description={t("home.workflow.description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{steps.map((s, idx) => (
|
||||
<motion.div
|
||||
key={s.k}
|
||||
initial={reduceMotion ? undefined : { opacity: 0, x: 14 }}
|
||||
whileInView={reduceMotion ? undefined : { opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-120px" }}
|
||||
transition={{ duration: 0.55, delay: idx * 0.06, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="group relative overflow-hidden rounded-3xl border border-white/10 bg-white/[0.02] p-6">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_30%,rgba(255,255,255,0.06),transparent_55%)] opacity-70" />
|
||||
<div className="absolute inset-0 noise-overlay opacity-[0.06] mix-blend-overlay" />
|
||||
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 flex-col items-center justify-center rounded-2xl bg-white/[0.06] text-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
|
||||
<div className="text-xs font-semibold tracking-[0.22em] text-muted-foreground">{s.k}</div>
|
||||
<s.icon className="mt-1 h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold tracking-tight">{s.title}</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-muted-foreground">{s.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Quality({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
||||
const items = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: t("home.quality.items.fast.title"),
|
||||
description: t("home.quality.items.fast.description"),
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: t("home.quality.items.private.title"),
|
||||
description: t("home.quality.items.private.description"),
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "AI-Powered Tools",
|
||||
description: "Enhance your assets with AI. Upscale images, remove backgrounds, and more.",
|
||||
href: "/tools/ai-tools",
|
||||
title: t("home.quality.items.designed.title"),
|
||||
description: t("home.quality.items.designed.description"),
|
||||
},
|
||||
];
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Lightning Fast",
|
||||
description: "Process files in seconds with our optimized infrastructure.",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Secure & Private",
|
||||
description: "Your files are encrypted and automatically deleted after processing.",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Built for Developers",
|
||||
description: "API access, batch processing, and tools designed for game development workflows.",
|
||||
},
|
||||
];
|
||||
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
description: "Perfect for trying out",
|
||||
features: [
|
||||
"10 processes per day",
|
||||
"50MB max file size",
|
||||
"Basic tools",
|
||||
"Community support",
|
||||
],
|
||||
cta: "Get Started",
|
||||
href: "/register",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$19",
|
||||
period: "/month",
|
||||
description: "For serious developers",
|
||||
features: [
|
||||
"Unlimited processes",
|
||||
"500MB max file size",
|
||||
"All tools including AI",
|
||||
"Priority support",
|
||||
"API access",
|
||||
],
|
||||
cta: "Start Free Trial",
|
||||
href: "/pricing",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
description: "For teams and businesses",
|
||||
features: [
|
||||
"Everything in Pro",
|
||||
"Unlimited file size",
|
||||
"Custom integrations",
|
||||
"Dedicated support",
|
||||
"SLA guarantee",
|
||||
],
|
||||
cta: "Contact Sales",
|
||||
href: "/contact",
|
||||
},
|
||||
];
|
||||
|
||||
function HeroSection() {
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-background to-background" />
|
||||
<div className="absolute inset-0 bg-[url(/grid.svg)] bg-cover opacity-10" />
|
||||
</div>
|
||||
<section className="border-t border-white/5 py-20 sm:py-24">
|
||||
<div className="container">
|
||||
<SectionHeader title={t("home.quality.title")} description={t("home.quality.description")} />
|
||||
|
||||
<div className="container py-24 md:py-32 xl:py-40 2xl:py-48">
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
{items.map((item, idx) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mx-auto max-w-5xl text-center 2xl:max-w-6xl 3xl:max-w-7xl"
|
||||
key={item.title}
|
||||
initial={reduceMotion ? undefined : { opacity: 0, y: 14 }}
|
||||
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-120px" }}
|
||||
transition={{ duration: 0.55, delay: idx * 0.06, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<Badge className="mb-4" variant="secondary">
|
||||
<Sparkles className="mr-1 h-3 w-3" />
|
||||
AI-Powered Tools
|
||||
</Badge>
|
||||
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl 2xl:text-9xl">
|
||||
Build Games{" "}
|
||||
<span className="bg-gradient-to-r from-purple-400 via-pink-500 to-blue-500 bg-clip-text text-transparent">
|
||||
Faster
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Transform your game development workflow with powerful AI tools. Video to frames,
|
||||
image compression, audio processing, and more.
|
||||
<Card className="h-full rounded-3xl border-white/10 bg-white/[0.02] p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-white/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.08)]">
|
||||
<item.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold tracking-tight">{item.title}</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FinalCTA({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
|
||||
return (
|
||||
<section className="py-20 sm:py-24">
|
||||
<div className="container">
|
||||
<motion.div
|
||||
initial={reduceMotion ? undefined : { opacity: 0, y: 16 }}
|
||||
whileInView={reduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-120px" }}
|
||||
transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative overflow-hidden rounded-[2rem] border border-white/10 bg-white/[0.03] p-10 sm:p-12"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,hsl(var(--primary)/0.22),transparent_55%),radial-gradient(circle_at_80%_70%,rgba(255,255,255,0.08),transparent_55%)]" />
|
||||
<div className="absolute inset-0 noise-overlay opacity-[0.09] mix-blend-overlay" />
|
||||
|
||||
<div className="relative mx-auto max-w-2xl text-center">
|
||||
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
{t("home.final.title")}
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-relaxed text-muted-foreground sm:text-lg">
|
||||
{t("home.final.description")}
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
|
||||
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
|
||||
<Link href="/tools">
|
||||
Start Building <ArrowRight className="ml-2 h-4 w-4" />
|
||||
|
||||
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg" className="rounded-full px-7">
|
||||
<Link href="/tools/image-compress">
|
||||
{t("home.final.primaryCta")} <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
|
||||
<Link href="/pricing">View Pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-16 grid grid-cols-3 gap-8 md:gap-16 xl:gap-24 2xl:gap-32"
|
||||
>
|
||||
<div>
|
||||
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">10K+</div>
|
||||
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Developers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">1M+</div>
|
||||
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Files Processed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">99.9%</div>
|
||||
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Uptime</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesSection() {
|
||||
return (
|
||||
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-16 text-center xl:mb-20 2xl:mb-24"
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
|
||||
Everything You Need
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Powerful tools designed specifically for game developers
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 xl:gap-8 2xl:gap-10">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={feature.href}>
|
||||
<Card className="h-full transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 xl:p-8 2xl:p-10">
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 xl:h-14 xl:w-14 2xl:h-16 2xl:w-16">
|
||||
<feature.icon className="h-6 w-6 text-primary xl:h-7 xl:w-7 2xl:h-8 2xl:w-8" />
|
||||
</div>
|
||||
<CardTitle className="text-xl xl:text-2xl 2xl:text-3xl">{feature.title}</CardTitle>
|
||||
<CardDescription className="text-base xl:text-lg 2xl:text-xl">{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg">
|
||||
Try it now <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitsSection() {
|
||||
return (
|
||||
<section className="py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
<div className="grid gap-12 lg:grid-cols-2 xl:gap-16 2xl:gap-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
|
||||
Why Choose Mini Game AI?
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
We understand the unique challenges of game development. Our tools are built to help
|
||||
you work faster and smarter.
|
||||
</p>
|
||||
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
|
||||
<Link href="/about">Learn More About Us</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-6 xl:space-y-8">
|
||||
{benefits.map((benefit, index) => (
|
||||
<motion.div
|
||||
key={benefit.title}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="xl:p-8 2xl:p-10">
|
||||
<CardContent className="flex gap-4 p-6 xl:gap-6 2xl:gap-8">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 xl:h-14 xl:w-14 2xl:h-16 2xl:w-16">
|
||||
<benefit.icon className="h-6 w-6 text-primary xl:h-7 xl:w-7 2xl:h-8 2xl:w-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold xl:text-xl 2xl:text-2xl">{benefit.title}</h3>
|
||||
<p className="text-muted-foreground text-sm md:text-base xl:text-lg 2xl:text-xl">{benefit.description}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingSection() {
|
||||
return (
|
||||
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-16 text-center xl:mb-20 2xl:mb-24"
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
|
||||
Simple, Transparent Pricing
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Start free, scale as you grow. No hidden fees.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3 xl:gap-10 2xl:gap-12">
|
||||
{pricingPlans.map((plan, index) => (
|
||||
<motion.div
|
||||
key={plan.name}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Card className={`relative h-full ${plan.popular ? "border-primary" : ""} xl:p-8 2xl:p-10`}>
|
||||
{plan.popular && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 xl:text-base 2xl:text-lg">
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl xl:text-2xl 2xl:text-3xl">{plan.name}</CardTitle>
|
||||
<CardDescription className="text-base xl:text-lg 2xl:text-xl">{plan.description}</CardDescription>
|
||||
<div className="mt-4">
|
||||
<span className="text-4xl font-bold xl:text-5xl 2xl:text-6xl">{plan.price}</span>
|
||||
{plan.period && (
|
||||
<span className="text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{plan.period}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 shrink-0 text-primary xl:h-6 xl:w-6 2xl:h-7 2xl:w-7" />
|
||||
<span className="text-sm md:text-base xl:text-lg 2xl:text-xl">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button className="w-full xl:text-lg xl:py-6 2xl:text-xl 2xl:py-7" variant={plan.popular ? "default" : "outline"} asChild>
|
||||
<Link href={plan.href}>{plan.cta}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CTASection() {
|
||||
return (
|
||||
<section className="py-24 xl:py-32 2xl:py-40">
|
||||
<div className="container">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary/20 via-primary/10 to-background p-12 text-center md:p-20 xl:p-24 2xl:p-32"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
|
||||
Ready to Level Up?
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
|
||||
Join thousands of game developers building amazing games with our tools.
|
||||
</p>
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
|
||||
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
|
||||
<Link href="/register">Get Started for Free</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
|
||||
<Link href="/contact">Contact Sales</Link>
|
||||
<Button asChild size="lg" variant="outline" className="rounded-full border-white/10 bg-white/[0.02] px-7 hover:bg-white/[0.04]">
|
||||
<Link href="/tools/video-frames">{t("home.final.secondaryCta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,13 +464,16 @@ function CTASection() {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useStableT();
|
||||
const reduceMotion = useReducedMotion() ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<BenefitsSection />
|
||||
<PricingSection />
|
||||
<CTASection />
|
||||
</>
|
||||
<div className="relative">
|
||||
<Hero t={t} reduceMotion={reduceMotion} />
|
||||
<ToolsShowcase t={t} reduceMotion={reduceMotion} />
|
||||
<Workflow t={t} reduceMotion={reduceMotion} />
|
||||
<Quality t={t} reduceMotion={reduceMotion} />
|
||||
<FinalCTA t={t} reduceMotion={reduceMotion} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles, Github, Twitter } from "lucide-react";
|
||||
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ name: "Features", href: "/features" },
|
||||
{ name: "Pricing", href: "/pricing" },
|
||||
{ name: "API", href: "/api" },
|
||||
{ name: "Documentation", href: "/docs" },
|
||||
],
|
||||
tools: [
|
||||
{ name: "Video to Frames", href: "/tools/video-frames" },
|
||||
{ name: "Image Compression", href: "/tools/image-compress" },
|
||||
{ name: "Audio Compression", href: "/tools/audio-compress" },
|
||||
{ name: "AI Tools", href: "/tools/ai-tools" },
|
||||
],
|
||||
company: [
|
||||
{ name: "About", href: "/about" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
{ name: "Careers", href: "/careers" },
|
||||
{ name: "Contact", href: "/contact" },
|
||||
],
|
||||
legal: [
|
||||
{ name: "Privacy", href: "/privacy" },
|
||||
{ name: "Terms", href: "/terms" },
|
||||
{ name: "Cookie Policy", href: "/cookies" },
|
||||
],
|
||||
};
|
||||
|
||||
const socialLinks = [
|
||||
{ name: "Twitter", icon: Twitter, href: "https://twitter.com" },
|
||||
{ name: "GitHub", icon: Github, href: "https://github.com" },
|
||||
];
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border/40 bg-background/50">
|
||||
<div className="container py-12 md:py-16">
|
||||
<div className="grid grid-cols-2 gap-8 md:grid-cols-6">
|
||||
{/* Brand */}
|
||||
<div className="col-span-2">
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Sparkles className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-xl font-bold">Mini Game AI</span>
|
||||
</Link>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
AI-powered tools for mini game developers. Process media files with ease.
|
||||
</p>
|
||||
</div>
|
||||
const pathname = usePathname();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
{/* Product */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Product</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.product.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
{/* Tools */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Tools</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.tools.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
const getT = (key: string, params?: Record<string, string | number>) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Company</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.company.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Legal</h3>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{footerLinks.legal.map((link) => (
|
||||
<li key={link.name}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="mt-12 flex flex-col items-center justify-between border-t border-border/40 pt-8 md:flex-row">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} Mini Game AI. All rights reserved.
|
||||
</p>
|
||||
<div className="mt-4 flex space-x-6 md:mt-0">
|
||||
{socialLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={link.name}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</Link>
|
||||
const toolLinks = useMemo(
|
||||
() => [
|
||||
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
||||
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress" },
|
||||
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[mounted]
|
||||
);
|
||||
})}
|
||||
|
||||
// Check if we're in the dashboard area (tools pages)
|
||||
const isDashboard = pathname?.startsWith("/tools");
|
||||
|
||||
return (
|
||||
<footer className={cn(
|
||||
"border-t border-white/5 bg-background/50",
|
||||
isDashboard && "lg:ml-64"
|
||||
)}>
|
||||
<div className="container py-12">
|
||||
<div className="grid gap-10 md:grid-cols-[1.5fr_1fr]">
|
||||
<div>
|
||||
<Link href="/" className="inline-flex items-center gap-2">
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary text-primary-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.1)]">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight">{getT("common.appName")}</span>
|
||||
</Link>
|
||||
|
||||
<p className="mt-4 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||||
{getT("footer.tagline")}
|
||||
</p>
|
||||
|
||||
<p className="mt-6 text-xs text-muted-foreground">
|
||||
{getT("footer.note")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{getT("footer.sections.tools")}
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
{toolLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="text-muted-foreground hover:text-foreground">
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{getT("footer.sections.company")}
|
||||
</div>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>
|
||||
<Link href="/" className="text-muted-foreground hover:text-foreground">
|
||||
{getT("footer.links.home")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-white/5 pt-6">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} {getT("common.appName")}. {getT("footer.copyright")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://beian.miit.gov.cn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
粤ICP备2021179543号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,104 +2,136 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Sparkles } from "lucide-react";
|
||||
import { ArrowRight, Menu, X, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ name: "Tools", href: "/tools" },
|
||||
{ name: "Pricing", href: "/pricing" },
|
||||
{ name: "Docs", href: "/docs" },
|
||||
{ name: "About", href: "/about" },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Auto detect language on first mount if not manually set
|
||||
const stored = localStorage.getItem("locale-storage");
|
||||
if (!stored) {
|
||||
const lang = navigator.language.toLowerCase();
|
||||
if (lang.includes("zh")) setLocale("zh");
|
||||
}
|
||||
}, [setLocale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale, mounted]);
|
||||
|
||||
// Prevent hydration mismatch by rendering a stable version initially
|
||||
const displayT = (key: string, params?: Record<string, string | number>) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
|
||||
const navItems = useMemo(
|
||||
() => [
|
||||
{ name: displayT("sidebar.videoToFrames"), href: "/tools/video-frames" },
|
||||
{ name: displayT("sidebar.imageCompression"), href: "/tools/image-compress" },
|
||||
{ name: displayT("sidebar.audioCompression"), href: "/tools/audio-compress" },
|
||||
{ name: displayT("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[mounted, locale]
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-white/5 bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/50">
|
||||
<nav className="container flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Sparkles className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-xl font-bold">Mini Game AI</span>
|
||||
<Link href="/" className="group flex items-center gap-2">
|
||||
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-primary text-primary-foreground shadow-[0_0_0_1px_rgba(255,255,255,0.1)]">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<span className="pointer-events-none absolute inset-0 rounded-xl opacity-0 transition-opacity duration-500 group-hover:opacity-100 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.35),transparent_60%)]" />
|
||||
</span>
|
||||
<span className="text-base font-semibold tracking-tight">{displayT("common.appName")}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex md:items-center md:space-x-6">
|
||||
{navItems.map((item) => (
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href || pathname?.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary",
|
||||
pathname === item.href ? "text-primary" : "text-muted-foreground"
|
||||
"rounded-full px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-white/8 text-foreground"
|
||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="hidden md:flex md:items-center md:space-x-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/register">Get Started</Link>
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{mounted && <LanguageSwitcher />}
|
||||
<Button asChild size="sm" className="rounded-full px-4">
|
||||
<Link href="/tools/image-compress">
|
||||
{displayT("common.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden"
|
||||
className="md:hidden rounded-full p-2 hover:bg-white/5"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={displayT("common.toggleMenu")}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
{isMobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="md:hidden border-t border-border/40"
|
||||
transition={{ duration: 0.18 }}
|
||||
className="md:hidden border-t border-white/5"
|
||||
>
|
||||
<div className="container space-y-4 py-6">
|
||||
<div className="container py-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
"block text-sm font-medium transition-colors hover:text-primary",
|
||||
pathname === item.href ? "text-primary" : "text-muted-foreground"
|
||||
"rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-white/8 text-foreground"
|
||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col space-y-2 pt-4">
|
||||
<Button variant="ghost" size="sm" asChild className="w-full">
|
||||
<Link href="/login">Sign In</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild className="w-full">
|
||||
<Link href="/register">Get Started</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
{mounted && <LanguageSwitcher />}
|
||||
<Button asChild size="sm" className="rounded-full px-4">
|
||||
<Link href="/tools/image-compress" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
{displayT("common.startBuilding")} <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
49
src/components/layout/LanguageSwitcher.tsx
Normal file
49
src/components/layout/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation, type Locale } from "@/lib/i18n";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale, locales } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleLocaleChange = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">{locales[locale].name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[120px]">
|
||||
{Object.entries(locales).map(([key, { name }]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleLocaleChange(key as Locale)}
|
||||
className="flex cursor-pointer items-center justify-between gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
{locale === key && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -7,43 +7,41 @@ import {
|
||||
Video,
|
||||
Image,
|
||||
Music,
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
CreditCard,
|
||||
Settings,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation, getServerTranslations } from "@/lib/i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const sidebarNavItems = [
|
||||
function useSidebarNavItems() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getT = (key: string) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key);
|
||||
return t(key);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Dashboard",
|
||||
title: getT("nav.dashboard"),
|
||||
items: [
|
||||
{ name: "Overview", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: getT("nav.overview"), href: "/", icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tools",
|
||||
title: getT("sidebar.tools"),
|
||||
items: [
|
||||
{ name: "Video to Frames", href: "/tools/video-frames", icon: Video },
|
||||
{ name: "Image Compression", href: "/tools/image-compress", icon: Image },
|
||||
{ name: "Audio Compression", href: "/tools/audio-compress", icon: Music },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI Tools",
|
||||
items: [
|
||||
{ name: "AI Image", href: "/tools/ai-image", icon: Sparkles },
|
||||
{ name: "AI Audio", href: "/tools/ai-audio", icon: Sparkles },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
items: [
|
||||
{ name: "Pricing", href: "/pricing", icon: CreditCard },
|
||||
{ name: "Settings", href: "/settings", icon: Settings },
|
||||
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
|
||||
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
|
||||
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
|
||||
{ name: getT("sidebar.textureAtlas"), href: "/tools/texture-atlas", icon: Layers },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string;
|
||||
@@ -51,6 +49,7 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const sidebarNavItems = useSidebarNavItems();
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -61,7 +60,9 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
>
|
||||
<div className="h-full overflow-y-auto py-6 pr-4">
|
||||
<nav className="space-y-8 px-4">
|
||||
{sidebarNavItems.map((section) => (
|
||||
{sidebarNavItems
|
||||
.filter((section) => section.items.length > 0)
|
||||
.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h3 className="mb-4 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
|
||||
@@ -6,14 +6,17 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSafeTranslation } from "@/lib/i18n";
|
||||
|
||||
export type ConfigValue = string | number | boolean | undefined;
|
||||
|
||||
export interface ConfigOption {
|
||||
id: string;
|
||||
type: "slider" | "select" | "toggle" | "radio";
|
||||
label: string;
|
||||
description?: string;
|
||||
value: any;
|
||||
options?: { label: string; value: any }[];
|
||||
value: ConfigValue;
|
||||
options?: { label: string; value: ConfigValue }[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
@@ -25,7 +28,7 @@ interface ConfigPanelProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
options: ConfigOption[];
|
||||
onChange: (id: string, value: any) => void;
|
||||
onChange: (id: string, value: ConfigValue) => void;
|
||||
onReset?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -38,6 +41,8 @@ export function ConfigPanel({
|
||||
onReset,
|
||||
className,
|
||||
}: ConfigPanelProps) {
|
||||
const { t } = useSafeTranslation();
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
@@ -50,7 +55,7 @@ export function ConfigPanel({
|
||||
</div>
|
||||
{onReset && (
|
||||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||
Reset
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -83,7 +88,7 @@ export function ConfigPanel({
|
||||
min={option.min ?? 0}
|
||||
max={option.max ?? 100}
|
||||
step={option.step ?? 1}
|
||||
value={[option.value]}
|
||||
value={[typeof option.value === "number" ? option.value : 0]}
|
||||
onValueChange={(values: number[]) => onChange(option.id, values[0])}
|
||||
className="mt-2"
|
||||
/>
|
||||
@@ -93,7 +98,7 @@ export function ConfigPanel({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{option.options.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
key={String(opt.value)}
|
||||
variant={option.value === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onChange(option.id, opt.value)}
|
||||
@@ -108,7 +113,7 @@ export function ConfigPanel({
|
||||
<div className="space-y-2">
|
||||
{option.options.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
key={String(opt.value)}
|
||||
className={cn(
|
||||
"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"
|
||||
@@ -117,7 +122,7 @@ export function ConfigPanel({
|
||||
<input
|
||||
type="radio"
|
||||
name={option.id}
|
||||
value={opt.value}
|
||||
value={String(opt.value)}
|
||||
checked={option.value === opt.value}
|
||||
onChange={() => onChange(option.id, opt.value)}
|
||||
className="h-4 w-4"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatFileSize, getFileExtension } from "@/lib/utils";
|
||||
import { useSafeTranslation } from "@/lib/i18n";
|
||||
import type { UploadedFile } from "@/types";
|
||||
|
||||
interface FileUploaderProps {
|
||||
@@ -35,6 +36,8 @@ export function FileUploader({
|
||||
maxFiles = 10,
|
||||
disabled = false,
|
||||
}: FileUploaderProps) {
|
||||
const { t, plural } = useSafeTranslation();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (disabled) return;
|
||||
@@ -85,14 +88,17 @@ export function FileUploader({
|
||||
<div className="mt-4">
|
||||
<p className="text-lg font-medium">
|
||||
{isDragActive
|
||||
? "Drop your files here"
|
||||
? t("uploader.dropActive")
|
||||
: isDragReject
|
||||
? "File type not accepted"
|
||||
: "Drag & drop files here"}
|
||||
? t("uploader.fileRejected")
|
||||
: t("uploader.dropFiles")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
or click to browse • Max {formatFileSize(maxSize)} • Up to {maxFiles} file
|
||||
{maxFiles > 1 ? "s" : ""}
|
||||
{t("uploader.browseFiles", {
|
||||
maxSize: formatFileSize(maxSize),
|
||||
maxFiles,
|
||||
file: plural("uploader.file", maxFiles),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
203
src/components/tools/ImageCompareSlider.tsx
Normal file
203
src/components/tools/ImageCompareSlider.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface ImageCompareSliderProps {
|
||||
originalSrc: string;
|
||||
compressedSrc: string;
|
||||
originalSize?: number;
|
||||
compressedSize?: number;
|
||||
className?: string;
|
||||
// Translation texts
|
||||
texts: {
|
||||
original: string;
|
||||
compressed: string;
|
||||
dragHint: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageCompareSlider({
|
||||
originalSrc,
|
||||
compressedSrc,
|
||||
originalSize,
|
||||
compressedSize,
|
||||
className,
|
||||
texts,
|
||||
}: ImageCompareSliderProps) {
|
||||
const [sliderPosition, setSliderPosition] = useState(50);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateSliderPosition = useCallback((clientX: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setSliderPosition(percentage);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
updateSliderPosition(e.clientX);
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
updateSliderPosition(e.clientX);
|
||||
}
|
||||
},
|
||||
[isDragging, updateSliderPosition]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setIsDragging(true);
|
||||
updateSliderPosition(e.touches[0].clientX);
|
||||
};
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
updateSliderPosition(e.touches[0].clientX);
|
||||
}
|
||||
},
|
||||
[isDragging, updateSliderPosition]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-lg bg-muted/50 select-none",
|
||||
isDragging && "cursor-col-resize",
|
||||
className
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
>
|
||||
{/* Original Image (Background - Left Side) */}
|
||||
<div className="relative w-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={originalSrc}
|
||||
alt={texts.original}
|
||||
className="w-full h-auto object-contain max-h-[60vh] block"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compressed Image (Foreground - Right Side with Clipping) */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden pointer-events-none"
|
||||
style={{ clipPath: `inset(0 0 0 ${sliderPosition}%)` }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={compressedSrc}
|
||||
alt={texts.compressed}
|
||||
className="w-full h-full object-contain max-h-[60vh]"
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider Handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-white cursor-col-resize pointer-events-none"
|
||||
style={{ left: `${sliderPosition}%` }}
|
||||
>
|
||||
{/* Handle Button */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center pointer-events-auto">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 2L8 6L4 10"
|
||||
stroke="#1a1a1a"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 2L4 6L8 10"
|
||||
stroke="#1a1a1a"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-black/60 backdrop-blur-sm text-white text-xs font-medium">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{texts.original}</span>
|
||||
{originalSize && <span className="text-white/70">({formatFileSize(originalSize)})</span>}
|
||||
</div>
|
||||
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-primary/80 backdrop-blur-sm text-primary-foreground text-xs font-medium">
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
<span>{texts.compressed}</span>
|
||||
{compressedSize && (
|
||||
<span className="text-primary-foreground/80">({formatFileSize(compressedSize)})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur-sm text-white/70 text-xs">
|
||||
{texts.dragHint}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSafeTranslation } from "@/lib/i18n";
|
||||
import type { ProcessingProgress } from "@/types";
|
||||
|
||||
interface ProgressBarProps {
|
||||
@@ -29,6 +30,7 @@ const statusColors = {
|
||||
};
|
||||
|
||||
export function ProgressBar({ progress, className }: ProgressBarProps) {
|
||||
const { t } = useSafeTranslation();
|
||||
const { status, progress: value, message, error } = progress;
|
||||
const showProgress = status === "uploading" || status === "processing";
|
||||
const Icon = statusIcons[status];
|
||||
@@ -46,11 +48,11 @@ export function ProgressBar({ progress, className }: ProgressBarProps) {
|
||||
statusColors[status]
|
||||
)}
|
||||
>
|
||||
{status === "idle" && "Ready to process"}
|
||||
{status === "uploading" && "Uploading..."}
|
||||
{status === "processing" && "Processing..."}
|
||||
{status === "completed" && "Completed!"}
|
||||
{status === "failed" && "Failed"}
|
||||
{status === "idle" && t("progress.status.idle")}
|
||||
{status === "uploading" && t("progress.status.uploading")}
|
||||
{status === "processing" && t("progress.status.processing")}
|
||||
{status === "completed" && t("progress.status.completed")}
|
||||
{status === "failed" && t("progress.status.failed")}
|
||||
</p>
|
||||
{showProgress && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Download, Share2, File, Image as ImageIcon, Video, Music } from "lucide-react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Download, Share2, File, Image as ImageIcon, Video, Music, Eye } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ImageCompareSlider } from "@/components/tools/ImageCompareSlider";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { useSafeTranslation } from "@/lib/i18n";
|
||||
import type { ProcessedFile } from "@/types";
|
||||
|
||||
interface ResultPreviewProps {
|
||||
@@ -21,6 +30,43 @@ export function ResultPreview({
|
||||
onShare,
|
||||
className,
|
||||
}: ResultPreviewProps) {
|
||||
const { t, plural } = useSafeTranslation();
|
||||
const [previewFile, setPreviewFile] = useState<ProcessedFile | null>(null);
|
||||
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null);
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Cleanup object URL when preview closes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePreview = useCallback((file: ProcessedFile) => {
|
||||
// Create object URL for original image
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
}
|
||||
const url = URL.createObjectURL(file.originalFile.file);
|
||||
objectUrlRef.current = url;
|
||||
setOriginalImageUrl(url);
|
||||
setPreviewFile(file);
|
||||
}, []);
|
||||
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewFile(null);
|
||||
// Small delay to allow dialog close animation before revoking
|
||||
setTimeout(() => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
setOriginalImageUrl(null);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
@@ -32,35 +78,50 @@ export function ResultPreview({
|
||||
|
||||
const getMetadataBadge = (file: ProcessedFile) => {
|
||||
const metadata = file.metadata;
|
||||
const badges = [];
|
||||
const badges: Array<{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = [];
|
||||
|
||||
if (metadata.compressionRatio) {
|
||||
badges.push({
|
||||
label: `Saved ${metadata.compressionRatio}%`,
|
||||
variant: "default" as const,
|
||||
label: t("results.saved", { ratio: metadata.compressionRatio }),
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
|
||||
if (metadata.format) {
|
||||
badges.push({
|
||||
label: metadata.format.toUpperCase(),
|
||||
variant: "secondary" as const,
|
||||
variant: "secondary",
|
||||
});
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const isImageFile = (file: ProcessedFile) => {
|
||||
return file.originalFile.file.type.startsWith("image/");
|
||||
};
|
||||
|
||||
// Translation texts for ImageCompareSlider
|
||||
const previewTexts = {
|
||||
original: t("preview.original"),
|
||||
compressed: t("preview.compressed"),
|
||||
dragHint: t("preview.dragHint"),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">Processing Complete</h3>
|
||||
<h3 className="text-lg font-semibold">{t("results.processingComplete")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{results.length} file{results.length > 1 ? "s" : ""} ready for download
|
||||
{t("results.filesReady", {
|
||||
count: results.length,
|
||||
file: plural("results.file", results.length),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +129,7 @@ export function ResultPreview({
|
||||
{results.map((result, index) => {
|
||||
const Icon = getFileIcon(result.originalFile.file.type);
|
||||
const badges = getMetadataBadge(result);
|
||||
const showPreview = isImageFile(result);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -76,20 +138,52 @@ export function ResultPreview({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<Card className="group overflow-hidden">
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Thumbnail preview for images */}
|
||||
{showPreview && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={result.processedUrl}
|
||||
alt={result.originalFile.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<Eye className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!showPreview && (
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{result.originalFile.name}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{result.metadata.originalSize && result.metadata.compressedSize ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{formatFileSize(result.metadata.originalSize)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
{formatFileSize(result.metadata.compressedSize)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(result.originalFile.size)}
|
||||
</span>
|
||||
)}
|
||||
{result.metadata.resolution && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
@@ -98,6 +192,14 @@ export function ResultPreview({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{result.metadata.quality && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Q: {result.metadata.quality}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{badges.length > 0 && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
@@ -111,11 +213,22 @@ export function ResultPreview({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{showPreview && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handlePreview(result)}
|
||||
title={t("preview.title")}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onDownload(result.id)}
|
||||
title="Download"
|
||||
title={t("common.download")}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -124,7 +237,7 @@ export function ResultPreview({
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onShare(result.id)}
|
||||
title="Share"
|
||||
title={t("common.share")}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -137,5 +250,38 @@ export function ResultPreview({
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<AnimatePresence>
|
||||
{previewFile && originalImageUrl && (
|
||||
<Dialog open={!!previewFile} onOpenChange={handleClosePreview}>
|
||||
<DialogContent className="p-0 gap-0 max-w-5xl w-[95vw] bg-background/95 backdrop-blur-xl border-border/40">
|
||||
<DialogHeader className="p-4 pb-0">
|
||||
<DialogTitle className="text-base">{t("preview.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4 pt-2">
|
||||
<ImageCompareSlider
|
||||
originalSrc={originalImageUrl}
|
||||
compressedSrc={previewFile.processedUrl}
|
||||
originalSize={previewFile.metadata.originalSize}
|
||||
compressedSize={previewFile.metadata.compressedSize}
|
||||
texts={previewTexts}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 pt-0 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("preview.filename")}: {previewFile.originalFile.name}
|
||||
</span>
|
||||
<span>
|
||||
{previewFile.metadata.compressionRatio && (
|
||||
<>{t("results.saved", { ratio: previewFile.metadata.compressionRatio })}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
649
src/components/tools/atlas/AtlasConfigPanel.tsx
Normal file
649
src/components/tools/atlas/AtlasConfigPanel.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import JSZip from "jszip";
|
||||
import UPNG from "upng-js";
|
||||
import {
|
||||
Settings2,
|
||||
Box,
|
||||
LayoutGrid,
|
||||
FileOutput,
|
||||
Download,
|
||||
Archive,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
Zap
|
||||
} 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, type SingleAtlasResult } 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata content and filename for an atlas
|
||||
*/
|
||||
function getMetadataForAtlas(
|
||||
atlas: SingleAtlasResult,
|
||||
config: TextureAtlasConfig,
|
||||
atlasIndex: number,
|
||||
totalAtlases: number
|
||||
): { content: string; filename: string; mimeType: string } {
|
||||
const suffix = totalAtlases > 1 ? `_${atlasIndex}` : "";
|
||||
const imageFilename = `atlas${suffix}.${config.format}`;
|
||||
|
||||
let content: string;
|
||||
let filename: string;
|
||||
let mimeType: string;
|
||||
|
||||
if (config.outputFormat === "cocos2d") {
|
||||
content = exportToCocos2dPlist(atlas.placements, atlas.width, atlas.height, imageFilename);
|
||||
filename = `atlas${suffix}.plist`;
|
||||
mimeType = "application/xml";
|
||||
} else if (config.outputFormat === "cocos-creator") {
|
||||
content = exportToCocosCreatorJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format);
|
||||
filename = `atlas${suffix}.json`;
|
||||
mimeType = "application/json";
|
||||
} else {
|
||||
content = exportToGenericJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format);
|
||||
filename = `atlas${suffix}.json`;
|
||||
mimeType = "application/json";
|
||||
}
|
||||
|
||||
return { content, filename, mimeType };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress PNG data using UPNG quantization (similar to TinyPNG)
|
||||
* @param dataUrl Original PNG data URL
|
||||
* @param width Image width
|
||||
* @param height Image height
|
||||
* @returns Compressed PNG as Blob
|
||||
*/
|
||||
async function compressPng(dataUrl: string, width: number, height: number): Promise<Blob> {
|
||||
// Create image from data URL
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = reject;
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
// Draw to canvas to get ImageData
|
||||
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");
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
|
||||
// Compress using UPNG (256 colors = 8-bit PNG)
|
||||
const compressed = UPNG.encode(
|
||||
[imageData.data.buffer],
|
||||
width,
|
||||
height,
|
||||
256 // 256 colors for best quality quantization
|
||||
);
|
||||
|
||||
return new Blob([compressed], { type: "image/png" });
|
||||
}
|
||||
|
||||
export function AtlasConfigPanel() {
|
||||
const { t } = useSafeTranslation();
|
||||
const { pack } = useAtlasWorker();
|
||||
|
||||
const {
|
||||
sprites,
|
||||
config,
|
||||
result,
|
||||
status,
|
||||
currentAtlasIndex,
|
||||
enableMultiAtlas,
|
||||
enableCompression,
|
||||
updateConfig,
|
||||
resetConfig,
|
||||
setEnableMultiAtlas,
|
||||
setEnableCompression,
|
||||
openAnimationDialog,
|
||||
} = useAtlasStore();
|
||||
|
||||
// Get current atlas
|
||||
const currentAtlas = result?.atlases[currentAtlasIndex] || null;
|
||||
const atlasCount = result?.atlases.length || 0;
|
||||
|
||||
// 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 current atlas image
|
||||
const downloadImage = useCallback(async () => {
|
||||
if (!currentAtlas?.imageDataUrl) return;
|
||||
|
||||
const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : "";
|
||||
const filename = `atlas${suffix}.${config.format}`;
|
||||
|
||||
let url: string;
|
||||
let needRevoke = false;
|
||||
|
||||
// Apply compression if enabled and format is PNG
|
||||
if (enableCompression && config.format === "png") {
|
||||
const compressedBlob = await compressPng(
|
||||
currentAtlas.imageDataUrl,
|
||||
currentAtlas.width,
|
||||
currentAtlas.height
|
||||
);
|
||||
url = URL.createObjectURL(compressedBlob);
|
||||
needRevoke = true;
|
||||
} else {
|
||||
url = currentAtlas.imageDataUrl;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
if (needRevoke) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [currentAtlas, currentAtlasIndex, atlasCount, config.format, enableCompression]);
|
||||
|
||||
// Download current atlas metadata
|
||||
const downloadMetadata = useCallback(() => {
|
||||
if (!currentAtlas) return;
|
||||
|
||||
const info = getMetadataForAtlas(currentAtlas, config, currentAtlasIndex, atlasCount);
|
||||
const blob = new Blob([info.content], { type: info.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = info.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [currentAtlas, config, currentAtlasIndex, atlasCount]);
|
||||
|
||||
// Download all atlases as zip
|
||||
const downloadAll = useCallback(async () => {
|
||||
if (!result || result.atlases.length === 0) return;
|
||||
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
const totalAtlases = result.atlases.length;
|
||||
const shouldCompress = enableCompression && config.format === "png";
|
||||
|
||||
for (let i = 0; i < totalAtlases; i++) {
|
||||
const atlas = result.atlases[i];
|
||||
if (!atlas.imageDataUrl) continue;
|
||||
|
||||
const suffix = totalAtlases > 1 ? `_${i}` : "";
|
||||
const imageFilename = `atlas${suffix}.${config.format}`;
|
||||
|
||||
// Add image to zip (with optional compression)
|
||||
if (shouldCompress) {
|
||||
const compressedBlob = await compressPng(
|
||||
atlas.imageDataUrl,
|
||||
atlas.width,
|
||||
atlas.height
|
||||
);
|
||||
zip.file(imageFilename, compressedBlob);
|
||||
} else {
|
||||
const base64Data = atlas.imageDataUrl.split(",")[1];
|
||||
zip.file(imageFilename, base64Data, { base64: true });
|
||||
}
|
||||
|
||||
// Add metadata to zip
|
||||
const metaInfo = getMetadataForAtlas(atlas, config, i, totalAtlases);
|
||||
zip.file(metaInfo.filename, metaInfo.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, enableCompression]);
|
||||
|
||||
// Check if can process
|
||||
const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering";
|
||||
const hasResult = !!result && result.atlases.length > 0;
|
||||
|
||||
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")}
|
||||
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>
|
||||
|
||||
{/* Advanced settings - Multi-atlas mode */}
|
||||
<ConfigSection icon={Copy} title={t("atlas.advancedSettings")}>
|
||||
<SelectOption
|
||||
label={t("config.textureAtlas.multiAtlas")}
|
||||
value={enableMultiAtlas}
|
||||
options={[
|
||||
{ label: t("common.off"), value: false },
|
||||
{ label: t("common.on"), value: true },
|
||||
]}
|
||||
onChange={(v) => setEnableMultiAtlas(v)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||
{t("atlas.multiAtlasHint")}
|
||||
</p>
|
||||
</ConfigSection>
|
||||
|
||||
{/* Compression settings */}
|
||||
<ConfigSection icon={Zap} title={t("atlas.compressionSettings")}>
|
||||
<SelectOption
|
||||
label={t("config.textureAtlas.compression")}
|
||||
value={enableCompression}
|
||||
options={[
|
||||
{ label: t("common.off"), value: false },
|
||||
{ label: t("common.on"), value: true },
|
||||
]}
|
||||
onChange={(v) => setEnableCompression(v)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||
{t("atlas.compressionHint")}
|
||||
</p>
|
||||
</ConfigSection>
|
||||
|
||||
{/* Unpacked sprites warning */}
|
||||
{result && result.unpackedSpriteIds.length > 0 && !enableMultiAtlas && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-950/40 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-amber-200">
|
||||
{t("atlas.unpackedCount", { count: result.unpackedSpriteIds.length })}
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-200/60 mt-1 leading-relaxed">
|
||||
{t("atlas.unpackedSuggestion")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result info */}
|
||||
{hasResult && (
|
||||
<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">
|
||||
{atlasCount > 1 ? (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("atlas.atlasCount")}:</span>
|
||||
<p className="font-medium">{atlasCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
|
||||
<p className="font-medium">{result.packedCount}</p>
|
||||
</div>
|
||||
{currentAtlas && (
|
||||
<>
|
||||
<div className="col-span-2 pt-1 border-t border-white/10">
|
||||
<span className="text-muted-foreground">
|
||||
{t("atlas.currentAtlas", { index: currentAtlasIndex + 1 })}:
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions")}:</span>
|
||||
<p className="font-medium">{currentAtlas.width} × {currentAtlas.height}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
|
||||
<p className="font-medium">{currentAtlas.placements.length}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : currentAtlas && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions")}:</span>
|
||||
<p className="font-medium">{currentAtlas.width} × {currentAtlas.height}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
|
||||
<p className="font-medium">{currentAtlas.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" />
|
||||
{atlasCount > 1
|
||||
? t("atlas.downloadAllAtlases", { count: atlasCount })
|
||||
: t("tools.textureAtlas.downloadAll")
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
584
src/components/tools/atlas/CanvasPreview.tsx
Normal file
584
src/components/tools/atlas/CanvasPreview.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Play,
|
||||
Layers,
|
||||
Move,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} 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<Map<number, { url: string; image: HTMLImageElement }>>(new Map());
|
||||
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,
|
||||
currentAtlasIndex,
|
||||
previewScale,
|
||||
previewOffset,
|
||||
selectedSpriteIds,
|
||||
setPreviewScale,
|
||||
setPreviewOffset,
|
||||
setCurrentAtlasIndex,
|
||||
selectSprite,
|
||||
openAnimationDialog,
|
||||
} = useAtlasStore();
|
||||
|
||||
// Get current atlas
|
||||
const currentAtlas = result?.atlases[currentAtlasIndex] || null;
|
||||
const atlasCount = result?.atlases.length || 0;
|
||||
|
||||
// Calculate dimensions
|
||||
const atlasWidth = currentAtlas?.width || 0;
|
||||
const atlasHeight = currentAtlas?.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 (currentAtlas && currentAtlas.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) {
|
||||
currentAtlas.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
|
||||
const cached = imageCacheRef.current.get(currentAtlasIndex);
|
||||
if (cached && cached.url === currentAtlas.imageDataUrl) {
|
||||
drawImage(cached.image);
|
||||
} else {
|
||||
// Load and draw the atlas image
|
||||
const img = new Image();
|
||||
img.src = currentAtlas.imageDataUrl;
|
||||
|
||||
img.onload = () => {
|
||||
imageCacheRef.current.set(currentAtlasIndex, { url: currentAtlas.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, currentAtlas, currentAtlasIndex, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, selectedSpriteIds, t]);
|
||||
|
||||
// Render loop for animation (selection highlight pulsing)
|
||||
useEffect(() => {
|
||||
if (selectedSpriteIds.length === 0) return;
|
||||
|
||||
let animationFrame: number;
|
||||
let mounted = true;
|
||||
|
||||
const render = () => {
|
||||
if (!mounted) return;
|
||||
|
||||
// Force re-render by updating containerSize (triggers canvas redraw)
|
||||
setContainerSize(s => ({ ...s }));
|
||||
animationFrame = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(render);
|
||||
return () => {
|
||||
mounted = false;
|
||||
cancelAnimationFrame(animationFrame);
|
||||
};
|
||||
}, [selectedSpriteIds.length > 0]); // Only care about whether there are selections
|
||||
|
||||
// Handle wheel zoom - use native event listener with passive: false
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setPreviewScale(previewScale + delta);
|
||||
};
|
||||
|
||||
container.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => container.removeEventListener("wheel", handleWheel);
|
||||
}, [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 && currentAtlas && 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 = currentAtlas.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, currentAtlas, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]);
|
||||
|
||||
// Fit to view
|
||||
const fitToView = useCallback(() => {
|
||||
if (!currentAtlas || 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 });
|
||||
}, [currentAtlas, 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 (!currentAtlas?.imageDataUrl) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = currentAtlas.imageDataUrl;
|
||||
const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : "";
|
||||
link.download = `atlas${suffix}_${atlasWidth}x${atlasHeight}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, [currentAtlas, currentAtlasIndex, atlasCount, atlasWidth, atlasHeight]);
|
||||
|
||||
// Navigate atlases
|
||||
const goToPrevAtlas = useCallback(() => {
|
||||
if (currentAtlasIndex > 0) {
|
||||
setCurrentAtlasIndex(currentAtlasIndex - 1);
|
||||
}
|
||||
}, [currentAtlasIndex, setCurrentAtlasIndex]);
|
||||
|
||||
const goToNextAtlas = useCallback(() => {
|
||||
if (currentAtlasIndex < atlasCount - 1) {
|
||||
setCurrentAtlasIndex(currentAtlasIndex + 1);
|
||||
}
|
||||
}, [currentAtlasIndex, atlasCount, setCurrentAtlasIndex]);
|
||||
|
||||
// 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>
|
||||
{currentAtlas && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{atlasWidth} × {atlasHeight}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Atlas navigation (only show when multiple atlases) */}
|
||||
{atlasCount > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={goToPrevAtlas}
|
||||
disabled={currentAtlasIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="px-2 text-xs text-muted-foreground min-w-[60px] text-center">
|
||||
{currentAtlasIndex + 1} / {atlasCount}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={goToNextAtlas}
|
||||
disabled={currentAtlasIndex === atlasCount - 1}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-border/40" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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={!currentAtlas}
|
||||
>
|
||||
<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={!currentAtlas?.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"
|
||||
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 */}
|
||||
{currentAtlas && !isPanning && result && result.unpackedSpriteIds.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Multi-atlas indicator */}
|
||||
{atlasCount > 1 && (
|
||||
<div className="absolute top-4 left-4 flex items-center gap-2 rounded-lg bg-primary/20 border border-primary/30 px-3 py-1.5 text-xs text-primary backdrop-blur-sm">
|
||||
<Layers className="h-3 w-3" />
|
||||
{t("atlas.atlasIndex", { current: currentAtlasIndex + 1, total: atlasCount })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unpacked sprites warning */}
|
||||
{result && result.unpackedSpriteIds.length > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-2 border-t border-amber-500/30 bg-amber-950/80 px-4 py-2.5 backdrop-blur-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-amber-200">
|
||||
{t("atlas.unpackedWarning", { count: result.unpackedSpriteIds.length })}
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-200/60 truncate">
|
||||
{t("atlas.unpackedHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 border-amber-500/50 bg-amber-500/10 text-amber-300">
|
||||
{result.unpackedSpriteIds.length} / {sprites.length}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
534
src/components/tools/atlas/FileListPanel.tsx
Normal file
534
src/components/tools/atlas/FileListPanel.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
"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,
|
||||
AlertCircle
|
||||
} 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,
|
||||
result,
|
||||
addSprites,
|
||||
removeSprite,
|
||||
clearSprites,
|
||||
setFolderName,
|
||||
selectedSpriteIds,
|
||||
selectSprite,
|
||||
setStatus,
|
||||
} = useAtlasStore();
|
||||
|
||||
// Get unpacked sprite IDs
|
||||
const unpackedSpriteIds = result?.unpackedSpriteIds || [];
|
||||
|
||||
/**
|
||||
* 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" : ""}
|
||||
${unpackedSpriteIds.includes(sprite.id) ? "bg-amber-500/10 border border-amber-500/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"
|
||||
/>
|
||||
{/* Unpacked indicator */}
|
||||
{unpackedSpriteIds.includes(sprite.id) && (
|
||||
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-amber-500 shadow-sm">
|
||||
<AlertCircle className="h-3 w-3 text-amber-950" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`truncate text-xs font-medium ${unpackedSpriteIds.includes(sprite.id) ? "text-amber-300" : ""}`}>
|
||||
{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";
|
||||
112
src/components/ui/dialog.tsx
Normal file
112
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-4xl translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/40 bg-background/95 p-6 shadow-lg backdrop-blur-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
191
src/components/ui/dropdown-menu.tsx
Normal file
191
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<span className="h-2 w-2 rounded-full bg-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
609
src/hooks/useAtlasWorker.ts
Normal file
609
src/hooks/useAtlasWorker.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Hook for managing Atlas Worker communication
|
||||
* Supports multi-atlas mode and PNG compression
|
||||
*/
|
||||
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { useAtlasStore, type BrowserSprite, type AtlasResult, type SingleAtlasResult } from "@/store/atlasStore";
|
||||
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
|
||||
import type { PackerPlacement, SinglePackerResult } from "@/lib/atlas-packer";
|
||||
|
||||
interface WorkerInputMessage {
|
||||
type: "pack";
|
||||
sprites: { id: string; name: string; width: number; height: number }[];
|
||||
config: TextureAtlasConfig;
|
||||
enableMultiAtlas: boolean;
|
||||
}
|
||||
|
||||
interface WorkerOutputMessage {
|
||||
type: "result" | "progress" | "error";
|
||||
result?: {
|
||||
atlases: SinglePackerResult[];
|
||||
packedCount: number;
|
||||
unpackedSprites: { id: string; name: string; width: number; height: number }[];
|
||||
};
|
||||
progress?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render sprites to canvas and get data URL
|
||||
* Always returns uncompressed data URL for preview
|
||||
*/
|
||||
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, enableMultiAtlas, 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 {
|
||||
const currentSprites = useAtlasStore.getState().sprites;
|
||||
const atlasCount = result.atlases.length;
|
||||
const atlasResults: SingleAtlasResult[] = [];
|
||||
|
||||
// Render each atlas
|
||||
for (let i = 0; i < atlasCount; i++) {
|
||||
const atlas = result.atlases[i];
|
||||
|
||||
const imageDataUrl = await renderAtlasToCanvas(
|
||||
currentSprites,
|
||||
atlas.placements,
|
||||
atlas.width,
|
||||
atlas.height
|
||||
);
|
||||
|
||||
const frames = buildFrames(atlas.placements);
|
||||
|
||||
atlasResults.push({
|
||||
index: atlas.index,
|
||||
width: atlas.width,
|
||||
height: atlas.height,
|
||||
placements: atlas.placements,
|
||||
frames,
|
||||
imageDataUrl,
|
||||
spriteIds: atlas.spriteIds,
|
||||
});
|
||||
|
||||
// Update progress for rendering
|
||||
setProgress(50 + ((i + 1) / atlasCount) * 50);
|
||||
}
|
||||
|
||||
// Extract unpacked sprite IDs
|
||||
const unpackedSpriteIds = result.unpackedSprites.map(s => s.id);
|
||||
|
||||
const atlasResult: AtlasResult = {
|
||||
atlases: atlasResults,
|
||||
packedCount: result.packedCount,
|
||||
unpackedSpriteIds,
|
||||
};
|
||||
|
||||
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,
|
||||
enableMultiAtlas,
|
||||
};
|
||||
|
||||
workerRef.current.postMessage(message);
|
||||
}, [sprites, config, enableMultiAtlas, 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 packSingleAtlas(sprites, config, atlasIndex) {
|
||||
const padding = config.padding;
|
||||
let packWidth = config.maxWidth;
|
||||
let packHeight = config.maxHeight;
|
||||
|
||||
if (config.pot) {
|
||||
packWidth = adjustSizeForPot(packWidth, true);
|
||||
packHeight = adjustSizeForPot(packHeight, true);
|
||||
packWidth = Math.min(packWidth, config.maxWidth);
|
||||
packHeight = Math.min(packHeight, config.maxHeight);
|
||||
}
|
||||
|
||||
const sortedSprites = sortSpritesBySize(sprites);
|
||||
const placements = new Map();
|
||||
const unpackedSprites = [];
|
||||
|
||||
if (config.algorithm === "MaxRects") {
|
||||
const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation);
|
||||
|
||||
for (const sprite of sortedSprites) {
|
||||
const paddedWidth = sprite.width + padding * 2;
|
||||
const paddedHeight = sprite.height + padding * 2;
|
||||
|
||||
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||
const position = packer.insert(paddedHeight, paddedWidth);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unpackedSprites.push(sprite);
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = packer.insert(paddedWidth, paddedHeight);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: position.rotated,
|
||||
});
|
||||
} else {
|
||||
unpackedSprites.push(sprite);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding);
|
||||
|
||||
for (const sprite of sortedSprites) {
|
||||
const paddedWidth = sprite.width + padding * 2;
|
||||
const paddedHeight = sprite.height + padding * 2;
|
||||
|
||||
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||
const position = packer.insert(sprite.height, sprite.width);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unpackedSprites.push(sprite);
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = packer.insert(sprite.width, sprite.height);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: position.rotated,
|
||||
});
|
||||
} else {
|
||||
unpackedSprites.push(sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, finalHeight;
|
||||
|
||||
if (config.pot) {
|
||||
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
|
||||
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
} else {
|
||||
finalWidth = Math.ceil(maxX);
|
||||
finalHeight = Math.ceil(maxY);
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
}
|
||||
|
||||
const resultPlacements = [];
|
||||
const spriteIds = [];
|
||||
|
||||
for (const sprite of sprites) {
|
||||
const placement = placements.get(sprite.id);
|
||||
if (placement) {
|
||||
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
|
||||
spriteIds.push(sprite.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
index: atlasIndex,
|
||||
width: finalWidth || 1,
|
||||
height: finalHeight || 1,
|
||||
placements: resultPlacements,
|
||||
spriteIds,
|
||||
},
|
||||
unpackedSprites,
|
||||
};
|
||||
}
|
||||
|
||||
function packSprites(sprites, config, enableMultiAtlas, postProgress) {
|
||||
if (sprites.length === 0) return null;
|
||||
|
||||
const atlases = [];
|
||||
let remainingSprites = [...sprites];
|
||||
let atlasIndex = 0;
|
||||
let totalProcessed = 0;
|
||||
|
||||
while (remainingSprites.length > 0) {
|
||||
const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex);
|
||||
|
||||
if (result.placements.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
atlases.push(result);
|
||||
totalProcessed += result.placements.length;
|
||||
postProgress((totalProcessed / sprites.length) * 100);
|
||||
|
||||
if (!enableMultiAtlas) {
|
||||
return {
|
||||
atlases,
|
||||
packedCount: result.placements.length,
|
||||
unpackedSprites,
|
||||
};
|
||||
}
|
||||
|
||||
remainingSprites = unpackedSprites;
|
||||
atlasIndex++;
|
||||
|
||||
if (atlasIndex > 100) {
|
||||
console.warn("Max atlas limit reached");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atlases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0);
|
||||
|
||||
return {
|
||||
atlases,
|
||||
packedCount: totalPacked,
|
||||
unpackedSprites: remainingSprites,
|
||||
};
|
||||
}
|
||||
|
||||
self.onmessage = function(event) {
|
||||
const { type, sprites, config, enableMultiAtlas } = event.data;
|
||||
|
||||
if (type === "pack") {
|
||||
try {
|
||||
const postProgress = (progress) => {
|
||||
self.postMessage({ type: "progress", progress });
|
||||
};
|
||||
|
||||
const result = packSprites(sprites, config, enableMultiAtlas, postProgress);
|
||||
|
||||
if (result) {
|
||||
self.postMessage({ type: "result", result });
|
||||
} else {
|
||||
self.postMessage({ type: "error", error: "No sprites could be packed. Check sprite sizes and max dimensions." });
|
||||
}
|
||||
} 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 || "";
|
||||
|
||||
@@ -72,7 +78,7 @@ export async function uploadFile(file: File, _onProgress?: (progress: number) =>
|
||||
/**
|
||||
* 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", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fileId, config }),
|
||||
@@ -82,7 +88,7 @@ export async function processVideoFrames(fileId: string, config: any) {
|
||||
/**
|
||||
* Process image compression
|
||||
*/
|
||||
export async function processImageCompression(fileId: string, config: any) {
|
||||
export async function processImageCompression(fileId: string, config: ImageCompressConfig) {
|
||||
return apiClient("/process/image-compress", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fileId, config }),
|
||||
@@ -92,7 +98,7 @@ export async function processImageCompression(fileId: string, config: any) {
|
||||
/**
|
||||
* Process audio compression
|
||||
*/
|
||||
export async function processAudioCompression(fileId: string, config: any) {
|
||||
export async function processAudioCompression(fileId: string, config: AudioCompressConfig) {
|
||||
return apiClient("/process/audio-compress", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fileId, config }),
|
||||
|
||||
720
src/lib/atlas-packer.ts
Normal file
720
src/lib/atlas-packer.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* Browser-side Texture Atlas Packing Algorithms
|
||||
* Implements MaxRects and Shelf algorithms for packing sprites
|
||||
* Supports multi-atlas mode (like TexturePacker)
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single atlas packing result
|
||||
*/
|
||||
export interface SinglePackerResult {
|
||||
index: number;
|
||||
width: number;
|
||||
height: number;
|
||||
placements: PackerPlacement[];
|
||||
spriteIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete packing result (supports multiple atlases)
|
||||
*/
|
||||
export interface PackerResult {
|
||||
atlases: SinglePackerResult[];
|
||||
packedCount: number;
|
||||
/** Sprites that couldn't fit (only when multi-atlas is disabled) */
|
||||
unpackedSprites: PackerSprite[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 into a single atlas
|
||||
* Returns placements and unpacked sprites
|
||||
*/
|
||||
function packSingleAtlas(
|
||||
sprites: PackerSprite[],
|
||||
config: TextureAtlasConfig,
|
||||
atlasIndex: number
|
||||
): { result: SinglePackerResult; unpackedSprites: PackerSprite[] } {
|
||||
const padding = config.padding;
|
||||
|
||||
// Use the max dimensions directly for packing
|
||||
let packWidth = config.maxWidth;
|
||||
let packHeight = config.maxHeight;
|
||||
|
||||
// For POT mode, ensure dimensions are power of two
|
||||
if (config.pot) {
|
||||
packWidth = adjustSizeForPot(packWidth, true);
|
||||
packHeight = adjustSizeForPot(packHeight, true);
|
||||
// Clamp to max
|
||||
packWidth = Math.min(packWidth, config.maxWidth);
|
||||
packHeight = Math.min(packHeight, config.maxHeight);
|
||||
}
|
||||
|
||||
// Sort sprites by size (largest first) for better packing
|
||||
const sortedSprites = sortSpritesBySize(sprites);
|
||||
|
||||
// Pack sprites one by one, tracking which ones fit
|
||||
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
|
||||
const unpackedSprites: PackerSprite[] = [];
|
||||
|
||||
// Create packer with max dimensions
|
||||
if (config.algorithm === "MaxRects") {
|
||||
const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation);
|
||||
|
||||
for (const sprite of sortedSprites) {
|
||||
const paddedWidth = sprite.width + padding * 2;
|
||||
const paddedHeight = sprite.height + padding * 2;
|
||||
|
||||
// Check if sprite is too large even without packing
|
||||
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||
// Try rotated if allowed
|
||||
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||
const position = packer.insert(paddedHeight, paddedWidth);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unpackedSprites.push(sprite);
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = packer.insert(paddedWidth, paddedHeight);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: position.rotated,
|
||||
});
|
||||
} else {
|
||||
unpackedSprites.push(sprite);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Shelf algorithm
|
||||
const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding);
|
||||
|
||||
for (const sprite of sortedSprites) {
|
||||
const paddedWidth = sprite.width + padding * 2;
|
||||
const paddedHeight = sprite.height + padding * 2;
|
||||
|
||||
// Check if sprite is too large
|
||||
if (paddedWidth > packWidth || paddedHeight > packHeight) {
|
||||
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
|
||||
const position = packer.insert(sprite.height, sprite.width);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
unpackedSprites.push(sprite);
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = packer.insert(sprite.width, sprite.height);
|
||||
if (position) {
|
||||
placements.set(sprite.id, {
|
||||
x: position.x + padding,
|
||||
y: position.y + padding,
|
||||
width: sprite.width,
|
||||
height: sprite.height,
|
||||
rotated: position.rotated,
|
||||
});
|
||||
} else {
|
||||
unpackedSprites.push(sprite);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual dimensions based on sprite placements
|
||||
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);
|
||||
}
|
||||
|
||||
// Calculate the minimum required dimensions
|
||||
let finalWidth: number;
|
||||
let finalHeight: number;
|
||||
|
||||
if (config.pot) {
|
||||
finalWidth = nextPowerOfTwo(Math.ceil(maxX));
|
||||
finalHeight = nextPowerOfTwo(Math.ceil(maxY));
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
} else {
|
||||
finalWidth = Math.ceil(maxX);
|
||||
finalHeight = Math.ceil(maxY);
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
}
|
||||
|
||||
// Build result placements in original order
|
||||
const resultPlacements: PackerPlacement[] = [];
|
||||
const spriteIds: string[] = [];
|
||||
|
||||
for (const sprite of sprites) {
|
||||
const placement = placements.get(sprite.id);
|
||||
if (placement) {
|
||||
resultPlacements.push({
|
||||
id: sprite.id,
|
||||
name: sprite.name,
|
||||
...placement,
|
||||
});
|
||||
spriteIds.push(sprite.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
index: atlasIndex,
|
||||
width: finalWidth || 1,
|
||||
height: finalHeight || 1,
|
||||
placements: resultPlacements,
|
||||
spriteIds,
|
||||
},
|
||||
unpackedSprites,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main packing function
|
||||
* Supports multi-atlas mode
|
||||
*/
|
||||
export function packSprites(
|
||||
sprites: PackerSprite[],
|
||||
config: TextureAtlasConfig,
|
||||
enableMultiAtlas: boolean = false
|
||||
): PackerResult | null {
|
||||
if (sprites.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const atlases: SinglePackerResult[] = [];
|
||||
let remainingSprites = [...sprites];
|
||||
let atlasIndex = 0;
|
||||
|
||||
// Pack into atlases
|
||||
while (remainingSprites.length > 0) {
|
||||
const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex);
|
||||
|
||||
// If nothing was packed, we have sprites that are too large
|
||||
if (result.placements.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
atlases.push(result);
|
||||
|
||||
// If multi-atlas is disabled, stop after first atlas
|
||||
if (!enableMultiAtlas) {
|
||||
// Return with unpacked sprites info
|
||||
return {
|
||||
atlases,
|
||||
packedCount: result.placements.length,
|
||||
unpackedSprites,
|
||||
};
|
||||
}
|
||||
|
||||
// Continue with remaining sprites
|
||||
remainingSprites = unpackedSprites;
|
||||
atlasIndex++;
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if (atlasIndex > 100) {
|
||||
console.warn("Max atlas limit reached");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atlases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0);
|
||||
|
||||
return {
|
||||
atlases,
|
||||
packedCount: totalPacked,
|
||||
unpackedSprites: remainingSprites,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
419
src/lib/atlas-worker.ts
Normal file
419
src/lib/atlas-worker.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Calculate the minimum required dimensions based on actual content
|
||||
let finalWidth: number;
|
||||
let finalHeight: number;
|
||||
|
||||
if (config.pot) {
|
||||
// Find smallest POT size that fits the actual content
|
||||
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
|
||||
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
|
||||
// Ensure we don't exceed max limits
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
} else {
|
||||
// For non-POT, use exact dimensions needed
|
||||
finalWidth = Math.ceil(maxX);
|
||||
finalHeight = Math.ceil(maxY);
|
||||
// Ensure we don't exceed max limits
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
}
|
||||
|
||||
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 {};
|
||||
305
src/lib/file-storage.ts
Normal file
305
src/lib/file-storage.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
/**
|
||||
* File storage service for handling temporary files
|
||||
* Files are automatically cleaned up after a specified TTL
|
||||
*/
|
||||
|
||||
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
|
||||
const DOWNLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "downloads");
|
||||
const FILE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
// Allowed MIME types for image upload
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
] as const;
|
||||
|
||||
// Maximum file size (50MB)
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
// Track cleanup timeout
|
||||
const cleanupTimeouts = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
/**
|
||||
* Initialize temporary directories
|
||||
*/
|
||||
export async function initFileStorage(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize file storage:", error);
|
||||
throw new Error("File storage initialization failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image file type and size
|
||||
*/
|
||||
export function validateImageFile(
|
||||
file: File | { name: string; size: number; type: string }
|
||||
): { valid: boolean; error?: string } {
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check file size (minimum 100 bytes to avoid corrupt files)
|
||||
if (file.size < 100) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "File is too small or corrupt",
|
||||
};
|
||||
}
|
||||
|
||||
// Check MIME type
|
||||
if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as typeof ALLOWED_IMAGE_TYPES[number])) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension matches MIME type
|
||||
const ext = getFileExtension(file.name).toLowerCase();
|
||||
const validExtensions = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"];
|
||||
|
||||
if (!validExtensions.includes(ext)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid file extension. Allowed: ${validExtensions.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
*/
|
||||
function getFileExtension(filename: string): string {
|
||||
const parts = filename.split(".");
|
||||
return parts.length > 1 ? parts[parts.length - 1] : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename to prevent path traversal
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// Remove any directory separators and special characters
|
||||
const sanitized = filename
|
||||
.replace(/[\\/]/g, "") // Remove path separators
|
||||
.replace(/\.{2,}/g, ".") // Remove double dots
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars
|
||||
.substring(0, 255); // Limit length
|
||||
|
||||
return sanitized || "file";
|
||||
}
|
||||
|
||||
/**
|
||||
* Save uploaded file to temp storage
|
||||
*/
|
||||
export async function saveUploadedFile(
|
||||
file: File | { name: string; type: string; arrayBuffer: () => Promise<ArrayBuffer> }
|
||||
): Promise<{ fileId: string; filePath: string; originalName: string; size: number; type: string }> {
|
||||
await initFileStorage();
|
||||
|
||||
const fileId = randomUUID();
|
||||
const sanitized = sanitizeFilename(file.name);
|
||||
const ext = getFileExtension(sanitized);
|
||||
const filename = `${fileId}.${ext}`;
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
// Save file
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Schedule cleanup
|
||||
scheduleCleanup(fileId, filePath);
|
||||
|
||||
return {
|
||||
fileId,
|
||||
filePath,
|
||||
originalName: file.name,
|
||||
size: buffer.length,
|
||||
type: file.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save processed file to download directory
|
||||
*/
|
||||
export async function saveProcessedFile(
|
||||
_fileId: string,
|
||||
buffer: Buffer,
|
||||
format: string,
|
||||
originalName: string
|
||||
): Promise<{ fileUrl: string; filename: string; size: number }> {
|
||||
await initFileStorage();
|
||||
|
||||
const downloadId = randomUUID();
|
||||
const sanitized = sanitizeFilename(originalName);
|
||||
const nameWithoutExt = sanitized.replace(/\.[^.]+$/, "");
|
||||
const filename = `${nameWithoutExt}_compressed.${format}`;
|
||||
const filePath = path.join(DOWNLOAD_DIR, `${downloadId}_${filename}`);
|
||||
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
// Schedule cleanup
|
||||
scheduleCleanup(downloadId, filePath);
|
||||
|
||||
return {
|
||||
fileUrl: `/api/download/${downloadId}`,
|
||||
filename,
|
||||
size: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file from upload directory
|
||||
*/
|
||||
export async function getUploadedFile(fileId: string): Promise<Buffer | null> {
|
||||
await initFileStorage();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(UPLOAD_DIR);
|
||||
const file = files.find((f) => f.startsWith(`${fileId}.`));
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, file);
|
||||
return await fs.readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processed file for download
|
||||
*/
|
||||
export async function getProcessedFile(downloadId: string): Promise<
|
||||
| { buffer: Buffer; filename: string; contentType: string }[]
|
||||
| null
|
||||
> {
|
||||
await initFileStorage();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(DOWNLOAD_DIR);
|
||||
const file = files.find((f) => f.startsWith(`${downloadId}_`));
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(DOWNLOAD_DIR, file);
|
||||
const buffer = await fs.readFile(filePath);
|
||||
|
||||
// Extract filename
|
||||
const filename = file.substring(downloadId.length + 1);
|
||||
|
||||
// Determine content type
|
||||
const ext = getFileExtension(filename);
|
||||
const contentTypes: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
buffer,
|
||||
filename,
|
||||
contentType: contentTypes[ext] || "application/octet-stream",
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule file cleanup
|
||||
*/
|
||||
function scheduleCleanup(fileId: string, filePath: string): void {
|
||||
// Clear existing timeout if any
|
||||
const existing = cleanupTimeouts.get(fileId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
// Schedule new cleanup
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
cleanupTimeouts.delete(fileId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to cleanup file ${fileId}:`, error);
|
||||
}
|
||||
}, FILE_TTL);
|
||||
|
||||
cleanupTimeouts.set(fileId, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually cleanup a file
|
||||
*/
|
||||
export async function cleanupFile(fileId: string): Promise<void> {
|
||||
const timeout = cleanupTimeouts.get(fileId);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
cleanupTimeouts.delete(fileId);
|
||||
}
|
||||
|
||||
// Try to delete from both directories
|
||||
try {
|
||||
const files = await fs.readdir(UPLOAD_DIR);
|
||||
const file = files.find((f) => f.startsWith(`${fileId}.`));
|
||||
if (file) {
|
||||
await fs.unlink(path.join(UPLOAD_DIR, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(DOWNLOAD_DIR);
|
||||
const file = files.find((f) => f.startsWith(`${fileId}_`));
|
||||
if (file) {
|
||||
await fs.unlink(path.join(DOWNLOAD_DIR, file));
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allowed image types
|
||||
*/
|
||||
export function getAllowedImageTypes(): readonly string[] {
|
||||
return ALLOWED_IMAGE_TYPES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum file size
|
||||
*/
|
||||
export function getMaxFileSize(): number {
|
||||
return MAX_FILE_SIZE;
|
||||
}
|
||||
124
src/lib/i18n.ts
Normal file
124
src/lib/i18n.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import { useState, useEffect } from "react";
|
||||
import en from "@/locales/en.json";
|
||||
import zh from "@/locales/zh.json";
|
||||
|
||||
export type Locale = "en" | "zh";
|
||||
export type LocaleMessages = typeof en;
|
||||
|
||||
export const locales: Record<Locale, { name: string }> = {
|
||||
en: { name: "English" },
|
||||
zh: { name: "中文" },
|
||||
};
|
||||
|
||||
const messages: Record<Locale, LocaleMessages> = { en, zh };
|
||||
|
||||
interface I18nState {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): string | unknown {
|
||||
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 {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||
const value = params[key];
|
||||
if (value === undefined || value === null) return `{{${key}}}`;
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
export const useI18nStore = create<I18nState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
locale: "en", // Default to en for SSR/Hydration
|
||||
setLocale: (locale) => set({ locale }),
|
||||
|
||||
t: (key, params) => {
|
||||
const { locale } = get();
|
||||
const value = getNestedValue(messages[locale], key);
|
||||
if (typeof value !== "string") return value as string;
|
||||
if (!params || Object.keys(params).length === 0) return value;
|
||||
return interpolate(value, params);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "locale-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Convenience hook
|
||||
export function useTranslation() {
|
||||
const { locale, setLocale, t } = useI18nStore();
|
||||
|
||||
const plural = (key: string, count: number) => {
|
||||
const suffix = count === 1 ? "_one" : "_other";
|
||||
return t(`${key}${suffix}`, { count });
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
plural,
|
||||
locales,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR-safe translation hook that prevents hydration mismatches.
|
||||
* Use this in client components that are rendered on the server.
|
||||
* Returns a stable translation during SSR and switches to client locale after hydration.
|
||||
*/
|
||||
export function useSafeTranslation() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const { locale, setLocale, t, plural, locales } = useTranslation();
|
||||
|
||||
// Use English during SSR, client locale after hydration
|
||||
const safeT: typeof t = (key, params) => {
|
||||
if (!mounted) return getServerTranslations("en").t(key, params);
|
||||
return t(key, params);
|
||||
};
|
||||
|
||||
const safePlural: typeof plural = (key, count) => {
|
||||
if (!mounted) {
|
||||
const suffix = count === 1 ? "_one" : "_other";
|
||||
return getServerTranslations("en").t(`${key}${suffix}`, { count });
|
||||
}
|
||||
return plural(key, count);
|
||||
};
|
||||
|
||||
return {
|
||||
locale,
|
||||
setLocale,
|
||||
t: safeT,
|
||||
plural: safePlural,
|
||||
locales,
|
||||
mounted, // Expose mounted state for conditional rendering if needed
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for SSR
|
||||
export function getServerTranslations(locale: Locale = "en") {
|
||||
return {
|
||||
locale,
|
||||
t: (key: string, params?: Record<string, string | number>) => {
|
||||
const value = getNestedValue(messages[locale], key);
|
||||
if (typeof value !== "string") return value as string;
|
||||
if (!params || Object.keys(params).length === 0) return value;
|
||||
return interpolate(value, params);
|
||||
},
|
||||
};
|
||||
}
|
||||
624
src/lib/image-processor.ts
Normal file
624
src/lib/image-processor.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import sharp from "sharp";
|
||||
import type { ImageCompressConfig } from "@/types";
|
||||
|
||||
/**
|
||||
* World-class Image Compression Engine
|
||||
*
|
||||
* 实现业界领先的图片压缩算法,核心策略:
|
||||
* 1. 智能格式检测与自适应压缩
|
||||
* 2. 多轮压缩迭代,确保最优结果
|
||||
* 3. 压缩后不大于原图保证
|
||||
* 4. 自动元数据剥离
|
||||
* 5. 智能调色板降级 (PNG)
|
||||
* 6. 基于内容的压缩策略
|
||||
*/
|
||||
|
||||
export interface ProcessedImageResult {
|
||||
buffer: Buffer;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
originalSize: number;
|
||||
compressedSize: number;
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
export interface ImageMetadata {
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
hasAlpha: boolean;
|
||||
isAnimated: boolean;
|
||||
colorSpace?: string;
|
||||
channels?: number;
|
||||
depth?: string;
|
||||
}
|
||||
|
||||
// Supported output formats for compression
|
||||
const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "tif"] as const;
|
||||
type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
|
||||
|
||||
/**
|
||||
* Get detailed image metadata
|
||||
*/
|
||||
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
|
||||
return {
|
||||
format: metadata.format || "unknown",
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
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
|
||||
*/
|
||||
export async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
return (
|
||||
metadata.format !== undefined &&
|
||||
metadata.width !== undefined &&
|
||||
metadata.width > 0 &&
|
||||
metadata.height !== undefined &&
|
||||
metadata.height > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if format is supported for output
|
||||
*/
|
||||
function isSupportedFormat(format: string): format is SupportedFormat {
|
||||
return SUPPORTED_OUTPUT_FORMATS.includes(format as SupportedFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析图片特征,选择最佳压缩策略
|
||||
*/
|
||||
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(
|
||||
buffer: Buffer,
|
||||
config: ImageCompressConfig
|
||||
): Promise<ProcessedImageResult> {
|
||||
// Validate input buffer
|
||||
const isValid = await validateImageBuffer(buffer);
|
||||
if (!isValid) {
|
||||
throw new Error("Invalid image data");
|
||||
}
|
||||
|
||||
const originalSize = buffer.length;
|
||||
const originalMetadata = await getImageMetadata(buffer);
|
||||
|
||||
// 选择最佳输出格式
|
||||
let outputFormat = await selectOptimalFormat(
|
||||
buffer,
|
||||
config.format,
|
||||
originalMetadata
|
||||
);
|
||||
|
||||
// BMP 不支持输出,转为合适格式
|
||||
if (outputFormat === "bmp") {
|
||||
outputFormat = originalMetadata.hasAlpha ? "png" : "jpeg";
|
||||
}
|
||||
|
||||
if (!isSupportedFormat(outputFormat)) {
|
||||
outputFormat = "jpeg";
|
||||
}
|
||||
|
||||
// 尝试多种压缩策略,选择最优结果
|
||||
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) {
|
||||
case "jpeg":
|
||||
case "jpg":
|
||||
pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
|
||||
break;
|
||||
|
||||
case "png":
|
||||
pipeline = pipeline.png(getPngOptions(adjustedQuality, strategy, characteristics, metadata));
|
||||
break;
|
||||
|
||||
case "webp":
|
||||
pipeline = pipeline.webp(getWebpOptions(adjustedQuality, strategy, characteristics, metadata));
|
||||
break;
|
||||
|
||||
case "avif":
|
||||
pipeline = pipeline.avif(getAvifOptions(adjustedQuality, strategy));
|
||||
break;
|
||||
|
||||
case "gif":
|
||||
pipeline = pipeline.gif(getGifOptions(strategy));
|
||||
break;
|
||||
|
||||
case "tiff":
|
||||
case "tif":
|
||||
pipeline = pipeline.tiff(getTiffOptions(adjustedQuality, strategy));
|
||||
break;
|
||||
|
||||
default:
|
||||
pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
|
||||
}
|
||||
|
||||
return await pipeline.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* JPEG 压缩选项 - 使用 MozJPEG 最佳实践
|
||||
*/
|
||||
function getJpegOptions(
|
||||
quality: number,
|
||||
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 {
|
||||
...baseOptions,
|
||||
quality: Math.max(1, quality - 10),
|
||||
quantisationTable: 0, // 最激进的量化表
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch compress multiple images
|
||||
*/
|
||||
export async function batchCompressImages(
|
||||
files: Array<{ buffer: Buffer; name: string }>,
|
||||
config: ImageCompressConfig,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
): Promise<Array<{ result: ProcessedImageResult; name: string }>> {
|
||||
const results: Array<{ result: ProcessedImageResult; name: string }> = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const result = await compressImage(files[i].buffer, config);
|
||||
results.push({ result, name: files[i].name });
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, files.length);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate recommended quality based on desired compression ratio
|
||||
*/
|
||||
export function calculateQualityForTargetRatio(
|
||||
targetRatio: number,
|
||||
currentRatio?: number,
|
||||
currentQuality?: number
|
||||
): number {
|
||||
if (currentRatio !== undefined && currentQuality !== undefined) {
|
||||
const difference = targetRatio - currentRatio;
|
||||
const adjustment = difference * 2;
|
||||
return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment)));
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ImageCompressConfig
|
||||
*/
|
||||
export function validateCompressConfig(config: ImageCompressConfig): {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (!config.quality || typeof config.quality !== "number") {
|
||||
return { valid: false, error: "Quality is required and must be a number" };
|
||||
}
|
||||
|
||||
if (config.quality < 1 || config.quality > 100) {
|
||||
return { valid: false, error: "Quality must be between 1 and 100" };
|
||||
}
|
||||
|
||||
const validFormats = ["original", "auto", "jpeg", "jpg", "png", "webp", "avif", "gif", "bmp", "tiff", "tif"];
|
||||
if (!validFormats.includes(config.format)) {
|
||||
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
|
||||
}
|
||||
|
||||
if (config.resize) {
|
||||
if (config.resize.width !== undefined) {
|
||||
if (
|
||||
typeof config.resize.width !== "number" ||
|
||||
config.resize.width < 1 ||
|
||||
config.resize.width > 16384
|
||||
) {
|
||||
return { valid: false, error: "Width must be between 1 and 16384" };
|
||||
}
|
||||
}
|
||||
|
||||
if (config.resize.height !== undefined) {
|
||||
if (
|
||||
typeof config.resize.height !== "number" ||
|
||||
config.resize.height < 1 ||
|
||||
config.resize.height > 16384
|
||||
) {
|
||||
return { valid: false, error: "Height must be between 1 and 16384" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.resize.width && !config.resize.height) {
|
||||
return { valid: false, error: "At least one of width or height must be specified" };
|
||||
}
|
||||
|
||||
const validFits = ["contain", "cover", "fill"];
|
||||
if (!validFits.includes(config.resize.fit)) {
|
||||
return { valid: false, error: `Invalid fit. Allowed: ${validFits.join(", ")}` };
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
824
src/lib/texture-atlas.ts
Normal file
824
src/lib/texture-atlas.ts
Normal file
@@ -0,0 +1,824 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Calculate the minimum required dimensions based on actual content
|
||||
// For POT mode: find the smallest power of two that fits the actual content
|
||||
// For non-POT mode: use the actual content size
|
||||
if (config.pot) {
|
||||
// Find smallest POT size that fits the actual content
|
||||
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
|
||||
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
|
||||
|
||||
// Ensure we don't exceed max limits
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
} else {
|
||||
// For non-POT, use exact dimensions needed
|
||||
finalWidth = Math.ceil(maxX);
|
||||
finalHeight = Math.ceil(maxY);
|
||||
|
||||
// Ensure we don't exceed max limits
|
||||
finalWidth = Math.min(finalWidth, config.maxWidth);
|
||||
finalHeight = Math.min(finalHeight, config.maxHeight);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
|
||||
394
src/locales/en.json
Normal file
394
src/locales/en.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "KYMR",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"download": "Download",
|
||||
"share": "Share",
|
||||
"reset": "Reset",
|
||||
"submit": "Submit",
|
||||
"tryNow": "Try it now",
|
||||
"learnMore": "Learn More",
|
||||
"getStarted": "Get Started",
|
||||
"startBuilding": "Start Building",
|
||||
"signIn": "Sign In",
|
||||
"register": "Register",
|
||||
"features": "Features",
|
||||
"settings": "Settings",
|
||||
"toggleMenu": "Toggle menu",
|
||||
"processing": "Processing...",
|
||||
"uploading": "Uploading...",
|
||||
"completed": "Completed!",
|
||||
"failed": "Failed",
|
||||
"ready": "Ready to process",
|
||||
"file": "File",
|
||||
"files": "files",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"nav": {
|
||||
"tools": "Tools",
|
||||
"docs": "Docs",
|
||||
"about": "About",
|
||||
"dashboard": "Dashboard",
|
||||
"overview": "Overview"
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"kicker": "Built for asset prep",
|
||||
"title": "Empowering Game Development",
|
||||
"description": "Video to frames, image compression, audio optimization. Everything you need to prepare game assets, in one place.",
|
||||
"startBuilding": "Start Building",
|
||||
"secondaryCta": "Explore tools",
|
||||
"note": "No plugins. No setup. Just ship assets faster.",
|
||||
"previewTitle": "Asset Processing Workbench",
|
||||
"stats": {
|
||||
"developers": "Developers",
|
||||
"filesProcessed": "Files Processed",
|
||||
"uptime": "Uptime"
|
||||
}
|
||||
},
|
||||
"featuresSection": {
|
||||
"title": "Everything You Need",
|
||||
"description": "Powerful tools designed specifically for game developers"
|
||||
},
|
||||
"showcase": {
|
||||
"kicker": "Three tools. Zero clutter.",
|
||||
"title": "A smoother pipeline for everyday assets",
|
||||
"description": "Minimal controls. Predictable results. Designed to keep you in flow.",
|
||||
"cta": "Open tool"
|
||||
},
|
||||
"workflow": {
|
||||
"title": "Treat assets like a build step",
|
||||
"description": "Clear inputs, clear outputs. Repeatable settings. Less trial-and-error.",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "Drop files",
|
||||
"description": "Batch-friendly. Drag files in and keep moving."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Tune the essentials",
|
||||
"description": "Only the knobs you actually need: quality, FPS, formats."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Export clean outputs",
|
||||
"description": "Structured results that fit right into art and engineering workflows."
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"title": "Fast, stable, intentional",
|
||||
"description": "Not feature bloat—just a better interaction loop.",
|
||||
"items": {
|
||||
"fast": {
|
||||
"title": "Lightning fast",
|
||||
"description": "Spend less time waiting and more time building."
|
||||
},
|
||||
"private": {
|
||||
"title": "Secure by default",
|
||||
"description": "Minimize exposure and keep processing straightforward."
|
||||
},
|
||||
"designed": {
|
||||
"title": "Designed for devs",
|
||||
"description": "Clear information hierarchy and repeatable rhythm across tools."
|
||||
}
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"title": "Start now. Keep your time for the game.",
|
||||
"description": "Pick a tool and build a faster workflow from the first asset.",
|
||||
"primaryCta": "Get started",
|
||||
"secondaryCta": "Try Video to Frames"
|
||||
},
|
||||
"tools": {
|
||||
"videoToFrames": {
|
||||
"title": "Video to Frames",
|
||||
"description": "Extract frames from videos with customizable frame rates. Perfect for sprite animations."
|
||||
},
|
||||
"imageCompression": {
|
||||
"title": "Image Compression",
|
||||
"description": "Optimize images for web and mobile without quality loss. Support for batch processing."
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "Audio Compression",
|
||||
"description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate."
|
||||
},
|
||||
"textureAtlas": {
|
||||
"title": "Texture Atlas",
|
||||
"description": "Combine multiple sprites into optimized texture atlases. Perfect for game performance."
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "More Tools",
|
||||
"description": "Additional utilities for game development. Coming soon."
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
"title": "Why Choose KYMR?",
|
||||
"description": "We understand the unique challenges of game development. Our tools are built to help you work faster and smarter.",
|
||||
"lightningFast": {
|
||||
"title": "Lightning Fast",
|
||||
"description": "Process files in seconds with our optimized infrastructure."
|
||||
},
|
||||
"secure": {
|
||||
"title": "Secure & Private",
|
||||
"description": "Your files are encrypted and automatically deleted after processing."
|
||||
},
|
||||
"forDevelopers": {
|
||||
"title": "Built for Developers",
|
||||
"description": "API access, batch processing, and tools designed for game development workflows."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to Level Up?",
|
||||
"description": "Join thousands of game developers building amazing games with our tools.",
|
||||
"getStarted": "Start Creating"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"tools": "Tools",
|
||||
"aiTools": "More Tools",
|
||||
"videoToFrames": "Video to Frames",
|
||||
"imageCompression": "Image Compression",
|
||||
"audioCompression": "Audio Compression",
|
||||
"textureAtlas": "Texture Atlas",
|
||||
"aiImage": "AI Image",
|
||||
"aiAudio": "AI Audio"
|
||||
},
|
||||
"uploader": {
|
||||
"dropFiles": "Drag & drop files here",
|
||||
"dropActive": "Drop your files here",
|
||||
"fileRejected": "File type not accepted",
|
||||
"browseFiles": "or click to browse • Max {{maxSize}} • Up to {{maxFiles}} {{file}}",
|
||||
"file_one": "file",
|
||||
"file_other": "files"
|
||||
},
|
||||
"progress": {
|
||||
"status": {
|
||||
"idle": "Ready to process",
|
||||
"uploading": "Uploading...",
|
||||
"processing": "Processing...",
|
||||
"completed": "Completed!",
|
||||
"failed": "Failed"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"imageCompression": {
|
||||
"title": "Compression Settings",
|
||||
"description": "Configure compression options",
|
||||
"quality": "Compression Quality",
|
||||
"qualityDescription": "Lower quality = smaller file size",
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Convert to a different format (optional)",
|
||||
"formatOriginal": "Original",
|
||||
"formatAuto": "Auto (Best)",
|
||||
"formatJpeg": "JPEG",
|
||||
"formatPng": "PNG",
|
||||
"formatWebp": "WebP",
|
||||
"formatAvif": "AVIF"
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "Export Settings",
|
||||
"description": "Configure how frames are extracted",
|
||||
"fps": "Frame Rate",
|
||||
"fpsDescription": "Number of frames to extract per second",
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Image format for the extracted frames",
|
||||
"quality": "Quality",
|
||||
"qualityDescription": "Image quality (for JPEG and WebP)"
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "Audio Settings",
|
||||
"description": "Configure compression parameters",
|
||||
"bitrate": "Bitrate",
|
||||
"bitrateDescription": "Higher bitrate = better quality, larger file",
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Target audio format",
|
||||
"sampleRate": "Sample Rate",
|
||||
"sampleRateDescription": "Audio sample rate in Hz",
|
||||
"channels": "Channels",
|
||||
"channelsDescription": "Audio channels",
|
||||
"stereo": "Stereo (2 channels)",
|
||||
"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)",
|
||||
"multiAtlas": "Multi-Atlas",
|
||||
"multiAtlasDescription": "Auto pack overflow sprites into multiple atlases",
|
||||
"compression": "PNG Compression",
|
||||
"compressionDescription": "Compress PNG using quantization algorithm"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"imageCompression": {
|
||||
"title": "Image Compression",
|
||||
"description": "World-class image compression with smart optimization",
|
||||
"compressImages": "Compress Images",
|
||||
"features": "Features",
|
||||
"featureList": [
|
||||
"Smart compression - guaranteed smaller output or return original",
|
||||
"Multi-strategy optimization - tries multiple algorithms to find the best result",
|
||||
"Auto format selection - intelligently picks the best format for your image",
|
||||
"MozJPEG & WebP - industry-leading compression algorithms",
|
||||
"Metadata stripping - automatic removal of EXIF and unnecessary data",
|
||||
"Batch processing - compress multiple images at once"
|
||||
]
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "Video to Frames",
|
||||
"description": "Extract frames from videos with customizable settings",
|
||||
"processVideo": "Process Video",
|
||||
"howItWorks": "How it works",
|
||||
"steps": [
|
||||
"Upload your video file (MP4, MOV, AVI, etc.)",
|
||||
"Configure frame rate, format, and quality",
|
||||
"Click \"Process Video\" to start extraction",
|
||||
"Download the ZIP file with all frames"
|
||||
]
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "Audio Compression",
|
||||
"description": "Compress and convert audio files with quality control",
|
||||
"compressAudio": "Compress Audio",
|
||||
"supportedFormats": "Supported Formats",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
|
||||
"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": {
|
||||
"uploadingImages": "Uploading images...",
|
||||
"compressingImages": "Compressing images...",
|
||||
"uploadingVideo": "Uploading video...",
|
||||
"extractingFrames": "Extracting frames...",
|
||||
"uploadingAudio": "Uploading audio...",
|
||||
"compressingAudio": "Compressing audio...",
|
||||
"uploadingSprites": "Uploading sprites...",
|
||||
"creatingAtlas": "Creating texture atlas...",
|
||||
"compressionComplete": "Compression complete!",
|
||||
"processingComplete": "Processing complete!",
|
||||
"atlasComplete": "Texture atlas created successfully!",
|
||||
"compressionFailed": "Compression failed",
|
||||
"processingFailed": "Processing failed",
|
||||
"unknownError": "Unknown error",
|
||||
"uploadProgress": "Uploading... {{progress}}%",
|
||||
"compressProgress": "Compressing... {{progress}}%",
|
||||
"processProgress": "Processing... {{progress}}%"
|
||||
},
|
||||
"results": {
|
||||
"processingComplete": "Processing Complete",
|
||||
"filesReady": "{{count}} {{file}} ready for download",
|
||||
"file_one": "file",
|
||||
"file_other": "files",
|
||||
"saved": "Saved {{ratio}}%"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Image Comparison",
|
||||
"original": "Original",
|
||||
"compressed": "Compressed",
|
||||
"dragHint": "Drag slider or click to compare",
|
||||
"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",
|
||||
"advancedSettings": "Advanced Settings",
|
||||
"multiAtlasHint": "When enabled, sprites that exceed the max size will be packed into multiple atlases",
|
||||
"unpackedCount": "{{count}} sprites could not fit",
|
||||
"unpackedSuggestion": "Try increasing max size or enable multi-atlas mode",
|
||||
"unpackedWarning": "{{count}} sprites could not fit in the atlas",
|
||||
"unpackedHint": "Try increasing max size or enable multi-atlas mode",
|
||||
"atlasCount": "Atlas Count",
|
||||
"atlasIndex": "Atlas {{current}} / {{total}}",
|
||||
"currentAtlas": "Current Atlas #{{index}}",
|
||||
"downloadAllAtlases": "Download All ({{count}} atlases)",
|
||||
"compressionSettings": "Compression",
|
||||
"compressionHint": "Enable PNG quantization to significantly reduce file size (similar to TinyPNG)"
|
||||
},
|
||||
"footer": {
|
||||
"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.",
|
||||
"sections": {
|
||||
"tools": "Tools",
|
||||
"company": "Product"
|
||||
},
|
||||
"links": {
|
||||
"home": "Home"
|
||||
},
|
||||
"copyright": "All rights reserved"
|
||||
}
|
||||
}
|
||||
394
src/locales/zh.json
Normal file
394
src/locales/zh.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "KYMR",
|
||||
"loading": "加载中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"download": "下载",
|
||||
"share": "分享",
|
||||
"reset": "重置",
|
||||
"submit": "提交",
|
||||
"tryNow": "立即尝试",
|
||||
"learnMore": "了解更多",
|
||||
"getStarted": "开始使用",
|
||||
"startBuilding": "开始创作",
|
||||
"signIn": "登录",
|
||||
"register": "注册",
|
||||
"features": "功能",
|
||||
"settings": "设置",
|
||||
"toggleMenu": "切换菜单",
|
||||
"processing": "处理中...",
|
||||
"uploading": "上传中...",
|
||||
"completed": "已完成!",
|
||||
"failed": "失败",
|
||||
"ready": "准备处理",
|
||||
"file": "文件",
|
||||
"files": "文件",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"on": "开启",
|
||||
"off": "关闭"
|
||||
},
|
||||
"nav": {
|
||||
"tools": "工具",
|
||||
"docs": "文档",
|
||||
"about": "关于",
|
||||
"dashboard": "仪表盘",
|
||||
"overview": "概览"
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"kicker": "为素材准备提速",
|
||||
"title": "为小游戏开发提供全链路提效赋能",
|
||||
"description": "视频抽帧、图片压缩、音频优化。一站式游戏素材处理工具,让开发更高效。",
|
||||
"startBuilding": "开始创作",
|
||||
"secondaryCta": "了解工具",
|
||||
"note": "无需安装插件。打开即用。",
|
||||
"previewTitle": "素材处理工作台",
|
||||
"stats": {
|
||||
"developers": "开发者",
|
||||
"filesProcessed": "文件处理量",
|
||||
"uptime": "正常运行时间"
|
||||
}
|
||||
},
|
||||
"featuresSection": {
|
||||
"title": "您需要的一切",
|
||||
"description": "专为游戏开发者设计的强大工具"
|
||||
},
|
||||
"showcase": {
|
||||
"kicker": "三件事,刚好够用",
|
||||
"title": "把素材准备变成一条顺畅的流水线",
|
||||
"description": "每个工具只保留最关键的控制项:更少打断,更快出结果。",
|
||||
"cta": "打开工具"
|
||||
},
|
||||
"workflow": {
|
||||
"title": "像写代码一样处理素材",
|
||||
"description": "清晰、可预期、可复用。把“试试”变成“稳定产出”。",
|
||||
"steps": {
|
||||
"step1": {
|
||||
"title": "拖进来",
|
||||
"description": "支持批量文件。把要处理的素材直接丢进页面。"
|
||||
},
|
||||
"step2": {
|
||||
"title": "调到刚好",
|
||||
"description": "只给你真正需要的参数:质量、帧率、格式。"
|
||||
},
|
||||
"step3": {
|
||||
"title": "拿走结果",
|
||||
"description": "输出结构清晰,便于集成到美术/工程流程。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality": {
|
||||
"title": "快、稳、讲究",
|
||||
"description": "不是花哨的功能堆叠,而是每一次点击都更顺手。",
|
||||
"items": {
|
||||
"fast": {
|
||||
"title": "极速处理",
|
||||
"description": "高频操作更快完成,把等待时间还给创作。"
|
||||
},
|
||||
"private": {
|
||||
"title": "安全私密",
|
||||
"description": "文件处理遵循最小化原则,减少不必要的暴露。"
|
||||
},
|
||||
"designed": {
|
||||
"title": "为开发者打造",
|
||||
"description": "简洁的信息架构与可复制的操作节奏,降低学习成本。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"final": {
|
||||
"title": "现在就开始,把时间留给游戏本身",
|
||||
"description": "选一个工具,从第一份素材开始建立你的高效流程。",
|
||||
"primaryCta": "立即开始",
|
||||
"secondaryCta": "先试试抽帧"
|
||||
},
|
||||
"tools": {
|
||||
"videoToFrames": {
|
||||
"title": "视频抽帧",
|
||||
"description": "从视频中提取帧,可自定义帧率。非常适合精灵动画制作。"
|
||||
},
|
||||
"imageCompression": {
|
||||
"title": "图片压缩",
|
||||
"description": "为网页和移动端优化图片,不影响质量。支持批量处理。"
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "音频压缩",
|
||||
"description": "压缩并转换音频文件为多种格式。调整比特率和采样率。"
|
||||
},
|
||||
"textureAtlas": {
|
||||
"title": "合图工具",
|
||||
"description": "将多张精灵图合并为优化的纹理图集。提升游戏性能的利器。"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "更多工具",
|
||||
"description": "更多游戏开发实用工具,敬请期待。"
|
||||
}
|
||||
},
|
||||
"benefits": {
|
||||
"title": "为什么选择 KYMR?",
|
||||
"description": "我们了解游戏开发的独特挑战。我们的工具帮助您更快速、更智能地工作。",
|
||||
"lightningFast": {
|
||||
"title": "极速处理",
|
||||
"description": "通过优化的基础设施,在几秒钟内处理文件。"
|
||||
},
|
||||
"secure": {
|
||||
"title": "安全私密",
|
||||
"description": "您的文件将被加密,处理完成后自动删除。"
|
||||
},
|
||||
"forDevelopers": {
|
||||
"title": "专为开发者打造",
|
||||
"description": "API 访问、批量处理,以及专为游戏开发工作流程设计的工具。"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "准备好升级了吗?",
|
||||
"description": "加入数千名使用我们工具开发精彩游戏的开发者。",
|
||||
"getStarted": "开始创作"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"tools": "工具",
|
||||
"aiTools": "更多工具",
|
||||
"videoToFrames": "视频抽帧",
|
||||
"imageCompression": "图片压缩",
|
||||
"audioCompression": "音频压缩",
|
||||
"textureAtlas": "合图工具",
|
||||
"aiImage": "AI 图片",
|
||||
"aiAudio": "AI 音频"
|
||||
},
|
||||
"uploader": {
|
||||
"dropFiles": "拖拽文件到这里",
|
||||
"dropActive": "释放文件即可上传",
|
||||
"fileRejected": "不支持的文件类型",
|
||||
"browseFiles": "或点击选择文件 • 最大 {{maxSize}} • 最多 {{maxFiles}} 个{{file}}",
|
||||
"file_one": "文件",
|
||||
"file_other": "文件"
|
||||
},
|
||||
"progress": {
|
||||
"status": {
|
||||
"idle": "准备处理",
|
||||
"uploading": "上传中...",
|
||||
"processing": "处理中...",
|
||||
"completed": "已完成!",
|
||||
"failed": "失败"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"imageCompression": {
|
||||
"title": "压缩设置",
|
||||
"description": "配置压缩选项",
|
||||
"quality": "压缩质量",
|
||||
"qualityDescription": "质量越低 = 文件越小",
|
||||
"format": "输出格式",
|
||||
"formatDescription": "转换为其他格式(可选)",
|
||||
"formatOriginal": "原始",
|
||||
"formatAuto": "自动(最佳)",
|
||||
"formatJpeg": "JPEG",
|
||||
"formatPng": "PNG",
|
||||
"formatWebp": "WebP",
|
||||
"formatAvif": "AVIF"
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "导出设置",
|
||||
"description": "配置帧提取方式",
|
||||
"fps": "帧率",
|
||||
"fpsDescription": "每秒提取的帧数",
|
||||
"format": "输出格式",
|
||||
"formatDescription": "提取帧的图片格式",
|
||||
"quality": "质量",
|
||||
"qualityDescription": "图片质量(适用于 JPEG 和 WebP)"
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "音频设置",
|
||||
"description": "配置压缩参数",
|
||||
"bitrate": "比特率",
|
||||
"bitrateDescription": "比特率越高 = 质量越好,文件越大",
|
||||
"format": "输出格式",
|
||||
"formatDescription": "目标音频格式",
|
||||
"sampleRate": "采样率",
|
||||
"sampleRateDescription": "音频采样率(Hz)",
|
||||
"channels": "声道",
|
||||
"channelsDescription": "音频声道",
|
||||
"stereo": "立体声(2 声道)",
|
||||
"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(快速)",
|
||||
"multiAtlas": "多图打包",
|
||||
"multiAtlasDescription": "超出尺寸的精灵自动打包到多张图片",
|
||||
"compression": "PNG 压缩",
|
||||
"compressionDescription": "使用量化算法压缩 PNG 图片"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"imageCompression": {
|
||||
"title": "图片压缩",
|
||||
"description": "世界一流的图片压缩,智能优化",
|
||||
"compressImages": "压缩图片",
|
||||
"features": "功能特点",
|
||||
"featureList": [
|
||||
"智能压缩 - 保证输出更小或返回原图",
|
||||
"多策略优化 - 尝试多种算法找到最佳结果",
|
||||
"自动格式选择 - 智能选择最适合的格式",
|
||||
"MozJPEG & WebP - 业界领先的压缩算法",
|
||||
"元数据剥离 - 自动移除 EXIF 等冗余数据",
|
||||
"批量处理 - 一次压缩多张图片"
|
||||
]
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "视频抽帧",
|
||||
"description": "从视频中提取帧,可自定义设置",
|
||||
"processVideo": "处理视频",
|
||||
"howItWorks": "工作原理",
|
||||
"steps": [
|
||||
"上传视频文件(MP4、MOV、AVI 等)",
|
||||
"配置帧率、格式和质量",
|
||||
"点击「处理视频」开始提取",
|
||||
"下载包含所有帧的 ZIP 文件"
|
||||
]
|
||||
},
|
||||
"audioCompression": {
|
||||
"title": "音频压缩",
|
||||
"description": "压缩并转换音频文件,可控质量",
|
||||
"compressAudio": "压缩音频",
|
||||
"supportedFormats": "支持的格式",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
|
||||
"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": {
|
||||
"uploadingImages": "上传图片中...",
|
||||
"compressingImages": "压缩图片中...",
|
||||
"uploadingVideo": "上传视频中...",
|
||||
"extractingFrames": "提取帧中...",
|
||||
"uploadingAudio": "上传音频中...",
|
||||
"compressingAudio": "压缩音频中...",
|
||||
"uploadingSprites": "上传精灵图中...",
|
||||
"creatingAtlas": "创建合图中...",
|
||||
"compressionComplete": "压缩完成!",
|
||||
"processingComplete": "处理完成!",
|
||||
"atlasComplete": "合图创建成功!",
|
||||
"compressionFailed": "压缩失败",
|
||||
"processingFailed": "处理失败",
|
||||
"unknownError": "未知错误",
|
||||
"uploadProgress": "上传中... {{progress}}%",
|
||||
"compressProgress": "压缩中... {{progress}}%",
|
||||
"processProgress": "处理中... {{progress}}%"
|
||||
},
|
||||
"results": {
|
||||
"processingComplete": "处理完成",
|
||||
"filesReady": "{{count}} 个{{file}}可下载",
|
||||
"file_one": "文件",
|
||||
"file_other": "文件",
|
||||
"saved": "节省 {{ratio}}%"
|
||||
},
|
||||
"preview": {
|
||||
"title": "图片对比",
|
||||
"original": "原图",
|
||||
"compressed": "压缩后",
|
||||
"dragHint": "拖动滑块或点击来对比",
|
||||
"filename": "文件名"
|
||||
},
|
||||
"atlas": {
|
||||
"dropSprites": "拖拽精灵图到这里",
|
||||
"supportFolder": "支持拖拽文件夹上传",
|
||||
"selectFiles": "选择文件",
|
||||
"selectFolder": "选择文件夹",
|
||||
"preview": "预览",
|
||||
"emptyPreview": "上传精灵图后预览合图效果",
|
||||
"dragHint": "拖拽文件或文件夹到左侧面板",
|
||||
"panHint": "拖拽平移,滚轮缩放",
|
||||
"packing": "打包中...",
|
||||
"rendering": "渲染中...",
|
||||
"previewAnimation": "预览动画",
|
||||
"sizeSettings": "尺寸设置",
|
||||
"layoutSettings": "布局设置",
|
||||
"outputSettings": "输出设置",
|
||||
"resultInfo": "合图信息",
|
||||
"animationPreview": "动画预览",
|
||||
"animationDescription": "预览精灵序列帧动画效果",
|
||||
"frame": "帧",
|
||||
"fps": "帧率",
|
||||
"spriteSize": "精灵尺寸",
|
||||
"totalFrames": "总帧数",
|
||||
"duration": "时长",
|
||||
"advancedSettings": "高级设置",
|
||||
"multiAtlasHint": "开启后,超出单张合图尺寸的精灵将自动打包到多张图片中",
|
||||
"unpackedCount": "有 {{count}} 张图片未能放入",
|
||||
"unpackedSuggestion": "建议增大最大尺寸,或开启多图打包功能",
|
||||
"unpackedWarning": "{{count}} 张精灵图未能放入合图",
|
||||
"unpackedHint": "请增大最大尺寸,或开启多图打包功能",
|
||||
"atlasCount": "合图数量",
|
||||
"atlasIndex": "合图 {{current}} / {{total}}",
|
||||
"currentAtlas": "当前合图 #{{index}}",
|
||||
"downloadAllAtlases": "打包下载全部 ({{count}} 张)",
|
||||
"compressionSettings": "压缩设置",
|
||||
"compressionHint": "开启后使用 PNG 量化压缩,可大幅减小文件体积(类似 TinyPNG)"
|
||||
},
|
||||
"footer": {
|
||||
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
|
||||
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
|
||||
"sections": {
|
||||
"tools": "工具",
|
||||
"company": "产品"
|
||||
},
|
||||
"links": {
|
||||
"home": "首页"
|
||||
},
|
||||
"copyright": "保留所有权利"
|
||||
}
|
||||
}
|
||||
293
src/store/atlasStore.ts
Normal file
293
src/store/atlasStore.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single atlas result
|
||||
*/
|
||||
export interface SingleAtlasResult {
|
||||
index: number;
|
||||
width: number;
|
||||
height: number;
|
||||
placements: PackerPlacement[];
|
||||
frames: AtlasFrame[];
|
||||
imageDataUrl: string | null;
|
||||
/** Sprite IDs in this atlas */
|
||||
spriteIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete atlas result (supports multiple atlases)
|
||||
*/
|
||||
export interface AtlasResult {
|
||||
/** All atlases */
|
||||
atlases: SingleAtlasResult[];
|
||||
/** Total number of packed sprites */
|
||||
packedCount: number;
|
||||
/** Sprites that couldn't fit in any atlas (only when multi-atlas is disabled) */
|
||||
unpackedSpriteIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy single result format for backward compatibility
|
||||
*/
|
||||
export interface LegacyAtlasResult {
|
||||
width: number;
|
||||
height: number;
|
||||
placements: PackerPlacement[];
|
||||
frames: AtlasFrame[];
|
||||
imageDataUrl: string | null;
|
||||
unpackedSpriteIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Multi-atlas mode
|
||||
enableMultiAtlas: boolean;
|
||||
|
||||
// Compression mode (PNG quantization)
|
||||
enableCompression: boolean;
|
||||
|
||||
// Processing state
|
||||
status: AtlasProcessStatus;
|
||||
progress: number;
|
||||
errorMessage: string | null;
|
||||
|
||||
// Result (supports multiple atlases)
|
||||
result: AtlasResult | null;
|
||||
|
||||
// Current preview atlas index
|
||||
currentAtlasIndex: number;
|
||||
|
||||
// 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;
|
||||
|
||||
setEnableMultiAtlas: (enable: boolean) => void;
|
||||
setEnableCompression: (enable: boolean) => void;
|
||||
|
||||
setStatus: (status: AtlasProcessStatus) => void;
|
||||
setProgress: (progress: number) => void;
|
||||
setError: (message: string | null) => void;
|
||||
|
||||
setResult: (result: AtlasResult | null) => void;
|
||||
setCurrentAtlasIndex: (index: number) => 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;
|
||||
|
||||
// Computed helpers
|
||||
getCurrentAtlas: () => SingleAtlasResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default texture atlas configuration
|
||||
*/
|
||||
const defaultConfig: TextureAtlasConfig = {
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
padding: 2,
|
||||
allowRotation: false,
|
||||
pot: false,
|
||||
format: "png",
|
||||
quality: 90,
|
||||
outputFormat: "cocos2d",
|
||||
algorithm: "MaxRects",
|
||||
};
|
||||
|
||||
/**
|
||||
* Atlas Store
|
||||
*/
|
||||
export const useAtlasStore = create<AtlasState>((set, get) => ({
|
||||
// Initial state
|
||||
sprites: [],
|
||||
folderName: "",
|
||||
config: { ...defaultConfig },
|
||||
enableMultiAtlas: false,
|
||||
enableCompression: false,
|
||||
status: "idle",
|
||||
progress: 0,
|
||||
errorMessage: null,
|
||||
result: null,
|
||||
currentAtlasIndex: 0,
|
||||
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, currentAtlasIndex: 0 };
|
||||
});
|
||||
},
|
||||
|
||||
removeSprite: (id) => {
|
||||
set((state) => ({
|
||||
sprites: state.sprites.filter((s) => s.id !== id),
|
||||
selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id),
|
||||
result: null,
|
||||
currentAtlasIndex: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
clearSprites: () => {
|
||||
// Release ImageBitmap resources
|
||||
const { sprites } = get();
|
||||
sprites.forEach((s) => s.image.close());
|
||||
|
||||
set({
|
||||
sprites: [],
|
||||
folderName: "",
|
||||
result: null,
|
||||
currentAtlasIndex: 0,
|
||||
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
|
||||
currentAtlasIndex: 0,
|
||||
}));
|
||||
},
|
||||
|
||||
resetConfig: () => set({ config: { ...defaultConfig }, result: null, currentAtlasIndex: 0 }),
|
||||
|
||||
// Multi-atlas mode
|
||||
setEnableMultiAtlas: (enable) => set({ enableMultiAtlas: enable, result: null, currentAtlasIndex: 0 }),
|
||||
|
||||
// Compression mode (only affects download, not preview)
|
||||
setEnableCompression: (enable) => set({ enableCompression: enable }),
|
||||
|
||||
// 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", currentAtlasIndex: 0 }),
|
||||
|
||||
setCurrentAtlasIndex: (index) => {
|
||||
const { result } = get();
|
||||
if (result && index >= 0 && index < result.atlases.length) {
|
||||
set({ currentAtlasIndex: index, previewOffset: { x: 0, y: 0 } });
|
||||
}
|
||||
},
|
||||
|
||||
// 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 ? [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)) }),
|
||||
|
||||
// Computed helpers
|
||||
getCurrentAtlas: () => {
|
||||
const { result, currentAtlasIndex } = get();
|
||||
if (!result || result.atlases.length === 0) return null;
|
||||
return result.atlases[currentAtlasIndex] || null;
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}));
|
||||
export const useCurrentAtlas = () => useAtlasStore((state) => {
|
||||
if (!state.result || state.result.atlases.length === 0) return null;
|
||||
return state.result.atlases[state.currentAtlasIndex] || null;
|
||||
});
|
||||
@@ -35,6 +35,9 @@ export interface ProcessMetadata {
|
||||
duration?: number;
|
||||
frames?: number;
|
||||
compressionRatio?: number;
|
||||
originalSize?: number;
|
||||
compressedSize?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ProcessingResult {
|
||||
@@ -56,7 +59,7 @@ export interface ProcessingProgress {
|
||||
* 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 {
|
||||
type: ToolType;
|
||||
@@ -126,7 +129,7 @@ export interface VideoFramesConfig {
|
||||
|
||||
export interface ImageCompressConfig {
|
||||
quality: number;
|
||||
format: "original" | "jpeg" | "png" | "webp";
|
||||
format: "original" | "auto" | "jpeg" | "png" | "webp" | "avif";
|
||||
resize?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -140,3 +143,52 @@ export interface AudioCompressConfig {
|
||||
sampleRate: 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;
|
||||
}
|
||||
|
||||
54
src/types/upng-js.d.ts
vendored
Normal file
54
src/types/upng-js.d.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
declare module "upng-js" {
|
||||
/**
|
||||
* Encode RGBA image data to PNG
|
||||
* @param imgs Array of ArrayBuffer containing RGBA data
|
||||
* @param w Width of the image
|
||||
* @param h Height of the image
|
||||
* @param cnum Number of colors (0 = lossless, 256 = 8-bit quantization)
|
||||
* @param dels Optional delays for APNG frames
|
||||
* @returns ArrayBuffer containing PNG data
|
||||
*/
|
||||
export function encode(
|
||||
imgs: ArrayBuffer[],
|
||||
w: number,
|
||||
h: number,
|
||||
cnum: number,
|
||||
dels?: number[]
|
||||
): ArrayBuffer;
|
||||
|
||||
/**
|
||||
* Decode PNG to RGBA image data
|
||||
* @param buffer PNG data
|
||||
* @returns Decoded image info
|
||||
*/
|
||||
export function decode(buffer: ArrayBuffer): {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
ctype: number;
|
||||
frames: Array<{
|
||||
rect: { x: number; y: number; width: number; height: number };
|
||||
delay: number;
|
||||
dispose: number;
|
||||
blend: number;
|
||||
}>;
|
||||
tabs: Record<string, unknown>;
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert decoded PNG to RGBA format
|
||||
* @param img Decoded image from decode()
|
||||
* @param frameIndex Frame index for APNG (default 0)
|
||||
* @returns Uint8Array of RGBA data
|
||||
*/
|
||||
export function toRGBA8(img: ReturnType<typeof decode>, frameIndex?: number): Uint8Array[];
|
||||
|
||||
const UPNG: {
|
||||
encode: typeof encode;
|
||||
decode: typeof decode;
|
||||
toRGBA8: typeof toRGBA8;
|
||||
};
|
||||
|
||||
export default UPNG;
|
||||
}
|
||||
Reference in New Issue
Block a user