@series-inc/stowkit-cli 0.1.12 → 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/app/blob-store.d.ts +2 -0
- package/dist/app/blob-store.js +6 -0
- package/dist/app/disk-project.d.ts +30 -1
- package/dist/app/process-cache.js +12 -0
- package/dist/app/state.d.ts +4 -0
- package/dist/app/state.js +2 -0
- package/dist/app/stowmeta-io.d.ts +18 -1
- package/dist/app/stowmeta-io.js +214 -3
- package/dist/cleanup.js +2 -0
- package/dist/core/types.d.ts +2 -1
- package/dist/core/types.js +1 -0
- package/dist/encoders/fbx-loader.d.ts +2 -2
- package/dist/encoders/fbx-loader.js +140 -2
- package/dist/encoders/glb-loader.d.ts +42 -0
- package/dist/encoders/glb-loader.js +592 -0
- package/dist/encoders/interfaces.d.ts +4 -1
- package/dist/node-fs.js +2 -0
- package/dist/orchestrator.js +253 -50
- package/dist/pipeline.d.ts +20 -1
- package/dist/pipeline.js +138 -2
- package/dist/server.js +623 -121
- package/dist/workers/process-worker.d.ts +1 -0
- package/dist/workers/process-worker.js +83 -0
- package/dist/workers/worker-pool.d.ts +41 -0
- package/dist/workers/worker-pool.js +130 -0
- package/package.json +1 -1
- package/skill.md +164 -11
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 {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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
|
|
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
|
|
91
|
-
|
|
92
|
-
console.log('[server] Initializing
|
|
93
|
-
const
|
|
94
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
234
|
-
asset.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
918
|
-
|
|
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
|
-
|
|
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, () => {
|