@series-inc/stowkit-cli 0.6.40 → 0.6.42

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
@@ -62,13 +62,16 @@ export async function initProject(projectDir, opts) {
62
62
  }
63
63
  // Prompt for engine setup unless explicitly set via flag
64
64
  let withEngine = opts?.withEngine ?? false;
65
+ let withPublish = opts?.withPublish ?? false;
65
66
  const noEngine = opts?.noEngine ?? false;
66
- if (!withEngine && !noEngine && process.stdin.isTTY) {
67
+ if (!withEngine && !noEngine && !withPublish && process.stdin.isTTY) {
67
68
  const choice = await promptMenu('What would you like to set up?', [
68
69
  'StowKit (asset pipeline only)',
69
70
  'StowKit + 3D Engine (includes @series-inc/rundot-3d-engine)',
71
+ 'StowKit + Publish (includes assets-package.json for publishing)',
70
72
  ]);
71
73
  withEngine = choice === 1;
74
+ withPublish = choice === 2;
72
75
  }
73
76
  // Create srcArtDir with .gitignore for cache files
74
77
  const srcArtDir = 'assets';
@@ -153,6 +156,12 @@ export async function initProject(projectDir, opts) {
153
156
  if (withEngine) {
154
157
  await installEngine(absDir);
155
158
  }
159
+ // Create assets-package.json for publishing
160
+ if (withPublish) {
161
+ const { initAssetsPackage } = await import('./assets-package.js');
162
+ await initAssetsPackage(absDir, { name: path.basename(absDir) });
163
+ console.log(` Publish config: assets-package.json`);
164
+ }
156
165
  console.log('');
157
166
  if (withEngine) {
158
167
  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,175 @@ 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 safeFolder = folder.replace(/\./g, '');
1761
+ const safeName = [safeFolder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
1762
+ const sheetName = `${safeName}_animation`;
1763
+ const targetFolder = parts.slice(0, -1).join('/');
1764
+ const baseName = `${sheetName}.stowspritesheet`;
1765
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
1766
+ // Check if already exists
1767
+ if (assets.find(a => a.id === fileName)) {
1768
+ json(res, { error: `Spritesheet already exists: ${fileName}` }, 409);
1769
+ return;
1770
+ }
1771
+ const settings = defaultAssetSettings();
1772
+ settings.spritesheetConfig = {
1773
+ textureAssetId: textureId,
1774
+ rows,
1775
+ columns,
1776
+ frameCount,
1777
+ frameRate: 12,
1778
+ };
1779
+ const asset = {
1780
+ id: fileName,
1781
+ fileName: sheetName,
1782
+ stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
1783
+ type: AssetType.SpriteSheet,
1784
+ status: 'ready',
1785
+ settings,
1786
+ sourceSize: 0,
1787
+ processedSize: 0,
1788
+ };
1789
+ assets.push(asset);
1790
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
1791
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
1792
+ const meta = assetSettingsToStowmeta(asset);
1793
+ await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
1794
+ broadcast({ type: 'refresh' });
1795
+ json(res, { ok: true, asset });
1796
+ return;
1797
+ }
1798
+ // POST /api/batch-convert-folder-name — find all folders matching a name and convert all textures under them to sprite sheets
1799
+ if (pathname === '/api/batch-convert-folder-name' && req.method === 'POST') {
1800
+ if (!projectConfig) {
1801
+ json(res, { error: 'No project open' }, 400);
1802
+ return;
1803
+ }
1804
+ const body = JSON.parse(await readBody(req));
1805
+ const folderName = body.folderName;
1806
+ if (!folderName) {
1807
+ json(res, { error: 'Missing folderName' }, 400);
1808
+ return;
1809
+ }
1810
+ // Find all folders that match the given name (case-insensitive)
1811
+ const matchingFolders = folders.filter(f => {
1812
+ const parts = f.replace(/\\/g, '/').split('/');
1813
+ return parts[parts.length - 1].toLowerCase() === folderName.toLowerCase();
1814
+ });
1815
+ if (matchingFolders.length === 0) {
1816
+ json(res, { error: `No folders found matching "${folderName}"` }, 404);
1817
+ return;
1818
+ }
1819
+ // Find all Texture2D assets under any of the matching folders
1820
+ const textureIds = [];
1821
+ for (const folder of matchingFolders) {
1822
+ const prefix = folder + '/';
1823
+ for (const asset of assets) {
1824
+ if (asset.type === AssetType.Texture2D && (asset.id.startsWith(prefix) || asset.id.replace(/\\/g, '/').startsWith(prefix))) {
1825
+ textureIds.push(asset.id);
1826
+ }
1827
+ }
1828
+ }
1829
+ if (textureIds.length === 0) {
1830
+ json(res, { error: `No textures found under folders matching "${folderName}"` }, 404);
1831
+ return;
1832
+ }
1833
+ // Convert each texture to a sprite sheet (reuse the same logic as convert-to-spritesheet)
1834
+ let succeeded = 0;
1835
+ let failed = 0;
1836
+ const created = [];
1837
+ for (const textureId of textureIds) {
1838
+ const textureAsset = assets.find(a => a.id === textureId);
1839
+ if (!textureAsset) {
1840
+ failed++;
1841
+ continue;
1842
+ }
1843
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, textureId);
1844
+ if (!dims) {
1845
+ failed++;
1846
+ continue;
1847
+ }
1848
+ const { columns, rows, frameCount } = detectSpriteGrid(dims.width, dims.height);
1849
+ const parts = textureId.replace(/\\/g, '/').split('/');
1850
+ const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
1851
+ const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
1852
+ const safeFolder = folder.replace(/\./g, '');
1853
+ const safeName = [safeFolder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
1854
+ const sheetName = `${safeName}_animation`;
1855
+ const targetFolder = parts.slice(0, -1).join('/');
1856
+ const baseName = `${sheetName}.stowspritesheet`;
1857
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
1858
+ if (assets.find(a => a.id === fileName)) {
1859
+ failed++;
1860
+ continue;
1861
+ }
1862
+ const settings = defaultAssetSettings();
1863
+ settings.spritesheetConfig = {
1864
+ textureAssetId: textureId,
1865
+ rows,
1866
+ columns,
1867
+ frameCount,
1868
+ frameRate: 12,
1869
+ };
1870
+ const asset = {
1871
+ id: fileName,
1872
+ fileName: sheetName,
1873
+ stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
1874
+ type: AssetType.SpriteSheet,
1875
+ status: 'ready',
1876
+ settings,
1877
+ sourceSize: 0,
1878
+ processedSize: 0,
1879
+ };
1880
+ assets.push(asset);
1881
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
1882
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
1883
+ const meta = assetSettingsToStowmeta(asset);
1884
+ await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
1885
+ created.push(asset);
1886
+ succeeded++;
1887
+ }
1888
+ broadcast({ type: 'refresh' });
1889
+ json(res, { ok: true, succeeded, failed, matchingFolders, assets: created });
1890
+ return;
1891
+ }
1632
1892
  // POST /api/create-folder
1633
1893
  if (pathname === '/api/create-folder' && req.method === 'POST') {
1634
1894
  if (!projectConfig) {
@@ -2043,6 +2303,31 @@ async function handleRequest(req, res, staticApps) {
2043
2303
  }
2044
2304
  return;
2045
2305
  }
2306
+ // POST /api/asset-store/package/:name/delist — remove package from registry (keeps GCS files)
2307
+ if (pathname.startsWith('/api/asset-store/package/') && pathname.endsWith('/delist') && req.method === 'POST') {
2308
+ if (!projectConfig) {
2309
+ json(res, { error: 'No project open' }, 400);
2310
+ return;
2311
+ }
2312
+ try {
2313
+ const packageName = decodeURIComponent(pathname.slice('/api/asset-store/package/'.length, -'/delist'.length));
2314
+ const { createFirestoreClient } = await import('./firestore.js');
2315
+ const firestore = await createFirestoreClient(projectConfig.projectDir);
2316
+ const pkg = await firestore.getPackage(packageName);
2317
+ if (!pkg) {
2318
+ json(res, { error: `Package "${packageName}" not found` }, 404);
2319
+ return;
2320
+ }
2321
+ await firestore.deletePackage(packageName);
2322
+ const { clearFirestoreCache } = await import('./firestore.js');
2323
+ clearFirestoreCache();
2324
+ json(res, { ok: true, packageName });
2325
+ }
2326
+ catch (err) {
2327
+ json(res, { error: err.message }, 500);
2328
+ }
2329
+ return;
2330
+ }
2046
2331
  // POST /api/publish — publish assets to GCS (SSE stream with progress)
2047
2332
  if (pathname === '/api/publish' && req.method === 'POST') {
2048
2333
  if (!projectConfig) {
@@ -2370,6 +2655,160 @@ async function handleRequest(req, res, staticApps) {
2370
2655
  }
2371
2656
  return;
2372
2657
  }
2658
+ // POST /api/ai/guess-spritesheet-grid/:id — ask Gemini to count rows/columns on a texture without creating anything
2659
+ if (pathname.startsWith('/api/ai/guess-spritesheet-grid/') && req.method === 'POST') {
2660
+ if (!projectConfig) {
2661
+ json(res, { error: 'No project open' }, 400);
2662
+ return;
2663
+ }
2664
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2665
+ if (!apiKey) {
2666
+ json(res, { error: 'Gemini API key not configured' }, 400);
2667
+ return;
2668
+ }
2669
+ const id = decodeURIComponent(pathname.slice('/api/ai/guess-spritesheet-grid/'.length));
2670
+ const asset = assets.find(a => a.id === id);
2671
+ if (!asset || asset.type !== AssetType.Texture2D) {
2672
+ json(res, { error: 'Asset is not a texture' }, 404);
2673
+ return;
2674
+ }
2675
+ try {
2676
+ const raw = await readFile(projectConfig.srcArtDir, id);
2677
+ if (!raw) {
2678
+ json(res, { error: 'Source file not found' }, 404);
2679
+ return;
2680
+ }
2681
+ const ext = id.split('.').pop()?.toLowerCase() ?? 'png';
2682
+ const mimeMap = {
2683
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
2684
+ };
2685
+ const mimeType = mimeMap[ext] ?? 'image/png';
2686
+ const { detectSpriteSheet: aiDetectSpriteSheet } = await import('./ai-tagger.js');
2687
+ const detection = await aiDetectSpriteSheet(apiKey, Buffer.from(raw), mimeType);
2688
+ if (detection.isSpriteSheet && detection.rows && detection.columns) {
2689
+ json(res, { rows: detection.rows, columns: detection.columns });
2690
+ }
2691
+ else {
2692
+ // Fall back to heuristic
2693
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, id);
2694
+ const grid = dims ? detectSpriteGrid(dims.width, dims.height) : { rows: 1, columns: 1 };
2695
+ json(res, { rows: grid.rows, columns: grid.columns });
2696
+ }
2697
+ }
2698
+ catch (err) {
2699
+ json(res, { error: err.message }, 500);
2700
+ }
2701
+ return;
2702
+ }
2703
+ // POST /api/ai/detect-spritesheet-batch — use Gemini to detect sprite sheets across multiple textures concurrently
2704
+ if (pathname === '/api/ai/detect-spritesheet-batch' && req.method === 'POST') {
2705
+ if (!projectConfig) {
2706
+ json(res, { error: 'No project open' }, 400);
2707
+ return;
2708
+ }
2709
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2710
+ if (!apiKey) {
2711
+ json(res, { error: 'Gemini API key not configured' }, 400);
2712
+ return;
2713
+ }
2714
+ try {
2715
+ const body = JSON.parse(await readBody(req));
2716
+ const ids = body.ids;
2717
+ if (!Array.isArray(ids) || ids.length === 0) {
2718
+ json(res, { error: 'ids must be a non-empty array' }, 400);
2719
+ return;
2720
+ }
2721
+ const { detectSpriteSheet: aiDetectSpriteSheet } = await import('./ai-tagger.js');
2722
+ const mimeMap = {
2723
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
2724
+ };
2725
+ let created = 0;
2726
+ let notSpriteSheet = 0;
2727
+ let alreadyExists = 0;
2728
+ const errors = [];
2729
+ const results = await Promise.allSettled(ids.map(async (id) => {
2730
+ const asset = assets.find(a => a.id === id);
2731
+ if (!asset || asset.type !== AssetType.Texture2D)
2732
+ throw new Error('Not a texture');
2733
+ const raw = await readFile(projectConfig.srcArtDir, id);
2734
+ if (!raw)
2735
+ throw new Error(`Source file not found: ${id}`);
2736
+ const ext = id.split('.').pop()?.toLowerCase() ?? 'png';
2737
+ const mimeType = mimeMap[ext] ?? 'image/png';
2738
+ const detection = await aiDetectSpriteSheet(apiKey, Buffer.from(raw), mimeType);
2739
+ if (!detection.isSpriteSheet)
2740
+ return 'notSpriteSheet';
2741
+ // Use AI-detected grid, falling back to heuristic
2742
+ let rows;
2743
+ let columns;
2744
+ if (detection.rows && detection.columns) {
2745
+ rows = detection.rows;
2746
+ columns = detection.columns;
2747
+ }
2748
+ else {
2749
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, id);
2750
+ if (!dims)
2751
+ throw new Error('Could not read image dimensions');
2752
+ const grid = detectSpriteGrid(dims.width, dims.height);
2753
+ rows = grid.rows;
2754
+ columns = grid.columns;
2755
+ }
2756
+ const frameCount = rows * columns;
2757
+ const parts = id.replace(/\\/g, '/').split('/');
2758
+ const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
2759
+ const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
2760
+ const safeFolder = folder.replace(/\./g, '');
2761
+ const safeName = [safeFolder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
2762
+ const sheetName = `${safeName}_animation`;
2763
+ const targetFolder = parts.slice(0, -1).join('/');
2764
+ const baseName = `${sheetName}.stowspritesheet`;
2765
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
2766
+ if (assets.find(a => a.id === fileName))
2767
+ return 'alreadyExists';
2768
+ const settings = defaultAssetSettings();
2769
+ settings.spritesheetConfig = { textureAssetId: id, rows, columns, frameCount, frameRate: 12 };
2770
+ const newAsset = {
2771
+ id: fileName,
2772
+ fileName: sheetName,
2773
+ stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
2774
+ type: AssetType.SpriteSheet,
2775
+ status: 'ready',
2776
+ settings,
2777
+ sourceSize: 0,
2778
+ processedSize: 0,
2779
+ };
2780
+ assets.push(newAsset);
2781
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
2782
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
2783
+ const newMeta = assetSettingsToStowmeta(newAsset);
2784
+ await writeStowmeta(projectConfig.srcArtDir, fileName, newMeta);
2785
+ return 'created';
2786
+ }));
2787
+ for (let i = 0; i < results.length; i++) {
2788
+ const r = results[i];
2789
+ if (r.status === 'fulfilled') {
2790
+ if (r.value === 'created')
2791
+ created++;
2792
+ else if (r.value === 'notSpriteSheet')
2793
+ notSpriteSheet++;
2794
+ else if (r.value === 'alreadyExists')
2795
+ alreadyExists++;
2796
+ }
2797
+ else {
2798
+ const errMsg = r.reason?.message ?? String(r.reason);
2799
+ console.error(`[ai-detect-spritesheet] ${ids[i]}: ${errMsg}`);
2800
+ errors.push({ id: ids[i], error: errMsg });
2801
+ }
2802
+ }
2803
+ if (created > 0)
2804
+ broadcast({ type: 'refresh' });
2805
+ json(res, { created, notSpriteSheet, alreadyExists, errors });
2806
+ }
2807
+ catch (err) {
2808
+ json(res, { error: err.message }, 500);
2809
+ }
2810
+ return;
2811
+ }
2373
2812
  // GET /api/thumbnails/cached — return which thumbnails are still valid in the cache
2374
2813
  if (pathname === '/api/thumbnails/cached' && req.method === 'GET') {
2375
2814
  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.42",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@google/genai": "^1.46.0",
22
22
  "@series-inc/stowkit-editor": "^0.1.8",
23
- "@series-inc/stowkit-packer-gui": "^0.1.30",
23
+ "@series-inc/stowkit-packer-gui": "^0.1.31",
24
24
  "@strangeape/ffmpeg-audio-wasm": "^0.1.0",
25
25
  "draco3d": "^1.5.7",
26
26
  "fbx-parser": "^2.1.3",