@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.
@@ -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 MODEL = 'gemini-3.1-flash-lite-preview';
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: 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: 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
@@ -2,5 +2,6 @@ export interface InitOptions {
2
2
  update?: boolean;
3
3
  withEngine?: boolean;
4
4
  noEngine?: boolean;
5
+ withPublish?: boolean;
5
6
  }
6
7
  export declare function initProject(projectDir: string, opts?: InitOptions): Promise<void>;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.6.40",
3
+ "version": "0.6.41",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"