@series-inc/stowkit-cli 0.6.40 → 0.6.41
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/dist/ai-tagger.d.ts +10 -0
- package/dist/ai-tagger.js +63 -3
- package/dist/cli.js +2 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +6 -0
- package/dist/server.js +436 -0
- package/package.json +1 -1
package/dist/ai-tagger.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { AssetType } from './core/types.js';
|
|
2
|
+
export interface SpriteSheetDetection {
|
|
3
|
+
isSpriteSheet: boolean;
|
|
4
|
+
rows?: number;
|
|
5
|
+
columns?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Use Gemini Vision to detect whether an image is a sprite sheet.
|
|
9
|
+
* If it is, returns the detected row/column grid dimensions.
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectSpriteSheet(apiKey: string, imageData: Buffer, mimeType: string): Promise<SpriteSheetDetection>;
|
|
2
12
|
/**
|
|
3
13
|
* Verify that a Gemini API key is valid by making a minimal request.
|
|
4
14
|
*/
|
package/dist/ai-tagger.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GoogleGenAI } from '@google/genai';
|
|
2
2
|
import { AssetType } from './core/types.js';
|
|
3
|
-
const
|
|
3
|
+
const MODEL_LITE = 'gemini-3.1-flash-lite-preview';
|
|
4
|
+
const MODEL_FLASH = 'gemini-3.1-pro-preview';
|
|
4
5
|
const ASSET_TYPE_LABELS = {
|
|
5
6
|
[AssetType.Texture2D]: 'texture',
|
|
6
7
|
[AssetType.StaticMesh]: '3D mesh',
|
|
@@ -26,6 +27,65 @@ Rules:
|
|
|
26
27
|
- no generic tags like asset, file, media, content
|
|
27
28
|
- only include tags directly supported by the input`;
|
|
28
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Use Gemini Vision to detect whether an image is a sprite sheet.
|
|
32
|
+
* If it is, returns the detected row/column grid dimensions.
|
|
33
|
+
*/
|
|
34
|
+
export async function detectSpriteSheet(apiKey, imageData, mimeType) {
|
|
35
|
+
const client = new GoogleGenAI({ apiKey });
|
|
36
|
+
const response = await client.models.generateContent({
|
|
37
|
+
model: MODEL_FLASH,
|
|
38
|
+
contents: [{
|
|
39
|
+
role: 'user',
|
|
40
|
+
parts: [
|
|
41
|
+
{
|
|
42
|
+
text: `Analyze this image and determine if it is a sprite sheet: a grid of multiple animation frames or game sprites arranged in regular rows and columns.
|
|
43
|
+
|
|
44
|
+
If it IS a sprite sheet, count the rows and columns of the grid.
|
|
45
|
+
If it is NOT a sprite sheet (e.g. a single image, photo, icon, or seamless texture), respond accordingly.
|
|
46
|
+
|
|
47
|
+
Respond with ONLY a JSON object:
|
|
48
|
+
- Sprite sheet: { "isSpriteSheet": true, "rows": <number>, "columns": <number> }
|
|
49
|
+
- Not a sprite sheet: { "isSpriteSheet": false }`,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
inlineData: {
|
|
53
|
+
data: imageData.toString('base64'),
|
|
54
|
+
mimeType,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}],
|
|
59
|
+
config: {
|
|
60
|
+
temperature: 0.1,
|
|
61
|
+
maxOutputTokens: 64,
|
|
62
|
+
responseMimeType: 'application/json',
|
|
63
|
+
responseSchema: {
|
|
64
|
+
type: 'OBJECT',
|
|
65
|
+
properties: {
|
|
66
|
+
isSpriteSheet: { type: 'BOOLEAN' },
|
|
67
|
+
rows: { type: 'INTEGER' },
|
|
68
|
+
columns: { type: 'INTEGER' },
|
|
69
|
+
},
|
|
70
|
+
required: ['isSpriteSheet'],
|
|
71
|
+
},
|
|
72
|
+
safetySettings: [
|
|
73
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
|
|
74
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
|
|
75
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
|
|
76
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const text = response.text ?? '{}';
|
|
81
|
+
const parsed = JSON.parse(text);
|
|
82
|
+
return {
|
|
83
|
+
isSpriteSheet: !!parsed.isSpriteSheet,
|
|
84
|
+
rows: typeof parsed.rows === 'number' && parsed.rows > 0 ? parsed.rows : undefined,
|
|
85
|
+
columns: typeof parsed.columns === 'number' && parsed.columns > 0 ? parsed.columns : undefined,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ─── API Key Verification ─────────────────────────────────────────────────────
|
|
29
89
|
/**
|
|
30
90
|
* Verify that a Gemini API key is valid by making a minimal request.
|
|
31
91
|
*/
|
|
@@ -33,7 +93,7 @@ export async function verifyApiKey(apiKey) {
|
|
|
33
93
|
try {
|
|
34
94
|
const client = new GoogleGenAI({ apiKey });
|
|
35
95
|
const response = await client.models.generateContent({
|
|
36
|
-
model:
|
|
96
|
+
model: MODEL_LITE,
|
|
37
97
|
contents: [{ role: 'user', parts: [{ text: 'Respond with the word "ok"' }] }],
|
|
38
98
|
config: { maxOutputTokens: 10 },
|
|
39
99
|
});
|
|
@@ -51,7 +111,7 @@ export async function verifyApiKey(apiKey) {
|
|
|
51
111
|
export async function tagAsset(apiKey, assetType, fileName, inputData, mimeType) {
|
|
52
112
|
const client = new GoogleGenAI({ apiKey });
|
|
53
113
|
const response = await client.models.generateContent({
|
|
54
|
-
model:
|
|
114
|
+
model: MODEL_LITE,
|
|
55
115
|
contents: [{
|
|
56
116
|
role: 'user',
|
|
57
117
|
parts: [
|
package/dist/cli.js
CHANGED
|
@@ -78,6 +78,7 @@ Usage:
|
|
|
78
78
|
stowkit init [dir] Initialize a StowKit project (interactive menu)
|
|
79
79
|
stowkit init --with-engine Initialize with 3D engine included
|
|
80
80
|
stowkit init --no-engine Initialize without 3D engine (skip prompt)
|
|
81
|
+
stowkit init --with-publish Initialize with assets-package.json for publishing
|
|
81
82
|
stowkit init --update [dir] Update AI skill files to match installed CLI version
|
|
82
83
|
stowkit build [dir] Full build: scan + process + pack
|
|
83
84
|
stowkit scan [dir] Detect new assets, generate .stowmeta defaults
|
|
@@ -175,6 +176,7 @@ async function main() {
|
|
|
175
176
|
update: args.includes('--update'),
|
|
176
177
|
withEngine: args.includes('--with-engine'),
|
|
177
178
|
noEngine: args.includes('--no-engine'),
|
|
179
|
+
withPublish: args.includes('--with-publish'),
|
|
178
180
|
});
|
|
179
181
|
break;
|
|
180
182
|
case 'update': {
|
package/dist/init.d.ts
CHANGED
package/dist/init.js
CHANGED
|
@@ -153,6 +153,12 @@ export async function initProject(projectDir, opts) {
|
|
|
153
153
|
if (withEngine) {
|
|
154
154
|
await installEngine(absDir);
|
|
155
155
|
}
|
|
156
|
+
// Create assets-package.json for publishing
|
|
157
|
+
if (opts?.withPublish) {
|
|
158
|
+
const { initAssetsPackage } = await import('./assets-package.js');
|
|
159
|
+
await initAssetsPackage(absDir, { name: path.basename(absDir) });
|
|
160
|
+
console.log(` Publish config: assets-package.json`);
|
|
161
|
+
}
|
|
156
162
|
console.log('');
|
|
157
163
|
if (withEngine) {
|
|
158
164
|
console.log('Ready to go! Run:');
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import * as fs from 'node:fs/promises';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
+
import { exec } from 'node:child_process';
|
|
4
5
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
6
|
import { AssetType } from './core/types.js';
|
|
6
7
|
import { defaultAssetSettings } from './app/state.js';
|
|
@@ -564,6 +565,68 @@ async function readBinaryBody(req) {
|
|
|
564
565
|
chunks.push(chunk);
|
|
565
566
|
return Buffer.concat(chunks);
|
|
566
567
|
}
|
|
568
|
+
// ─── SpriteSheet grid detection ──────────────────────────────────────────────
|
|
569
|
+
const COMMON_TILE_SIZES = [16, 32, 48, 64, 128];
|
|
570
|
+
function detectSpriteGrid(width, height) {
|
|
571
|
+
const result = detectGridDimensions(width, height);
|
|
572
|
+
if (result)
|
|
573
|
+
return { ...result, frameCount: result.columns * result.rows };
|
|
574
|
+
// Absolute fallback: single frame
|
|
575
|
+
return { columns: 1, rows: 1, frameCount: 1 };
|
|
576
|
+
}
|
|
577
|
+
function detectGridDimensions(width, height) {
|
|
578
|
+
// 1. Try common square tile sizes (covers most character/monster/item sheets)
|
|
579
|
+
for (const tile of COMMON_TILE_SIZES) {
|
|
580
|
+
if (width % tile === 0 && height % tile === 0) {
|
|
581
|
+
return { columns: width / tile, rows: height / tile };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// 2. Square frames where frame_size = height (single-row FX sheets)
|
|
585
|
+
if (height < width && width % height === 0) {
|
|
586
|
+
return { columns: width / height, rows: 1 };
|
|
587
|
+
}
|
|
588
|
+
// 3. Near-square single-row: find frame width closest to height among width's divisors
|
|
589
|
+
if (height < 64 && width > height * 1.5) {
|
|
590
|
+
const match = findBestSingleRowCols(width, height);
|
|
591
|
+
if (match)
|
|
592
|
+
return { columns: match, rows: 1 };
|
|
593
|
+
}
|
|
594
|
+
// 4. Non-square common tile combos
|
|
595
|
+
for (const th of COMMON_TILE_SIZES) {
|
|
596
|
+
if (height % th === 0) {
|
|
597
|
+
const rows = height / th;
|
|
598
|
+
for (const tw of COMMON_TILE_SIZES) {
|
|
599
|
+
if (width % tw === 0)
|
|
600
|
+
return { columns: width / tw, rows };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// 5. frame_width = 16 with non-standard height
|
|
605
|
+
if (width % 16 === 0)
|
|
606
|
+
return { columns: width / 16, rows: 1 };
|
|
607
|
+
// 6. Near-square fallback for any size
|
|
608
|
+
const cols = findBestSingleRowCols(width, height);
|
|
609
|
+
if (cols)
|
|
610
|
+
return { columns: cols, rows: 1 };
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
function findBestSingleRowCols(width, height) {
|
|
614
|
+
let bestCloseness = Infinity;
|
|
615
|
+
let bestCols = null;
|
|
616
|
+
for (let cols = 1; cols <= width; cols++) {
|
|
617
|
+
if (width % cols !== 0)
|
|
618
|
+
continue;
|
|
619
|
+
const fw = width / cols;
|
|
620
|
+
if (fw < height * 0.4 || fw > height * 2.5)
|
|
621
|
+
continue;
|
|
622
|
+
const closeness = Math.abs(fw / height - 1.0);
|
|
623
|
+
if (closeness < bestCloseness) {
|
|
624
|
+
bestCloseness = closeness;
|
|
625
|
+
bestCols = cols;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return bestCloseness < 0.6 ? bestCols : null;
|
|
629
|
+
}
|
|
567
630
|
function json(res, data, status = 200) {
|
|
568
631
|
res.writeHead(status, {
|
|
569
632
|
'Content-Type': 'application/json',
|
|
@@ -1456,6 +1519,34 @@ async function handleRequest(req, res, staticApps) {
|
|
|
1456
1519
|
json(res, { ok: true, removedAssets: removedIds.length });
|
|
1457
1520
|
return;
|
|
1458
1521
|
}
|
|
1522
|
+
// POST /api/reveal — open file or folder in OS file explorer
|
|
1523
|
+
if (pathname === '/api/reveal' && req.method === 'POST') {
|
|
1524
|
+
if (!projectConfig) {
|
|
1525
|
+
json(res, { error: 'No project open' }, 400);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const body = JSON.parse(await readBody(req));
|
|
1529
|
+
const relativePath = body.path;
|
|
1530
|
+
if (!relativePath) {
|
|
1531
|
+
json(res, { error: 'Missing path' }, 400);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const fullPath = path.resolve(projectConfig.srcArtDir, relativePath);
|
|
1535
|
+
// Ensure the path is inside the project
|
|
1536
|
+
if (!fullPath.startsWith(path.resolve(projectConfig.srcArtDir))) {
|
|
1537
|
+
json(res, { error: 'Path outside project' }, 403);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
1541
|
+
if (stat?.isDirectory()) {
|
|
1542
|
+
exec(`explorer "${fullPath}"`);
|
|
1543
|
+
}
|
|
1544
|
+
else {
|
|
1545
|
+
exec(`explorer /select,"${fullPath}"`);
|
|
1546
|
+
}
|
|
1547
|
+
json(res, { ok: true });
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1459
1550
|
// GET /api/asset/:id/source — serve source file for preview
|
|
1460
1551
|
if (pathname.startsWith('/api/asset/') && pathname.endsWith('/source') && req.method === 'GET') {
|
|
1461
1552
|
const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/source'.length));
|
|
@@ -1629,6 +1720,173 @@ async function handleRequest(req, res, staticApps) {
|
|
|
1629
1720
|
json(res, { ok: true, asset });
|
|
1630
1721
|
return;
|
|
1631
1722
|
}
|
|
1723
|
+
// POST /api/convert-to-spritesheet — auto-detect grid from a texture and create a .stowspritesheet
|
|
1724
|
+
if (pathname === '/api/convert-to-spritesheet' && req.method === 'POST') {
|
|
1725
|
+
if (!projectConfig) {
|
|
1726
|
+
json(res, { error: 'No project open' }, 400);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const body = JSON.parse(await readBody(req));
|
|
1730
|
+
const textureId = body.textureId;
|
|
1731
|
+
if (!textureId) {
|
|
1732
|
+
json(res, { error: 'Missing textureId' }, 400);
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
const textureAsset = assets.find(a => a.id === textureId);
|
|
1736
|
+
if (!textureAsset || textureAsset.type !== AssetType.Texture2D) {
|
|
1737
|
+
json(res, { error: 'Asset is not a texture' }, 400);
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
// Use caller-supplied grid override, otherwise heuristic-detect from image dimensions
|
|
1741
|
+
let rows;
|
|
1742
|
+
let columns;
|
|
1743
|
+
if (typeof body.rows === 'number' && body.rows > 0 && typeof body.columns === 'number' && body.columns > 0) {
|
|
1744
|
+
rows = body.rows;
|
|
1745
|
+
columns = body.columns;
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
const dims = await probeImageDimensions(projectConfig.srcArtDir, textureId);
|
|
1749
|
+
if (!dims) {
|
|
1750
|
+
json(res, { error: 'Could not read image dimensions' }, 500);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
({ rows, columns } = detectSpriteGrid(dims.width, dims.height));
|
|
1754
|
+
}
|
|
1755
|
+
const frameCount = rows * columns;
|
|
1756
|
+
// Build name: foldername_imagename_animation
|
|
1757
|
+
const parts = textureId.replace(/\\/g, '/').split('/');
|
|
1758
|
+
const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
|
|
1759
|
+
const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
|
|
1760
|
+
const safeName = [folder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
|
|
1761
|
+
const sheetName = `${safeName}_animation`;
|
|
1762
|
+
const targetFolder = parts.slice(0, -1).join('/');
|
|
1763
|
+
const baseName = `${sheetName}.stowspritesheet`;
|
|
1764
|
+
const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
|
|
1765
|
+
// Check if already exists
|
|
1766
|
+
if (assets.find(a => a.id === fileName)) {
|
|
1767
|
+
json(res, { error: `Spritesheet already exists: ${fileName}` }, 409);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const settings = defaultAssetSettings();
|
|
1771
|
+
settings.spritesheetConfig = {
|
|
1772
|
+
textureAssetId: textureId,
|
|
1773
|
+
rows,
|
|
1774
|
+
columns,
|
|
1775
|
+
frameCount,
|
|
1776
|
+
frameRate: 12,
|
|
1777
|
+
};
|
|
1778
|
+
const asset = {
|
|
1779
|
+
id: fileName,
|
|
1780
|
+
fileName: sheetName,
|
|
1781
|
+
stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
|
|
1782
|
+
type: AssetType.SpriteSheet,
|
|
1783
|
+
status: 'ready',
|
|
1784
|
+
settings,
|
|
1785
|
+
sourceSize: 0,
|
|
1786
|
+
processedSize: 0,
|
|
1787
|
+
};
|
|
1788
|
+
assets.push(asset);
|
|
1789
|
+
const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
|
|
1790
|
+
await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
|
|
1791
|
+
const meta = assetSettingsToStowmeta(asset);
|
|
1792
|
+
await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
|
|
1793
|
+
broadcast({ type: 'refresh' });
|
|
1794
|
+
json(res, { ok: true, asset });
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
// POST /api/batch-convert-folder-name — find all folders matching a name and convert all textures under them to sprite sheets
|
|
1798
|
+
if (pathname === '/api/batch-convert-folder-name' && req.method === 'POST') {
|
|
1799
|
+
if (!projectConfig) {
|
|
1800
|
+
json(res, { error: 'No project open' }, 400);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
const body = JSON.parse(await readBody(req));
|
|
1804
|
+
const folderName = body.folderName;
|
|
1805
|
+
if (!folderName) {
|
|
1806
|
+
json(res, { error: 'Missing folderName' }, 400);
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
// Find all folders that match the given name (case-insensitive)
|
|
1810
|
+
const matchingFolders = folders.filter(f => {
|
|
1811
|
+
const parts = f.replace(/\\/g, '/').split('/');
|
|
1812
|
+
return parts[parts.length - 1].toLowerCase() === folderName.toLowerCase();
|
|
1813
|
+
});
|
|
1814
|
+
if (matchingFolders.length === 0) {
|
|
1815
|
+
json(res, { error: `No folders found matching "${folderName}"` }, 404);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
// Find all Texture2D assets under any of the matching folders
|
|
1819
|
+
const textureIds = [];
|
|
1820
|
+
for (const folder of matchingFolders) {
|
|
1821
|
+
const prefix = folder + '/';
|
|
1822
|
+
for (const asset of assets) {
|
|
1823
|
+
if (asset.type === AssetType.Texture2D && (asset.id.startsWith(prefix) || asset.id.replace(/\\/g, '/').startsWith(prefix))) {
|
|
1824
|
+
textureIds.push(asset.id);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (textureIds.length === 0) {
|
|
1829
|
+
json(res, { error: `No textures found under folders matching "${folderName}"` }, 404);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
// Convert each texture to a sprite sheet (reuse the same logic as convert-to-spritesheet)
|
|
1833
|
+
let succeeded = 0;
|
|
1834
|
+
let failed = 0;
|
|
1835
|
+
const created = [];
|
|
1836
|
+
for (const textureId of textureIds) {
|
|
1837
|
+
const textureAsset = assets.find(a => a.id === textureId);
|
|
1838
|
+
if (!textureAsset) {
|
|
1839
|
+
failed++;
|
|
1840
|
+
continue;
|
|
1841
|
+
}
|
|
1842
|
+
const dims = await probeImageDimensions(projectConfig.srcArtDir, textureId);
|
|
1843
|
+
if (!dims) {
|
|
1844
|
+
failed++;
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
const { columns, rows, frameCount } = detectSpriteGrid(dims.width, dims.height);
|
|
1848
|
+
const parts = textureId.replace(/\\/g, '/').split('/');
|
|
1849
|
+
const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
|
|
1850
|
+
const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
|
|
1851
|
+
const safeName = [folder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
|
|
1852
|
+
const sheetName = `${safeName}_animation`;
|
|
1853
|
+
const targetFolder = parts.slice(0, -1).join('/');
|
|
1854
|
+
const baseName = `${sheetName}.stowspritesheet`;
|
|
1855
|
+
const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
|
|
1856
|
+
if (assets.find(a => a.id === fileName)) {
|
|
1857
|
+
failed++;
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
const settings = defaultAssetSettings();
|
|
1861
|
+
settings.spritesheetConfig = {
|
|
1862
|
+
textureAssetId: textureId,
|
|
1863
|
+
rows,
|
|
1864
|
+
columns,
|
|
1865
|
+
frameCount,
|
|
1866
|
+
frameRate: 12,
|
|
1867
|
+
};
|
|
1868
|
+
const asset = {
|
|
1869
|
+
id: fileName,
|
|
1870
|
+
fileName: sheetName,
|
|
1871
|
+
stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
|
|
1872
|
+
type: AssetType.SpriteSheet,
|
|
1873
|
+
status: 'ready',
|
|
1874
|
+
settings,
|
|
1875
|
+
sourceSize: 0,
|
|
1876
|
+
processedSize: 0,
|
|
1877
|
+
};
|
|
1878
|
+
assets.push(asset);
|
|
1879
|
+
const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
|
|
1880
|
+
await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
|
|
1881
|
+
const meta = assetSettingsToStowmeta(asset);
|
|
1882
|
+
await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
|
|
1883
|
+
created.push(asset);
|
|
1884
|
+
succeeded++;
|
|
1885
|
+
}
|
|
1886
|
+
broadcast({ type: 'refresh' });
|
|
1887
|
+
json(res, { ok: true, succeeded, failed, matchingFolders, assets: created });
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1632
1890
|
// POST /api/create-folder
|
|
1633
1891
|
if (pathname === '/api/create-folder' && req.method === 'POST') {
|
|
1634
1892
|
if (!projectConfig) {
|
|
@@ -2043,6 +2301,31 @@ async function handleRequest(req, res, staticApps) {
|
|
|
2043
2301
|
}
|
|
2044
2302
|
return;
|
|
2045
2303
|
}
|
|
2304
|
+
// POST /api/asset-store/package/:name/delist — remove package from registry (keeps GCS files)
|
|
2305
|
+
if (pathname.startsWith('/api/asset-store/package/') && pathname.endsWith('/delist') && req.method === 'POST') {
|
|
2306
|
+
if (!projectConfig) {
|
|
2307
|
+
json(res, { error: 'No project open' }, 400);
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
try {
|
|
2311
|
+
const packageName = decodeURIComponent(pathname.slice('/api/asset-store/package/'.length, -'/delist'.length));
|
|
2312
|
+
const { createFirestoreClient } = await import('./firestore.js');
|
|
2313
|
+
const firestore = await createFirestoreClient(projectConfig.projectDir);
|
|
2314
|
+
const pkg = await firestore.getPackage(packageName);
|
|
2315
|
+
if (!pkg) {
|
|
2316
|
+
json(res, { error: `Package "${packageName}" not found` }, 404);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
await firestore.deletePackage(packageName);
|
|
2320
|
+
const { clearFirestoreCache } = await import('./firestore.js');
|
|
2321
|
+
clearFirestoreCache();
|
|
2322
|
+
json(res, { ok: true, packageName });
|
|
2323
|
+
}
|
|
2324
|
+
catch (err) {
|
|
2325
|
+
json(res, { error: err.message }, 500);
|
|
2326
|
+
}
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2046
2329
|
// POST /api/publish — publish assets to GCS (SSE stream with progress)
|
|
2047
2330
|
if (pathname === '/api/publish' && req.method === 'POST') {
|
|
2048
2331
|
if (!projectConfig) {
|
|
@@ -2370,6 +2653,159 @@ async function handleRequest(req, res, staticApps) {
|
|
|
2370
2653
|
}
|
|
2371
2654
|
return;
|
|
2372
2655
|
}
|
|
2656
|
+
// POST /api/ai/guess-spritesheet-grid/:id — ask Gemini to count rows/columns on a texture without creating anything
|
|
2657
|
+
if (pathname.startsWith('/api/ai/guess-spritesheet-grid/') && req.method === 'POST') {
|
|
2658
|
+
if (!projectConfig) {
|
|
2659
|
+
json(res, { error: 'No project open' }, 400);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
const apiKey = projectConfig.config.ai?.geminiApiKey;
|
|
2663
|
+
if (!apiKey) {
|
|
2664
|
+
json(res, { error: 'Gemini API key not configured' }, 400);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const id = decodeURIComponent(pathname.slice('/api/ai/guess-spritesheet-grid/'.length));
|
|
2668
|
+
const asset = assets.find(a => a.id === id);
|
|
2669
|
+
if (!asset || asset.type !== AssetType.Texture2D) {
|
|
2670
|
+
json(res, { error: 'Asset is not a texture' }, 404);
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
try {
|
|
2674
|
+
const raw = await readFile(projectConfig.srcArtDir, id);
|
|
2675
|
+
if (!raw) {
|
|
2676
|
+
json(res, { error: 'Source file not found' }, 404);
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
const ext = id.split('.').pop()?.toLowerCase() ?? 'png';
|
|
2680
|
+
const mimeMap = {
|
|
2681
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
|
2682
|
+
};
|
|
2683
|
+
const mimeType = mimeMap[ext] ?? 'image/png';
|
|
2684
|
+
const { detectSpriteSheet: aiDetectSpriteSheet } = await import('./ai-tagger.js');
|
|
2685
|
+
const detection = await aiDetectSpriteSheet(apiKey, Buffer.from(raw), mimeType);
|
|
2686
|
+
if (detection.isSpriteSheet && detection.rows && detection.columns) {
|
|
2687
|
+
json(res, { rows: detection.rows, columns: detection.columns });
|
|
2688
|
+
}
|
|
2689
|
+
else {
|
|
2690
|
+
// Fall back to heuristic
|
|
2691
|
+
const dims = await probeImageDimensions(projectConfig.srcArtDir, id);
|
|
2692
|
+
const grid = dims ? detectSpriteGrid(dims.width, dims.height) : { rows: 1, columns: 1 };
|
|
2693
|
+
json(res, { rows: grid.rows, columns: grid.columns });
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
catch (err) {
|
|
2697
|
+
json(res, { error: err.message }, 500);
|
|
2698
|
+
}
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
// POST /api/ai/detect-spritesheet-batch — use Gemini to detect sprite sheets across multiple textures concurrently
|
|
2702
|
+
if (pathname === '/api/ai/detect-spritesheet-batch' && req.method === 'POST') {
|
|
2703
|
+
if (!projectConfig) {
|
|
2704
|
+
json(res, { error: 'No project open' }, 400);
|
|
2705
|
+
return;
|
|
2706
|
+
}
|
|
2707
|
+
const apiKey = projectConfig.config.ai?.geminiApiKey;
|
|
2708
|
+
if (!apiKey) {
|
|
2709
|
+
json(res, { error: 'Gemini API key not configured' }, 400);
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
try {
|
|
2713
|
+
const body = JSON.parse(await readBody(req));
|
|
2714
|
+
const ids = body.ids;
|
|
2715
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
2716
|
+
json(res, { error: 'ids must be a non-empty array' }, 400);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const { detectSpriteSheet: aiDetectSpriteSheet } = await import('./ai-tagger.js');
|
|
2720
|
+
const mimeMap = {
|
|
2721
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
|
|
2722
|
+
};
|
|
2723
|
+
let created = 0;
|
|
2724
|
+
let notSpriteSheet = 0;
|
|
2725
|
+
let alreadyExists = 0;
|
|
2726
|
+
const errors = [];
|
|
2727
|
+
const results = await Promise.allSettled(ids.map(async (id) => {
|
|
2728
|
+
const asset = assets.find(a => a.id === id);
|
|
2729
|
+
if (!asset || asset.type !== AssetType.Texture2D)
|
|
2730
|
+
throw new Error('Not a texture');
|
|
2731
|
+
const raw = await readFile(projectConfig.srcArtDir, id);
|
|
2732
|
+
if (!raw)
|
|
2733
|
+
throw new Error(`Source file not found: ${id}`);
|
|
2734
|
+
const ext = id.split('.').pop()?.toLowerCase() ?? 'png';
|
|
2735
|
+
const mimeType = mimeMap[ext] ?? 'image/png';
|
|
2736
|
+
const detection = await aiDetectSpriteSheet(apiKey, Buffer.from(raw), mimeType);
|
|
2737
|
+
if (!detection.isSpriteSheet)
|
|
2738
|
+
return 'notSpriteSheet';
|
|
2739
|
+
// Use AI-detected grid, falling back to heuristic
|
|
2740
|
+
let rows;
|
|
2741
|
+
let columns;
|
|
2742
|
+
if (detection.rows && detection.columns) {
|
|
2743
|
+
rows = detection.rows;
|
|
2744
|
+
columns = detection.columns;
|
|
2745
|
+
}
|
|
2746
|
+
else {
|
|
2747
|
+
const dims = await probeImageDimensions(projectConfig.srcArtDir, id);
|
|
2748
|
+
if (!dims)
|
|
2749
|
+
throw new Error('Could not read image dimensions');
|
|
2750
|
+
const grid = detectSpriteGrid(dims.width, dims.height);
|
|
2751
|
+
rows = grid.rows;
|
|
2752
|
+
columns = grid.columns;
|
|
2753
|
+
}
|
|
2754
|
+
const frameCount = rows * columns;
|
|
2755
|
+
const parts = id.replace(/\\/g, '/').split('/');
|
|
2756
|
+
const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
|
|
2757
|
+
const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
|
|
2758
|
+
const safeName = [folder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
|
|
2759
|
+
const sheetName = `${safeName}_animation`;
|
|
2760
|
+
const targetFolder = parts.slice(0, -1).join('/');
|
|
2761
|
+
const baseName = `${sheetName}.stowspritesheet`;
|
|
2762
|
+
const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
|
|
2763
|
+
if (assets.find(a => a.id === fileName))
|
|
2764
|
+
return 'alreadyExists';
|
|
2765
|
+
const settings = defaultAssetSettings();
|
|
2766
|
+
settings.spritesheetConfig = { textureAssetId: id, rows, columns, frameCount, frameRate: 12 };
|
|
2767
|
+
const newAsset = {
|
|
2768
|
+
id: fileName,
|
|
2769
|
+
fileName: sheetName,
|
|
2770
|
+
stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
|
|
2771
|
+
type: AssetType.SpriteSheet,
|
|
2772
|
+
status: 'ready',
|
|
2773
|
+
settings,
|
|
2774
|
+
sourceSize: 0,
|
|
2775
|
+
processedSize: 0,
|
|
2776
|
+
};
|
|
2777
|
+
assets.push(newAsset);
|
|
2778
|
+
const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
|
|
2779
|
+
await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
|
|
2780
|
+
const newMeta = assetSettingsToStowmeta(newAsset);
|
|
2781
|
+
await writeStowmeta(projectConfig.srcArtDir, fileName, newMeta);
|
|
2782
|
+
return 'created';
|
|
2783
|
+
}));
|
|
2784
|
+
for (let i = 0; i < results.length; i++) {
|
|
2785
|
+
const r = results[i];
|
|
2786
|
+
if (r.status === 'fulfilled') {
|
|
2787
|
+
if (r.value === 'created')
|
|
2788
|
+
created++;
|
|
2789
|
+
else if (r.value === 'notSpriteSheet')
|
|
2790
|
+
notSpriteSheet++;
|
|
2791
|
+
else if (r.value === 'alreadyExists')
|
|
2792
|
+
alreadyExists++;
|
|
2793
|
+
}
|
|
2794
|
+
else {
|
|
2795
|
+
const errMsg = r.reason?.message ?? String(r.reason);
|
|
2796
|
+
console.error(`[ai-detect-spritesheet] ${ids[i]}: ${errMsg}`);
|
|
2797
|
+
errors.push({ id: ids[i], error: errMsg });
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
if (created > 0)
|
|
2801
|
+
broadcast({ type: 'refresh' });
|
|
2802
|
+
json(res, { created, notSpriteSheet, alreadyExists, errors });
|
|
2803
|
+
}
|
|
2804
|
+
catch (err) {
|
|
2805
|
+
json(res, { error: err.message }, 500);
|
|
2806
|
+
}
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2373
2809
|
// GET /api/thumbnails/cached — return which thumbnails are still valid in the cache
|
|
2374
2810
|
if (pathname === '/api/thumbnails/cached' && req.method === 'GET') {
|
|
2375
2811
|
if (!projectConfig) {
|