@series-inc/stowkit-cli 0.1.11 → 0.1.13

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
@@ -6,15 +6,12 @@ import { AssetType } from './core/types.js';
6
6
  import { defaultAssetSettings } from './app/state.js';
7
7
  import { BlobStore } from './app/blob-store.js';
8
8
  import { readProjectConfig, scanDirectory, readFile, writeFile, renameFile, deleteFile, getFileSnapshot, } from './node-fs.js';
9
- import { detectAssetType, readStowmeta, writeStowmeta, stowmetaToAssetSettings, assetSettingsToStowmeta, generateDefaultStowmeta, } from './app/stowmeta-io.js';
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
- import { processAsset, buildPack } from './pipeline.js';
13
- import { NodeBasisEncoder } from './encoders/basis-encoder.js';
14
- import { NodeDracoEncoder } from './encoders/draco-encoder.js';
15
- import { NodeAacEncoder, NodeAudioDecoder } from './encoders/aac-encoder.js';
16
- import { NodeFbxImporter } from './encoders/fbx-loader.js';
17
- import { SharpImageDecoder } from './encoders/image-decoder.js';
12
+ import { buildPack, validatePackDependencies, processExtractedAnimations } from './pipeline.js';
13
+ import { parseGlb, pbrToMaterialConfig } from './encoders/glb-loader.js';
14
+ import { WorkerPool } from './workers/worker-pool.js';
18
15
  async function scanPrefabFiles(dir, prefix) {
19
16
  const results = [];
20
17
  let entries;
@@ -40,7 +37,7 @@ async function scanPrefabFiles(dir, prefix) {
40
37
  let projectConfig = null;
41
38
  let assets = [];
42
39
  let folders = [];
43
- let processingCtx = null;
40
+ let workerPool = null;
44
41
  let encodersReady = false;
45
42
  let lastScanTime = 0;
46
43
  // Track in-flight processing
@@ -55,6 +52,97 @@ function broadcast(msg) {
55
52
  ws.send(data);
56
53
  }
57
54
  }
55
+ /**
56
+ * Single entry point for all processing orchestration.
57
+ * - Skips materials (they don't need processing)
58
+ * - Routes GLB children through their parent container
59
+ * - Ensures containers are processed before direct assets
60
+ * - Deduplicates with the `processing` Set
61
+ * - Runs up to maxConcurrent workers in parallel
62
+ */
63
+ function queueProcessing(opts = {}) {
64
+ const { force = false, maxConcurrent = 8 } = opts;
65
+ // Wait for encoders, then build and drain the queue
66
+ const task = (async () => {
67
+ while (!workerPool)
68
+ await new Promise(r => setTimeout(r, 100));
69
+ // Determine which assets to process
70
+ let targets;
71
+ if (opts.ids) {
72
+ targets = opts.ids
73
+ .map(id => assets.find(a => a.id === id))
74
+ .filter((a) => !!a);
75
+ }
76
+ else if (force) {
77
+ targets = assets.filter(a => a.type !== AssetType.MaterialSchema);
78
+ }
79
+ else {
80
+ targets = assets.filter(a => a.status === 'pending' && a.type !== AssetType.MaterialSchema);
81
+ }
82
+ if (targets.length === 0)
83
+ return;
84
+ // Mark assets as pending if force
85
+ if (force) {
86
+ for (const a of targets) {
87
+ if (a.type !== AssetType.MaterialSchema) {
88
+ a.status = 'pending';
89
+ }
90
+ }
91
+ }
92
+ // Build the processing queue:
93
+ // - GLB children → promote their parent container
94
+ // - Containers go first (they process their own children internally)
95
+ // - Deduplicate
96
+ const containerIds = new Set();
97
+ const directIds = [];
98
+ const seen = new Set();
99
+ for (const a of targets) {
100
+ if (a.parentId) {
101
+ // GLB child — process via parent container
102
+ if (!containerIds.has(a.parentId)) {
103
+ containerIds.add(a.parentId);
104
+ const container = assets.find(x => x.id === a.parentId);
105
+ if (container)
106
+ container.status = 'pending';
107
+ }
108
+ }
109
+ else if (!seen.has(a.id)) {
110
+ seen.add(a.id);
111
+ directIds.push(a.id);
112
+ }
113
+ }
114
+ // Containers first, then other assets (containers already in directIds stay in place)
115
+ const queue = [];
116
+ for (const cid of containerIds) {
117
+ if (!seen.has(cid)) {
118
+ queue.push(cid);
119
+ seen.add(cid);
120
+ }
121
+ }
122
+ queue.push(...directIds);
123
+ if (queue.length === 0)
124
+ return;
125
+ console.log(`[server] Processing ${queue.length} asset(s)${force ? ' (force)' : ''}...`);
126
+ // Drain with concurrent workers
127
+ async function drain() {
128
+ while (queue.length > 0) {
129
+ const id = queue.shift();
130
+ await processOneAsset(id);
131
+ }
132
+ }
133
+ const workers = [];
134
+ for (let i = 0; i < Math.min(maxConcurrent, queue.length); i++) {
135
+ workers.push(drain());
136
+ }
137
+ await Promise.all(workers);
138
+ broadcast({ type: 'processing-complete' });
139
+ })();
140
+ if (opts.await)
141
+ return task;
142
+ // Fire-and-forget: log errors but don't block caller
143
+ task.catch(err => console.error('[server] queueProcessing error:', err));
144
+ return Promise.resolve();
145
+ }
58
146
  // ─── Helpers ────────────────────────────────────────────────────────────────
59
147
  function resolvePackName(pack, packs) {
60
148
  if (packs.length === 0)
@@ -63,6 +151,231 @@ function resolvePackName(pack, packs) {
63
151
  return pack;
64
152
  return packs[0].name;
65
153
  }
154
+ // ─── GLB Container helpers ──────────────────────────────────────────────────
155
+ function childTypeToAssetType(childType) {
156
+ switch (childType) {
157
+ case 'texture': return AssetType.Texture2D;
158
+ case 'staticMesh': return AssetType.StaticMesh;
159
+ case 'skinnedMesh': return AssetType.SkinnedMesh;
160
+ case 'animationClip': return AssetType.AnimationClip;
161
+ case 'materialSchema': return AssetType.MaterialSchema;
162
+ default: return AssetType.Unknown;
163
+ }
164
+ }
165
+ async function expandGlbChildren(srcArtDir, containerId, containerMeta, configuredPacks) {
166
+ const children = [];
167
+ for (const child of containerMeta.children) {
168
+ const childId = `${containerId}/${child.name}`;
169
+ const baseName = child.name.replace(/\.[^.]+$/, '');
170
+ // Read settings from inline child entry
171
+ const { type: childType, settings: childSettings } = glbChildToAssetSettings(child);
172
+ childSettings.pack = resolvePackName(childSettings.pack, configuredPacks);
173
+ const asset = {
174
+ id: childId,
175
+ fileName: child.name,
176
+ stringId: child.stringId || baseName,
177
+ type: childType,
178
+ status: 'pending',
179
+ settings: childSettings,
180
+ sourceSize: 0,
181
+ processedSize: 0,
182
+ parentId: containerId,
183
+ locked: true,
184
+ };
185
+ // Check cache for child
186
+ if (child.cache) {
187
+ const cached = await readCacheBlobs(srcArtDir, childId);
188
+ if (cached && cached.size > 0) {
189
+ for (const [key, data] of cached) {
190
+ if (key.endsWith(':__metadata__')) {
191
+ try {
192
+ asset.metadata = JSON.parse(new TextDecoder().decode(data));
193
+ }
194
+ catch { /* skip */ }
195
+ }
196
+ else {
197
+ BlobStore.setProcessed(key, data);
198
+ }
199
+ }
200
+ asset.status = 'ready';
201
+ asset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
202
+ }
203
+ }
204
+ children.push(asset);
205
+ }
206
+ return children;
207
+ }
208
+ async function processGlbContainer(containerId) {
209
+ if (!projectConfig || !workerPool)
210
+ return;
211
+ const containerAsset = assets.find(a => a.id === containerId);
212
+ if (!containerAsset || containerAsset.type !== AssetType.GlbContainer)
213
+ return;
214
+ // Load source
215
+ let sourceData = BlobStore.getSource(containerId);
216
+ if (!sourceData) {
217
+ const data = await readFile(projectConfig.srcArtDir, containerId);
218
+ if (!data)
219
+ throw new Error(`Could not read source: ${containerId}`);
220
+ BlobStore.setSource(containerId, data);
221
+ sourceData = data;
222
+ }
223
+ // Read container stowmeta (for preserveHierarchy and existing children)
224
+ const containerStowmeta = await readStowmeta(projectConfig.srcArtDir, containerId);
225
+ const preserveHierarchy = containerStowmeta?.preserveHierarchy ?? false;
226
+ // Parse GLB
227
+ const extract = await parseGlb(sourceData, { preserveHierarchy });
228
+ const configuredPacks = projectConfig.config.packs ?? [];
229
+ // Build inline children with full settings
230
+ // Preserve existing child settings from the container stowmeta when possible
231
+ const existingChildren = new Map((containerStowmeta?.children ?? []).map(c => [c.name, c]));
232
+ const childrenManifest = [];
233
+ // Process textures
234
+ for (const tex of extract.textures) {
235
+ const existing = existingChildren.get(tex.name);
236
+ childrenManifest.push(existing ?? generateDefaultGlbChild(tex.name, 'texture'));
237
+ const childId = `${containerId}/${tex.name}`;
238
+ BlobStore.setSource(childId, tex.data);
239
+ }
240
+ // Process meshes
241
+ for (const mesh of extract.meshes) {
242
+ const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
243
+ const existing = existingChildren.get(mesh.name);
244
+ childrenManifest.push(existing ?? generateDefaultGlbChild(mesh.name, typeName));
245
+ }
246
+ // Process materials
247
+ for (const mat of extract.materials) {
248
+ const matName = `${mat.name}.stowmat`;
249
+ const existing = existingChildren.get(matName);
250
+ if (existing) {
251
+ childrenManifest.push(existing);
252
+ }
253
+ else {
254
+ const child = generateDefaultGlbChild(matName, 'materialSchema');
255
+ // Store extracted PBR config as materialConfig on the inline child
256
+ const matConfig = pbrToMaterialConfig(mat.pbrConfig, containerId);
257
+ child.materialConfig = {
258
+ schemaId: matConfig.schemaId,
259
+ properties: matConfig.properties.map(p => ({
260
+ fieldName: p.fieldName,
261
+ fieldType: typeof p.fieldType === 'number'
262
+ ? (['texture', 'color', 'float', 'vec2', 'vec3', 'vec4', 'int'][p.fieldType] ?? 'color')
263
+ : p.fieldType,
264
+ previewFlag: typeof p.previewFlag === 'number'
265
+ ? (['none', 'mainTex', 'tint', 'alphaTest'][p.previewFlag] ?? 'none')
266
+ : p.previewFlag,
267
+ value: [...p.value],
268
+ textureAsset: p.textureAssetId,
269
+ })),
270
+ };
271
+ childrenManifest.push(child);
272
+ }
273
+ }
274
+ // Process animations
275
+ for (const anim of extract.animations) {
276
+ const existing = existingChildren.get(anim.name);
277
+ childrenManifest.push(existing ?? generateDefaultGlbChild(anim.name, 'animationClip'));
278
+ }
279
+ // Update container stowmeta with full inline children
280
+ if (containerStowmeta) {
281
+ containerStowmeta.children = childrenManifest;
282
+ await writeStowmeta(projectConfig.srcArtDir, containerId, containerStowmeta);
283
+ }
284
+ // Remove old child assets from the list
285
+ const prefix = containerId + '/';
286
+ assets = assets.filter(a => !a.id.startsWith(prefix));
287
+ // Create child assets from inline entries
288
+ const childAssets = await expandGlbChildren(projectConfig.srcArtDir, containerId, { ...containerStowmeta, children: childrenManifest }, configuredPacks);
289
+ // Set up material configs for extracted GLB materials (runtime config from inline)
290
+ for (const mat of extract.materials) {
291
+ const matName = `${mat.name}.stowmat`;
292
+ const childId = `${containerId}/${matName}`;
293
+ const childAsset = childAssets.find(a => a.id === childId);
294
+ if (childAsset) {
295
+ // If the child didn't already have a materialConfig from an existing inline entry,
296
+ // set it from the extracted PBR data
297
+ if (!childAsset.settings.materialConfig?.properties?.length) {
298
+ childAsset.settings.materialConfig = pbrToMaterialConfig(mat.pbrConfig, containerId);
299
+ }
300
+ childAsset.status = 'ready';
301
+ }
302
+ }
303
+ assets.push(...childAssets);
304
+ containerAsset.status = 'ready';
305
+ // Process mesh and animation children directly from extract result
306
+ // (they can't go through processOneAsset because there's no file on disk to read)
307
+ for (const mesh of extract.meshes) {
308
+ const childId = `${containerId}/${mesh.name}`;
309
+ const childAsset = childAssets.find(a => a.id === childId);
310
+ if (childAsset && childAsset.status === 'pending') {
311
+ try {
312
+ // Auto-assign materialOverrides from GLB sub-mesh→material mapping
313
+ // (only if user hasn't already set explicit overrides)
314
+ if (Object.keys(childAsset.settings.materialOverrides).length === 0) {
315
+ const overrides = {};
316
+ for (let si = 0; si < mesh.imported.subMeshes.length; si++) {
317
+ const matIdx = mesh.imported.subMeshes[si].materialIndex;
318
+ const matName = mesh.imported.materials[matIdx]?.name;
319
+ if (matName) {
320
+ const matChildId = `${containerId}/${matName}.stowmat`;
321
+ if (childAssets.some(a => a.id === matChildId)) {
322
+ overrides[si] = matChildId;
323
+ }
324
+ }
325
+ }
326
+ if (Object.keys(overrides).length > 0) {
327
+ childAsset.settings.materialOverrides = overrides;
328
+ // Persist the auto-assigned overrides into the inline child entry
329
+ const childName = childAsset.id.split('/').pop();
330
+ const inlineChild = childrenManifest.find(c => c.name === childName);
331
+ if (inlineChild) {
332
+ inlineChild.materialOverrides = {};
333
+ for (const [k, v] of Object.entries(overrides)) {
334
+ inlineChild.materialOverrides[k] = v;
335
+ }
336
+ }
337
+ }
338
+ }
339
+ const { result, blobs } = await workerPool.processExtractedMesh({ childId, imported: mesh.imported, hasSkeleton: mesh.hasSkeleton, stringId: childAsset.stringId, settings: childAsset.settings }, (pid, msg) => broadcast({ type: 'progress', id: pid, message: msg }));
340
+ for (const [key, data] of blobs)
341
+ BlobStore.setProcessed(key, data);
342
+ childAsset.status = 'ready';
343
+ childAsset.metadata = result.metadata;
344
+ childAsset.processedSize = result.processedSize;
345
+ }
346
+ catch (err) {
347
+ childAsset.status = 'error';
348
+ childAsset.error = err instanceof Error ? err.message : String(err);
349
+ }
350
+ }
351
+ }
352
+ for (const anim of extract.animations) {
353
+ const childId = `${containerId}/${anim.name}`;
354
+ const childAsset = childAssets.find(a => a.id === childId);
355
+ if (childAsset && childAsset.status === 'pending') {
356
+ try {
357
+ const result = processExtractedAnimations(childId, anim.clips, childAsset.stringId);
358
+ childAsset.status = 'ready';
359
+ childAsset.metadata = result.metadata;
360
+ childAsset.processedSize = result.processedSize;
361
+ }
362
+ catch (err) {
363
+ childAsset.status = 'error';
364
+ childAsset.error = err instanceof Error ? err.message : String(err);
365
+ }
366
+ }
367
+ }
368
+ // Persist updated stowmeta (may have auto-assigned materialOverrides)
369
+ if (containerStowmeta) {
370
+ containerStowmeta.children = childrenManifest;
371
+ await writeStowmeta(projectConfig.srcArtDir, containerId, containerStowmeta);
372
+ }
373
+ // Queue texture children for processing (their raw image data is in BlobStore)
374
+ const pendingTextures = childAssets.filter(a => a.status === 'pending' && a.type === AssetType.Texture2D);
375
+ for (const child of pendingTextures) {
376
+ processOneAsset(child.id);
377
+ }
378
+ }
66
379
  async function readBody(req) {
67
380
  const chunks = [];
68
381
  for await (const chunk of req)
@@ -87,35 +400,14 @@ function cors(res) {
87
400
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
88
401
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
89
402
  }
90
- // ─── Initialize encoders ────────────────────────────────────────────────────
91
- async function initEncoders(wasmDir) {
92
- console.log('[server] Initializing encoders...');
93
- const textureEncoder = new NodeBasisEncoder(wasmDir);
94
- const meshEncoder = new NodeDracoEncoder();
95
- const aacEncoder = new NodeAacEncoder();
96
- const audioDecoder = new NodeAudioDecoder();
97
- const meshImporter = new NodeFbxImporter();
98
- const imageDecoder = new SharpImageDecoder();
99
- await Promise.all([
100
- textureEncoder.initialize(),
101
- meshEncoder.initialize(),
102
- aacEncoder.initialize(),
103
- audioDecoder.initialize(),
104
- ]);
105
- console.log('[server] All encoders ready');
403
+ // ─── Initialize worker pool ──────────────────────────────────────────────────
404
+ function initWorkerPool(wasmDir) {
405
+ console.log('[server] Initializing worker pool...');
406
+ const pool = new WorkerPool({ wasmDir });
407
+ console.log('[server] Worker pool ready');
106
408
  encodersReady = true;
107
409
  broadcast({ type: 'encoders-ready' });
108
- return {
109
- textureEncoder,
110
- meshEncoder,
111
- meshImporter,
112
- imageDecoder,
113
- audioDecoder,
114
- aacEncoder,
115
- onProgress: (id, msg) => {
116
- broadcast({ type: 'progress', id, message: msg });
117
- },
118
- };
410
+ return pool;
119
411
  }
120
412
  // ─── Open project ──────────────────────────────────────────────────────────
121
413
  async function openProject(projectDir) {
@@ -173,6 +465,15 @@ async function openProject(projectDir) {
173
465
  }
174
466
  }
175
467
  assets.push(asset);
468
+ // If this is a GlbContainer, expand its children
469
+ if (metaType === AssetType.GlbContainer && meta.type === 'glbContainer') {
470
+ const glbMeta = meta;
471
+ if (glbMeta.children && glbMeta.children.length > 0) {
472
+ asset.status = 'ready';
473
+ const childAssets = await expandGlbChildren(projectConfig.srcArtDir, file.relativePath, glbMeta, configuredPacks);
474
+ assets.push(...childAssets);
475
+ }
476
+ }
176
477
  }
177
478
  // Materials from .stowmat files
178
479
  for (const matFile of scan.matFiles) {
@@ -205,8 +506,11 @@ async function openProject(projectDir) {
205
506
  }
206
507
  // ─── Process single asset ─────────────────────────────────────────────────
207
508
  async function processOneAsset(id) {
208
- if (!projectConfig || !processingCtx)
509
+ if (!projectConfig)
209
510
  return;
511
+ // Wait for encoders to be ready
512
+ while (!workerPool)
513
+ await new Promise(r => setTimeout(r, 100));
210
514
  if (processing.has(id))
211
515
  return;
212
516
  const asset = assets.find(a => a.id === id);
@@ -214,6 +518,110 @@ async function processOneAsset(id) {
214
518
  return;
215
519
  if (asset.type === AssetType.MaterialSchema)
216
520
  return;
521
+ // GLB children: if source data isn't in BlobStore, re-extract from parent GLB
522
+ // and process just this child (not the entire container)
523
+ if (asset.parentId && !BlobStore.getSource(id)) {
524
+ const parentAsset = assets.find(a => a.id === asset.parentId);
525
+ if (!parentAsset)
526
+ return;
527
+ processing.add(id);
528
+ asset.status = 'processing';
529
+ broadcast({ type: 'asset-update', id, updates: { status: 'processing' } });
530
+ try {
531
+ let parentSource = BlobStore.getSource(asset.parentId);
532
+ if (!parentSource) {
533
+ const data = await readFile(projectConfig.srcArtDir, asset.parentId);
534
+ if (!data)
535
+ throw new Error(`Could not read parent GLB: ${asset.parentId}`);
536
+ parentSource = data;
537
+ }
538
+ const extract = await parseGlb(parentSource);
539
+ const childName = id.split('/').pop();
540
+ if (asset.type === AssetType.Texture2D) {
541
+ const tex = extract.textures.find(t => t.name === childName);
542
+ if (!tex)
543
+ throw new Error(`Texture "${childName}" not found in GLB`);
544
+ BlobStore.setSource(id, tex.data);
545
+ // Process via worker pool
546
+ const { result, blobs } = await workerPool.processAsset({ id, sourceData: tex.data, type: asset.type, stringId: asset.stringId, settings: asset.settings }, (pid, msg) => broadcast({ type: 'progress', id: pid, message: msg }));
547
+ for (const [key, data] of blobs)
548
+ BlobStore.setProcessed(key, data);
549
+ if (asset.status !== 'pending') {
550
+ asset.status = 'ready';
551
+ asset.metadata = result.metadata;
552
+ asset.processedSize = result.processedSize;
553
+ broadcast({
554
+ type: 'asset-update', id,
555
+ updates: { status: 'ready', metadata: result.metadata, processedSize: result.processedSize },
556
+ });
557
+ }
558
+ }
559
+ else if (asset.type === AssetType.StaticMesh || asset.type === AssetType.SkinnedMesh) {
560
+ const mesh = extract.meshes.find(m => m.name === childName);
561
+ if (!mesh)
562
+ throw new Error(`Mesh "${childName}" not found in GLB`);
563
+ const { result, blobs } = await workerPool.processExtractedMesh({ childId: id, imported: mesh.imported, hasSkeleton: mesh.hasSkeleton, stringId: asset.stringId, settings: asset.settings }, (pid, msg) => broadcast({ type: 'progress', id: pid, message: msg }));
564
+ for (const [key, data] of blobs)
565
+ BlobStore.setProcessed(key, data);
566
+ if (asset.status !== 'pending') {
567
+ asset.status = 'ready';
568
+ asset.metadata = result.metadata;
569
+ asset.processedSize = result.processedSize;
570
+ broadcast({
571
+ type: 'asset-update', id,
572
+ updates: { status: 'ready', metadata: result.metadata, processedSize: result.processedSize },
573
+ });
574
+ }
575
+ }
576
+ else {
577
+ // Animation or other — fall back to full container reprocess
578
+ processing.delete(id);
579
+ if (!processing.has(asset.parentId)) {
580
+ parentAsset.status = 'pending';
581
+ await processOneAsset(asset.parentId);
582
+ }
583
+ return;
584
+ }
585
+ console.log(`[server] Re-processed GLB child ${id}`);
586
+ }
587
+ catch (err) {
588
+ asset.status = 'error';
589
+ asset.error = err instanceof Error ? err.message : String(err);
590
+ broadcast({ type: 'asset-update', id, updates: { status: 'error', error: asset.error } });
591
+ console.error(`[server] Failed re-processing GLB child ${id}: ${asset.error}`);
592
+ }
593
+ finally {
594
+ processing.delete(id);
595
+ if (asset.status === 'pending')
596
+ queueProcessing({ ids: [id] });
597
+ }
598
+ return;
599
+ }
600
+ // GlbContainer: use special processing flow
601
+ if (asset.type === AssetType.GlbContainer) {
602
+ processing.add(id);
603
+ asset.status = 'processing';
604
+ broadcast({ type: 'asset-update', id, updates: { status: 'processing' } });
605
+ try {
606
+ await processGlbContainer(id);
607
+ if (asset.status !== 'pending') {
608
+ asset.status = 'ready';
609
+ broadcast({ type: 'asset-update', id, updates: { status: 'ready' } });
610
+ }
611
+ }
612
+ catch (err) {
613
+ asset.status = 'error';
614
+ asset.error = err instanceof Error ? err.message : String(err);
615
+ console.error(`[server] processGlbContainer(${id}) failed:`, err);
616
+ broadcast({ type: 'asset-update', id, updates: { status: 'error', error: asset.error } });
617
+ }
618
+ finally {
619
+ processing.delete(id);
620
+ if (asset.status === 'pending')
621
+ queueProcessing({ ids: [id] });
622
+ }
623
+ return;
624
+ }
217
625
  processing.add(id);
218
626
  asset.status = 'processing';
219
627
  broadcast({ type: 'asset-update', id, updates: { status: 'processing' } });
@@ -228,21 +636,26 @@ async function processOneAsset(id) {
228
636
  sourceData = data;
229
637
  }
230
638
  const t0 = performance.now();
231
- const result = await processAsset(id, sourceData, asset.type, asset.stringId, asset.settings, processingCtx);
639
+ const { result, blobs } = await workerPool.processAsset({ id, sourceData, type: asset.type, stringId: asset.stringId, settings: asset.settings }, (pid, msg) => broadcast({ type: 'progress', id: pid, message: msg }));
640
+ for (const [key, data] of blobs)
641
+ BlobStore.setProcessed(key, data);
232
642
  const elapsed = (performance.now() - t0).toFixed(0);
233
- asset.status = 'ready';
234
- asset.metadata = result.metadata;
235
- asset.processedSize = result.processedSize;
236
- asset.sourceSize = sourceData.length;
237
- broadcast({
238
- type: 'asset-update', id,
239
- updates: {
240
- status: 'ready',
241
- metadata: result.metadata,
242
- processedSize: result.processedSize,
243
- sourceSize: sourceData.length,
244
- },
245
- });
643
+ // Only mark ready if settings didn't change mid-processing (which sets status back to 'pending')
644
+ if (asset.status !== 'pending') {
645
+ asset.status = 'ready';
646
+ asset.metadata = result.metadata;
647
+ asset.processedSize = result.processedSize;
648
+ asset.sourceSize = sourceData.length;
649
+ broadcast({
650
+ type: 'asset-update', id,
651
+ updates: {
652
+ status: 'ready',
653
+ metadata: result.metadata,
654
+ processedSize: result.processedSize,
655
+ sourceSize: sourceData.length,
656
+ },
657
+ });
658
+ }
246
659
  console.log(`[server] Processed ${id} (${elapsed}ms)`);
247
660
  // Write cache
248
661
  const cacheEntries = new Map();
@@ -286,6 +699,11 @@ async function processOneAsset(id) {
286
699
  }
287
700
  finally {
288
701
  processing.delete(id);
702
+ // If settings changed while we were processing, the asset will have been
703
+ // reset to 'pending'. Re-queue so it doesn't get stuck.
704
+ if (asset.status === 'pending') {
705
+ queueProcessing({ ids: [id] });
706
+ }
289
707
  }
290
708
  }
291
709
  // ─── Route handler ──────────────────────────────────────────────────────────
@@ -303,7 +721,8 @@ async function handleRequest(req, res, staticApps) {
303
721
  if (projectConfig && Date.now() - lastScanTime > 5000) {
304
722
  lastScanTime = Date.now();
305
723
  const scan = await scanDirectory(projectConfig.srcArtDir);
306
- const currentIds = new Set(assets.map(a => a.id));
724
+ // Only compare top-level (non-child) asset IDs with disk files
725
+ const currentIds = new Set(assets.filter(a => !a.parentId).map(a => a.id));
307
726
  const diskIds = new Set([
308
727
  ...scan.sourceFiles.map(f => f.relativePath),
309
728
  ...scan.matFiles.map(f => f.relativePath),
@@ -313,6 +732,7 @@ async function handleRequest(req, res, staticApps) {
313
732
  [...diskIds].some(id => !currentIds.has(id));
314
733
  if (changed) {
315
734
  await openProject(projectConfig.projectDir);
735
+ queueProcessing();
316
736
  }
317
737
  }
318
738
  json(res, {
@@ -343,35 +763,11 @@ async function handleRequest(req, res, staticApps) {
343
763
  // POST /api/process — process all pending or specific IDs
344
764
  if (pathname === '/api/process' && req.method === 'POST') {
345
765
  const body = JSON.parse(await readBody(req));
346
- const ids = body.ids ?? assets.filter(a => a.status === 'pending').map(a => a.id);
347
766
  const force = body.force ?? false;
348
- if (force) {
349
- for (const id of ids) {
350
- const asset = assets.find(a => a.id === id);
351
- if (asset && asset.type !== AssetType.MaterialSchema) {
352
- asset.status = 'pending';
353
- }
354
- }
355
- }
356
- // Process concurrently (max 8)
357
- const pending = ids.filter(id => {
358
- const a = assets.find(x => x.id === id);
359
- return a && (a.status === 'pending' || force) && a.type !== AssetType.MaterialSchema;
360
- });
361
- const queue = [...pending];
362
- async function drain() {
363
- while (queue.length > 0) {
364
- const id = queue.shift();
365
- await processOneAsset(id);
366
- }
367
- }
368
- const workers = [];
369
- for (let i = 0; i < Math.min(8, pending.length); i++)
370
- workers.push(drain());
371
- Promise.all(workers).then(() => {
372
- broadcast({ type: 'processing-complete' });
373
- });
374
- json(res, { ok: true, queued: pending.length });
767
+ const ids = body.ids;
768
+ queueProcessing({ force, ids });
769
+ const count = ids?.length ?? assets.filter(a => a.status === 'pending' || force).length;
770
+ json(res, { ok: true, queued: count });
375
771
  return;
376
772
  }
377
773
  // POST /api/build — build .stow packs
@@ -385,19 +781,34 @@ async function handleRequest(req, res, staticApps) {
385
781
  const cdnDir = path.resolve(projectConfig.projectDir, projectConfig.config.cdnAssetsPath ?? 'public/cdn-assets');
386
782
  await fs.mkdir(cdnDir, { recursive: true });
387
783
  const results = [];
784
+ const allErrors = [];
388
785
  for (const pack of packs) {
389
786
  const packAssets = assets.filter(a => a.status === 'ready' &&
390
787
  (a.settings.pack === pack.name || (!a.settings.pack && pack.name === 'default')));
391
788
  if (packAssets.length === 0)
392
789
  continue;
790
+ // Validate dependencies before building
791
+ const depErrors = validatePackDependencies(packAssets, assetsById);
792
+ if (depErrors.length > 0) {
793
+ allErrors.push({ pack: pack.name, errors: depErrors });
794
+ for (const err of depErrors)
795
+ console.error(`[server] ${pack.name}: ${err.message}`);
796
+ continue;
797
+ }
393
798
  const packData = buildPack(packAssets, assetsById);
394
799
  const outPath = path.join(cdnDir, `${pack.name}.stow`);
395
800
  await fs.writeFile(outPath, packData);
396
801
  results.push({ name: pack.name, size: packData.length, assetCount: packAssets.length });
397
802
  console.log(`[server] Built ${pack.name}.stow (${packAssets.length} assets, ${(packData.length / 1024).toFixed(0)} KB)`);
398
803
  }
399
- broadcast({ type: 'build-complete', packs: results });
400
- json(res, { ok: true, packs: results });
804
+ if (allErrors.length > 0) {
805
+ broadcast({ type: 'build-complete', packs: results, errors: allErrors });
806
+ json(res, { ok: false, packs: results, errors: allErrors });
807
+ }
808
+ else {
809
+ broadcast({ type: 'build-complete', packs: results });
810
+ json(res, { ok: true, packs: results });
811
+ }
401
812
  return;
402
813
  }
403
814
  // PUT /api/asset/:id/settings — update settings
@@ -409,26 +820,51 @@ async function handleRequest(req, res, staticApps) {
409
820
  json(res, { error: 'Asset not found' }, 404);
410
821
  return;
411
822
  }
823
+ // Allow stringId updates via the settings endpoint
824
+ if (body.stringId !== undefined) {
825
+ asset.stringId = body.stringId;
826
+ }
412
827
  const merged = { ...asset.settings, ...body.settings };
413
828
  asset.settings = merged;
414
829
  // Persist to disk
415
830
  if (projectConfig) {
416
- const meta = assetSettingsToStowmeta(asset);
417
- await writeStowmeta(projectConfig.srcArtDir, id, meta);
418
- if (asset.type === AssetType.MaterialSchema && body.settings.materialConfig) {
419
- const stowmat = materialConfigToStowmat(merged.materialConfig);
420
- await writeStowmat(projectConfig.srcArtDir, id, stowmat);
831
+ if (asset.parentId) {
832
+ // GLB child: write inline entry into container stowmeta
833
+ const childName = id.split('/').pop();
834
+ const childType = asset.type === AssetType.Texture2D ? 'texture'
835
+ : asset.type === AssetType.StaticMesh ? 'staticMesh'
836
+ : asset.type === AssetType.SkinnedMesh ? 'skinnedMesh'
837
+ : asset.type === AssetType.AnimationClip ? 'animationClip'
838
+ : asset.type === AssetType.MaterialSchema ? 'materialSchema'
839
+ : 'unknown';
840
+ await writeGlbChildSettings(projectConfig.srcArtDir, asset.parentId, childName, asset.stringId, childType, merged);
841
+ }
842
+ else {
843
+ const meta = assetSettingsToStowmeta(asset);
844
+ // For GlbContainers, preserve existing children in stowmeta
845
+ if (asset.type === AssetType.GlbContainer && projectConfig) {
846
+ const existing = await readStowmeta(projectConfig.srcArtDir, id);
847
+ if (existing && existing.type === 'glbContainer') {
848
+ meta.children = existing.children;
849
+ meta.preserveHierarchy = merged.preserveHierarchy || undefined;
850
+ }
851
+ }
852
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
853
+ if (asset.type === AssetType.MaterialSchema && body.settings.materialConfig) {
854
+ const stowmat = materialConfigToStowmat(merged.materialConfig);
855
+ await writeStowmat(projectConfig.srcArtDir, id, stowmat);
856
+ }
421
857
  }
422
858
  }
423
859
  // Check if needs reprocessing
424
860
  const needsReprocess = (asset.type === AssetType.Texture2D && (body.settings.quality !== undefined || body.settings.resize !== undefined || body.settings.generateMipmaps !== undefined)) ||
425
- (asset.type === AssetType.StaticMesh && body.settings.dracoQuality !== undefined) ||
426
- (asset.type === AssetType.Audio && (body.settings.aacQuality !== undefined || body.settings.audioSampleRate !== undefined));
861
+ (asset.type === AssetType.StaticMesh && (body.settings.dracoQuality !== undefined || body.settings.preserveHierarchy !== undefined)) ||
862
+ (asset.type === AssetType.Audio && (body.settings.aacQuality !== undefined || body.settings.audioSampleRate !== undefined)) ||
863
+ (asset.type === AssetType.GlbContainer && body.settings.preserveHierarchy !== undefined);
427
864
  if (needsReprocess) {
428
865
  asset.status = 'pending';
429
866
  broadcast({ type: 'asset-update', id, updates: { settings: merged, status: 'pending' } });
430
- // Auto-trigger processing
431
- processOneAsset(id);
867
+ queueProcessing({ ids: [id] });
432
868
  }
433
869
  else {
434
870
  broadcast({ type: 'asset-update', id, updates: { settings: merged } });
@@ -453,7 +889,7 @@ async function handleRequest(req, res, staticApps) {
453
889
  await writeStowmeta(projectConfig.srcArtDir, id, meta);
454
890
  }
455
891
  broadcast({ type: 'asset-update', id, updates: { type: body.type, status: 'pending', metadata: undefined } });
456
- processOneAsset(id);
892
+ queueProcessing({ ids: [id] });
457
893
  json(res, { ok: true });
458
894
  return;
459
895
  }
@@ -475,6 +911,10 @@ async function handleRequest(req, res, staticApps) {
475
911
  json(res, { error: 'Asset not found' }, 404);
476
912
  return;
477
913
  }
914
+ if (asset.locked) {
915
+ json(res, { error: 'Cannot rename locked child asset' }, 403);
916
+ return;
917
+ }
478
918
  // Build new ID: same folder, new filename (preserve original extension)
479
919
  const folder = id.includes('/') ? id.slice(0, id.lastIndexOf('/') + 1) : '';
480
920
  const oldBase = id.split('/').pop() ?? id;
@@ -500,6 +940,64 @@ async function handleRequest(req, res, staticApps) {
500
940
  json(res, { ok: true, newId });
501
941
  return;
502
942
  }
943
+ // PUT /api/asset/:id/move — move asset to a different folder
944
+ if (pathname.startsWith('/api/asset/') && pathname.endsWith('/move') && req.method === 'PUT') {
945
+ const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/move'.length));
946
+ if (!projectConfig) {
947
+ json(res, { error: 'No project open' }, 400);
948
+ return;
949
+ }
950
+ const body = JSON.parse(await readBody(req));
951
+ const targetFolder = body.targetFolder ?? '';
952
+ const asset = assets.find(a => a.id === id);
953
+ if (!asset) {
954
+ json(res, { error: 'Asset not found' }, 404);
955
+ return;
956
+ }
957
+ if (asset.locked) {
958
+ json(res, { error: 'Cannot move locked child asset' }, 403);
959
+ return;
960
+ }
961
+ const fileName = id.split('/').pop() ?? id;
962
+ const newId = targetFolder ? `${targetFolder}/${fileName}` : fileName;
963
+ if (newId === id) {
964
+ json(res, { ok: true, newId });
965
+ return;
966
+ }
967
+ // Move source file, .stowmeta, .stowcache
968
+ await renameFile(projectConfig.srcArtDir, id, newId);
969
+ await renameFile(projectConfig.srcArtDir, `${id}.stowmeta`, `${newId}.stowmeta`);
970
+ await renameFile(projectConfig.srcArtDir, `${id}.stowcache`, `${newId}.stowcache`);
971
+ // For GlbContainer, also move the .children companion directory
972
+ if (asset.type === AssetType.GlbContainer) {
973
+ await renameFile(projectConfig.srcArtDir, `${id}.children`, `${newId}.children`);
974
+ // Update all child assets
975
+ const oldPrefix = id + '/';
976
+ const newPrefix = newId + '/';
977
+ for (const child of assets) {
978
+ if (child.parentId === id) {
979
+ const oldChildId = child.id;
980
+ child.id = newPrefix + child.id.slice(oldPrefix.length);
981
+ child.parentId = newId;
982
+ BlobStore.renameAll(oldChildId, child.id);
983
+ // Update texture references in material configs
984
+ if (child.type === AssetType.MaterialSchema && child.settings.materialConfig) {
985
+ for (const prop of child.settings.materialConfig.properties) {
986
+ if (prop.textureAssetId?.startsWith(oldPrefix)) {
987
+ prop.textureAssetId = newPrefix + prop.textureAssetId.slice(oldPrefix.length);
988
+ }
989
+ }
990
+ }
991
+ }
992
+ }
993
+ }
994
+ // Update the asset itself
995
+ asset.id = newId;
996
+ BlobStore.renameAll(id, newId);
997
+ lastScanTime = Date.now();
998
+ json(res, { ok: true, newId });
999
+ return;
1000
+ }
503
1001
  // DELETE /api/asset/:id — delete asset
504
1002
  if (pathname.startsWith('/api/asset/') && req.method === 'DELETE') {
505
1003
  const id = decodeURIComponent(pathname.slice('/api/asset/'.length));
@@ -507,6 +1005,22 @@ async function handleRequest(req, res, staticApps) {
507
1005
  json(res, { error: 'No project open' }, 400);
508
1006
  return;
509
1007
  }
1008
+ const asset = assets.find(a => a.id === id);
1009
+ if (asset?.locked) {
1010
+ json(res, { error: 'Cannot delete locked child asset' }, 403);
1011
+ return;
1012
+ }
1013
+ // If it's a container, cascade delete all children
1014
+ if (asset?.type === AssetType.GlbContainer) {
1015
+ for (const child of assets.filter(a => a.parentId === id)) {
1016
+ BlobStore.remove(child.id);
1017
+ broadcast({ type: 'asset-removed', id: child.id });
1018
+ }
1019
+ assets = assets.filter(a => a.parentId !== id);
1020
+ // Remove the .children/ cache directory
1021
+ const childrenDir = path.join(projectConfig.srcArtDir, `${id}.children`);
1022
+ fs.rm(childrenDir, { recursive: true, force: true }).catch(() => { });
1023
+ }
510
1024
  assets = assets.filter(a => a.id !== id);
511
1025
  BlobStore.remove(id);
512
1026
  deleteFile(projectConfig.srcArtDir, id);
@@ -558,7 +1072,18 @@ async function handleRequest(req, res, staticApps) {
558
1072
  json(res, { error: 'No project open' }, 400);
559
1073
  return;
560
1074
  }
561
- const data = await readFile(projectConfig.srcArtDir, id);
1075
+ let data = BlobStore.getSource(id);
1076
+ if (!data)
1077
+ data = await readFile(projectConfig.srcArtDir, id) ?? undefined;
1078
+ // For GLB children (mesh/texture), fall back to the parent GLB source
1079
+ if (!data) {
1080
+ const asset = assets.find(a => a.id === id);
1081
+ if (asset?.parentId) {
1082
+ data = BlobStore.getSource(asset.parentId);
1083
+ if (!data)
1084
+ data = await readFile(projectConfig.srcArtDir, asset.parentId) ?? undefined;
1085
+ }
1086
+ }
562
1087
  if (!data) {
563
1088
  res.writeHead(404);
564
1089
  res.end();
@@ -630,7 +1155,8 @@ async function handleRequest(req, res, staticApps) {
630
1155
  BlobStore.setSource(relativePath, sourceData);
631
1156
  assets.push(asset);
632
1157
  broadcast({ type: 'asset-added', asset });
633
- processOneAsset(relativePath);
1158
+ // Queue processing in the background (don't block the HTTP response)
1159
+ queueProcessing({ ids: [relativePath] });
634
1160
  json(res, { ok: true, id: relativePath });
635
1161
  return;
636
1162
  }
@@ -828,6 +1354,7 @@ async function handleRequest(req, res, staticApps) {
828
1354
  await openProject(projectConfig.projectDir);
829
1355
  broadcast({ type: 'project-reloaded' });
830
1356
  json(res, { ok: true, assetCount: assets.length });
1357
+ queueProcessing();
831
1358
  return;
832
1359
  }
833
1360
  // Static file serving for GUI apps (packer, editor)
@@ -914,37 +1441,12 @@ export async function startServer(opts = {}) {
914
1441
  }));
915
1442
  ws.on('close', () => wsClients.delete(ws));
916
1443
  });
917
- // Initialize encoders in background
918
- initEncoders(opts.wasmDir).then(ctx => {
919
- processingCtx = ctx;
920
- }).catch(err => {
921
- console.error('[server] Encoder init failed:', err);
922
- });
1444
+ // Initialize worker pool
1445
+ workerPool = initWorkerPool(opts.wasmDir);
923
1446
  // Open project if specified
924
1447
  if (opts.projectDir) {
925
1448
  await openProject(opts.projectDir);
926
- // Auto-process pending assets once encoders are ready
927
- const waitForEncoders = async () => {
928
- while (!processingCtx)
929
- await new Promise(r => setTimeout(r, 100));
930
- const pending = assets.filter(a => a.status === 'pending' && a.type !== AssetType.MaterialSchema);
931
- if (pending.length > 0) {
932
- console.log(`[server] Auto-processing ${pending.length} pending assets...`);
933
- const queue = pending.map(a => a.id);
934
- async function drain() {
935
- while (queue.length > 0) {
936
- const id = queue.shift();
937
- await processOneAsset(id);
938
- }
939
- }
940
- const workers = [];
941
- for (let i = 0; i < Math.min(8, pending.length); i++)
942
- workers.push(drain());
943
- await Promise.all(workers);
944
- broadcast({ type: 'processing-complete' });
945
- }
946
- };
947
- waitForEncoders();
1449
+ queueProcessing();
948
1450
  }
949
1451
  return new Promise((resolve) => {
950
1452
  server.listen(port, () => {