@series-inc/stowkit-cli 0.6.35 → 0.6.37

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.
@@ -0,0 +1,13 @@
1
+ import { AssetType } from './core/types.js';
2
+ /**
3
+ * Verify that a Gemini API key is valid by making a minimal request.
4
+ */
5
+ export declare function verifyApiKey(apiKey: string): Promise<{
6
+ valid: boolean;
7
+ error?: string;
8
+ }>;
9
+ /**
10
+ * Tag a single asset by sending its thumbnail/audio to Gemini.
11
+ * Returns an array of lowercase tag strings.
12
+ */
13
+ export declare function tagAsset(apiKey: string, assetType: AssetType, fileName: string, inputData: Buffer, mimeType: string): Promise<string[]>;
@@ -0,0 +1,95 @@
1
+ import { GoogleGenAI } from '@google/genai';
2
+ import { AssetType } from './core/types.js';
3
+ const MODEL = 'gemini-3.1-flash-lite-preview';
4
+ const ASSET_TYPE_LABELS = {
5
+ [AssetType.Texture2D]: 'texture',
6
+ [AssetType.StaticMesh]: '3D mesh',
7
+ [AssetType.SkinnedMesh]: 'skinned 3D mesh',
8
+ [AssetType.AnimationClip]: 'animation',
9
+ [AssetType.Audio]: 'audio clip',
10
+ [AssetType.GlbContainer]: '3D model',
11
+ };
12
+ function buildPrompt(assetType, fileName) {
13
+ const label = ASSET_TYPE_LABELS[assetType] ?? 'asset';
14
+ return `You are a strict asset tagging engine for a game development library.
15
+
16
+ This is a game ${label} (${fileName}).
17
+
18
+ Return only a JSON array of concise lowercase asset tags.
19
+
20
+ Rules:
21
+ - 4 to 12 tags
22
+ - lowercase only
23
+ - no duplicates
24
+ - short searchable tags only
25
+ - no explanations
26
+ - no generic tags like asset, file, media, content
27
+ - only include tags directly supported by the input`;
28
+ }
29
+ /**
30
+ * Verify that a Gemini API key is valid by making a minimal request.
31
+ */
32
+ export async function verifyApiKey(apiKey) {
33
+ try {
34
+ const client = new GoogleGenAI({ apiKey });
35
+ const response = await client.models.generateContent({
36
+ model: MODEL,
37
+ contents: [{ role: 'user', parts: [{ text: 'Respond with the word "ok"' }] }],
38
+ config: { maxOutputTokens: 10 },
39
+ });
40
+ const text = response.text?.trim().toLowerCase() ?? '';
41
+ return { valid: text.includes('ok') };
42
+ }
43
+ catch (err) {
44
+ return { valid: false, error: err.message };
45
+ }
46
+ }
47
+ /**
48
+ * Tag a single asset by sending its thumbnail/audio to Gemini.
49
+ * Returns an array of lowercase tag strings.
50
+ */
51
+ export async function tagAsset(apiKey, assetType, fileName, inputData, mimeType) {
52
+ const client = new GoogleGenAI({ apiKey });
53
+ const response = await client.models.generateContent({
54
+ model: MODEL,
55
+ contents: [{
56
+ role: 'user',
57
+ parts: [
58
+ { text: buildPrompt(assetType, fileName) },
59
+ {
60
+ inlineData: {
61
+ data: inputData.toString('base64'),
62
+ mimeType,
63
+ },
64
+ },
65
+ ],
66
+ }],
67
+ config: {
68
+ temperature: 0.3,
69
+ topP: 0.95,
70
+ maxOutputTokens: 1024,
71
+ responseMimeType: 'application/json',
72
+ responseSchema: {
73
+ type: 'ARRAY',
74
+ items: { type: 'STRING' },
75
+ minItems: 4,
76
+ maxItems: 12,
77
+ },
78
+ safetySettings: [
79
+ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
80
+ { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
81
+ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
82
+ { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
83
+ ],
84
+ thinkingConfig: { thinkingBudget: 0 },
85
+ },
86
+ });
87
+ const text = response.text ?? '[]';
88
+ const parsed = JSON.parse(text);
89
+ if (!Array.isArray(parsed))
90
+ return [];
91
+ return parsed
92
+ .filter((t) => typeof t === 'string')
93
+ .map((t) => t.toLowerCase().trim())
94
+ .filter((t) => t.length > 0);
95
+ }
@@ -27,6 +27,9 @@ export interface FelicityProject {
27
27
  prefabsPath?: string;
28
28
  packs?: PackConfig[];
29
29
  defaults?: ProjectDefaults;
30
+ ai?: {
31
+ geminiApiKey?: string;
32
+ };
30
33
  }
31
34
  export interface FileSnapshot {
32
35
  relativePath: string;
package/dist/cli.js CHANGED
@@ -11,7 +11,6 @@ import { createMaterial } from './create-material.js';
11
11
  import { renameAsset, moveAsset, deleteAsset, setStringId } from './asset-commands.js';
12
12
  import { inspectPack } from './inspect.js';
13
13
  import { publishPackage } from './publish.js';
14
- import { storeSearch, storeList, storeInfo } from './store.js';
15
14
  const args = process.argv.slice(2);
16
15
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
17
16
  const STOWKIT_PACKAGES = [
@@ -108,7 +107,6 @@ Options:
108
107
  --schema Material schema template: pbr (default), unlit, or custom name
109
108
  --bucket GCS bucket for publish/store (overrides default)
110
109
  --dry-run Show what would be published without uploading
111
- --json Output store results as JSON (for AI agents)
112
110
  --type Filter store search by asset type
113
111
  --limit Max number of results to return (default: all)
114
112
  --help Show this help message
@@ -163,7 +161,6 @@ async function main() {
163
161
  const portIdx = args.indexOf('--port');
164
162
  const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : 3210;
165
163
  const dryRun = args.includes('--dry-run');
166
- const jsonOutput = args.includes('--json');
167
164
  const bucketIdx = args.indexOf('--bucket');
168
165
  const bucket = bucketIdx >= 0 ? args[bucketIdx + 1] : undefined;
169
166
  const typeIdx = args.indexOf('--type');
@@ -306,35 +303,29 @@ async function main() {
306
303
  const subCmd = args[1];
307
304
  const serverUrl = `http://localhost:${port}`;
308
305
  const serverUp = await isStowKitRunning(port);
306
+ if (!serverUp) {
307
+ console.error('StowKit server is not running. Start it with: stowkit serve');
308
+ process.exit(1);
309
+ }
309
310
  if (subCmd === 'search') {
310
311
  const query = args.filter(a => !a.startsWith('-') && a !== 'store' && a !== 'search').join(' ');
311
312
  if (!query) {
312
- console.error('Usage: stowkit store search <query> [--type <type>] [--limit <n>] [--json]');
313
+ console.error('Usage: stowkit store search <query> [--type <type>] [--limit <n>]');
313
314
  process.exit(1);
314
315
  }
315
- if (serverUp) {
316
- const params = new URLSearchParams({ q: query });
317
- if (typeFilter)
318
- params.set('type', typeFilter);
319
- if (limitFilter)
320
- params.set('limit', String(limitFilter));
321
- const res = await fetch(`${serverUrl}/api/asset-store/search?${params}`);
322
- const results = await res.json();
323
- console.log(JSON.stringify(results, null, 2));
324
- }
325
- else {
326
- await storeSearch(query, { json: jsonOutput, bucket, type: typeFilter, limit: limitFilter });
327
- }
316
+ const params = new URLSearchParams({ q: query });
317
+ if (typeFilter)
318
+ params.set('type', typeFilter);
319
+ if (limitFilter)
320
+ params.set('limit', String(limitFilter));
321
+ const res = await fetch(`${serverUrl}/api/asset-store/search?${params}`);
322
+ const results = await res.json();
323
+ console.log(JSON.stringify(results, null, 2));
328
324
  }
329
325
  else if (subCmd === 'list') {
330
- if (serverUp) {
331
- const res = await fetch(`${serverUrl}/api/asset-store/packages`);
332
- const packages = await res.json();
333
- console.log(JSON.stringify(packages, null, 2));
334
- }
335
- else {
336
- await storeList({ json: jsonOutput, bucket });
337
- }
326
+ const res = await fetch(`${serverUrl}/api/asset-store/packages`);
327
+ const packages = await res.json();
328
+ console.log(JSON.stringify(packages, null, 2));
338
329
  }
339
330
  else if (subCmd === 'info') {
340
331
  const pkgName = args.find(a => !a.startsWith('-') && a !== 'store' && a !== 'info');
@@ -342,14 +333,9 @@ async function main() {
342
333
  console.error('Usage: stowkit store info <package-name> [--json]');
343
334
  process.exit(1);
344
335
  }
345
- if (serverUp) {
346
- const res = await fetch(`${serverUrl}/api/asset-store/package/${encodeURIComponent(pkgName)}`);
347
- const info = await res.json();
348
- console.log(JSON.stringify(info, null, 2));
349
- }
350
- else {
351
- await storeInfo(pkgName, { json: jsonOutput, bucket });
352
- }
336
+ const res = await fetch(`${serverUrl}/api/asset-store/package/${encodeURIComponent(pkgName)}`);
337
+ const info = await res.json();
338
+ console.log(JSON.stringify(info, null, 2));
353
339
  }
354
340
  else {
355
341
  console.error('Usage: stowkit store <search|list|info> [args]');
package/dist/firestore.js CHANGED
@@ -70,6 +70,23 @@ function objectToFields(data) {
70
70
  }
71
71
  return fields;
72
72
  }
73
+ // ─── In-memory cache ─────────────────────────────────────────────────────────
74
+ const CACHE_TTL_MS = 60_000; // 1 minute
75
+ const cache = new Map();
76
+ function cacheGet(key) {
77
+ const entry = cache.get(key);
78
+ if (!entry)
79
+ return undefined;
80
+ if (Date.now() > entry.expiry) {
81
+ cache.delete(key);
82
+ return undefined;
83
+ }
84
+ return entry.value;
85
+ }
86
+ function cacheSet(key, value) {
87
+ cache.set(key, { value, expiry: Date.now() + CACHE_TTL_MS });
88
+ return value;
89
+ }
73
90
  // ─── REST helpers ────────────────────────────────────────────────────────────
74
91
  const BASE = 'https://firestore.googleapis.com/v1';
75
92
  function docPath(projectId, ...segments) {
@@ -78,17 +95,25 @@ function docPath(projectId, ...segments) {
78
95
  // ─── Shared read operations ─────────────────────────────────────────────────
79
96
  function buildReadOps(projectId, fetchWithAuth) {
80
97
  async function getPackage(name) {
98
+ const key = `pkg:${projectId}:${name}`;
99
+ const cached = cacheGet(key);
100
+ if (cached !== undefined)
101
+ return cached;
81
102
  const res = await fetchWithAuth(docPath(projectId, 'packages', name));
82
103
  if (res.status === 404)
83
- return null;
104
+ return cacheSet(key, null);
84
105
  if (!res.ok) {
85
106
  const text = await res.text();
86
107
  throw new Error(`Firestore GET packages/${name} failed (${res.status}): ${text}`);
87
108
  }
88
109
  const doc = (await res.json());
89
- return docToObject(doc.fields);
110
+ return cacheSet(key, docToObject(doc.fields));
90
111
  }
91
112
  async function listPackages() {
113
+ const key = `pkgList:${projectId}`;
114
+ const cached = cacheGet(key);
115
+ if (cached)
116
+ return cached;
92
117
  const results = [];
93
118
  let pageToken;
94
119
  do {
@@ -111,20 +136,28 @@ function buildReadOps(projectId, fetchWithAuth) {
111
136
  }
112
137
  pageToken = body.nextPageToken;
113
138
  } while (pageToken);
114
- return results;
139
+ return cacheSet(key, results);
115
140
  }
116
141
  async function getVersion(packageName, version) {
142
+ const key = `ver:${projectId}:${packageName}:${version}`;
143
+ const cached = cacheGet(key);
144
+ if (cached !== undefined)
145
+ return cached;
117
146
  const res = await fetchWithAuth(docPath(projectId, 'packages', packageName, 'versions', version));
118
147
  if (res.status === 404)
119
- return null;
148
+ return cacheSet(key, null);
120
149
  if (!res.ok) {
121
150
  const text = await res.text();
122
151
  throw new Error(`Firestore GET packages/${packageName}/versions/${version} failed (${res.status}): ${text}`);
123
152
  }
124
153
  const doc = (await res.json());
125
- return docToObject(doc.fields);
154
+ return cacheSet(key, docToObject(doc.fields));
126
155
  }
127
156
  async function listVersionKeys(packageName) {
157
+ const key = `verKeys:${projectId}:${packageName}`;
158
+ const cached = cacheGet(key);
159
+ if (cached)
160
+ return cached;
128
161
  const results = [];
129
162
  let pageToken;
130
163
  do {
@@ -144,7 +177,7 @@ function buildReadOps(projectId, fetchWithAuth) {
144
177
  }
145
178
  pageToken = body.nextPageToken;
146
179
  } while (pageToken);
147
- return results;
180
+ return cacheSet(key, results);
148
181
  }
149
182
  return { getPackage, listPackages, getVersion, listVersionKeys };
150
183
  }
package/dist/server.js CHANGED
@@ -1608,6 +1608,15 @@ async function handleRequest(req, res, staticApps) {
1608
1608
  if (Object.prototype.hasOwnProperty.call(body, 'defaults')) {
1609
1609
  nextConfig.defaults = normalizeProjectDefaults(body.defaults);
1610
1610
  }
1611
+ if (Object.prototype.hasOwnProperty.call(body, 'ai')) {
1612
+ const aiBody = body.ai;
1613
+ if (aiBody && typeof aiBody.geminiApiKey === 'string' && aiBody.geminiApiKey.trim()) {
1614
+ nextConfig.ai = { geminiApiKey: aiBody.geminiApiKey.trim() };
1615
+ }
1616
+ else {
1617
+ nextConfig.ai = undefined;
1618
+ }
1619
+ }
1611
1620
  const renamedPacks = Array.isArray(body.renamedPacks)
1612
1621
  ? body.renamedPacks.filter((rename) => typeof rename?.oldName === 'string'
1613
1622
  && typeof rename?.newName === 'string'
@@ -1783,14 +1792,17 @@ async function handleRequest(req, res, staticApps) {
1783
1792
  const { createFirestoreReader } = await import('./firestore.js');
1784
1793
  const reader = createFirestoreReader();
1785
1794
  const allPackages = await reader.listPackages();
1795
+ // Fetch all version keys in parallel
1796
+ const allVersionKeys = await Promise.all(allPackages.map(({ name }) => reader.listVersionKeys(name)));
1797
+ // Fetch all version docs in parallel
1798
+ const allVersionDocs = await Promise.all(allPackages.map(({ name }, i) => Promise.all(allVersionKeys[i].map(ver => reader.getVersion(name, ver).then(doc => ({ ver, doc }))))));
1786
1799
  const packages = {};
1787
- for (const { name, data: pkg } of allPackages) {
1788
- const versionKeys = await reader.listVersionKeys(name);
1800
+ for (let i = 0; i < allPackages.length; i++) {
1801
+ const { name, data: pkg } = allPackages[i];
1789
1802
  const versions = {};
1790
- for (const ver of versionKeys) {
1791
- const versionDoc = await reader.getVersion(name, ver);
1792
- if (versionDoc)
1793
- versions[ver] = versionDoc;
1803
+ for (const { ver, doc } of allVersionDocs[i]) {
1804
+ if (doc)
1805
+ versions[ver] = doc;
1794
1806
  }
1795
1807
  packages[name] = {
1796
1808
  description: pkg.description,
@@ -2011,6 +2023,267 @@ async function handleRequest(req, res, staticApps) {
2011
2023
  }
2012
2024
  return;
2013
2025
  }
2026
+ // POST /api/ai/verify-key — validate a Gemini API key
2027
+ if (pathname === '/api/ai/verify-key' && req.method === 'POST') {
2028
+ try {
2029
+ const body = JSON.parse(await readBody(req));
2030
+ const apiKey = body.apiKey;
2031
+ if (!apiKey || typeof apiKey !== 'string') {
2032
+ json(res, { valid: false, error: 'Missing apiKey' });
2033
+ return;
2034
+ }
2035
+ const { verifyApiKey } = await import('./ai-tagger.js');
2036
+ const result = await verifyApiKey(apiKey);
2037
+ json(res, result);
2038
+ }
2039
+ catch (err) {
2040
+ json(res, { valid: false, error: err.message }, 500);
2041
+ }
2042
+ return;
2043
+ }
2044
+ // POST /api/ai/tag/:id — AI-tag a single asset
2045
+ if (pathname.startsWith('/api/ai/tag/') && req.method === 'POST' && !pathname.endsWith('/tag-missing')) {
2046
+ if (!projectConfig) {
2047
+ json(res, { error: 'No project open' }, 400);
2048
+ return;
2049
+ }
2050
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2051
+ if (!apiKey) {
2052
+ json(res, { error: 'Gemini API key not configured' }, 400);
2053
+ return;
2054
+ }
2055
+ const id = decodeURIComponent(pathname.slice('/api/ai/tag/'.length));
2056
+ const asset = assets.find(a => a.id === id);
2057
+ if (!asset) {
2058
+ json(res, { error: 'Asset not found' }, 404);
2059
+ return;
2060
+ }
2061
+ if (asset.type === AssetType.MaterialSchema) {
2062
+ json(res, { error: 'Material schemas cannot be tagged' }, 400);
2063
+ return;
2064
+ }
2065
+ try {
2066
+ const { tagAsset: aiTagAsset } = await import('./ai-tagger.js');
2067
+ let inputData;
2068
+ let mimeType;
2069
+ if (asset.type === AssetType.Audio) {
2070
+ const ext = asset.fileName.split('.').pop()?.toLowerCase() ?? 'wav';
2071
+ const mimeMap = {
2072
+ wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg',
2073
+ flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
2074
+ };
2075
+ mimeType = mimeMap[ext] ?? 'audio/wav';
2076
+ const raw = await readFile(projectConfig.srcArtDir, id);
2077
+ if (!raw) {
2078
+ json(res, { error: 'Source audio file not found' }, 404);
2079
+ return;
2080
+ }
2081
+ inputData = Buffer.from(raw);
2082
+ }
2083
+ else {
2084
+ const { readManifest, readThumbnailFile } = await import('./app/thumbnail-cache.js');
2085
+ const manifest = await readManifest(projectConfig.srcArtDir);
2086
+ const entry = manifest[asset.stringId];
2087
+ if (!entry) {
2088
+ json(res, { error: 'No cached thumbnail — capture thumbnails first' }, 400);
2089
+ return;
2090
+ }
2091
+ const thumbData = await readThumbnailFile(projectConfig.srcArtDir, asset.stringId, entry.format);
2092
+ if (!thumbData) {
2093
+ json(res, { error: 'Thumbnail file missing from cache' }, 400);
2094
+ return;
2095
+ }
2096
+ inputData = thumbData;
2097
+ const formatMime = {
2098
+ png: 'image/png', webp: 'image/webp', webm: 'video/webm',
2099
+ };
2100
+ mimeType = formatMime[entry.format] ?? 'image/webp';
2101
+ }
2102
+ const newTags = await aiTagAsset(apiKey, asset.type, asset.fileName, inputData, mimeType);
2103
+ const existingTags = asset.settings.tags ?? [];
2104
+ const merged = [...new Set([...existingTags, ...newTags])];
2105
+ asset.settings = { ...asset.settings, tags: merged };
2106
+ if (!asset.parentId) {
2107
+ const meta = assetSettingsToStowmeta(asset);
2108
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
2109
+ }
2110
+ broadcast({ type: 'asset-update', id, updates: { settings: asset.settings } });
2111
+ json(res, { tags: merged });
2112
+ }
2113
+ catch (err) {
2114
+ json(res, { error: err.message }, 500);
2115
+ }
2116
+ return;
2117
+ }
2118
+ // POST /api/ai/tag-missing — bulk AI-tag all assets with empty tags
2119
+ if (pathname === '/api/ai/tag-missing' && req.method === 'POST') {
2120
+ if (!projectConfig) {
2121
+ json(res, { error: 'No project open' }, 400);
2122
+ return;
2123
+ }
2124
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2125
+ if (!apiKey) {
2126
+ json(res, { error: 'Gemini API key not configured' }, 400);
2127
+ return;
2128
+ }
2129
+ try {
2130
+ const { tagAsset: aiTagAsset } = await import('./ai-tagger.js');
2131
+ const { readManifest, readThumbnailFile } = await import('./app/thumbnail-cache.js');
2132
+ const manifest = await readManifest(projectConfig.srcArtDir);
2133
+ const eligible = assets.filter(a => a.status === 'ready' &&
2134
+ !a.settings.excluded &&
2135
+ (!a.settings.tags || a.settings.tags.length === 0) &&
2136
+ a.type !== AssetType.MaterialSchema &&
2137
+ !a.parentId);
2138
+ let tagged = 0;
2139
+ let skipped = 0;
2140
+ const errors = [];
2141
+ for (const asset of eligible) {
2142
+ try {
2143
+ let inputData;
2144
+ let mimeType;
2145
+ if (asset.type === AssetType.Audio) {
2146
+ const ext = asset.fileName.split('.').pop()?.toLowerCase() ?? 'wav';
2147
+ const mimeMap = {
2148
+ wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg',
2149
+ flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
2150
+ };
2151
+ mimeType = mimeMap[ext] ?? 'audio/wav';
2152
+ const raw = await readFile(projectConfig.srcArtDir, asset.id);
2153
+ if (!raw) {
2154
+ skipped++;
2155
+ continue;
2156
+ }
2157
+ inputData = Buffer.from(raw);
2158
+ }
2159
+ else {
2160
+ const entry = manifest[asset.stringId];
2161
+ if (!entry) {
2162
+ skipped++;
2163
+ continue;
2164
+ }
2165
+ const thumbData = await readThumbnailFile(projectConfig.srcArtDir, asset.stringId, entry.format);
2166
+ if (!thumbData) {
2167
+ skipped++;
2168
+ continue;
2169
+ }
2170
+ inputData = thumbData;
2171
+ const formatMime = {
2172
+ png: 'image/png', webp: 'image/webp', webm: 'video/webm',
2173
+ };
2174
+ mimeType = formatMime[entry.format] ?? 'image/webp';
2175
+ }
2176
+ const tagPromise = aiTagAsset(apiKey, asset.type, asset.fileName, inputData, mimeType);
2177
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Gemini request timed out (30s)')), 30_000));
2178
+ const newTags = await Promise.race([tagPromise, timeoutPromise]);
2179
+ asset.settings = { ...asset.settings, tags: newTags };
2180
+ if (!asset.parentId) {
2181
+ const meta = assetSettingsToStowmeta(asset);
2182
+ await writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
2183
+ }
2184
+ broadcast({ type: 'asset-update', id: asset.id, updates: { settings: asset.settings } });
2185
+ tagged++;
2186
+ await new Promise(r => setTimeout(r, 200));
2187
+ }
2188
+ catch (err) {
2189
+ errors.push({ id: asset.id, error: err.message });
2190
+ }
2191
+ }
2192
+ json(res, { tagged, skipped, errors });
2193
+ }
2194
+ catch (err) {
2195
+ json(res, { error: err.message }, 500);
2196
+ }
2197
+ return;
2198
+ }
2199
+ // POST /api/ai/tag-batch — AI-tag specific assets by ID, concurrently
2200
+ if (pathname === '/api/ai/tag-batch' && req.method === 'POST') {
2201
+ if (!projectConfig) {
2202
+ json(res, { error: 'No project open' }, 400);
2203
+ return;
2204
+ }
2205
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2206
+ if (!apiKey) {
2207
+ json(res, { error: 'Gemini API key not configured' }, 400);
2208
+ return;
2209
+ }
2210
+ try {
2211
+ const body = JSON.parse(await readBody(req));
2212
+ const ids = body.ids;
2213
+ if (!Array.isArray(ids) || ids.length === 0) {
2214
+ json(res, { error: 'ids must be a non-empty array' }, 400);
2215
+ return;
2216
+ }
2217
+ const { tagAsset: aiTagAsset } = await import('./ai-tagger.js');
2218
+ const { readManifest, readThumbnailFile } = await import('./app/thumbnail-cache.js');
2219
+ const manifest = await readManifest(projectConfig.srcArtDir);
2220
+ const eligible = assets.filter(a => ids.includes(a.id) &&
2221
+ a.status === 'ready' &&
2222
+ !a.settings.excluded &&
2223
+ (!a.settings.tags || a.settings.tags.length === 0) &&
2224
+ a.type !== AssetType.MaterialSchema &&
2225
+ !a.parentId);
2226
+ let tagged = 0;
2227
+ let skipped = ids.length - eligible.length;
2228
+ const errors = [];
2229
+ const CONCURRENCY = 20;
2230
+ // Process in batches of CONCURRENCY
2231
+ for (let i = 0; i < eligible.length; i += CONCURRENCY) {
2232
+ const batch = eligible.slice(i, i + CONCURRENCY);
2233
+ const results = await Promise.allSettled(batch.map(async (asset) => {
2234
+ let inputData;
2235
+ let mimeType;
2236
+ if (asset.type === AssetType.Audio) {
2237
+ const ext = asset.fileName.split('.').pop()?.toLowerCase() ?? 'wav';
2238
+ const mimeMap = {
2239
+ wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg',
2240
+ flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
2241
+ };
2242
+ mimeType = mimeMap[ext] ?? 'audio/wav';
2243
+ const raw = await readFile(projectConfig.srcArtDir, asset.id);
2244
+ if (!raw)
2245
+ throw new Error('Source file not found');
2246
+ inputData = Buffer.from(raw);
2247
+ }
2248
+ else {
2249
+ const entry = manifest[asset.stringId];
2250
+ if (!entry)
2251
+ throw new Error('No thumbnail cached');
2252
+ const thumbData = await readThumbnailFile(projectConfig.srcArtDir, asset.stringId, entry.format);
2253
+ if (!thumbData)
2254
+ throw new Error('Thumbnail file missing');
2255
+ inputData = thumbData;
2256
+ const formatMime = {
2257
+ png: 'image/png', webp: 'image/webp', webm: 'video/webm',
2258
+ };
2259
+ mimeType = formatMime[entry.format] ?? 'image/webp';
2260
+ }
2261
+ const newTags = await Promise.race([
2262
+ aiTagAsset(apiKey, asset.type, asset.fileName, inputData, mimeType),
2263
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Gemini request timed out (30s)')), 30_000)),
2264
+ ]);
2265
+ asset.settings = { ...asset.settings, tags: newTags };
2266
+ if (!asset.parentId) {
2267
+ const meta = assetSettingsToStowmeta(asset);
2268
+ await writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
2269
+ }
2270
+ broadcast({ type: 'asset-update', id: asset.id, updates: { settings: asset.settings } });
2271
+ return asset.id;
2272
+ }));
2273
+ for (const r of results) {
2274
+ if (r.status === 'fulfilled')
2275
+ tagged++;
2276
+ else
2277
+ errors.push({ id: 'unknown', error: r.reason?.message ?? String(r.reason) });
2278
+ }
2279
+ }
2280
+ json(res, { tagged, skipped, errors });
2281
+ }
2282
+ catch (err) {
2283
+ json(res, { error: err.message }, 500);
2284
+ }
2285
+ return;
2286
+ }
2014
2287
  // GET /api/thumbnails/cached — return which thumbnails are still valid in the cache
2015
2288
  if (pathname === '/api/thumbnails/cached' && req.method === 'GET') {
2016
2289
  if (!projectConfig) {
package/dist/store.d.ts CHANGED
@@ -35,17 +35,3 @@ export declare function resolveAssetDeps(firestore: FirestoreReader, packageName
35
35
  resolvedIds: string[];
36
36
  files: string[];
37
37
  }>;
38
- export declare function storeSearch(query: string, opts?: {
39
- type?: string;
40
- json?: boolean;
41
- bucket?: string;
42
- limit?: number;
43
- }): Promise<void>;
44
- export declare function storeList(opts?: {
45
- json?: boolean;
46
- bucket?: string;
47
- }): Promise<void>;
48
- export declare function storeInfo(packageName: string, opts?: {
49
- json?: boolean;
50
- bucket?: string;
51
- }): Promise<void>;
package/dist/store.js CHANGED
@@ -1,6 +1,4 @@
1
1
  import { resolveTransitiveDeps, resolveFiles } from './assets-package.js';
2
- import { createFirestoreReader } from './firestore.js';
3
- const DEFAULT_BUCKET = 'rungame-shared-assets-test';
4
2
  // ─── Search ──────────────────────────────────────────────────────────────────
5
3
  export async function searchAssets(firestore, bucket, query, opts) {
6
4
  // Support comma-separated terms: "coral, sea, ocean" matches any term
@@ -13,9 +11,12 @@ export async function searchAssets(firestore, bucket, query, opts) {
13
11
  if (opts?.package) {
14
12
  packages = packages.filter(p => p.name === opts.package);
15
13
  }
16
- for (const { name: pkgName, data: pkg } of packages) {
14
+ // Fetch all latest versions in parallel
15
+ const versions = await Promise.all(packages.map(({ name, data: pkg }) => firestore.getVersion(name, pkg.latest)));
16
+ for (let i = 0; i < packages.length; i++) {
17
+ const { name: pkgName, data: pkg } = packages[i];
17
18
  const verStr = pkg.latest;
18
- const ver = await firestore.getVersion(pkgName, verStr);
19
+ const ver = versions[i];
19
20
  if (!ver)
20
21
  continue;
21
22
  for (const asset of ver.assets) {
@@ -178,14 +179,18 @@ function scoreAsset(terms, asset, pkg, pkgName, skipPkgMeta = false) {
178
179
  // ─── List Packages ───────────────────────────────────────────────────────────
179
180
  export async function listStorePackages(firestore, bucket) {
180
181
  const packages = await firestore.listPackages();
181
- const results = [];
182
- for (const { name, data: pkg } of packages) {
183
- const ver = await firestore.getVersion(name, pkg.latest);
184
- const versionKeys = await firestore.listVersionKeys(name);
182
+ // Fetch versions and version keys in parallel for all packages
183
+ const [versions, versionKeysList] = await Promise.all([
184
+ Promise.all(packages.map(({ name, data: pkg }) => firestore.getVersion(name, pkg.latest))),
185
+ Promise.all(packages.map(({ name }) => firestore.listVersionKeys(name))),
186
+ ]);
187
+ return packages.map(({ name, data: pkg }, i) => {
188
+ const ver = versions[i];
189
+ const versionKeys = versionKeysList[i];
185
190
  const thumbnailUrl = pkg.thumbnail
186
191
  ? `https://storage.googleapis.com/${bucket}/packages/${name}/${pkg.latest}/${pkg.thumbnail}`
187
192
  : undefined;
188
- results.push({
193
+ return {
189
194
  name,
190
195
  description: pkg.description,
191
196
  author: pkg.author,
@@ -195,9 +200,8 @@ export async function listStorePackages(firestore, bucket) {
195
200
  assetCount: ver?.assets.length ?? 0,
196
201
  totalSize: ver?.totalSize ?? 0,
197
202
  thumbnailUrl,
198
- });
199
- }
200
- return results;
203
+ };
204
+ });
201
205
  }
202
206
  // ─── Resolve Dependencies ────────────────────────────────────────────────────
203
207
  export async function resolveAssetDeps(firestore, packageName, stringIds, version) {
@@ -212,96 +216,3 @@ export async function resolveAssetDeps(firestore, packageName, stringIds, versio
212
216
  const files = resolveFiles(resolvedIds, ver.assets);
213
217
  return { resolvedIds, files };
214
218
  }
215
- // ─── CLI Commands ────────────────────────────────────────────────────────────
216
- export async function storeSearch(query, opts) {
217
- const bucket = (opts?.bucket ?? DEFAULT_BUCKET).replace(/^gs:\/\//, '').replace(/\/$/, '');
218
- const firestore = createFirestoreReader();
219
- let results = await searchAssets(firestore, bucket, query, { type: opts?.type });
220
- if (opts?.limit && opts.limit > 0)
221
- results = results.slice(0, opts.limit);
222
- if (opts?.json) {
223
- console.log(JSON.stringify(results, null, 2));
224
- return;
225
- }
226
- if (results.length === 0) {
227
- console.log(`No assets found matching "${query}"`);
228
- return;
229
- }
230
- console.log(`\nFound ${results.length} asset${results.length !== 1 ? 's' : ''} matching "${query}":\n`);
231
- for (const r of results) {
232
- const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : '';
233
- const deps = r.dependencies.length > 0 ? ` → ${r.dependencies.join(', ')}` : '';
234
- const thumb = r.thumbnail ? ' (has thumbnail)' : '';
235
- console.log(` [${r.type}] ${r.stringId} — ${r.packageName}@${r.version}${tags}${deps}${thumb}`);
236
- console.log(` ${r.file} (${formatBytes(r.size)})`);
237
- }
238
- console.log('');
239
- }
240
- export async function storeList(opts) {
241
- const bucket = (opts?.bucket ?? DEFAULT_BUCKET).replace(/^gs:\/\//, '').replace(/\/$/, '');
242
- const firestore = createFirestoreReader();
243
- const packages = await listStorePackages(firestore, bucket);
244
- if (opts?.json) {
245
- console.log(JSON.stringify(packages, null, 2));
246
- return;
247
- }
248
- if (packages.length === 0) {
249
- console.log('No packages published yet.');
250
- return;
251
- }
252
- console.log(`\n${packages.length} package${packages.length !== 1 ? 's' : ''} in the asset store:\n`);
253
- for (const p of packages) {
254
- const tags = p.tags.length > 0 ? ` [${p.tags.join(', ')}]` : '';
255
- const desc = p.description ? ` — ${p.description}` : '';
256
- console.log(` ${p.name}@${p.latest}${desc}${tags}`);
257
- console.log(` ${p.assetCount} assets, ${formatBytes(p.totalSize)}, ${p.versions.length} version${p.versions.length !== 1 ? 's' : ''}`);
258
- }
259
- console.log('');
260
- }
261
- export async function storeInfo(packageName, opts) {
262
- const bucket = (opts?.bucket ?? DEFAULT_BUCKET).replace(/^gs:\/\//, '').replace(/\/$/, '');
263
- const firestore = createFirestoreReader();
264
- const pkg = await firestore.getPackage(packageName);
265
- if (!pkg) {
266
- console.error(`Package "${packageName}" not found.`);
267
- process.exit(1);
268
- }
269
- const ver = await firestore.getVersion(packageName, pkg.latest);
270
- const versionKeys = await firestore.listVersionKeys(packageName);
271
- if (opts?.json) {
272
- console.log(JSON.stringify({
273
- name: packageName,
274
- description: pkg.description,
275
- author: pkg.author,
276
- tags: pkg.tags ?? [],
277
- latest: pkg.latest,
278
- versions: versionKeys,
279
- assets: ver?.assets ?? [],
280
- }, null, 2));
281
- return;
282
- }
283
- console.log(`\n${packageName}@${pkg.latest}`);
284
- if (pkg.description)
285
- console.log(` ${pkg.description}`);
286
- if (pkg.author)
287
- console.log(` Author: ${pkg.author}`);
288
- if (pkg.tags?.length)
289
- console.log(` Tags: ${pkg.tags.join(', ')}`);
290
- console.log(` Versions: ${versionKeys.join(', ')}`);
291
- if (ver) {
292
- console.log(`\nAssets (${ver.assets.length}):\n`);
293
- for (const a of ver.assets) {
294
- const tags = a.tags.length > 0 ? ` [${a.tags.join(', ')}]` : '';
295
- const deps = a.dependencies.length > 0 ? ` → ${a.dependencies.join(', ')}` : '';
296
- console.log(` [${a.type}] ${a.stringId}${tags}${deps} (${formatBytes(a.size)})`);
297
- }
298
- }
299
- console.log('');
300
- }
301
- function formatBytes(bytes) {
302
- if (bytes < 1024)
303
- return `${bytes} B`;
304
- if (bytes < 1024 * 1024)
305
- return `${(bytes / 1024).toFixed(1)} KB`;
306
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
307
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.6.35",
3
+ "version": "0.6.37",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -18,11 +18,12 @@
18
18
  "dev": "tsc --watch"
19
19
  },
20
20
  "dependencies": {
21
- "@series-inc/stowkit-packer-gui": "^0.1.22",
21
+ "@google/genai": "^1.46.0",
22
22
  "@series-inc/stowkit-editor": "^0.1.8",
23
+ "@series-inc/stowkit-packer-gui": "^0.1.24",
24
+ "@strangeape/ffmpeg-audio-wasm": "^0.1.0",
23
25
  "draco3d": "^1.5.7",
24
26
  "fbx-parser": "^2.1.3",
25
- "@strangeape/ffmpeg-audio-wasm": "^0.1.0",
26
27
  "sharp": "^0.33.5",
27
28
  "three": "^0.182.0",
28
29
  "ws": "^8.18.0"