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 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
+ [![npm version](https://img.shields.io/npm/v/spritemint.svg?style=flat-square)](https://npmjs.org/package/spritemint)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
9
+ [![Node.js](https://img.shields.io/badge/Node.js-v18+-green.svg?style=flat-square)](https://nodejs.org/)
10
+ [![CLI Tool](https://img.shields.io/badge/CLI-Terminal-blue.svg?style=flat-square)](#)
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>
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { showHelp } from '../src/help.js';
4
+
5
+ const args = process.argv.slice(2);
6
+
7
+ if (args.includes('--help') || args.includes('-h')) {
8
+ showHelp();
9
+ process.exit(0);
10
+ }
11
+
12
+ import('../src/index.js');
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
+ }