@series-inc/stowkit-cli 0.6.35 → 0.6.36
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 +13 -0
- package/dist/ai-tagger.js +95 -0
- package/dist/app/disk-project.d.ts +3 -0
- package/dist/cli.js +19 -33
- package/dist/firestore.js +39 -6
- package/dist/server.js +191 -6
- package/dist/store.d.ts +0 -14
- package/dist/store.js +16 -105
- package/package.json +4 -3
|
@@ -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-2.5-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
|
+
}
|
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>]
|
|
313
|
+
console.error('Usage: stowkit store search <query> [--type <type>] [--limit <n>]');
|
|
313
314
|
process.exit(1);
|
|
314
315
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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 (
|
|
1788
|
-
const
|
|
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
|
|
1791
|
-
|
|
1792
|
-
|
|
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,179 @@ 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
|
+
}
|
|
2014
2199
|
// GET /api/thumbnails/cached — return which thumbnails are still valid in the cache
|
|
2015
2200
|
if (pathname === '/api/thumbnails/cached' && req.method === 'GET') {
|
|
2016
2201
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.36",
|
|
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
|
-
"@
|
|
21
|
+
"@google/genai": "^1.46.0",
|
|
22
22
|
"@series-inc/stowkit-editor": "^0.1.8",
|
|
23
|
+
"@series-inc/stowkit-packer-gui": "^0.1.23",
|
|
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"
|