@series-inc/stowkit-cli 0.6.17 → 0.6.18

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/server.js CHANGED
@@ -5,13 +5,14 @@ import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { AssetType } from './core/types.js';
6
6
  import { defaultAssetSettings } from './app/state.js';
7
7
  import { BlobStore } from './app/blob-store.js';
8
- import { readProjectConfig, scanDirectory, readFile, writeFile, renameFile, deleteFile, getFileSnapshot, } from './node-fs.js';
8
+ import { readProjectConfig, scanDirectory, readFile, writeFile, renameFile, deleteFile, getFileSnapshot, probeImageDimensions, } from './node-fs.js';
9
9
  import { detectAssetType, readStowmeta, writeStowmeta, stowmetaToAssetSettings, assetSettingsToStowmeta, generateDefaultStowmeta, glbChildToAssetSettings, generateDefaultGlbChild, writeGlbChildSettings, } from './app/stowmeta-io.js';
10
10
  import { readStowmat, writeStowmat, stowmatToMaterialConfig, materialConfigToStowmat } from './app/stowmat-io.js';
11
11
  import { readCacheBlobs, writeCacheBlobs, buildCacheStamp, isCacheValid, } from './app/process-cache.js';
12
12
  import { buildPack, validatePackDependencies, processExtractedAnimations } from './pipeline.js';
13
13
  import { syncRuntimeAssets } from './sync-runtime-assets.js';
14
14
  import { parseGlb, pbrToMaterialConfig } from './encoders/glb-loader.js';
15
+ import { AAC_QUALITY_STRINGS, AUDIO_SAMPLE_RATE_STRINGS, DRACO_QUALITY_STRINGS, KTX2_QUALITY_STRINGS, TEXTURE_FILTER_STRINGS, TEXTURE_RESIZE_STRINGS, } from './app/disk-project.js';
15
16
  import { WorkerPool } from './workers/worker-pool.js';
