spritemint 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/bin/spritemint.js +12 -0
- package/package.json +36 -0
- package/src/commands/buildSheet.js +90 -0
- package/src/commands/extractHorizontal.js +97 -0
- package/src/commands/normalize.js +47 -0
- package/src/core/buildSpriteSheet.js +87 -0
- package/src/core/extractAndBuildHorizontal.js +174 -0
- package/src/core/normalizeSprites.js +52 -0
- package/src/help.js +44 -0
- package/src/index.js +41 -0
- package/src/utils/folder.js +155 -0
- package/src/utils/image.js +125 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rafet KAYA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🍃 SpriteMint
|
|
4
|
+
|
|
5
|
+
**Interactive Node.js CLI tool for Unity 2D developers**
|
|
6
|
+
|
|
7
|
+
[](https://npmjs.org/package/spritemint)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://nodejs.org/)
|
|
10
|
+
[](#)
|
|
11
|
+
|
|
12
|
+
*Normalize sprites, extract sprite sheets, and build new sprite sheets — directly from the terminal, no GUI required.*
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🚀 Demo
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
$ spritemint
|
|
22
|
+
|
|
23
|
+
🍃 SpriteMint — Unity Sprite Processor
|
|
24
|
+
|
|
25
|
+
? What do you want to do?
|
|
26
|
+
❯ Normalize Sprites
|
|
27
|
+
Extract Sprites from Sheet and Build Horizontal Output
|
|
28
|
+
Build Sprite Sheet from Selected PNGs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 📖 Help
|
|
34
|
+
|
|
35
|
+
Not sure what to do? Just run:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
spritemint --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> You can also use the shorthand: `spritemint -h`
|
|
42
|
+
|
|
43
|
+
This will print a full usage guide directly in your terminal:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
SpriteMint — Unity Sprite Processor
|
|
47
|
+
|
|
48
|
+
USAGE
|
|
49
|
+
spritemint Launch interactive menu
|
|
50
|
+
spritemint --help Show this help message
|
|
51
|
+
spritemint -h Show this help message
|
|
52
|
+
|
|
53
|
+
COMMANDS
|
|
54
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
55
|
+
│ 1. Normalize Sprites │
|
|
56
|
+
│ Resize PNGs to a square canvas with transparent padding. │
|
|
57
|
+
│ Aspect ratio is always preserved. │
|
|
58
|
+
│ Output → <folder>/normalized/ │
|
|
59
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
60
|
+
│ 2. Extract Sprites from Sheet & Build Horizontal Output │
|
|
61
|
+
│ Extract frames from a grid-based sprite sheet, │
|
|
62
|
+
│ normalize each one, and stitch into a horizontal strip. │
|
|
63
|
+
│ Output → <folder>/output_horizontal.png │
|
|
64
|
+
├─────────────────────────────────────────────────────────────────┤
|
|
65
|
+
│ 3. Build Sprite Sheet from Selected PNGs │
|
|
66
|
+
│ Pick individual PNGs and combine them into a sprite sheet. │
|
|
67
|
+
│ Supports horizontal strip or grid layout. │
|
|
68
|
+
│ Output → <folder>/spritesheet_output.png │
|
|
69
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
70
|
+
|
|
71
|
+
REQUIREMENTS
|
|
72
|
+
Node.js v18+ | PNG image files
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## ✨ Features
|
|
78
|
+
|
|
79
|
+
### 1️⃣ Normalize Sprites
|
|
80
|
+
- 📂 **Interactive Selection**: Scans a folder and presents PNG files in an interactive checkbox list.
|
|
81
|
+
- 📐 **Smart Resizing**: Resizes each sprite so its largest side fits within the chosen canvas size (256, 512, 1024, or custom).
|
|
82
|
+
- 🖼️ **Aspect Ratio Preservation**: Never stretches or distorts your art.
|
|
83
|
+
- 🎯 **Perfect Centering**: Centers each sprite on a transparent square canvas with equal padding on all four sides.
|
|
84
|
+
- 💾 **Clean Output**: Saves results directly into a `normalized/` folder in your working directory.
|
|
85
|
+
|
|
86
|
+
### 2️⃣ Extract Sprites from Sheet & Build Horizontal Output
|
|
87
|
+
- 🧩 **Grid Extraction**: Accepts a sprite sheet PNG and extracts each grid cell using exact pixel coordinates.
|
|
88
|
+
- 🔍 **Smart Auto Detect**: Analyses image dimensions to find the most likely grid — prefers square, power-of-two cells. If detection fails, falls back to asking you for rows and columns.
|
|
89
|
+
- ✂️ **Automatic Trimming**: Trims transparent edges from each extracted sprite automatically.
|
|
90
|
+
- 🔄 **Normalization**: Normalizes each extracted sprite into an equal-sized square canvas.
|
|
91
|
+
- 🎞️ **Horizontal Compositing**: Composites all sprites side-by-side into a single, seamless horizontal PNG strip, perfect for Unity animations.
|
|
92
|
+
- 📂 **Subfolder Scanning**: Searches the current folder and its immediate subdirectories for PNG files.
|
|
93
|
+
|
|
94
|
+
### 3️⃣ Build Sprite Sheet from Selected PNGs
|
|
95
|
+
- ☑️ **Interactive File Picker**: Select any combination of PNG files from a folder using an interactive checkbox list.
|
|
96
|
+
- 🗂️ **Flexible Layouts**: Choose between a **Horizontal Strip** (all sprites in one row) or a **Grid** (specify columns, rows calculated automatically).
|
|
97
|
+
- 📏 **Configurable Cell Size**: Pick from 256, 512, or 1024 px — or enter a custom value.
|
|
98
|
+
- 🎯 **Auto-Centering**: Every sprite is trimmed, scaled to fit the cell, and centered with equal padding on all sides.
|
|
99
|
+
- 🖼️ **Transparent Canvas**: The output sheet is a fully transparent PNG — compositing-ready for Unity or any tool.
|
|
100
|
+
- 💾 **Saves to Working Directory**: Output filename is configurable (defaults to `sheet.png`) and is written to wherever you invoked the CLI.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## ⚙️ Installation
|
|
105
|
+
|
|
106
|
+
### Global Install (Recommended)
|
|
107
|
+
|
|
108
|
+
To run SpriteMint from anywhere on your machine, install it globally:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install -g spritemint
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then simply use the command:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
spritemint
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Local / Development Setup
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
git clone https://github.com/rakaya07/spritemint.git
|
|
124
|
+
cd spritemint
|
|
125
|
+
npm install
|
|
126
|
+
npm start
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 🕹️ Usage Examples
|
|
132
|
+
|
|
133
|
+
### 1. Normalize Sprites
|
|
134
|
+
|
|
135
|
+
Run SpriteMint in your project folder, and it will guide you interactively:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
$ spritemint
|
|
139
|
+
|
|
140
|
+
? What do you want to do?
|
|
141
|
+
❯ Normalize Sprites
|
|
142
|
+
|
|
143
|
+
? Select folder containing PNG files:
|
|
144
|
+
❯ Use current folder
|
|
145
|
+
|
|
146
|
+
? Select PNG sprites to normalize (use space to select):
|
|
147
|
+
❯ ◉ hero_idle.png
|
|
148
|
+
◉ hero_walk1.png
|
|
149
|
+
◉ hero_walk2.png
|
|
150
|
+
|
|
151
|
+
? Output sprite size:
|
|
152
|
+
❯ 512
|
|
153
|
+
|
|
154
|
+
✔ Done! 3 sprites normalized.
|
|
155
|
+
|
|
156
|
+
Output : /your/project/normalized
|
|
157
|
+
Size : 512×512px
|
|
158
|
+
Files : 3 processed
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 2. Extract Sprites from Sheet
|
|
162
|
+
|
|
163
|
+
Perfect for converting existing grid sheets into Unity-friendly horizontal strips:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
$ spritemint
|
|
167
|
+
|
|
168
|
+
? What do you want to do?
|
|
169
|
+
❯ Extract Sprites from Sheet and Build Horizontal Output
|
|
170
|
+
|
|
171
|
+
? Select PNG file:
|
|
172
|
+
❯ sheet_characters.png
|
|
173
|
+
sprites/hero_sheet.png
|
|
174
|
+
|
|
175
|
+
? Sprite detection mode:
|
|
176
|
+
❯ Auto Detect
|
|
177
|
+
|
|
178
|
+
ℹ Auto-detected grid: 2 rows × 2 cols
|
|
179
|
+
|
|
180
|
+
? Output cell size (px):
|
|
181
|
+
❯ 512
|
|
182
|
+
|
|
183
|
+
? Output filename: output-horizontal.png
|
|
184
|
+
|
|
185
|
+
✔ Done!
|
|
186
|
+
|
|
187
|
+
Input : /your/project/sheet_characters.png
|
|
188
|
+
Mode : Auto Detect
|
|
189
|
+
Grid : 2 rows × 2 cols
|
|
190
|
+
Sprites : 4 / 4
|
|
191
|
+
Cell size: 512×512px
|
|
192
|
+
Output : /your/project/output-horizontal.png
|
|
193
|
+
Canvas : 2048×512px
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
> If Auto Detect cannot determine the grid (non-power-of-two dimensions), SpriteMint will automatically ask you to enter the row and column count manually.
|
|
197
|
+
|
|
198
|
+
### 3. Build Sprite Sheet from Selected PNGs
|
|
199
|
+
|
|
200
|
+
Combine individual PNG files into a new sprite sheet with full control over layout and cell size:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
$ spritemint
|
|
204
|
+
|
|
205
|
+
? What do you want to do?
|
|
206
|
+
❯ Build Sprite Sheet from Selected PNGs
|
|
207
|
+
|
|
208
|
+
? Select folder containing PNG files:
|
|
209
|
+
❯ Use current folder
|
|
210
|
+
|
|
211
|
+
? Select PNG files to include (use space to select):
|
|
212
|
+
❯ ◉ hero_idle.png
|
|
213
|
+
◉ hero_run1.png
|
|
214
|
+
◉ hero_run2.png
|
|
215
|
+
◉ hero_run3.png
|
|
216
|
+
|
|
217
|
+
? Output layout:
|
|
218
|
+
❯ Grid
|
|
219
|
+
|
|
220
|
+
? Output cell size (px):
|
|
221
|
+
❯ 512
|
|
222
|
+
|
|
223
|
+
? Number of columns (4 images selected): 2
|
|
224
|
+
|
|
225
|
+
? Output filename: hero_sheet.png
|
|
226
|
+
|
|
227
|
+
✔ Done! Sprite sheet built from 4 images.
|
|
228
|
+
|
|
229
|
+
Images : 4 selected
|
|
230
|
+
Layout : Grid
|
|
231
|
+
Grid : 2 cols × 2 rows
|
|
232
|
+
Cell : 512×512px
|
|
233
|
+
Output : /your/project/hero_sheet.png
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 🗺️ Visual Workflow
|
|
239
|
+
|
|
240
|
+
### Normalizing Individual Sprites
|
|
241
|
+
```text
|
|
242
|
+
[ RAW SPRITES ]
|
|
243
|
+
hero_idle.png
|
|
244
|
+
hero_walk1.png
|
|
245
|
+
hero_walk2.png
|
|
246
|
+
│
|
|
247
|
+
▼
|
|
248
|
+
🍃 SpriteMint
|
|
249
|
+
Center & Normalize
|
|
250
|
+
│
|
|
251
|
+
▼
|
|
252
|
+
[ NORMALIZED OUTPUT ] normalized/
|
|
253
|
+
hero_idle.png (512×512, centered on transparent canvas)
|
|
254
|
+
hero_walk1.png
|
|
255
|
+
hero_walk2.png
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Extracting & Building a Horizontal Strip
|
|
259
|
+
```text
|
|
260
|
+
[ sheet_characters.png ] (1024×1024, 2×2 grid)
|
|
261
|
+
│
|
|
262
|
+
▼
|
|
263
|
+
🍃 SpriteMint
|
|
264
|
+
Extract → Normalize
|
|
265
|
+
│
|
|
266
|
+
▼
|
|
267
|
+
[ output-horizontal.png ] (2048×512 seamless strip)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Building a Sheet from Selected PNGs
|
|
271
|
+
```text
|
|
272
|
+
[ INDIVIDUAL PNGs ]
|
|
273
|
+
hero_idle.png
|
|
274
|
+
hero_run1.png ─────────┐
|
|
275
|
+
hero_run2.png │ 🍃 SpriteMint
|
|
276
|
+
hero_run3.png ─────────┘ Select → Layout → Composite
|
|
277
|
+
│
|
|
278
|
+
▼
|
|
279
|
+
[ hero_sheet.png ] (1024×1024, 2×2 grid)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## 📂 Project Structure
|
|
285
|
+
|
|
286
|
+
```text
|
|
287
|
+
spritemint/
|
|
288
|
+
├── bin/
|
|
289
|
+
│ └── spritemint.js # CLI entry point
|
|
290
|
+
├── src/
|
|
291
|
+
│ ├── index.js # Main menu & orchestration
|
|
292
|
+
│ ├── commands/
|
|
293
|
+
│ │ ├── normalize.js # Flow: Normalize Sprites
|
|
294
|
+
│ │ ├── extractHorizontal.js # Flow: Extract & Composite
|
|
295
|
+
│ │ └── buildSheet.js # Flow: Build Sprite Sheet
|
|
296
|
+
│ ├── core/
|
|
297
|
+
│ │ ├── normalizeSprites.js # Logic: normalizing
|
|
298
|
+
│ │ ├── extractAndBuildHorizontal.js # Logic: extraction & compositing
|
|
299
|
+
│ │ └── buildSpriteSheet.js # Logic: sheet building
|
|
300
|
+
│ └── utils/
|
|
301
|
+
│ ├── folder.js # Shared folder/file selection prompts
|
|
302
|
+
│ └── image.js # Shared image processing & SIZE_CHOICES
|
|
303
|
+
├── package.json
|
|
304
|
+
└── README.md
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## 🛣️ Roadmap
|
|
310
|
+
|
|
311
|
+
We are constantly aiming to make `SpriteMint` better. Planned features include:
|
|
312
|
+
|
|
313
|
+
- [x] **Grid Auto Detection**: Detects grid layout from image dimensions (power-of-two cell analysis). Falls back to manual input if detection fails.
|
|
314
|
+
- [ ] **Advanced Auto Sprite Detection**: Detect sprites without a rigid grid (using alpha/pixel clustering).
|
|
315
|
+
- [ ] **GUI Version**: A standalone Desktop companion app (Electron/Tauri) for visual users.
|
|
316
|
+
- [ ] **WebP/GIF Support**: Output configurations for highly-compressed formats.
|
|
317
|
+
- [ ] **Custom Paddings & Offsets**: More granular controls over sprite placement on canvas.
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 🤝 Contributing
|
|
322
|
+
|
|
323
|
+
Contributions are completely welcome and encouraged! Here's how you can help:
|
|
324
|
+
|
|
325
|
+
1. **Fork** the repository.
|
|
326
|
+
2. Create your feature branch (`git checkout -b feature/AmazingFeature`).
|
|
327
|
+
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
|
|
328
|
+
4. Push to the branch (`git push origin feature/AmazingFeature`).
|
|
329
|
+
5. Open a **Pull Request**.
|
|
330
|
+
|
|
331
|
+
All issues and PRs are appreciated to make this tool better for the open source and game developer community.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## 📄 License
|
|
336
|
+
|
|
337
|
+
This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
|
|
338
|
+
|
|
339
|
+
<div align="center">
|
|
340
|
+
<i>Built with ❤️ for Game Developers & the Unity Community.</i>
|
|
341
|
+
</div>
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spritemint",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive CLI tool for normalizing, extracting, and building Unity sprite sheets",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"spritemint": "./bin/spritemint.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/spritemint.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"sprite",
|
|
15
|
+
"spritesheet",
|
|
16
|
+
"unity",
|
|
17
|
+
"image",
|
|
18
|
+
"cli",
|
|
19
|
+
"normalize",
|
|
20
|
+
"extract",
|
|
21
|
+
"sharp",
|
|
22
|
+
"game-dev",
|
|
23
|
+
"pixel-art"
|
|
24
|
+
],
|
|
25
|
+
"author": "Rafet",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"chalk": "^5.3.0",
|
|
32
|
+
"inquirer": "^9.3.7",
|
|
33
|
+
"ora": "^8.1.1",
|
|
34
|
+
"sharp": "^0.34.5"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { buildSpriteSheet } from '../core/buildSpriteSheet.js';
|
|
4
|
+
import { selectFolder, selectPngFiles } from '../utils/folder.js';
|
|
5
|
+
import { SIZE_CHOICES } from '../utils/image.js';
|
|
6
|
+
|
|
7
|
+
const LAYOUT_CHOICES = {
|
|
8
|
+
HORIZONTAL: 'Horizontal Strip',
|
|
9
|
+
GRID: 'Grid',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function runBuildSheet() {
|
|
13
|
+
console.log(chalk.cyan('\n Build Sprite Sheet from Selected PNGs\n'));
|
|
14
|
+
|
|
15
|
+
const folder = await selectFolder();
|
|
16
|
+
|
|
17
|
+
const files = await selectPngFiles(folder, 'Select PNG files to include (use space to select):');
|
|
18
|
+
if (files.length === 0) {
|
|
19
|
+
console.log(chalk.yellow('\n No files selected. Operation cancelled.\n'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { layout } = await inquirer.prompt([
|
|
24
|
+
{
|
|
25
|
+
type: 'list',
|
|
26
|
+
name: 'layout',
|
|
27
|
+
message: 'Output layout:',
|
|
28
|
+
choices: Object.values(LAYOUT_CHOICES),
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const { sizeChoice } = await inquirer.prompt([
|
|
33
|
+
{
|
|
34
|
+
type: 'list',
|
|
35
|
+
name: 'sizeChoice',
|
|
36
|
+
message: 'Output cell size (px):',
|
|
37
|
+
choices: Object.values(SIZE_CHOICES),
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
let cellSize;
|
|
42
|
+
if (sizeChoice === SIZE_CHOICES.CUSTOM) {
|
|
43
|
+
const { customSize } = await inquirer.prompt([
|
|
44
|
+
{
|
|
45
|
+
type: 'input',
|
|
46
|
+
name: 'customSize',
|
|
47
|
+
message: 'Enter custom cell size (px):',
|
|
48
|
+
validate: (input) => {
|
|
49
|
+
const n = parseInt(input, 10);
|
|
50
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
51
|
+
},
|
|
52
|
+
filter: (input) => parseInt(input, 10),
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
cellSize = customSize;
|
|
56
|
+
} else {
|
|
57
|
+
cellSize = parseInt(sizeChoice, 10);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let cols = files.length;
|
|
61
|
+
if (layout === LAYOUT_CHOICES.GRID) {
|
|
62
|
+
const { colCount } = await inquirer.prompt([
|
|
63
|
+
{
|
|
64
|
+
type: 'input',
|
|
65
|
+
name: 'colCount',
|
|
66
|
+
message: `Number of columns (${files.length} images selected):`,
|
|
67
|
+
validate: (input) => {
|
|
68
|
+
const n = parseInt(input, 10);
|
|
69
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
70
|
+
},
|
|
71
|
+
filter: (input) => parseInt(input, 10),
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
cols = colCount;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { rawName } = await inquirer.prompt([
|
|
78
|
+
{
|
|
79
|
+
type: 'input',
|
|
80
|
+
name: 'rawName',
|
|
81
|
+
message: 'Output filename:',
|
|
82
|
+
default: 'sheet.png',
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
const outputName = rawName.trim().toLowerCase().endsWith('.png')
|
|
86
|
+
? rawName.trim()
|
|
87
|
+
: `${rawName.trim()}.png`;
|
|
88
|
+
|
|
89
|
+
await buildSpriteSheet({ files, cellSize, layout, cols, outputName });
|
|
90
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { extractAndBuildHorizontal } from '../core/extractAndBuildHorizontal.js';
|
|
4
|
+
import { selectPngFile } from '../utils/folder.js';
|
|
5
|
+
import { SIZE_CHOICES } from '../utils/image.js';
|
|
6
|
+
|
|
7
|
+
const DETECTION_MODE = {
|
|
8
|
+
AUTO: 'Auto Detect',
|
|
9
|
+
MANUAL: 'Manual Grid',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function runExtractHorizontal() {
|
|
13
|
+
console.log(chalk.cyan('\n Extract Sprites → Horizontal Sheet\n'));
|
|
14
|
+
|
|
15
|
+
const inputFile = await selectPngFile();
|
|
16
|
+
|
|
17
|
+
const { detectionMode } = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: 'list',
|
|
20
|
+
name: 'detectionMode',
|
|
21
|
+
message: 'Sprite detection mode:',
|
|
22
|
+
choices: Object.values(DETECTION_MODE),
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
let grid = null;
|
|
27
|
+
if (detectionMode === DETECTION_MODE.MANUAL) {
|
|
28
|
+
const { rows, cols } = await inquirer.prompt([
|
|
29
|
+
{
|
|
30
|
+
type: 'input',
|
|
31
|
+
name: 'rows',
|
|
32
|
+
message: 'Number of rows:',
|
|
33
|
+
validate: (input) => {
|
|
34
|
+
const n = parseInt(input, 10);
|
|
35
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
36
|
+
},
|
|
37
|
+
filter: (input) => parseInt(input, 10),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
type: 'input',
|
|
41
|
+
name: 'cols',
|
|
42
|
+
message: 'Number of columns:',
|
|
43
|
+
validate: (input) => {
|
|
44
|
+
const n = parseInt(input, 10);
|
|
45
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
46
|
+
},
|
|
47
|
+
filter: (input) => parseInt(input, 10),
|
|
48
|
+
},
|
|
49
|
+
]);
|
|
50
|
+
grid = { rows, cols };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { sizeChoice } = await inquirer.prompt([
|
|
54
|
+
{
|
|
55
|
+
type: 'list',
|
|
56
|
+
name: 'sizeChoice',
|
|
57
|
+
message: 'Output cell size (px):',
|
|
58
|
+
choices: Object.values(SIZE_CHOICES),
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
let cellSize;
|
|
63
|
+
if (sizeChoice === SIZE_CHOICES.CUSTOM) {
|
|
64
|
+
const { customSize } = await inquirer.prompt([
|
|
65
|
+
{
|
|
66
|
+
type: 'input',
|
|
67
|
+
name: 'customSize',
|
|
68
|
+
message: 'Enter custom cell size (px):',
|
|
69
|
+
validate: (input) => {
|
|
70
|
+
const n = parseInt(input, 10);
|
|
71
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
72
|
+
},
|
|
73
|
+
filter: (input) => parseInt(input, 10),
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
cellSize = customSize;
|
|
77
|
+
} else {
|
|
78
|
+
cellSize = parseInt(sizeChoice, 10);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { outputFile } = await inquirer.prompt([
|
|
82
|
+
{
|
|
83
|
+
type: 'input',
|
|
84
|
+
name: 'outputFile',
|
|
85
|
+
message: 'Output filename:',
|
|
86
|
+
default: 'output-horizontal.png',
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
await extractAndBuildHorizontal({
|
|
91
|
+
inputFile,
|
|
92
|
+
detectionMode,
|
|
93
|
+
grid,
|
|
94
|
+
cellSize,
|
|
95
|
+
outputFile: outputFile.trim(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { normalizeSprites } from '../core/normalizeSprites.js';
|
|
4
|
+
import { selectFolder, selectPngFiles } from '../utils/folder.js';
|
|
5
|
+
import { SIZE_CHOICES } from '../utils/image.js';
|
|
6
|
+
|
|
7
|
+
export async function runNormalize() {
|
|
8
|
+
console.log(chalk.cyan('\n Normalize Sprites\n'));
|
|
9
|
+
|
|
10
|
+
const folder = await selectFolder();
|
|
11
|
+
|
|
12
|
+
const files = await selectPngFiles(folder, 'Select PNG sprites to normalize (use space to select):');
|
|
13
|
+
if (files.length === 0) {
|
|
14
|
+
console.log(chalk.yellow('\n No files selected. Operation cancelled.\n'));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { sizeChoice } = await inquirer.prompt([
|
|
19
|
+
{
|
|
20
|
+
type: 'list',
|
|
21
|
+
name: 'sizeChoice',
|
|
22
|
+
message: 'Output sprite size:',
|
|
23
|
+
choices: Object.values(SIZE_CHOICES),
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
let size;
|
|
28
|
+
if (sizeChoice === SIZE_CHOICES.CUSTOM) {
|
|
29
|
+
const { customSize } = await inquirer.prompt([
|
|
30
|
+
{
|
|
31
|
+
type: 'input',
|
|
32
|
+
name: 'customSize',
|
|
33
|
+
message: 'Enter custom size (px):',
|
|
34
|
+
validate: (input) => {
|
|
35
|
+
const n = parseInt(input, 10);
|
|
36
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
37
|
+
},
|
|
38
|
+
filter: (input) => parseInt(input, 10),
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
size = customSize;
|
|
42
|
+
} else {
|
|
43
|
+
size = parseInt(sizeChoice, 10);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await normalizeSprites({ files, size });
|
|
47
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { normalizeToCell } from '../utils/image.js';
|
|
6
|
+
|
|
7
|
+
const LAYOUT = {
|
|
8
|
+
HORIZONTAL: 'Horizontal Strip',
|
|
9
|
+
GRID: 'Grid',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds a sprite sheet from the given PNG files.
|
|
14
|
+
*
|
|
15
|
+
* @param {{
|
|
16
|
+
* files: string[],
|
|
17
|
+
* cellSize: number,
|
|
18
|
+
* layout: string,
|
|
19
|
+
* cols: number,
|
|
20
|
+
* outputName: string,
|
|
21
|
+
* }} options
|
|
22
|
+
*/
|
|
23
|
+
export async function buildSpriteSheet({ files, cellSize, layout, cols, outputName }) {
|
|
24
|
+
const isGrid = layout === LAYOUT.GRID;
|
|
25
|
+
const sheetCols = isGrid ? cols : files.length;
|
|
26
|
+
const rows = isGrid ? Math.ceil(files.length / cols) : 1;
|
|
27
|
+
|
|
28
|
+
const sheetWidth = cellSize * sheetCols;
|
|
29
|
+
const sheetHeight = cellSize * rows;
|
|
30
|
+
|
|
31
|
+
const spinner = ora(`Processing ${files.length} sprites…`).start();
|
|
32
|
+
|
|
33
|
+
// Normalize all sprites in parallel
|
|
34
|
+
const results = await Promise.all(
|
|
35
|
+
files.map(async (inputPath, i) => {
|
|
36
|
+
const filename = path.basename(inputPath);
|
|
37
|
+
try {
|
|
38
|
+
const buf = await normalizeToCell(inputPath, cellSize);
|
|
39
|
+
const col = isGrid ? i % cols : i;
|
|
40
|
+
const row = isGrid ? Math.floor(i / cols) : 0;
|
|
41
|
+
return { ok: true, input: buf, left: col * cellSize, top: row * cellSize };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, filename, message: err.message };
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const composites = results.filter((r) => r.ok).map(({ input, left, top }) => ({ input, left, top }));
|
|
49
|
+
const errors = results.filter((r) => !r.ok);
|
|
50
|
+
const processed = composites.length;
|
|
51
|
+
const failed = errors.length;
|
|
52
|
+
|
|
53
|
+
spinner.text = 'Compositing sprite sheet…';
|
|
54
|
+
|
|
55
|
+
const outputPath = path.join(process.cwd(), outputName);
|
|
56
|
+
|
|
57
|
+
await sharp({
|
|
58
|
+
create: {
|
|
59
|
+
width: sheetWidth,
|
|
60
|
+
height: sheetHeight,
|
|
61
|
+
channels: 4,
|
|
62
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
.png()
|
|
66
|
+
.composite(composites)
|
|
67
|
+
.toFile(outputPath);
|
|
68
|
+
|
|
69
|
+
if (failed === 0) {
|
|
70
|
+
spinner.succeed(chalk.green(`Done! Sprite sheet built from ${processed} image${processed !== 1 ? 's' : ''}.`));
|
|
71
|
+
} else {
|
|
72
|
+
spinner.warn(chalk.yellow(`Finished with ${failed} error${failed !== 1 ? 's' : ''}.`));
|
|
73
|
+
for (const { filename, message } of errors) {
|
|
74
|
+
console.log(chalk.red(` ✖ ${filename}: `) + chalk.gray(message));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(
|
|
79
|
+
chalk.gray('\n Images : ') + chalk.white(`${processed} selected`) +
|
|
80
|
+
chalk.gray('\n Layout : ') + chalk.white(layout) +
|
|
81
|
+
(isGrid
|
|
82
|
+
? chalk.gray('\n Grid : ') + chalk.white(`${sheetCols} cols × ${rows} rows`)
|
|
83
|
+
: '') +
|
|
84
|
+
chalk.gray('\n Cell : ') + chalk.white(`${cellSize}×${cellSize}px`) +
|
|
85
|
+
chalk.gray('\n Output : ') + chalk.white(outputPath) + '\n'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { normalizeToCell, autoDetectGrid } from '../utils/image.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{ rows: number, cols: number }} Grid
|
|
10
|
+
*
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* inputFile: string,
|
|
13
|
+
* detectionMode: 'Auto Detect' | 'Manual Grid',
|
|
14
|
+
* grid: Grid | null,
|
|
15
|
+
* cellSize: number,
|
|
16
|
+
* outputFile: string,
|
|
17
|
+
* }} ExtractHorizontalOptions
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts individual sprites from a sprite sheet, normalizes each one into
|
|
22
|
+
* a square cell with transparent padding, and composites them into a single
|
|
23
|
+
* horizontal PNG strip.
|
|
24
|
+
*
|
|
25
|
+
* @param {ExtractHorizontalOptions} options
|
|
26
|
+
*/
|
|
27
|
+
export async function extractAndBuildHorizontal({
|
|
28
|
+
inputFile,
|
|
29
|
+
detectionMode,
|
|
30
|
+
grid,
|
|
31
|
+
cellSize,
|
|
32
|
+
outputFile,
|
|
33
|
+
}) {
|
|
34
|
+
const spinner = ora('Loading image…').start();
|
|
35
|
+
|
|
36
|
+
const source = sharp(inputFile);
|
|
37
|
+
const { width: imageWidth, height: imageHeight } = await source.metadata();
|
|
38
|
+
|
|
39
|
+
spinner.text = 'Determining grid…';
|
|
40
|
+
|
|
41
|
+
let rows, cols;
|
|
42
|
+
|
|
43
|
+
if (detectionMode === 'Manual Grid') {
|
|
44
|
+
rows = grid.rows;
|
|
45
|
+
cols = grid.cols;
|
|
46
|
+
} else {
|
|
47
|
+
const detected = autoDetectGrid(imageWidth, imageHeight);
|
|
48
|
+
if (detected) {
|
|
49
|
+
rows = detected.rows;
|
|
50
|
+
cols = detected.cols;
|
|
51
|
+
spinner.info(chalk.cyan(` Auto-detected grid: ${rows} rows × ${cols} cols`));
|
|
52
|
+
spinner.start('Extracting sprite regions…');
|
|
53
|
+
} else {
|
|
54
|
+
spinner.stop();
|
|
55
|
+
console.log(chalk.yellow(' Could not auto-detect grid. Please enter manually.\n'));
|
|
56
|
+
const { manualRows, manualCols } = await inquirer.prompt([
|
|
57
|
+
{
|
|
58
|
+
type: 'input',
|
|
59
|
+
name: 'manualRows',
|
|
60
|
+
message: 'Number of rows:',
|
|
61
|
+
validate: (input) => {
|
|
62
|
+
const n = parseInt(input, 10);
|
|
63
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
64
|
+
},
|
|
65
|
+
filter: (input) => parseInt(input, 10),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'input',
|
|
69
|
+
name: 'manualCols',
|
|
70
|
+
message: 'Number of columns:',
|
|
71
|
+
validate: (input) => {
|
|
72
|
+
const n = parseInt(input, 10);
|
|
73
|
+
return (Number.isInteger(n) && n > 0) || 'Please enter a positive integer.';
|
|
74
|
+
},
|
|
75
|
+
filter: (input) => parseInt(input, 10),
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
rows = manualRows;
|
|
79
|
+
cols = manualCols;
|
|
80
|
+
spinner.start('Extracting sprite regions…');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const cellWidth = Math.floor(imageWidth / cols);
|
|
85
|
+
const cellHeight = Math.floor(imageHeight / rows);
|
|
86
|
+
const spriteCount = rows * cols;
|
|
87
|
+
|
|
88
|
+
// Extract all grid cells in parallel
|
|
89
|
+
const extractedBuffers = await Promise.all(
|
|
90
|
+
Array.from({ length: spriteCount }, (_, i) => {
|
|
91
|
+
const row = Math.floor(i / cols);
|
|
92
|
+
const col = i % cols;
|
|
93
|
+
return sharp(inputFile)
|
|
94
|
+
.extract({
|
|
95
|
+
left: col * cellWidth,
|
|
96
|
+
top: row * cellHeight,
|
|
97
|
+
width: cellWidth,
|
|
98
|
+
height: cellHeight,
|
|
99
|
+
})
|
|
100
|
+
.png()
|
|
101
|
+
.toBuffer();
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Normalize each sprite in parallel, capturing per-sprite errors
|
|
106
|
+
spinner.text = 'Normalizing sprites…';
|
|
107
|
+
|
|
108
|
+
const normalizeResults = await Promise.all(
|
|
109
|
+
extractedBuffers.map(async (buf, i) => {
|
|
110
|
+
try {
|
|
111
|
+
const normalized = await normalizeToCell(buf, cellSize);
|
|
112
|
+
return { ok: true, buf: normalized, index: i };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { ok: false, index: i, message: err.message };
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const succeeded = normalizeResults.filter((r) => r.ok);
|
|
120
|
+
const failed = normalizeResults.filter((r) => !r.ok);
|
|
121
|
+
|
|
122
|
+
if (succeeded.length === 0) {
|
|
123
|
+
spinner.fail(chalk.red('All sprites failed to normalize.'));
|
|
124
|
+
for (const { index, message } of failed) {
|
|
125
|
+
console.log(chalk.red(` ✖ sprite ${index + 1}: `) + chalk.gray(message));
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build horizontal strip
|
|
131
|
+
spinner.text = 'Building horizontal sheet…';
|
|
132
|
+
|
|
133
|
+
const stripWidth = cellSize * succeeded.length;
|
|
134
|
+
const compositeInput = succeeded.map(({ buf }, i) => ({
|
|
135
|
+
input: buf,
|
|
136
|
+
left: i * cellSize,
|
|
137
|
+
top: 0,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const outputPath = path.isAbsolute(outputFile)
|
|
141
|
+
? outputFile
|
|
142
|
+
: path.join(process.cwd(), outputFile);
|
|
143
|
+
|
|
144
|
+
await sharp({
|
|
145
|
+
create: {
|
|
146
|
+
width: stripWidth,
|
|
147
|
+
height: cellSize,
|
|
148
|
+
channels: 4,
|
|
149
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
.png()
|
|
153
|
+
.composite(compositeInput)
|
|
154
|
+
.toFile(outputPath);
|
|
155
|
+
|
|
156
|
+
if (failed.length === 0) {
|
|
157
|
+
spinner.succeed(chalk.green('Done!'));
|
|
158
|
+
} else {
|
|
159
|
+
spinner.warn(chalk.yellow(`Finished with ${failed.length} error${failed.length !== 1 ? 's' : ''}.`));
|
|
160
|
+
for (const { index, message } of failed) {
|
|
161
|
+
console.log(chalk.red(` ✖ sprite ${index + 1}: `) + chalk.gray(message));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(
|
|
166
|
+
chalk.gray('\n Input : ') + chalk.white(inputFile) +
|
|
167
|
+
chalk.gray('\n Mode : ') + chalk.white(detectionMode) +
|
|
168
|
+
chalk.gray('\n Grid : ') + chalk.white(`${rows} rows × ${cols} cols`) +
|
|
169
|
+
chalk.gray('\n Sprites : ') + chalk.white(`${succeeded.length} / ${spriteCount}`) +
|
|
170
|
+
chalk.gray('\n Cell size: ') + chalk.white(`${cellSize}×${cellSize}px`) +
|
|
171
|
+
chalk.gray('\n Output : ') + chalk.white(outputPath) +
|
|
172
|
+
chalk.gray('\n Canvas : ') + chalk.white(`${stripWidth}×${cellSize}px`) + '\n'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import sharp from 'sharp';
|
|
6
|
+
import { normalizeToCell } from '../utils/image.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{ files: string[], size: number }} options
|
|
10
|
+
* files - Absolute paths of the PNG files to process.
|
|
11
|
+
* size - Side length of the output square canvas in pixels.
|
|
12
|
+
*/
|
|
13
|
+
export async function normalizeSprites({ files, size }) {
|
|
14
|
+
const outputDir = path.join(process.cwd(), 'normalized');
|
|
15
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
const spinner = ora(`Processing 1 / ${files.length}`).start();
|
|
18
|
+
let processed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
const errors = [];
|
|
21
|
+
|
|
22
|
+
for (const inputPath of files) {
|
|
23
|
+
const filename = path.basename(inputPath);
|
|
24
|
+
spinner.text = `Processing ${processed + 1} / ${files.length} — ${filename}`;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const buf = await normalizeToCell(inputPath, size);
|
|
28
|
+
const outputPath = path.join(outputDir, filename);
|
|
29
|
+
await sharp(buf).toFile(outputPath);
|
|
30
|
+
processed++;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
failed++;
|
|
33
|
+
errors.push({ filename, message: err.message });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (failed === 0) {
|
|
38
|
+
spinner.succeed(chalk.green(`Done! ${processed} sprite${processed !== 1 ? 's' : ''} normalized.`));
|
|
39
|
+
} else {
|
|
40
|
+
spinner.warn(chalk.yellow(`Finished with ${failed} error${failed !== 1 ? 's' : ''}.`));
|
|
41
|
+
for (const { filename, message } of errors) {
|
|
42
|
+
console.log(chalk.red(` ✖ ${filename}: `) + chalk.gray(message));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log(
|
|
47
|
+
chalk.gray('\n Output : ') + chalk.white(outputDir) +
|
|
48
|
+
chalk.gray('\n Size : ') + chalk.white(`${size}×${size}px`) +
|
|
49
|
+
chalk.gray('\n Files : ') + chalk.white(`${processed} processed`) +
|
|
50
|
+
(failed > 0 ? chalk.red(`, ${failed} failed`) : '') + '\n'
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/help.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export function showHelp() {
|
|
4
|
+
console.log(`
|
|
5
|
+
${chalk.bold.green('SpriteMint')} ${chalk.gray('— Unity Sprite Processor')}
|
|
6
|
+
|
|
7
|
+
${chalk.bold('USAGE')}
|
|
8
|
+
${chalk.cyan('spritemint')} Interactive mode (main menu)
|
|
9
|
+
${chalk.cyan('spritemint --help')} Show this help message
|
|
10
|
+
${chalk.cyan('spritemint -h')} Show this help message
|
|
11
|
+
|
|
12
|
+
${chalk.bold('COMMANDS')} ${chalk.gray('(selected interactively)')}
|
|
13
|
+
|
|
14
|
+
${chalk.yellow('1. Normalize Sprites')}
|
|
15
|
+
PNG dosyalarını kare canvas'a sığdırır.
|
|
16
|
+
Aspect ratio korunur, şeffaf padding eklenir.
|
|
17
|
+
Çıktı: ${chalk.gray('<klasör>/normalized/')}
|
|
18
|
+
|
|
19
|
+
${chalk.yellow('2. Extract Sprites from Sheet & Build Horizontal Output')}
|
|
20
|
+
Grid tabanlı sprite sheet'ten kareleri çıkarır,
|
|
21
|
+
normalize eder ve yatay bir animasyon strip'i oluşturur.
|
|
22
|
+
Çıktı: ${chalk.gray('<klasör>/output_horizontal.png')}
|
|
23
|
+
|
|
24
|
+
${chalk.yellow('3. Build Sprite Sheet from Selected PNGs')}
|
|
25
|
+
Birden fazla PNG dosyasını seçerek yeni bir sprite sheet'e
|
|
26
|
+
birleştirir. Yatay strip veya grid layout seçilebilir.
|
|
27
|
+
Çıktı: ${chalk.gray('<klasör>/spritesheet_output.png')}
|
|
28
|
+
|
|
29
|
+
${chalk.bold('REQUIREMENTS')}
|
|
30
|
+
- PNG formatında görsel dosyalar
|
|
31
|
+
- Node.js v18+
|
|
32
|
+
|
|
33
|
+
${chalk.bold('EXAMPLES')}
|
|
34
|
+
${chalk.gray('# Aracı başlat ve interaktif menüden seç')}
|
|
35
|
+
${chalk.cyan('spritemint')}
|
|
36
|
+
|
|
37
|
+
${chalk.gray('# Yardım mesajını göster')}
|
|
38
|
+
${chalk.cyan('spritemint --help')}
|
|
39
|
+
|
|
40
|
+
${chalk.bold('LINKS')}
|
|
41
|
+
GitHub ${chalk.underline('https://github.com/rakaya07/spritemint')}
|
|
42
|
+
License MIT
|
|
43
|
+
`);
|
|
44
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { runNormalize } from './commands/normalize.js';
|
|
4
|
+
import { runExtractHorizontal } from './commands/extractHorizontal.js';
|
|
5
|
+
import { runBuildSheet } from './commands/buildSheet.js';
|
|
6
|
+
|
|
7
|
+
const MENU_CHOICES = {
|
|
8
|
+
NORMALIZE: 'Normalize Sprites',
|
|
9
|
+
EXTRACT_HORIZONTAL:'Extract Sprites from Sheet and Build Horizontal Output',
|
|
10
|
+
BUILD_SHEET: 'Build Sprite Sheet from Selected PNGs',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
console.log(chalk.bold.green('\n SpriteMint ') + chalk.gray('— Unity Sprite Processor\n'));
|
|
15
|
+
|
|
16
|
+
const { action } = await inquirer.prompt([
|
|
17
|
+
{
|
|
18
|
+
type: 'list',
|
|
19
|
+
name: 'action',
|
|
20
|
+
message: 'What do you want to do?',
|
|
21
|
+
choices: Object.values(MENU_CHOICES),
|
|
22
|
+
},
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
switch (action) {
|
|
26
|
+
case MENU_CHOICES.NORMALIZE:
|
|
27
|
+
await runNormalize();
|
|
28
|
+
break;
|
|
29
|
+
case MENU_CHOICES.EXTRACT_HORIZONTAL:
|
|
30
|
+
await runExtractHorizontal();
|
|
31
|
+
break;
|
|
32
|
+
case MENU_CHOICES.BUILD_SHEET:
|
|
33
|
+
await runBuildSheet();
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch((err) => {
|
|
39
|
+
console.error(chalk.red('\nError: ') + err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
const CURRENT_DIR = 'Use current folder';
|
|
7
|
+
const MANUAL_ENTRY = 'Enter path manually';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Presents a list of folders containing PNG files and returns the chosen path.
|
|
11
|
+
* Checks process.cwd() and its immediate subdirectories.
|
|
12
|
+
*
|
|
13
|
+
* @returns {Promise<string>} Absolute path of the chosen folder.
|
|
14
|
+
*/
|
|
15
|
+
export async function selectFolder() {
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
const cwdHasPngs = dirContainsPngs(cwd);
|
|
18
|
+
const subfolders = subdirectoriesWithPngs(cwd);
|
|
19
|
+
|
|
20
|
+
if (!cwdHasPngs && subfolders.length === 0) return promptManualPath();
|
|
21
|
+
|
|
22
|
+
const choices = [
|
|
23
|
+
...(cwdHasPngs ? [CURRENT_DIR] : []),
|
|
24
|
+
...subfolders,
|
|
25
|
+
new inquirer.Separator(),
|
|
26
|
+
MANUAL_ENTRY,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const { selected } = await inquirer.prompt([
|
|
30
|
+
{
|
|
31
|
+
type: 'list',
|
|
32
|
+
name: 'selected',
|
|
33
|
+
message: 'Select folder containing PNG files:',
|
|
34
|
+
choices,
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
if (selected === MANUAL_ENTRY) return promptManualPath();
|
|
39
|
+
if (selected === CURRENT_DIR) return cwd;
|
|
40
|
+
return path.join(cwd, selected);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Presents a list of PNG files from process.cwd() and its immediate
|
|
45
|
+
* subdirectories, then returns the absolute path of the chosen file.
|
|
46
|
+
*
|
|
47
|
+
* @returns {Promise<string>} Absolute path of the chosen PNG file.
|
|
48
|
+
*/
|
|
49
|
+
export async function selectPngFile() {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
const allFiles = [];
|
|
52
|
+
|
|
53
|
+
getPngsInDir(cwd).forEach((f) =>
|
|
54
|
+
allFiles.push({ name: f, value: path.join(cwd, f) })
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
for (const dir of subdirectoriesWithPngs(cwd)) {
|
|
58
|
+
getPngsInDir(path.join(cwd, dir)).forEach((f) =>
|
|
59
|
+
allFiles.push({ name: path.join(dir, f), value: path.join(cwd, dir, f) })
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (allFiles.length === 0) {
|
|
64
|
+
console.log(chalk.yellow(' No PNG files found in current directory or subfolders.\n'));
|
|
65
|
+
const { manualPath } = await inquirer.prompt([
|
|
66
|
+
{
|
|
67
|
+
type: 'input',
|
|
68
|
+
name: 'manualPath',
|
|
69
|
+
message: 'Input PNG file path:',
|
|
70
|
+
validate: (input) => input.trim().length > 0 || 'Please enter a file path.',
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
return manualPath.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { selected } = await inquirer.prompt([
|
|
77
|
+
{
|
|
78
|
+
type: 'list',
|
|
79
|
+
name: 'selected',
|
|
80
|
+
message: 'Select PNG file:',
|
|
81
|
+
choices: allFiles,
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
return selected;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Presents a checkbox list of PNG files in `folder` and returns
|
|
90
|
+
* the absolute paths of the selected files.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} folder
|
|
93
|
+
* @param {string} [message]
|
|
94
|
+
* @returns {Promise<string[]>}
|
|
95
|
+
*/
|
|
96
|
+
export async function selectPngFiles(folder, message = 'Select PNG files (use space to select):') {
|
|
97
|
+
const pngNames = fs
|
|
98
|
+
.readdirSync(folder)
|
|
99
|
+
.filter((f) => f.toLowerCase().endsWith('.png'))
|
|
100
|
+
.sort();
|
|
101
|
+
|
|
102
|
+
const { selected } = await inquirer.prompt([
|
|
103
|
+
{
|
|
104
|
+
type: 'checkbox',
|
|
105
|
+
name: 'selected',
|
|
106
|
+
message,
|
|
107
|
+
choices: pngNames,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
return selected.map((name) => path.join(folder, name));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export function dirContainsPngs(dir) {
|
|
117
|
+
try {
|
|
118
|
+
return fs.readdirSync(dir).some((f) => f.toLowerCase().endsWith('.png'));
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function subdirectoriesWithPngs(dir) {
|
|
125
|
+
try {
|
|
126
|
+
return fs
|
|
127
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
128
|
+
.filter((entry) => entry.isDirectory())
|
|
129
|
+
.map((entry) => entry.name)
|
|
130
|
+
.filter((name) => dirContainsPngs(path.join(dir, name)));
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getPngsInDir(dir) {
|
|
137
|
+
try {
|
|
138
|
+
return fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.png')).sort();
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function promptManualPath() {
|
|
145
|
+
console.log(chalk.yellow(' No PNG files found in current directory or subfolders.\n'));
|
|
146
|
+
const { manualPath } = await inquirer.prompt([
|
|
147
|
+
{
|
|
148
|
+
type: 'input',
|
|
149
|
+
name: 'manualPath',
|
|
150
|
+
message: 'Path to folder containing PNG files:',
|
|
151
|
+
validate: (input) => input.trim().length > 0 || 'Please enter a folder path.',
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
return manualPath.trim();
|
|
155
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
export const SIZE_CHOICES = {
|
|
4
|
+
S256: '256',
|
|
5
|
+
S512: '512',
|
|
6
|
+
S1024: '1024',
|
|
7
|
+
CUSTOM: 'Custom size',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if the file extension is a supported image type.
|
|
12
|
+
* @param {string} filename
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isSupportedImage(filename) {
|
|
16
|
+
return /\.(png|jpg|jpeg|webp)$/i.test(filename);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clamps a number between min and max.
|
|
21
|
+
* @param {number} value
|
|
22
|
+
* @param {number} min
|
|
23
|
+
* @param {number} max
|
|
24
|
+
* @returns {number}
|
|
25
|
+
*/
|
|
26
|
+
export function clamp(value, min, max) {
|
|
27
|
+
return Math.min(Math.max(value, min), max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Trims transparent edges, resizes to fit inside `cellSize` (preserving aspect
|
|
32
|
+
* ratio, never upscaling), centers on a transparent square canvas, and returns
|
|
33
|
+
* the result as a PNG Buffer.
|
|
34
|
+
*
|
|
35
|
+
* @param {string|Buffer} input - File path or raw PNG buffer.
|
|
36
|
+
* @param {number} cellSize
|
|
37
|
+
* @returns {Promise<Buffer>}
|
|
38
|
+
*/
|
|
39
|
+
export async function normalizeToCell(input, cellSize) {
|
|
40
|
+
const trimmed = await sharp(input).trim().png().toBuffer();
|
|
41
|
+
const { width: trimW, height: trimH } = await sharp(trimmed).metadata();
|
|
42
|
+
|
|
43
|
+
const scale = Math.min(cellSize / trimW, cellSize / trimH, 1);
|
|
44
|
+
const resizedW = Math.round(trimW * scale);
|
|
45
|
+
const resizedH = Math.round(trimH * scale);
|
|
46
|
+
|
|
47
|
+
const resized = await sharp(trimmed)
|
|
48
|
+
.resize(resizedW, resizedH, { fit: 'fill' })
|
|
49
|
+
.png()
|
|
50
|
+
.toBuffer();
|
|
51
|
+
|
|
52
|
+
const left = Math.floor((cellSize - resizedW) / 2);
|
|
53
|
+
const top = Math.floor((cellSize - resizedH) / 2);
|
|
54
|
+
|
|
55
|
+
return sharp({
|
|
56
|
+
create: {
|
|
57
|
+
width: cellSize,
|
|
58
|
+
height: cellSize,
|
|
59
|
+
channels: 4,
|
|
60
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
.png()
|
|
64
|
+
.composite([{ input: resized, left, top }])
|
|
65
|
+
.toBuffer();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Attempts to auto-detect the grid layout of a sprite sheet by finding common
|
|
70
|
+
* row/column combinations where each cell has power-of-two dimensions.
|
|
71
|
+
* Falls back to any combination that produces square cells.
|
|
72
|
+
* Returns null if no suitable grid is found.
|
|
73
|
+
*
|
|
74
|
+
* @param {number} imageWidth
|
|
75
|
+
* @param {number} imageHeight
|
|
76
|
+
* @returns {{ rows: number, cols: number } | null}
|
|
77
|
+
*/
|
|
78
|
+
export function autoDetectGrid(imageWidth, imageHeight) {
|
|
79
|
+
// Exclude [1,1] — a single-cell "grid" is never a useful sprite sheet
|
|
80
|
+
const candidates = [
|
|
81
|
+
[1, 2], [2, 1], [2, 2],
|
|
82
|
+
[1, 4], [4, 1], [2, 4], [4, 2], [4, 4],
|
|
83
|
+
[1, 8], [8, 1], [2, 8], [8, 2], [4, 8], [8, 4], [8, 8],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Collect all candidates where both cell dimensions are powers of two
|
|
87
|
+
const valid = [];
|
|
88
|
+
for (const [rows, cols] of candidates) {
|
|
89
|
+
const cellW = imageWidth / cols;
|
|
90
|
+
const cellH = imageHeight / rows;
|
|
91
|
+
if (
|
|
92
|
+
Number.isInteger(cellW) && Number.isInteger(cellH) &&
|
|
93
|
+
isPowerOfTwo(cellW) && isPowerOfTwo(cellH)
|
|
94
|
+
) {
|
|
95
|
+
valid.push({ rows, cols, cellW, cellH });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (valid.length > 0) {
|
|
100
|
+
// Prefer: 1) most square cell (aspect ratio closest to 1:1)
|
|
101
|
+
// 2) fewest cells (simplest grid)
|
|
102
|
+
valid.sort((a, b) => {
|
|
103
|
+
const ratioA = Math.max(a.cellW, a.cellH) / Math.min(a.cellW, a.cellH);
|
|
104
|
+
const ratioB = Math.max(b.cellW, b.cellH) / Math.min(b.cellW, b.cellH);
|
|
105
|
+
if (ratioA !== ratioB) return ratioA - ratioB;
|
|
106
|
+
return (a.rows * a.cols) - (b.rows * b.cols);
|
|
107
|
+
});
|
|
108
|
+
return { rows: valid[0].rows, cols: valid[0].cols };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fallback: any integer division producing square cells
|
|
112
|
+
for (const [rows, cols] of candidates) {
|
|
113
|
+
const cellW = imageWidth / cols;
|
|
114
|
+
const cellH = imageHeight / rows;
|
|
115
|
+
if (Number.isInteger(cellW) && Number.isInteger(cellH) && cellW === cellH) {
|
|
116
|
+
return { rows, cols };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPowerOfTwo(n) {
|
|
124
|
+
return n > 0 && (n & (n - 1)) === 0;
|
|
125
|
+
}
|