@series-inc/stowkit-cli 0.1.12 → 0.1.14
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 +663 -125
- 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 +2 -2
- 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,16 +721,54 @@ 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
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
724
|
+
// Compare top-level (non-child) asset IDs with disk files
|
|
725
|
+
const currentAssets = assets.filter(a => !a.parentId);
|
|
726
|
+
const currentIds = new Set(currentAssets.map(a => a.id));
|
|
727
|
+
const diskFiles = new Map([
|
|
728
|
+
...scan.sourceFiles.map(f => [f.relativePath, f]),
|
|
729
|
+
...scan.matFiles.map(f => [f.relativePath, f]),
|
|
310
730
|
]);
|
|
311
|
-
const
|
|
731
|
+
const diskIds = new Set(diskFiles.keys());
|
|
732
|
+
// Check for added/removed files
|
|
733
|
+
let changed = currentIds.size !== diskIds.size ||
|
|
312
734
|
[...currentIds].some(id => !diskIds.has(id)) ||
|
|
313
735
|
[...diskIds].some(id => !currentIds.has(id));
|
|
736
|
+
// Check for modified files (size or mtime changed)
|
|
737
|
+
const modifiedIds = [];
|
|
738
|
+
if (!changed) {
|
|
739
|
+
for (const asset of currentAssets) {
|
|
740
|
+
const diskFile = diskFiles.get(asset.id);
|
|
741
|
+
if (diskFile && asset.sourceSize > 0 && diskFile.size !== asset.sourceSize) {
|
|
742
|
+
modifiedIds.push(asset.id);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
314
746
|
if (changed) {
|
|
315
747
|
await openProject(projectConfig.projectDir);
|
|
748
|
+
queueProcessing();
|
|
749
|
+
}
|
|
750
|
+
else if (modifiedIds.length > 0) {
|
|
751
|
+
// Re-read source and reprocess modified assets without full project reload
|
|
752
|
+
for (const id of modifiedIds) {
|
|
753
|
+
const asset = assets.find(a => a.id === id);
|
|
754
|
+
if (!asset)
|
|
755
|
+
continue;
|
|
756
|
+
const diskFile = diskFiles.get(id);
|
|
757
|
+
if (diskFile)
|
|
758
|
+
asset.sourceSize = diskFile.size;
|
|
759
|
+
BlobStore.remove(id);
|
|
760
|
+
// Also clear children for GLB containers
|
|
761
|
+
if (asset.type === AssetType.GlbContainer) {
|
|
762
|
+
const prefix = id + '/';
|
|
763
|
+
for (const child of assets) {
|
|
764
|
+
if (child.id.startsWith(prefix))
|
|
765
|
+
BlobStore.remove(child.id);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
asset.status = 'pending';
|
|
769
|
+
broadcast({ type: 'asset-update', id, updates: { status: 'pending', sourceSize: asset.sourceSize } });
|
|
770
|
+
}
|
|
771
|
+
queueProcessing({ ids: modifiedIds });
|
|
316
772
|
}
|
|
317
773
|
}
|
|
318
774
|
json(res, {
|
|
@@ -343,35 +799,11 @@ async function handleRequest(req, res, staticApps) {
|
|
|
343
799
|
// POST /api/process — process all pending or specific IDs
|
|
344
800
|
if (pathname === '/api/process' && req.method === 'POST') {
|
|
345
801
|
const body = JSON.parse(await readBody(req));
|
|
346
|
-
const ids = body.ids ?? assets.filter(a => a.status === 'pending').map(a => a.id);
|
|
347
802
|
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 });
|
|
803
|
+
const ids = body.ids;
|
|
804
|
+
queueProcessing({ force, ids });
|
|
805
|
+
const count = ids?.length ?? assets.filter(a => a.status === 'pending' || force).length;
|
|
806
|
+
json(res, { ok: true, queued: count });
|
|
375
807
|
return;
|
|
376
808
|
}
|
|
377
809
|
// POST /api/build — build .stow packs
|
|
@@ -385,19 +817,34 @@ async function handleRequest(req, res, staticApps) {
|
|
|
385
817
|
const cdnDir = path.resolve(projectConfig.projectDir, projectConfig.config.cdnAssetsPath ?? 'public/cdn-assets');
|
|
386
818
|
await fs.mkdir(cdnDir, { recursive: true });
|
|
387
819
|
const results = [];
|
|
820
|
+
const allErrors = [];
|
|
388
821
|
for (const pack of packs) {
|
|
389
822
|
const packAssets = assets.filter(a => a.status === 'ready' &&
|
|
390
823
|
(a.settings.pack === pack.name || (!a.settings.pack && pack.name === 'default')));
|
|
391
824
|
if (packAssets.length === 0)
|
|
392
825
|
continue;
|
|
826
|
+
// Validate dependencies before building
|
|
827
|
+
const depErrors = validatePackDependencies(packAssets, assetsById);
|
|
828
|
+
if (depErrors.length > 0) {
|
|
829
|
+
allErrors.push({ pack: pack.name, errors: depErrors });
|
|
830
|
+
for (const err of depErrors)
|
|
831
|
+
console.error(`[server] ${pack.name}: ${err.message}`);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
393
834
|
const packData = buildPack(packAssets, assetsById);
|
|
394
835
|
const outPath = path.join(cdnDir, `${pack.name}.stow`);
|
|
395
836
|
await fs.writeFile(outPath, packData);
|
|
396
837
|
results.push({ name: pack.name, size: packData.length, assetCount: packAssets.length });
|
|
397
838
|
console.log(`[server] Built ${pack.name}.stow (${packAssets.length} assets, ${(packData.length / 1024).toFixed(0)} KB)`);
|
|
398
839
|
}
|
|
399
|
-
|
|
400
|
-
|
|
840
|
+
if (allErrors.length > 0) {
|
|
841
|
+
broadcast({ type: 'build-complete', packs: results, errors: allErrors });
|
|
842
|
+
json(res, { ok: false, packs: results, errors: allErrors });
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
broadcast({ type: 'build-complete', packs: results });
|
|
846
|
+
json(res, { ok: true, packs: results });
|
|
847
|
+
}
|
|
401
848
|
return;
|
|
402
849
|
}
|
|
403
850
|
// PUT /api/asset/:id/settings — update settings
|
|
@@ -409,26 +856,51 @@ async function handleRequest(req, res, staticApps) {
|
|
|
409
856
|
json(res, { error: 'Asset not found' }, 404);
|
|
410
857
|
return;
|
|
411
858
|
}
|
|
859
|
+
// Allow stringId updates via the settings endpoint
|
|
860
|
+
if (body.stringId !== undefined) {
|
|
861
|
+
asset.stringId = body.stringId;
|
|
862
|
+
}
|
|
412
863
|
const merged = { ...asset.settings, ...body.settings };
|
|
413
864
|
asset.settings = merged;
|
|
414
865
|
// Persist to disk
|
|
415
866
|
if (projectConfig) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
867
|
+
if (asset.parentId) {
|
|
868
|
+
// GLB child: write inline entry into container stowmeta
|
|
869
|
+
const childName = id.split('/').pop();
|
|
870
|
+
const childType = asset.type === AssetType.Texture2D ? 'texture'
|
|
871
|
+
: asset.type === AssetType.StaticMesh ? 'staticMesh'
|
|
872
|
+
: asset.type === AssetType.SkinnedMesh ? 'skinnedMesh'
|
|
873
|
+
: asset.type === AssetType.AnimationClip ? 'animationClip'
|
|
874
|
+
: asset.type === AssetType.MaterialSchema ? 'materialSchema'
|
|
875
|
+
: 'unknown';
|
|
876
|
+
await writeGlbChildSettings(projectConfig.srcArtDir, asset.parentId, childName, asset.stringId, childType, merged);
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
const meta = assetSettingsToStowmeta(asset);
|
|
880
|
+
// For GlbContainers, preserve existing children in stowmeta
|
|
881
|
+
if (asset.type === AssetType.GlbContainer && projectConfig) {
|
|
882
|
+
const existing = await readStowmeta(projectConfig.srcArtDir, id);
|
|
883
|
+
if (existing && existing.type === 'glbContainer') {
|
|
884
|
+
meta.children = existing.children;
|
|
885
|
+
meta.preserveHierarchy = merged.preserveHierarchy || undefined;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
await writeStowmeta(projectConfig.srcArtDir, id, meta);
|
|
889
|
+
if (asset.type === AssetType.MaterialSchema && body.settings.materialConfig) {
|
|
890
|
+
const stowmat = materialConfigToStowmat(merged.materialConfig);
|
|
891
|
+
await writeStowmat(projectConfig.srcArtDir, id, stowmat);
|
|
892
|
+
}
|
|
421
893
|
}
|
|
422
894
|
}
|
|
423
895
|
// Check if needs reprocessing
|
|
424
896
|
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))
|
|
897
|
+
(asset.type === AssetType.StaticMesh && (body.settings.dracoQuality !== undefined || body.settings.preserveHierarchy !== undefined)) ||
|
|
898
|
+
(asset.type === AssetType.Audio && (body.settings.aacQuality !== undefined || body.settings.audioSampleRate !== undefined)) ||
|
|
899
|
+
(asset.type === AssetType.GlbContainer && body.settings.preserveHierarchy !== undefined);
|
|
427
900
|
if (needsReprocess) {
|
|
428
901
|
asset.status = 'pending';
|
|
429
902
|
broadcast({ type: 'asset-update', id, updates: { settings: merged, status: 'pending' } });
|
|
430
|
-
|
|
431
|
-
processOneAsset(id);
|
|
903
|
+
queueProcessing({ ids: [id] });
|
|
432
904
|
}
|
|
433
905
|
else {
|
|
434
906
|
broadcast({ type: 'asset-update', id, updates: { settings: merged } });
|
|
@@ -453,7 +925,7 @@ async function handleRequest(req, res, staticApps) {
|
|
|
453
925
|
await writeStowmeta(projectConfig.srcArtDir, id, meta);
|
|
454
926
|
}
|
|
455
927
|
broadcast({ type: 'asset-update', id, updates: { type: body.type, status: 'pending', metadata: undefined } });
|
|
456
|
-
|
|
928
|
+
queueProcessing({ ids: [id] });
|
|
457
929
|
json(res, { ok: true });
|
|
458
930
|
return;
|
|
459
931
|
}
|
|
@@ -475,6 +947,10 @@ async function handleRequest(req, res, staticApps) {
|
|
|
475
947
|
json(res, { error: 'Asset not found' }, 404);
|
|
476
948
|
return;
|
|
477
949
|
}
|
|
950
|
+
if (asset.locked) {
|
|
951
|
+
json(res, { error: 'Cannot rename locked child asset' }, 403);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
478
954
|
// Build new ID: same folder, new filename (preserve original extension)
|
|
479
955
|
const folder = id.includes('/') ? id.slice(0, id.lastIndexOf('/') + 1) : '';
|
|
480
956
|
const oldBase = id.split('/').pop() ?? id;
|
|
@@ -500,6 +976,64 @@ async function handleRequest(req, res, staticApps) {
|
|
|
500
976
|
json(res, { ok: true, newId });
|
|
501
977
|
return;
|
|
502
978
|
}
|
|
979
|
+
// PUT /api/asset/:id/move — move asset to a different folder
|
|
980
|
+
if (pathname.startsWith('/api/asset/') && pathname.endsWith('/move') && req.method === 'PUT') {
|
|
981
|
+
const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/move'.length));
|
|
982
|
+
if (!projectConfig) {
|
|
983
|
+
json(res, { error: 'No project open' }, 400);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const body = JSON.parse(await readBody(req));
|
|
987
|
+
const targetFolder = body.targetFolder ?? '';
|
|
988
|
+
const asset = assets.find(a => a.id === id);
|
|
989
|
+
if (!asset) {
|
|
990
|
+
json(res, { error: 'Asset not found' }, 404);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (asset.locked) {
|
|
994
|
+
json(res, { error: 'Cannot move locked child asset' }, 403);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const fileName = id.split('/').pop() ?? id;
|
|
998
|
+
const newId = targetFolder ? `${targetFolder}/${fileName}` : fileName;
|
|
999
|
+
if (newId === id) {
|
|
1000
|
+
json(res, { ok: true, newId });
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
// Move source file, .stowmeta, .stowcache
|
|
1004
|
+
await renameFile(projectConfig.srcArtDir, id, newId);
|
|
1005
|
+
await renameFile(projectConfig.srcArtDir, `${id}.stowmeta`, `${newId}.stowmeta`);
|
|
1006
|
+
await renameFile(projectConfig.srcArtDir, `${id}.stowcache`, `${newId}.stowcache`);
|
|
1007
|
+
// For GlbContainer, also move the .children companion directory
|
|
1008
|
+
if (asset.type === AssetType.GlbContainer) {
|
|
1009
|
+
await renameFile(projectConfig.srcArtDir, `${id}.children`, `${newId}.children`);
|
|
1010
|
+
// Update all child assets
|
|
1011
|
+
const oldPrefix = id + '/';
|
|
1012
|
+
const newPrefix = newId + '/';
|
|
1013
|
+
for (const child of assets) {
|
|
1014
|
+
if (child.parentId === id) {
|
|
1015
|
+
const oldChildId = child.id;
|
|
1016
|
+
child.id = newPrefix + child.id.slice(oldPrefix.length);
|
|
1017
|
+
child.parentId = newId;
|
|
1018
|
+
BlobStore.renameAll(oldChildId, child.id);
|
|
1019
|
+
// Update texture references in material configs
|
|
1020
|
+
if (child.type === AssetType.MaterialSchema && child.settings.materialConfig) {
|
|
1021
|
+
for (const prop of child.settings.materialConfig.properties) {
|
|
1022
|
+
if (prop.textureAssetId?.startsWith(oldPrefix)) {
|
|
1023
|
+
prop.textureAssetId = newPrefix + prop.textureAssetId.slice(oldPrefix.length);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Update the asset itself
|
|
1031
|
+
asset.id = newId;
|
|
1032
|
+
BlobStore.renameAll(id, newId);
|
|
1033
|
+
lastScanTime = Date.now();
|
|
1034
|
+
json(res, { ok: true, newId });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
503
1037
|
// DELETE /api/asset/:id — delete asset
|
|
504
1038
|
if (pathname.startsWith('/api/asset/') && req.method === 'DELETE') {
|
|
505
1039
|
const id = decodeURIComponent(pathname.slice('/api/asset/'.length));
|
|
@@ -507,6 +1041,22 @@ async function handleRequest(req, res, staticApps) {
|
|
|
507
1041
|
json(res, { error: 'No project open' }, 400);
|
|
508
1042
|
return;
|
|
509
1043
|
}
|
|
1044
|
+
const asset = assets.find(a => a.id === id);
|
|
1045
|
+
if (asset?.locked) {
|
|
1046
|
+
json(res, { error: 'Cannot delete locked child asset' }, 403);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
// If it's a container, cascade delete all children
|
|
1050
|
+
if (asset?.type === AssetType.GlbContainer) {
|
|
1051
|
+
for (const child of assets.filter(a => a.parentId === id)) {
|
|
1052
|
+
BlobStore.remove(child.id);
|
|
1053
|
+
broadcast({ type: 'asset-removed', id: child.id });
|
|
1054
|
+
}
|
|
1055
|
+
assets = assets.filter(a => a.parentId !== id);
|
|
1056
|
+
// Remove the .children/ cache directory
|
|
1057
|
+
const childrenDir = path.join(projectConfig.srcArtDir, `${id}.children`);
|
|
1058
|
+
fs.rm(childrenDir, { recursive: true, force: true }).catch(() => { });
|
|
1059
|
+
}
|
|
510
1060
|
assets = assets.filter(a => a.id !== id);
|
|
511
1061
|
BlobStore.remove(id);
|
|
512
1062
|
deleteFile(projectConfig.srcArtDir, id);
|
|
@@ -558,7 +1108,18 @@ async function handleRequest(req, res, staticApps) {
|
|
|
558
1108
|
json(res, { error: 'No project open' }, 400);
|
|
559
1109
|
return;
|
|
560
1110
|
}
|
|
561
|
-
|
|
1111
|
+
let data = BlobStore.getSource(id);
|
|
1112
|
+
if (!data)
|
|
1113
|
+
data = await readFile(projectConfig.srcArtDir, id) ?? undefined;
|
|
1114
|
+
// For GLB children (mesh/texture), fall back to the parent GLB source
|
|
1115
|
+
if (!data) {
|
|
1116
|
+
const asset = assets.find(a => a.id === id);
|
|
1117
|
+
if (asset?.parentId) {
|
|
1118
|
+
data = BlobStore.getSource(asset.parentId);
|
|
1119
|
+
if (!data)
|
|
1120
|
+
data = await readFile(projectConfig.srcArtDir, asset.parentId) ?? undefined;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
562
1123
|
if (!data) {
|
|
563
1124
|
res.writeHead(404);
|
|
564
1125
|
res.end();
|
|
@@ -630,7 +1191,8 @@ async function handleRequest(req, res, staticApps) {
|
|
|
630
1191
|
BlobStore.setSource(relativePath, sourceData);
|
|
631
1192
|
assets.push(asset);
|
|
632
1193
|
broadcast({ type: 'asset-added', asset });
|
|
633
|
-
|
|
1194
|
+
// Queue processing in the background (don't block the HTTP response)
|
|
1195
|
+
queueProcessing({ ids: [relativePath] });
|
|
634
1196
|
json(res, { ok: true, id: relativePath });
|
|
635
1197
|
return;
|
|
636
1198
|
}
|
|
@@ -828,6 +1390,7 @@ async function handleRequest(req, res, staticApps) {
|
|
|
828
1390
|
await openProject(projectConfig.projectDir);
|
|
829
1391
|
broadcast({ type: 'project-reloaded' });
|
|
830
1392
|
json(res, { ok: true, assetCount: assets.length });
|
|
1393
|
+
queueProcessing();
|
|
831
1394
|
return;
|
|
832
1395
|
}
|
|
833
1396
|
// Static file serving for GUI apps (packer, editor)
|
|
@@ -914,37 +1477,12 @@ export async function startServer(opts = {}) {
|
|
|
914
1477
|
}));
|
|
915
1478
|
ws.on('close', () => wsClients.delete(ws));
|
|
916
1479
|
});
|
|
917
|
-
// Initialize
|
|
918
|
-
|
|
919
|
-
processingCtx = ctx;
|
|
920
|
-
}).catch(err => {
|
|
921
|
-
console.error('[server] Encoder init failed:', err);
|
|
922
|
-
});
|
|
1480
|
+
// Initialize worker pool
|
|
1481
|
+
workerPool = initWorkerPool(opts.wasmDir);
|
|
923
1482
|
// Open project if specified
|
|
924
1483
|
if (opts.projectDir) {
|
|
925
1484
|
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();
|
|
1485
|
+
queueProcessing();
|
|
948
1486
|
}
|
|
949
1487
|
return new Promise((resolve) => {
|
|
950
1488
|
server.listen(port, () => {
|