16
17
  async function scanPrefabFiles(dir, prefix) {
17
18
  const results = [];
@@ -149,6 +150,120 @@ function resolvePackName(pack, packs) {
149
150
  return pack;
150
151
  return packs[0].name;
151
152
  }
153
+ function normalizeOptionalProjectString(value) {
154
+ if (value == null)
155
+ return undefined;
156
+ if (typeof value !== 'string') {
157
+ throw new Error('Project config values must be strings.');
158
+ }
159
+ const trimmed = value.trim();
160
+ return trimmed === '' ? undefined : trimmed;
161
+ }
162
+ function normalizeProjectPacks(value) {
163
+ if (!Array.isArray(value)) {
164
+ throw new Error('Project packs must be an array.');
165
+ }
166
+ const packs = value.map((pack) => {
167
+ if (!pack || typeof pack !== 'object' || typeof pack.name !== 'string') {
168
+ throw new Error('Each pack must be an object with a name.');
169
+ }
170
+ const name = pack.name.trim();
171
+ if (!name) {
172
+ throw new Error('Pack names cannot be empty.');
173
+ }
174
+ return { name };
175
+ });
176
+ const unique = new Set(packs.map((pack) => pack.name));
177
+ if (unique.size !== packs.length) {
178
+ throw new Error('Pack names must be unique.');
179
+ }
180
+ return packs.length > 0 ? packs : [{ name: 'default' }];
181
+ }
182
+ function normalizeProjectDefaults(value) {
183
+ if (value == null)
184
+ return undefined;
185
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
186
+ throw new Error('Project defaults must be an object.');
187
+ }
188
+ const rawDefaults = value;
189
+ const nextDefaults = {};
190
+ if (rawDefaults.texture !== undefined) {
191
+ const rawTexture = rawDefaults.texture;
192
+ if (!rawTexture || typeof rawTexture !== 'object' || Array.isArray(rawTexture)) {
193
+ throw new Error('Texture defaults must be an object.');
194
+ }
195
+ const texture = rawTexture;
196
+ const nextTexture = {};
197
+ if (texture.quality !== undefined) {
198
+ if (typeof texture.quality !== 'string' || !(texture.quality in KTX2_QUALITY_STRINGS)) {
199
+ throw new Error('Texture default quality is invalid.');
200
+ }
201
+ nextTexture.quality = texture.quality;
202
+ }
203
+ if (texture.resize !== undefined) {
204
+ if (typeof texture.resize !== 'string' || !(texture.resize in TEXTURE_RESIZE_STRINGS)) {
205
+ throw new Error('Texture default resize is invalid.');
206
+ }
207
+ nextTexture.resize = texture.resize;
208
+ }
209
+ if (texture.filtering !== undefined) {
210
+ if (typeof texture.filtering !== 'string' || !(texture.filtering in TEXTURE_FILTER_STRINGS)) {
211
+ throw new Error('Texture default filtering is invalid.');
212
+ }
213
+ nextTexture.filtering = texture.filtering;
214
+ }
215
+ if (texture.generateMipmaps !== undefined) {
216
+ if (typeof texture.generateMipmaps !== 'boolean') {
217
+ throw new Error('Texture default mipmap setting must be true or false.');
218
+ }
219
+ nextTexture.generateMipmaps = texture.generateMipmaps;
220
+ }
221
+ if (Object.keys(nextTexture).length > 0) {
222
+ nextDefaults.texture = nextTexture;
223
+ }
224
+ }
225
+ if (rawDefaults.audio !== undefined) {
226
+ const rawAudio = rawDefaults.audio;
227
+ if (!rawAudio || typeof rawAudio !== 'object' || Array.isArray(rawAudio)) {
228
+ throw new Error('Audio defaults must be an object.');
229
+ }
230
+ const audio = rawAudio;
231
+ const nextAudio = {};
232
+ if (audio.aacQuality !== undefined) {
233
+ if (typeof audio.aacQuality !== 'string' || !(audio.aacQuality in AAC_QUALITY_STRINGS)) {
234
+ throw new Error('Audio default AAC quality is invalid.');
235
+ }
236
+ nextAudio.aacQuality = audio.aacQuality;
237
+ }
238
+ if (audio.sampleRate !== undefined) {
239
+ if (typeof audio.sampleRate !== 'string' || !(audio.sampleRate in AUDIO_SAMPLE_RATE_STRINGS)) {
240
+ throw new Error('Audio default sample rate is invalid.');
241
+ }
242
+ nextAudio.sampleRate = audio.sampleRate;
243
+ }
244
+ if (Object.keys(nextAudio).length > 0) {
245
+ nextDefaults.audio = nextAudio;
246
+ }
247
+ }
248
+ if (rawDefaults.mesh !== undefined) {
249
+ const rawMesh = rawDefaults.mesh;
250
+ if (!rawMesh || typeof rawMesh !== 'object' || Array.isArray(rawMesh)) {
251
+ throw new Error('Mesh defaults must be an object.');
252
+ }
253
+ const mesh = rawMesh;
254
+ const nextMesh = {};
255
+ if (mesh.dracoQuality !== undefined) {
256
+ if (typeof mesh.dracoQuality !== 'string' || !(mesh.dracoQuality in DRACO_QUALITY_STRINGS)) {
257
+ throw new Error('Mesh default Draco quality is invalid.');
258
+ }
259
+ nextMesh.dracoQuality = mesh.dracoQuality;
260
+ }
261
+ if (Object.keys(nextMesh).length > 0) {
262
+ nextDefaults.mesh = nextMesh;
263
+ }
264
+ }
265
+ return Object.keys(nextDefaults).length > 0 ? nextDefaults : undefined;
266
+ }
152
267
  // ─── GLB Container helpers ──────────────────────────────────────────────────
153
268
  function childTypeToAssetType(childType) {
154
269
  switch (childType) {
@@ -232,14 +347,14 @@ async function processGlbContainer(containerId) {
232
347
  // Process textures
233
348
  for (const tex of extract.textures) {
234
349
  const existing = existingChildren.get(tex.name);
235
- childrenManifest.push(existing ?? generateDefaultGlbChild(tex.name, 'texture'));
350
+ childrenManifest.push(existing ?? generateDefaultGlbChild(tex.name, 'texture', projectConfig.config.defaults));
236
351
  const childId = `${containerId}/${tex.name}`;
237
352
  BlobStore.setSource(childId, tex.data);
238
353
  }
239
354
  // Process meshes
240
355
  for (const mesh of extract.meshes) {
241
356
  const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
242
- const meshChild = existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName);
357
+ const meshChild = existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName, projectConfig.config.defaults);
243
358
  // Store scene node names so AI agents can see the hierarchy in the stowmeta
244
359
  if (mesh.imported.nodes.length > 1) {
245
360
  meshChild.sceneNodeNames = mesh.imported.nodes.map(n => n.name);
@@ -257,7 +372,7 @@ async function processGlbContainer(containerId) {
257
372
  childrenManifest.push(existing);
258
373
  }
259
374
  else {
260
- const child = generateDefaultGlbChild(matName, 'materialSchema');
375
+ const child = generateDefaultGlbChild(matName, 'materialSchema', projectConfig.config.defaults);
261
376
  // Store extracted PBR config as materialConfig on the inline child
262
377
  const matConfig = pbrToMaterialConfig(mat.pbrConfig, containerId);
263
378
  child.materialConfig = {
@@ -280,7 +395,7 @@ async function processGlbContainer(containerId) {
280
395
  // Process animations
281
396
  for (const anim of extract.animations) {
282
397
  const existing = existingChildren.get(anim.name);
283
- childrenManifest.push(existing ?? generateDefaultGlbChild(anim.name, 'animationClip'));
398
+ childrenManifest.push(existing ?? generateDefaultGlbChild(anim.name, 'animationClip', projectConfig.config.defaults));
284
399
  }
285
400
  // Clear old cache stamps on all children — processGlbContainer always re-processes
286
401
  // everything (the container was marked pending, so children must be reprocessed too).
@@ -480,7 +595,10 @@ async function openProject(projectDir) {
480
595
  const fileName = file.relativePath.split('/').pop() ?? file.relativePath;
481
596
  let meta = await readStowmeta(projectConfig.srcArtDir, file.relativePath);
482
597
  if (!meta) {
483
- meta = generateDefaultStowmeta(file.relativePath, type);
598
+ const imageDimensions = type === AssetType.Texture2D
599
+ ? await probeImageDimensions(projectConfig.srcArtDir, file.relativePath)
600
+ : null;
601
+ meta = generateDefaultStowmeta(file.relativePath, type, projectConfig.config.defaults, imageDimensions);
484
602
  await writeStowmeta(projectConfig.srcArtDir, file.relativePath, meta);
485
603
  }
486
604
  const { type: metaType, settings: metaSettings } = stowmetaToAssetSettings(meta);
@@ -536,7 +654,7 @@ async function openProject(projectDir) {
536
654
  const baseName = fileName.replace(/\.[^.]+$/, '');
537
655
  let meta = await readStowmeta(projectConfig.srcArtDir, id);
538
656
  if (!meta) {
539
- meta = generateDefaultStowmeta(id, AssetType.MaterialSchema);
657
+ meta = generateDefaultStowmeta(id, AssetType.MaterialSchema, projectConfig.config.defaults);
540
658
  await writeStowmeta(projectConfig.srcArtDir, id, meta);
541
659
  }
542
660
  const materialConfig = mat ? stowmatToMaterialConfig(mat) : { schemaId: '', properties: [] };
@@ -913,6 +1031,21 @@ async function handleRequest(req, res, staticApps) {
913
1031
  const packs = projectConfig.config.packs ?? [{ name: 'default' }];
914
1032
  const cdnDir = path.resolve(projectConfig.projectDir, projectConfig.config.cdnAssetsPath ?? 'public/cdn-assets');
915
1033
  await fs.mkdir(cdnDir, { recursive: true });
1034
+ // Validate unique stringIds before building
1035
+ const idCounts = new Map();
1036
+ for (const a of assets) {
1037
+ if (a.status !== 'ready' || a.settings.excluded)
1038
+ continue;
1039
+ const files = idCounts.get(a.stringId) ?? [];
1040
+ files.push(a.id);
1041
+ idCounts.set(a.stringId, files);
1042
+ }
1043
+ const duplicates = [...idCounts.entries()].filter(([, files]) => files.length > 1);
1044
+ if (duplicates.length > 0) {
1045
+ const lines = duplicates.map(([id, files]) => `"${id}" used by: ${files.join(', ')}`);
1046
+ json(res, { error: `Duplicate stringIds found: ${lines.join('; ')}` }, 400);
1047
+ return;
1048
+ }
916
1049
  const results = [];
917
1050
  const allErrors = [];
918
1051
  for (const pack of packs) {
@@ -993,7 +1126,7 @@ async function handleRequest(req, res, staticApps) {
993
1126
  }
994
1127
  }
995
1128
  // Check if needs reprocessing
996
- const needsReprocess = (asset.type === AssetType.Texture2D && (body.settings.quality !== undefined || body.settings.resize !== undefined || body.settings.generateMipmaps !== undefined)) ||
1129
+ const needsReprocess = (asset.type === AssetType.Texture2D && (body.settings.quality !== undefined || body.settings.resize !== undefined || body.settings.filtering !== undefined || body.settings.generateMipmaps !== undefined)) ||
997
1130
  (asset.type === AssetType.StaticMesh && (body.settings.dracoQuality !== undefined || body.settings.preserveHierarchy !== undefined)) ||
998
1131
  (asset.type === AssetType.Audio && (body.settings.aacQuality !== undefined || body.settings.audioSampleRate !== undefined)) ||
999
1132
  (asset.type === AssetType.GlbContainer && body.settings.preserveHierarchy !== undefined);
@@ -1008,6 +1141,64 @@ async function handleRequest(req, res, staticApps) {
1008
1141
  json(res, { ok: true });
1009
1142
  return;
1010
1143
  }
1144
+ // POST /api/resolve-duplicate-ids — fix duplicate stringIds by switching them to path-based IDs
1145
+ if (pathname === '/api/resolve-duplicate-ids' && req.method === 'POST') {
1146
+ if (!projectConfig) {
1147
+ json(res, { error: 'No project open' }, 400);
1148
+ return;
1149
+ }
1150
+ // Find duplicates
1151
+ const stringIdToAssets = new Map();
1152
+ for (const a of assets) {
1153
+ if (a.settings.excluded || a.type === AssetType.GlbContainer)
1154
+ continue;
1155
+ const list = stringIdToAssets.get(a.stringId) ?? [];
1156
+ list.push(a);
1157
+ stringIdToAssets.set(a.stringId, list);
1158
+ }
1159
+ let fixed = 0;
1160
+ for (const [, group] of stringIdToAssets) {
1161
+ if (group.length <= 1)
1162
+ continue;
1163
+ for (const asset of group) {
1164
+ const pathId = asset.id.replace(/\.[^.]+$/, '').toLowerCase();
1165
+ if (asset.stringId === pathId)
1166
+ continue;
1167
+ asset.stringId = pathId;
1168
+ // Persist to disk
1169
+ const meta = assetSettingsToStowmeta(asset);
1170
+ await writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
1171
+ broadcast({ type: 'asset-update', id: asset.id, updates: { stringId: pathId } });
1172
+ fixed++;
1173
+ }
1174
+ }
1175
+ json(res, { ok: true, fixed });
1176
+ return;
1177
+ }
1178
+ // POST /api/reset-string-ids — reset all stringIds to match their file paths
1179
+ if (pathname === '/api/reset-string-ids' && req.method === 'POST') {
1180
+ if (!projectConfig) {
1181
+ json(res, { error: 'No project open' }, 400);
1182
+ return;
1183
+ }
1184
+ let updated = 0;
1185
+ for (const asset of assets) {
1186
+ if (asset.settings.excluded || asset.type === AssetType.GlbContainer)
1187
+ continue;
1188
+ const pathId = asset.type === AssetType.MaterialSchema
1189
+ ? asset.id.replace(/\.[^.]+$/, '')
1190
+ : asset.id.replace(/\.[^.]+$/, '').toLowerCase();
1191
+ if (asset.stringId === pathId)
1192
+ continue;
1193
+ asset.stringId = pathId;
1194
+ const meta = assetSettingsToStowmeta(asset);
1195
+ await writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
1196
+ broadcast({ type: 'asset-update', id: asset.id, updates: { stringId: pathId } });
1197
+ updated++;
1198
+ }
1199
+ json(res, { ok: true, updated });
1200
+ return;
1201
+ }
1011
1202
  // PUT /api/asset/:id/type — change asset type
1012
1203
  if (pathname.startsWith('/api/asset/') && pathname.endsWith('/type') && req.method === 'PUT') {
1013
1204
  const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/type'.length));
@@ -1273,7 +1464,10 @@ async function handleRequest(req, res, staticApps) {
1273
1464
  await writeFile(projectConfig.srcArtDir, relativePath, sourceData);
1274
1465
  const type = detectAssetType(fileName);
1275
1466
  const configuredPacks = projectConfig.config.packs ?? [];
1276
- const meta = generateDefaultStowmeta(relativePath, type);
1467
+ const imageDimensions = type === AssetType.Texture2D
1468
+ ? await probeImageDimensions(projectConfig.srcArtDir, relativePath)
1469
+ : null;
1470
+ const meta = generateDefaultStowmeta(relativePath, type, projectConfig.config.defaults, imageDimensions);
1277
1471
  meta.pack = resolvePackName(meta.pack ?? '', configuredPacks);
1278
1472
  await writeStowmeta(projectConfig.srcArtDir, relativePath, meta);
1279
1473
  const settings = defaultAssetSettings();
@@ -1367,23 +1561,89 @@ async function handleRequest(req, res, staticApps) {
1367
1561
  return;
1368
1562
  }
1369
1563
  const body = JSON.parse(await readBody(req));
1370
- if (body.packs !== undefined) {
1371
- projectConfig.config.packs = body.packs;
1564
+ const currentConfig = projectConfig.config;
1565
+ const nextConfig = { ...currentConfig };
1566
+ if (Object.prototype.hasOwnProperty.call(body, 'srcArtDir')) {
1567
+ nextConfig.srcArtDir = normalizeOptionalProjectString(body.srcArtDir) ?? '';
1568
+ }
1569
+ if (!nextConfig.srcArtDir) {
1570
+ json(res, { error: '.felicityproject must include "srcArtDir".' }, 400);
1571
+ return;
1372
1572
  }
1373
- if (body.renamedPack) {
1374
- const { oldName, newName } = body.renamedPack;
1573
+ if (Object.prototype.hasOwnProperty.call(body, 'name')) {
1574
+ nextConfig.name = normalizeOptionalProjectString(body.name);
1575
+ }
1576
+ if (Object.prototype.hasOwnProperty.call(body, 'cdnAssetsPath')) {
1577
+ nextConfig.cdnAssetsPath = normalizeOptionalProjectString(body.cdnAssetsPath);
1578
+ }
1579
+ if (Object.prototype.hasOwnProperty.call(body, 'prefabsPath')) {
1580
+ nextConfig.prefabsPath = normalizeOptionalProjectString(body.prefabsPath);
1581
+ }
1582
+ if (Object.prototype.hasOwnProperty.call(body, 'packs')) {
1583
+ nextConfig.packs = normalizeProjectPacks(body.packs);
1584
+ }
1585
+ if (Object.prototype.hasOwnProperty.call(body, 'defaults')) {
1586
+ nextConfig.defaults = normalizeProjectDefaults(body.defaults);
1587
+ }
1588
+ const renamedPacks = Array.isArray(body.renamedPacks)
1589
+ ? body.renamedPacks.filter((rename) => typeof rename?.oldName === 'string'
1590
+ && typeof rename?.newName === 'string'
1591
+ && rename.oldName.trim() !== ''
1592
+ && rename.newName.trim() !== '').map((rename) => ({
1593
+ oldName: rename.oldName.trim(),
1594
+ newName: rename.newName.trim(),
1595
+ }))
1596
+ : [];
1597
+ const srcArtDirChanged = nextConfig.srcArtDir !== currentConfig.srcArtDir;
1598
+ projectConfig.config = nextConfig;
1599
+ projectConfig.projectName = nextConfig.name || path.basename(projectConfig.projectDir);
1600
+ const configPath = path.join(projectConfig.projectDir, '.felicityproject');
1601
+ await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2));
1602
+ if (srcArtDirChanged) {
1603
+ await openProject(projectConfig.projectDir);
1604
+ const pending = assets
1605
+ .filter((asset) => asset.status === 'loading' || asset.status === 'pending')
1606
+ .map((asset) => asset.id);
1607
+ if (pending.length > 0) {
1608
+ queueProcessing({ ids: pending });
1609
+ }
1610
+ }
1611
+ else if (Object.prototype.hasOwnProperty.call(body, 'packs') || renamedPacks.length > 0) {
1612
+ const configuredPacks = nextConfig.packs ?? [{ name: 'default' }];
1375
1613
  for (const asset of assets) {
1376
- if ((asset.settings.pack ?? 'default') === oldName) {
1377
- asset.settings.pack = newName;
1378
- const meta = assetSettingsToStowmeta(asset);
1379
- writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
1614
+ let nextPack = asset.settings.pack ?? 'default';
1615
+ const renamed = renamedPacks.find((entry) => entry.oldName === nextPack);
1616
+ if (renamed) {
1617
+ nextPack = renamed.newName;
1380
1618
  }
1619
+ nextPack = resolvePackName(nextPack, configuredPacks);
1620
+ if (nextPack === asset.settings.pack)
1621
+ continue;
1622
+ asset.settings.pack = nextPack;
1623
+ if (asset.parentId) {
1624
+ const childName = asset.id.split('/').pop();
1625
+ const childType = asset.type === AssetType.Texture2D ? 'texture'
1626
+ : asset.type === AssetType.StaticMesh ? 'staticMesh'
1627
+ : asset.type === AssetType.SkinnedMesh ? 'skinnedMesh'
1628
+ : asset.type === AssetType.AnimationClip ? 'animationClip'
1629
+ : asset.type === AssetType.MaterialSchema ? 'materialSchema'
1630
+ : 'unknown';
1631
+ await writeGlbChildSettings(projectConfig.srcArtDir, asset.parentId, childName, asset.stringId, childType, asset.settings);
1632
+ continue;
1633
+ }
1634
+ const meta = assetSettingsToStowmeta(asset);
1635
+ if (asset.type === AssetType.GlbContainer) {
1636
+ const existing = await readStowmeta(projectConfig.srcArtDir, asset.id);
1637
+ if (existing && existing.type === 'glbContainer') {
1638
+ meta.children = existing.children;
1639
+ meta.preserveHierarchy = asset.settings.preserveHierarchy || undefined;
1640
+ }
1641
+ }
1642
+ await writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
1381
1643
  }
1382
1644
  }
1383
- const configPath = path.join(projectConfig.projectDir, '.felicityproject');
1384
- await fs.writeFile(configPath, JSON.stringify(projectConfig.config, null, 2));
1385
1645
  broadcast({ type: 'config-updated', config: projectConfig.config });
1386
- json(res, { ok: true });
1646
+ json(res, { ok: true, projectName: projectConfig.projectName, config: projectConfig.config });
1387
1647
  return;
1388
1648
  }
1389
1649
  // ── Editor endpoints: prefabs + packs ─────────────────────────────────────
@@ -1493,6 +1753,293 @@ async function handleRequest(req, res, staticApps) {
1493
1753
  queueProcessing();
1494
1754
  return;
1495
1755
  }
1756
+ // ─── Asset Store Endpoints ──────────────────────────────────────────────
1757
+ // GET /api/asset-store/registry — fetch the public registry
1758
+ if (pathname === '/api/asset-store/registry' && req.method === 'GET') {
1759
+ try {
1760
+ const bucketParam = url.searchParams.get('bucket') ?? 'venus-shared-assets-test';
1761
+ const bucketName = bucketParam.replace(/^gs:\/\//, '').replace(/\/$/, '');
1762
+ const registryUrl = `https://storage.googleapis.com/${bucketName}/registry.json?t=${Date.now()}`;
1763
+ const fetchRes = await fetch(registryUrl);
1764
+ if (!fetchRes.ok) {
1765
+ if (fetchRes.status === 404) {
1766
+ json(res, { schemaVersion: 1, packages: {} });
1767
+ }
1768
+ else {
1769
+ json(res, { error: `Failed to fetch registry: ${fetchRes.status}` }, 502);
1770
+ }
1771
+ return;
1772
+ }
1773
+ const registry = await fetchRes.json();
1774
+ json(res, registry);
1775
+ }
1776
+ catch (err) {
1777
+ json(res, { error: err.message }, 500);
1778
+ }
1779
+ return;
1780
+ }
1781
+ // GET /api/asset-store/search — search assets using ranked scoring
1782
+ if (pathname === '/api/asset-store/search' && req.method === 'GET') {
1783
+ try {
1784
+ const query = url.searchParams.get('q') ?? '';
1785
+ const type = url.searchParams.get('type') ?? undefined;
1786
+ const pkg = url.searchParams.get('package') ?? undefined;
1787
+ const bucketParam = url.searchParams.get('bucket') ?? undefined;
1788
+ const { fetchRegistry, searchAssets } = await import('./store.js');
1789
+ const registry = await fetchRegistry(bucketParam);
1790
+ const results = searchAssets(registry, query, { type, package: pkg });
1791
+ json(res, results);
1792
+ }
1793
+ catch (err) {
1794
+ json(res, { error: err.message }, 500);
1795
+ }
1796
+ return;
1797
+ }
1798
+ // GET /api/asset-store/packages — list all packages
1799
+ if (pathname === '/api/asset-store/packages' && req.method === 'GET') {
1800
+ try {
1801
+ const bucketParam = url.searchParams.get('bucket') ?? undefined;
1802
+ const { fetchRegistry, listPackages } = await import('./store.js');
1803
+ const registry = await fetchRegistry(bucketParam);
1804
+ const packages = listPackages(registry);
1805
+ json(res, packages);
1806
+ }
1807
+ catch (err) {
1808
+ json(res, { error: err.message }, 500);
1809
+ }
1810
+ return;
1811
+ }
1812
+ // POST /api/asset-store/download — download assets (with transitive deps) into project
1813
+ if (pathname === '/api/asset-store/download' && req.method === 'POST') {
1814
+ if (!projectConfig) {
1815
+ json(res, { error: 'No project open' }, 400);
1816
+ return;
1817
+ }
1818
+ try {
1819
+ const body = JSON.parse(await readBody(req));
1820
+ const { packageName, version, stringIds, bucket: bucketParam } = body;
1821
+ if (!packageName || !version || !stringIds?.length) {
1822
+ json(res, { error: 'Missing packageName, version, or stringIds' }, 400);
1823
+ return;
1824
+ }
1825
+ const bucketName = (bucketParam ?? 'venus-shared-assets-test').replace(/^gs:\/\//, '').replace(/\/$/, '');
1826
+ const baseUrl = `https://storage.googleapis.com/${bucketName}`;
1827
+ // Fetch registry to resolve deps
1828
+ const regRes = await fetch(`${baseUrl}/registry.json`);
1829
+ if (!regRes.ok) {
1830
+ json(res, { error: 'Could not fetch registry' }, 502);
1831
+ return;
1832
+ }
1833
+ const registry = await regRes.json();
1834
+ const pkg = registry.packages[packageName];
1835
+ if (!pkg) {
1836
+ json(res, { error: `Package "${packageName}" not found` }, 404);
1837
+ return;
1838
+ }
1839
+ const ver = pkg.versions[version];
1840
+ if (!ver) {
1841
+ json(res, { error: `Version "${version}" not found` }, 404);
1842
+ return;
1843
+ }
1844
+ // Resolve transitive dependencies
1845
+ const { resolveTransitiveDeps, resolveFiles } = await import('./assets-package.js');
1846
+ const allIds = resolveTransitiveDeps(stringIds, ver.assets);
1847
+ const files = resolveFiles(allIds, ver.assets);
1848
+ const prefix = `packages/${packageName}/${version}`;
1849
+ let downloaded = 0;
1850
+ const errors = [];
1851
+ for (const filePath of files) {
1852
+ const fileUrl = `${baseUrl}/${prefix}/${encodeURIComponent(filePath)}`;
1853
+ try {
1854
+ const fileRes = await fetch(fileUrl);
1855
+ if (!fileRes.ok) {
1856
+ errors.push(`${filePath}: HTTP ${fileRes.status}`);
1857
+ continue;
1858
+ }
1859
+ const data = new Uint8Array(await fileRes.arrayBuffer());
1860
+ const destPath = path.join(projectConfig.srcArtDir, filePath);
1861
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
1862
+ await fs.writeFile(destPath, data);
1863
+ downloaded++;
1864
+ }
1865
+ catch (err) {
1866
+ errors.push(`${filePath}: ${err.message}`);
1867
+ }
1868
+ }
1869
+ // Trigger rescan so packer picks up the new files
1870
+ await openProject(projectConfig.projectDir);
1871
+ broadcast({ type: 'project-reloaded' });
1872
+ queueProcessing();
1873
+ json(res, {
1874
+ ok: true,
1875
+ downloaded,
1876
+ totalFiles: files.length,
1877
+ resolvedAssets: allIds,
1878
+ errors: errors.length > 0 ? errors : undefined,
1879
+ });
1880
+ }
1881
+ catch (err) {
1882
+ json(res, { error: err.message }, 500);
1883
+ }
1884
+ return;
1885
+ }
1886
+ // POST /api/publish — publish assets to GCS (SSE stream with progress)
1887
+ if (pathname === '/api/publish' && req.method === 'POST') {
1888
+ if (!projectConfig) {
1889
+ json(res, { error: 'No project open' }, 400);
1890
+ return;
1891
+ }
1892
+ res.writeHead(200, {
1893
+ 'Content-Type': 'text/event-stream',
1894
+ 'Cache-Control': 'no-cache',
1895
+ 'Connection': 'keep-alive',
1896
+ });
1897
+ const sendEvent = (event, data) => {
1898
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1899
+ };
1900
+ try {
1901
+ const body = JSON.parse(await readBody(req));
1902
+ const { thumbnails, options } = body;
1903
+ if (options?.version || options?.author !== undefined || options?.description !== undefined || options?.tags) {
1904
+ const { readAssetsPackage, writeAssetsPackage, initAssetsPackage } = await import('./assets-package.js');
1905
+ let pkg = await readAssetsPackage(projectConfig.projectDir);
1906
+ if (!pkg) {
1907
+ pkg = await initAssetsPackage(projectConfig.projectDir, { name: projectConfig.projectName });
1908
+ }
1909
+ if (options.version && pkg.version !== options.version)
1910
+ pkg.version = options.version;
1911
+ if (options.author !== undefined)
1912
+ pkg.author = options.author;
1913
+ if (options.description !== undefined)
1914
+ pkg.description = options.description;
1915
+ if (options.tags)
1916
+ pkg.tags = options.tags;
1917
+ await writeAssetsPackage(projectConfig.projectDir, pkg);
1918
+ }
1919
+ const { publishPackage } = await import('./publish.js');
1920
+ const result = await publishPackage(projectConfig.projectDir, {
1921
+ force: options?.force,
1922
+ bucket: options?.bucket,
1923
+ verbose: true,
1924
+ thumbnails: thumbnails ?? undefined,
1925
+ onProgress: (progress) => sendEvent('progress', progress),
1926
+ });
1927
+ sendEvent('done', result);
1928
+ res.end();
1929
+ }
1930
+ catch (err) {
1931
+ sendEvent('error', { error: err.message });
1932
+ res.end();
1933
+ }
1934
+ return;
1935
+ }
1936
+ // GET /api/publish/config — check if assets-package.json exists
1937
+ if (pathname === '/api/publish/config' && req.method === 'GET') {
1938
+ if (!projectConfig) {
1939
+ json(res, { error: 'No project open' }, 400);
1940
+ return;
1941
+ }
1942
+ try {
1943
+ const { readAssetsPackage } = await import('./assets-package.js');
1944
+ const pkg = await readAssetsPackage(projectConfig.projectDir);
1945
+ json(res, { exists: !!pkg, config: pkg });
1946
+ }
1947
+ catch (err) {
1948
+ json(res, { error: err.message }, 500);
1949
+ }
1950
+ return;
1951
+ }
1952
+ // GET /api/thumbnails/cached — return which thumbnails are still valid in the cache
1953
+ if (pathname === '/api/thumbnails/cached' && req.method === 'GET') {
1954
+ if (!projectConfig) {
1955
+ json(res, { error: 'No project open' }, 400);
1956
+ return;
1957
+ }
1958
+ try {
1959
+ const { readManifest, computeSettingsHash, computeMaterialDepsHash, isThumbnailCacheValid } = await import('./app/thumbnail-cache.js');
1960
+ const manifest = await readManifest(projectConfig.srcArtDir);
1961
+ const validEntries = {};
1962
+ for (const asset of assets) {
1963
+ const entry = manifest[asset.stringId];
1964
+ if (!entry)
1965
+ continue;
1966
+ const settingsHash = computeSettingsHash(asset.settings);
1967
+ const materialDepsHash = computeMaterialDepsHash(asset.settings, assets.map(a => ({ id: a.id, stringId: a.stringId, sourceSize: a.sourceSize, settings: a.settings })));
1968
+ if (isThumbnailCacheValid(entry, asset.sourceSize, settingsHash, materialDepsHash)) {
1969
+ validEntries[asset.stringId] = { format: entry.format };
1970
+ }
1971
+ }
1972
+ json(res, validEntries);
1973
+ }
1974
+ catch (err) {
1975
+ json(res, { error: err.message }, 500);
1976
+ }
1977
+ return;
1978
+ }
1979
+ // GET /api/thumbnails/:stringId — return cached thumbnail binary
1980
+ if (pathname.startsWith('/api/thumbnails/') && req.method === 'GET' && pathname !== '/api/thumbnails/cached') {
1981
+ if (!projectConfig) {
1982
+ json(res, { error: 'No project open' }, 400);
1983
+ return;
1984
+ }
1985
+ const stringId = decodeURIComponent(pathname.slice('/api/thumbnails/'.length));
1986
+ try {
1987
+ const { readManifest, readThumbnailFile } = await import('./app/thumbnail-cache.js');
1988
+ const manifest = await readManifest(projectConfig.srcArtDir);
1989
+ const entry = manifest[stringId];
1990
+ if (!entry) {
1991
+ res.writeHead(404);
1992
+ res.end('Not found');
1993
+ return;
1994
+ }
1995
+ const data = await readThumbnailFile(projectConfig.srcArtDir, stringId, entry.format);
1996
+ if (!data) {
1997
+ res.writeHead(404);
1998
+ res.end('Not found');
1999
+ return;
2000
+ }
2001
+ const contentType = entry.format === 'webm' ? 'video/webm' : entry.format === 'webp' ? 'image/webp' : 'image/png';
2002
+ res.writeHead(200, { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*' });
2003
+ res.end(data);
2004
+ }
2005
+ catch (err) {
2006
+ json(res, { error: err.message }, 500);
2007
+ }
2008
+ return;
2009
+ }
2010
+ // POST /api/thumbnails — store newly captured thumbnails and update manifest
2011
+ if (pathname === '/api/thumbnails' && req.method === 'POST') {
2012
+ if (!projectConfig) {
2013
+ json(res, { error: 'No project open' }, 400);
2014
+ return;
2015
+ }
2016
+ try {
2017
+ const body = JSON.parse(await readBody(req));
2018
+ const thumbnails = body.thumbnails;
2019
+ const { readManifest, writeManifest, writeThumbnailFile, computeSettingsHash, computeMaterialDepsHash } = await import('./app/thumbnail-cache.js');
2020
+ const manifest = await readManifest(projectConfig.srcArtDir);
2021
+ const allAssetData = assets.map(a => ({ id: a.id, stringId: a.stringId, sourceSize: a.sourceSize, settings: a.settings }));
2022
+ for (const [stringId, thumb] of Object.entries(thumbnails)) {
2023
+ const asset = assets.find(a => a.stringId === stringId);
2024
+ if (!asset)
2025
+ continue;
2026
+ const data = Buffer.from(thumb.data, 'base64');
2027
+ await writeThumbnailFile(projectConfig.srcArtDir, stringId, thumb.format, data);
2028
+ manifest[stringId] = {
2029
+ format: thumb.format,
2030
+ sourceSize: asset.sourceSize,
2031
+ settingsHash: computeSettingsHash(asset.settings),
2032
+ materialDepsHash: computeMaterialDepsHash(asset.settings, allAssetData),
2033
+ };
2034
+ }
2035
+ await writeManifest(projectConfig.srcArtDir, manifest);
2036
+ json(res, { ok: true, stored: Object.keys(thumbnails).length });
2037
+ }
2038
+ catch (err) {
2039
+ json(res, { error: err.message }, 500);
2040
+ }
2041
+ return;
2042
+ }
1496
2043
  // Static file serving for GUI apps (packer, editor)
1497
2044
  // Sort: longer prefixes first so /packer matches before /
1498
2045
  const mimeTypes = {