@series-inc/stowkit-cli 0.1.0

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.
Files changed (56) hide show
  1. package/dist/app/blob-store.d.ts +9 -0
  2. package/dist/app/blob-store.js +42 -0
  3. package/dist/app/disk-project.d.ts +84 -0
  4. package/dist/app/disk-project.js +70 -0
  5. package/dist/app/process-cache.d.ts +10 -0
  6. package/dist/app/process-cache.js +126 -0
  7. package/dist/app/state.d.ts +38 -0
  8. package/dist/app/state.js +16 -0
  9. package/dist/app/stowmat-io.d.ts +6 -0
  10. package/dist/app/stowmat-io.js +48 -0
  11. package/dist/app/stowmeta-io.d.ts +14 -0
  12. package/dist/app/stowmeta-io.js +207 -0
  13. package/dist/cleanup.d.ts +3 -0
  14. package/dist/cleanup.js +72 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +148 -0
  17. package/dist/core/binary.d.ts +41 -0
  18. package/dist/core/binary.js +118 -0
  19. package/dist/core/constants.d.ts +64 -0
  20. package/dist/core/constants.js +65 -0
  21. package/dist/core/path.d.ts +3 -0
  22. package/dist/core/path.js +27 -0
  23. package/dist/core/types.d.ts +204 -0
  24. package/dist/core/types.js +76 -0
  25. package/dist/encoders/aac-encoder.d.ts +12 -0
  26. package/dist/encoders/aac-encoder.js +179 -0
  27. package/dist/encoders/basis-encoder.d.ts +15 -0
  28. package/dist/encoders/basis-encoder.js +116 -0
  29. package/dist/encoders/draco-encoder.d.ts +11 -0
  30. package/dist/encoders/draco-encoder.js +155 -0
  31. package/dist/encoders/fbx-loader.d.ts +4 -0
  32. package/dist/encoders/fbx-loader.js +540 -0
  33. package/dist/encoders/image-decoder.d.ts +13 -0
  34. package/dist/encoders/image-decoder.js +33 -0
  35. package/dist/encoders/interfaces.d.ts +105 -0
  36. package/dist/encoders/interfaces.js +1 -0
  37. package/dist/encoders/skinned-mesh-builder.d.ts +7 -0
  38. package/dist/encoders/skinned-mesh-builder.js +135 -0
  39. package/dist/format/metadata.d.ts +18 -0
  40. package/dist/format/metadata.js +381 -0
  41. package/dist/format/packer.d.ts +8 -0
  42. package/dist/format/packer.js +87 -0
  43. package/dist/index.d.ts +28 -0
  44. package/dist/index.js +35 -0
  45. package/dist/init.d.ts +1 -0
  46. package/dist/init.js +73 -0
  47. package/dist/node-fs.d.ts +22 -0
  48. package/dist/node-fs.js +148 -0
  49. package/dist/orchestrator.d.ts +20 -0
  50. package/dist/orchestrator.js +301 -0
  51. package/dist/pipeline.d.ts +23 -0
  52. package/dist/pipeline.js +354 -0
  53. package/dist/server.d.ts +9 -0
  54. package/dist/server.js +859 -0
  55. package/package.json +35 -0
  56. package/skill.md +211 -0
package/dist/server.js ADDED
@@ -0,0 +1,859 @@
1
+ import * as http from 'node:http';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { AssetType } from './core/types.js';
6
+ import { defaultAssetSettings } from './app/state.js';
7
+ import { BlobStore } from './app/blob-store.js';
8
+ import { readProjectConfig, scanDirectory, readFile, writeFile, deleteFile, getFileSnapshot, } from './node-fs.js';
9
+ import { detectAssetType, readStowmeta, writeStowmeta, stowmetaToAssetSettings, assetSettingsToStowmeta, generateDefaultStowmeta, } from './app/stowmeta-io.js';
10
+ import { readStowmat, writeStowmat, stowmatToMaterialConfig, materialConfigToStowmat } from './app/stowmat-io.js';
11
+ import { readCacheBlobs, writeCacheBlobs, buildCacheStamp, isCacheValid, } from './app/process-cache.js';
12
+ import { processAsset, buildPack } from './pipeline.js';
13
+ import { NodeBasisEncoder } from './encoders/basis-encoder.js';
14
+ import { NodeDracoEncoder } from './encoders/draco-encoder.js';
15
+ import { NodeAacEncoder, NodeAudioDecoder } from './encoders/aac-encoder.js';
16
+ import { NodeFbxImporter } from './encoders/fbx-loader.js';
17
+ import { SharpImageDecoder } from './encoders/image-decoder.js';
18
+ async function scanPrefabFiles(dir, prefix) {
19
+ const results = [];
20
+ let entries;
21
+ try {
22
+ entries = await fs.readdir(dir, { withFileTypes: true });
23
+ }
24
+ catch {
25
+ return results;
26
+ }
27
+ for (const entry of entries) {
28
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
29
+ if (entry.isDirectory()) {
30
+ results.push(...await scanPrefabFiles(path.join(dir, entry.name), relPath));
31
+ }
32
+ else if (entry.isFile() && entry.name.endsWith('.stowprefab')) {
33
+ const stat = await fs.stat(path.join(dir, entry.name));
34
+ results.push({ name: entry.name.replace(/\.stowprefab$/, ''), path: relPath, lastModified: stat.mtimeMs });
35
+ }
36
+ }
37
+ return results;
38
+ }
39
+ // ─── State ──────────────────────────────────────────────────────────────────
40
+ let projectConfig = null;
41
+ let assets = [];
42
+ let folders = [];
43
+ let processingCtx = null;
44
+ let encodersReady = false;
45
+ // Track in-flight processing
46
+ const processing = new Set();
47
+ // staticApps is passed per-server via closure, not module-level
48
+ // WebSocket clients
49
+ const wsClients = new Set();
50
+ function broadcast(msg) {
51
+ const data = JSON.stringify(msg);
52
+ for (const ws of wsClients) {
53
+ if (ws.readyState === WebSocket.OPEN)
54
+ ws.send(data);
55
+ }
56
+ }
57
+ // ─── Helpers ────────────────────────────────────────────────────────────────
58
+ function resolvePackName(pack, packs) {
59
+ if (packs.length === 0)
60
+ return pack;
61
+ if (packs.some(p => p.name === pack))
62
+ return pack;
63
+ return packs[0].name;
64
+ }
65
+ async function readBody(req) {
66
+ const chunks = [];
67
+ for await (const chunk of req)
68
+ chunks.push(chunk);
69
+ return Buffer.concat(chunks).toString('utf-8');
70
+ }
71
+ async function readBinaryBody(req) {
72
+ const chunks = [];
73
+ for await (const chunk of req)
74
+ chunks.push(chunk);
75
+ return Buffer.concat(chunks);
76
+ }
77
+ function json(res, data, status = 200) {
78
+ res.writeHead(status, {
79
+ 'Content-Type': 'application/json',
80
+ 'Access-Control-Allow-Origin': '*',
81
+ });
82
+ res.end(JSON.stringify(data));
83
+ }
84
+ function cors(res) {
85
+ res.setHeader('Access-Control-Allow-Origin', '*');
86
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
87
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
88
+ }
89
+ // ─── Initialize encoders ────────────────────────────────────────────────────
90
+ async function initEncoders(wasmDir) {
91
+ console.log('[server] Initializing encoders...');
92
+ const textureEncoder = new NodeBasisEncoder(wasmDir);
93
+ const meshEncoder = new NodeDracoEncoder();
94
+ const aacEncoder = new NodeAacEncoder();
95
+ const audioDecoder = new NodeAudioDecoder();
96
+ const meshImporter = new NodeFbxImporter();
97
+ const imageDecoder = new SharpImageDecoder();
98
+ await Promise.all([
99
+ textureEncoder.initialize(),
100
+ meshEncoder.initialize(),
101
+ aacEncoder.initialize(),
102
+ audioDecoder.initialize(),
103
+ ]);
104
+ console.log('[server] All encoders ready');
105
+ encodersReady = true;
106
+ broadcast({ type: 'encoders-ready' });
107
+ return {
108
+ textureEncoder,
109
+ meshEncoder,
110
+ meshImporter,
111
+ imageDecoder,
112
+ audioDecoder,
113
+ aacEncoder,
114
+ onProgress: (id, msg) => {
115
+ broadcast({ type: 'progress', id, message: msg });
116
+ },
117
+ };
118
+ }
119
+ // ─── Open project ──────────────────────────────────────────────────────────
120
+ async function openProject(projectDir) {
121
+ projectConfig = await readProjectConfig(projectDir);
122
+ BlobStore.clear();
123
+ assets = [];
124
+ folders = [];
125
+ const scan = await scanDirectory(projectConfig.srcArtDir);
126
+ folders = scan.folders;
127
+ const configuredPacks = projectConfig.config.packs ?? [];
128
+ // Build asset shells from source files
129
+ for (const file of scan.sourceFiles) {
130
+ const type = detectAssetType(file.relativePath);
131
+ if (type === AssetType.Unknown)
132
+ continue;
133
+ const fileName = file.relativePath.split('/').pop() ?? file.relativePath;
134
+ let meta = await readStowmeta(projectConfig.srcArtDir, file.relativePath);
135
+ if (!meta) {
136
+ meta = generateDefaultStowmeta(file.relativePath, type);
137
+ await writeStowmeta(projectConfig.srcArtDir, file.relativePath, meta);
138
+ }
139
+ const { type: metaType, settings: metaSettings } = stowmetaToAssetSettings(meta);
140
+ metaSettings.pack = resolvePackName(metaSettings.pack, configuredPacks);
141
+ const asset = {
142
+ id: file.relativePath,
143
+ fileName,
144
+ stringId: meta.stringId,
145
+ type: metaType,
146
+ status: 'pending',
147
+ settings: metaSettings,
148
+ sourceSize: file.size,
149
+ processedSize: 0,
150
+ };
151
+ // Check cache
152
+ if (meta.cache) {
153
+ const snapshot = await getFileSnapshot(projectConfig.srcArtDir, file.relativePath);
154
+ if (snapshot && isCacheValid(meta, snapshot, metaType, metaSettings)) {
155
+ const cached = await readCacheBlobs(projectConfig.srcArtDir, file.relativePath);
156
+ if (cached && cached.size > 0) {
157
+ for (const [key, data] of cached) {
158
+ if (key.endsWith(':__metadata__')) {
159
+ try {
160
+ asset.metadata = JSON.parse(new TextDecoder().decode(data));
161
+ }
162
+ catch { /* skip */ }
163
+ }
164
+ else {
165
+ BlobStore.setProcessed(key, data);
166
+ }
167
+ }
168
+ asset.status = 'ready';
169
+ asset.processedSize = BlobStore.getProcessed(file.relativePath)?.length ?? 0;
170
+ }
171
+ }
172
+ }
173
+ assets.push(asset);
174
+ }
175
+ // Materials from .stowmat files
176
+ for (const matFile of scan.matFiles) {
177
+ const id = matFile.relativePath;
178
+ const mat = await readStowmat(projectConfig.srcArtDir, id);
179
+ const fileName = id.split('/').pop() ?? id;
180
+ const baseName = fileName.replace(/\.[^.]+$/, '');
181
+ let meta = await readStowmeta(projectConfig.srcArtDir, id);
182
+ if (!meta) {
183
+ meta = generateDefaultStowmeta(id, AssetType.MaterialSchema);
184
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
185
+ }
186
+ const materialConfig = mat ? stowmatToMaterialConfig(mat) : { schemaId: '', properties: [] };
187
+ const settings = defaultAssetSettings();
188
+ settings.materialConfig = materialConfig;
189
+ settings.pack = meta.pack ?? 'default';
190
+ settings.tags = meta.tags ?? [];
191
+ assets.push({
192
+ id,
193
+ fileName: baseName,
194
+ stringId: meta.stringId || baseName,
195
+ type: AssetType.MaterialSchema,
196
+ status: 'ready',
197
+ settings,
198
+ sourceSize: matFile.size,
199
+ processedSize: 0,
200
+ });
201
+ }
202
+ console.log(`[server] Opened: ${projectConfig.projectName} (${assets.length} assets, ${folders.length} folders)`);
203
+ }
204
+ // ─── Process single asset ─────────────────────────────────────────────────
205
+ async function processOneAsset(id) {
206
+ if (!projectConfig || !processingCtx)
207
+ return;
208
+ if (processing.has(id))
209
+ return;
210
+ const asset = assets.find(a => a.id === id);
211
+ if (!asset)
212
+ return;
213
+ if (asset.type === AssetType.MaterialSchema)
214
+ return;
215
+ processing.add(id);
216
+ asset.status = 'processing';
217
+ broadcast({ type: 'asset-update', id, updates: { status: 'processing' } });
218
+ try {
219
+ // Load source
220
+ let sourceData = BlobStore.getSource(id);
221
+ if (!sourceData) {
222
+ const data = await readFile(projectConfig.srcArtDir, id);
223
+ if (!data)
224
+ throw new Error(`Could not read source: ${id}`);
225
+ BlobStore.setSource(id, data);
226
+ sourceData = data;
227
+ }
228
+ const t0 = performance.now();
229
+ const result = await processAsset(id, sourceData, asset.type, asset.stringId, asset.settings, processingCtx);
230
+ const elapsed = (performance.now() - t0).toFixed(0);
231
+ asset.status = 'ready';
232
+ asset.metadata = result.metadata;
233
+ asset.processedSize = result.processedSize;
234
+ asset.sourceSize = sourceData.length;
235
+ broadcast({
236
+ type: 'asset-update', id,
237
+ updates: {
238
+ status: 'ready',
239
+ metadata: result.metadata,
240
+ processedSize: result.processedSize,
241
+ sourceSize: sourceData.length,
242
+ },
243
+ });
244
+ console.log(`[server] Processed ${id} (${elapsed}ms)`);
245
+ // Write cache
246
+ const cacheEntries = new Map();
247
+ const processed = BlobStore.getProcessed(id);
248
+ if (processed)
249
+ cacheEntries.set(id, processed);
250
+ if (result.metadata) {
251
+ cacheEntries.set(`${id}:__metadata__`, new TextEncoder().encode(JSON.stringify(result.metadata)));
252
+ }
253
+ for (const suffix of [':skinnedMeta', ':animMeta', ':animCount']) {
254
+ const blob = BlobStore.getProcessed(`${id}${suffix}`);
255
+ if (blob)
256
+ cacheEntries.set(`${id}${suffix}`, blob);
257
+ }
258
+ const animCountBlob = BlobStore.getProcessed(`${id}:animCount`);
259
+ const clipCount = animCountBlob ? animCountBlob[0] : 0;
260
+ for (let ci = 0; ci < clipCount; ci++) {
261
+ for (const key of [`${id}:anim:${ci}`, `${id}:animMeta:${ci}`]) {
262
+ const blob = BlobStore.getProcessed(key);
263
+ if (blob)
264
+ cacheEntries.set(key, blob);
265
+ }
266
+ }
267
+ if (cacheEntries.size > 0) {
268
+ await writeCacheBlobs(projectConfig.srcArtDir, id, cacheEntries);
269
+ const snapshot = await getFileSnapshot(projectConfig.srcArtDir, id);
270
+ if (snapshot) {
271
+ const meta = await readStowmeta(projectConfig.srcArtDir, id);
272
+ if (meta) {
273
+ meta.cache = buildCacheStamp(snapshot, asset.type, asset.settings);
274
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ catch (err) {
280
+ asset.status = 'error';
281
+ asset.error = err instanceof Error ? err.message : String(err);
282
+ broadcast({ type: 'asset-update', id, updates: { status: 'error', error: asset.error } });
283
+ console.error(`[server] Failed ${id}: ${asset.error}`);
284
+ }
285
+ finally {
286
+ processing.delete(id);
287
+ }
288
+ }
289
+ // ─── Route handler ──────────────────────────────────────────────────────────
290
+ async function handleRequest(req, res, staticApps) {
291
+ cors(res);
292
+ if (req.method === 'OPTIONS') {
293
+ res.writeHead(204);
294
+ res.end();
295
+ return;
296
+ }
297
+ const url = new URL(req.url ?? '/', `http://localhost`);
298
+ const pathname = url.pathname;
299
+ // GET /api/project — project state
300
+ if (pathname === '/api/project' && req.method === 'GET') {
301
+ json(res, {
302
+ project: projectConfig ? {
303
+ projectName: projectConfig.projectName,
304
+ projectDir: projectConfig.projectDir,
305
+ srcArtDir: projectConfig.srcArtDir,
306
+ config: projectConfig.config,
307
+ } : null,
308
+ assets,
309
+ folders,
310
+ encodersReady,
311
+ });
312
+ return;
313
+ }
314
+ // POST /api/open — open a project by path
315
+ if (pathname === '/api/open' && req.method === 'POST') {
316
+ const body = JSON.parse(await readBody(req));
317
+ try {
318
+ await openProject(body.projectDir);
319
+ json(res, { ok: true, projectName: projectConfig.projectName, assetCount: assets.length });
320
+ }
321
+ catch (err) {
322
+ json(res, { ok: false, error: err.message }, 400);
323
+ }
324
+ return;
325
+ }
326
+ // POST /api/process — process all pending or specific IDs
327
+ if (pathname === '/api/process' && req.method === 'POST') {
328
+ const body = JSON.parse(await readBody(req));
329
+ const ids = body.ids ?? assets.filter(a => a.status === 'pending').map(a => a.id);
330
+ const force = body.force ?? false;
331
+ if (force) {
332
+ for (const id of ids) {
333
+ const asset = assets.find(a => a.id === id);
334
+ if (asset && asset.type !== AssetType.MaterialSchema) {
335
+ asset.status = 'pending';
336
+ }
337
+ }
338
+ }
339
+ // Process concurrently (max 8)
340
+ const pending = ids.filter(id => {
341
+ const a = assets.find(x => x.id === id);
342
+ return a && (a.status === 'pending' || force) && a.type !== AssetType.MaterialSchema;
343
+ });
344
+ const queue = [...pending];
345
+ async function drain() {
346
+ while (queue.length > 0) {
347
+ const id = queue.shift();
348
+ await processOneAsset(id);
349
+ }
350
+ }
351
+ const workers = [];
352
+ for (let i = 0; i < Math.min(8, pending.length); i++)
353
+ workers.push(drain());
354
+ Promise.all(workers).then(() => {
355
+ broadcast({ type: 'processing-complete' });
356
+ });
357
+ json(res, { ok: true, queued: pending.length });
358
+ return;
359
+ }
360
+ // POST /api/build — build .stow packs
361
+ if (pathname === '/api/build' && req.method === 'POST') {
362
+ if (!projectConfig) {
363
+ json(res, { error: 'No project open' }, 400);
364
+ return;
365
+ }
366
+ const assetsById = new Map(assets.map(a => [a.id, a]));
367
+ const packs = projectConfig.config.packs ?? [{ name: 'default' }];
368
+ const cdnDir = path.resolve(projectConfig.projectDir, projectConfig.config.cdnAssetsPath ?? 'public/cdn-assets');
369
+ await fs.mkdir(cdnDir, { recursive: true });
370
+ const results = [];
371
+ for (const pack of packs) {
372
+ const packAssets = assets.filter(a => a.status === 'ready' &&
373
+ (a.settings.pack === pack.name || (!a.settings.pack && pack.name === 'default')));
374
+ if (packAssets.length === 0)
375
+ continue;
376
+ const packData = buildPack(packAssets, assetsById);
377
+ const outPath = path.join(cdnDir, `${pack.name}.stow`);
378
+ await fs.writeFile(outPath, packData);
379
+ results.push({ name: pack.name, size: packData.length, assetCount: packAssets.length });
380
+ console.log(`[server] Built ${pack.name}.stow (${packAssets.length} assets, ${(packData.length / 1024).toFixed(0)} KB)`);
381
+ }
382
+ broadcast({ type: 'build-complete', packs: results });
383
+ json(res, { ok: true, packs: results });
384
+ return;
385
+ }
386
+ // PUT /api/asset/:id/settings — update settings
387
+ if (pathname.startsWith('/api/asset/') && pathname.endsWith('/settings') && req.method === 'PUT') {
388
+ const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/settings'.length));
389
+ const body = JSON.parse(await readBody(req));
390
+ const asset = assets.find(a => a.id === id);
391
+ if (!asset) {
392
+ json(res, { error: 'Asset not found' }, 404);
393
+ return;
394
+ }
395
+ const merged = { ...asset.settings, ...body.settings };
396
+ asset.settings = merged;
397
+ // Persist to disk
398
+ if (projectConfig) {
399
+ const meta = assetSettingsToStowmeta(asset);
400
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
401
+ if (asset.type === AssetType.MaterialSchema && body.settings.materialConfig) {
402
+ const stowmat = materialConfigToStowmat(merged.materialConfig);
403
+ await writeStowmat(projectConfig.srcArtDir, id, stowmat);
404
+ }
405
+ }
406
+ // Check if needs reprocessing
407
+ const needsReprocess = (asset.type === AssetType.Texture2D && (body.settings.quality !== undefined || body.settings.resize !== undefined || body.settings.generateMipmaps !== undefined)) ||
408
+ (asset.type === AssetType.StaticMesh && body.settings.dracoQuality !== undefined) ||
409
+ (asset.type === AssetType.Audio && (body.settings.aacQuality !== undefined || body.settings.audioSampleRate !== undefined));
410
+ if (needsReprocess) {
411
+ asset.status = 'pending';
412
+ broadcast({ type: 'asset-update', id, updates: { settings: merged, status: 'pending' } });
413
+ // Auto-trigger processing
414
+ processOneAsset(id);
415
+ }
416
+ else {
417
+ broadcast({ type: 'asset-update', id, updates: { settings: merged } });
418
+ }
419
+ json(res, { ok: true });
420
+ return;
421
+ }
422
+ // PUT /api/asset/:id/type — change asset type
423
+ if (pathname.startsWith('/api/asset/') && pathname.endsWith('/type') && req.method === 'PUT') {
424
+ const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/type'.length));
425
+ const body = JSON.parse(await readBody(req));
426
+ const asset = assets.find(a => a.id === id);
427
+ if (!asset) {
428
+ json(res, { error: 'Asset not found' }, 404);
429
+ return;
430
+ }
431
+ asset.type = body.type;
432
+ asset.status = 'pending';
433
+ asset.metadata = undefined;
434
+ if (projectConfig) {
435
+ const meta = assetSettingsToStowmeta(asset);
436
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
437
+ }
438
+ broadcast({ type: 'asset-update', id, updates: { type: body.type, status: 'pending', metadata: undefined } });
439
+ processOneAsset(id);
440
+ json(res, { ok: true });
441
+ return;
442
+ }
443
+ // DELETE /api/asset/:id — delete asset
444
+ if (pathname.startsWith('/api/asset/') && req.method === 'DELETE') {
445
+ const id = decodeURIComponent(pathname.slice('/api/asset/'.length));
446
+ if (!projectConfig) {
447
+ json(res, { error: 'No project open' }, 400);
448
+ return;
449
+ }
450
+ assets = assets.filter(a => a.id !== id);
451
+ BlobStore.remove(id);
452
+ deleteFile(projectConfig.srcArtDir, id);
453
+ deleteFile(projectConfig.srcArtDir, `${id}.stowmeta`);
454
+ deleteFile(projectConfig.srcArtDir, `${id}.stowcache`);
455
+ broadcast({ type: 'asset-removed', id });
456
+ json(res, { ok: true });
457
+ return;
458
+ }
459
+ // GET /api/asset/:id/source — serve source file for preview
460
+ if (pathname.startsWith('/api/asset/') && pathname.endsWith('/source') && req.method === 'GET') {
461
+ const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/source'.length));
462
+ if (!projectConfig) {
463
+ json(res, { error: 'No project open' }, 400);
464
+ return;
465
+ }
466
+ const data = await readFile(projectConfig.srcArtDir, id);
467
+ if (!data) {
468
+ res.writeHead(404);
469
+ res.end();
470
+ return;
471
+ }
472
+ const ext = id.split('.').pop()?.toLowerCase() ?? '';
473
+ const mimeMap = {
474
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
475
+ bmp: 'image/bmp', webp: 'image/webp', gif: 'image/gif',
476
+ wav: 'audio/wav', mp3: 'audio/mpeg', ogg: 'audio/ogg',
477
+ flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
478
+ };
479
+ res.writeHead(200, {
480
+ 'Content-Type': mimeMap[ext] ?? 'application/octet-stream',
481
+ 'Content-Length': data.length,
482
+ 'Access-Control-Allow-Origin': '*',
483
+ });
484
+ res.end(Buffer.from(data));
485
+ return;
486
+ }
487
+ // GET /api/asset/:id/processed — serve processed data (for audio preview)
488
+ if (pathname.startsWith('/api/asset/') && pathname.endsWith('/processed') && req.method === 'GET') {
489
+ const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/processed'.length));
490
+ const data = BlobStore.getProcessed(id);
491
+ if (!data) {
492
+ res.writeHead(404);
493
+ res.end();
494
+ return;
495
+ }
496
+ const asset = assets.find(a => a.id === id);
497
+ const mime = asset?.type === AssetType.Audio ? 'audio/mp4' : 'application/octet-stream';
498
+ res.writeHead(200, {
499
+ 'Content-Type': mime,
500
+ 'Content-Length': data.length,
501
+ 'Access-Control-Allow-Origin': '*',
502
+ });
503
+ res.end(Buffer.from(data));
504
+ return;
505
+ }
506
+ // POST /api/import — import files (receives multipart or raw binary)
507
+ if (pathname === '/api/import' && req.method === 'POST') {
508
+ if (!projectConfig) {
509
+ json(res, { error: 'No project open' }, 400);
510
+ return;
511
+ }
512
+ const fileName = url.searchParams.get('name') ?? 'unknown';
513
+ const targetFolder = url.searchParams.get('folder') ?? '';
514
+ const relativePath = targetFolder ? `${targetFolder}/${fileName}` : fileName;
515
+ const data = await readBinaryBody(req);
516
+ const sourceData = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
517
+ await writeFile(projectConfig.srcArtDir, relativePath, sourceData);
518
+ const type = detectAssetType(fileName);
519
+ const configuredPacks = projectConfig.config.packs ?? [];
520
+ const meta = generateDefaultStowmeta(relativePath, type);
521
+ meta.pack = resolvePackName(meta.pack ?? '', configuredPacks);
522
+ await writeStowmeta(projectConfig.srcArtDir, relativePath, meta);
523
+ const settings = defaultAssetSettings();
524
+ settings.pack = resolvePackName(settings.pack, configuredPacks);
525
+ const asset = {
526
+ id: relativePath,
527
+ fileName,
528
+ stringId: meta.stringId,
529
+ type,
530
+ status: 'pending',
531
+ settings,
532
+ sourceSize: sourceData.length,
533
+ processedSize: 0,
534
+ };
535
+ BlobStore.setSource(relativePath, sourceData);
536
+ assets.push(asset);
537
+ broadcast({ type: 'asset-added', asset });
538
+ processOneAsset(relativePath);
539
+ json(res, { ok: true, id: relativePath });
540
+ return;
541
+ }
542
+ // POST /api/create-material
543
+ if (pathname === '/api/create-material' && req.method === 'POST') {
544
+ if (!projectConfig) {
545
+ json(res, { error: 'No project open' }, 400);
546
+ return;
547
+ }
548
+ const body = JSON.parse(await readBody(req));
549
+ const targetFolder = body.targetFolder ?? '';
550
+ const count = assets.filter(a => a.type === AssetType.MaterialSchema).length;
551
+ const name = `Material ${count + 1}`;
552
+ const baseName = `${name.replace(/\s+/g, '_')}.stowmat`;
553
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
554
+ const settings = defaultAssetSettings();
555
+ settings.materialConfig = {
556
+ schemaId: '',
557
+ properties: [
558
+ { fieldName: 'Diffuse', fieldType: 0, previewFlag: 1, value: [1, 1, 1, 1], textureAssetId: null },
559
+ { fieldName: 'Tint', fieldType: 1, previewFlag: 2, value: [1, 1, 1, 1], textureAssetId: null },
560
+ ],
561
+ };
562
+ const asset = {
563
+ id: fileName,
564
+ fileName: name,
565
+ stringId: name,
566
+ type: AssetType.MaterialSchema,
567
+ status: 'ready',
568
+ settings,
569
+ sourceSize: 0,
570
+ processedSize: 0,
571
+ };
572
+ assets.push(asset);
573
+ const stowmat = materialConfigToStowmat(settings.materialConfig);
574
+ await writeStowmat(projectConfig.srcArtDir, fileName, stowmat);
575
+ const meta = assetSettingsToStowmeta(asset);
576
+ await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
577
+ broadcast({ type: 'asset-added', asset });
578
+ json(res, { ok: true, asset });
579
+ return;
580
+ }
581
+ // POST /api/create-folder
582
+ if (pathname === '/api/create-folder' && req.method === 'POST') {
583
+ if (!projectConfig) {
584
+ json(res, { error: 'No project open' }, 400);
585
+ return;
586
+ }
587
+ const body = JSON.parse(await readBody(req));
588
+ const parentFolder = body.parentFolder ?? '';
589
+ let folderName = 'New Folder';
590
+ let counter = 1;
591
+ const existingFolders = new Set(folders);
592
+ const parentPrefix = parentFolder ? `${parentFolder}/` : '';
593
+ while (existingFolders.has(`${parentPrefix}${folderName}`)) {
594
+ folderName = `New Folder ${counter}`;
595
+ counter++;
596
+ }
597
+ const folderPath = parentPrefix + folderName;
598
+ const fullPath = path.join(projectConfig.srcArtDir, folderPath);
599
+ await fs.mkdir(fullPath, { recursive: true });
600
+ folders.push(folderPath);
601
+ broadcast({ type: 'folder-added', path: folderPath });
602
+ json(res, { ok: true, path: folderPath });
603
+ return;
604
+ }
605
+ // PUT /api/project/config — update .felicityproject
606
+ if (pathname === '/api/project/config' && req.method === 'PUT') {
607
+ if (!projectConfig) {
608
+ json(res, { error: 'No project open' }, 400);
609
+ return;
610
+ }
611
+ const body = JSON.parse(await readBody(req));
612
+ if (body.packs !== undefined) {
613
+ projectConfig.config.packs = body.packs;
614
+ }
615
+ if (body.renamedPack) {
616
+ const { oldName, newName } = body.renamedPack;
617
+ for (const asset of assets) {
618
+ if ((asset.settings.pack ?? 'default') === oldName) {
619
+ asset.settings.pack = newName;
620
+ const meta = assetSettingsToStowmeta(asset);
621
+ writeStowmeta(projectConfig.srcArtDir, asset.id, meta);
622
+ }
623
+ }
624
+ }
625
+ const configPath = path.join(projectConfig.projectDir, '.felicityproject');
626
+ await fs.writeFile(configPath, JSON.stringify(projectConfig.config, null, 2));
627
+ broadcast({ type: 'config-updated', config: projectConfig.config });
628
+ json(res, { ok: true });
629
+ return;
630
+ }
631
+ // ── Editor endpoints: prefabs + packs ─────────────────────────────────────
632
+ // GET /api/prefabs — list all .stowprefab files
633
+ if (pathname === '/api/prefabs' && req.method === 'GET') {
634
+ if (!projectConfig) {
635
+ json(res, { error: 'No project open' }, 400);
636
+ return;
637
+ }
638
+ const prefabsDir = projectConfig.config.prefabsPath
639
+ ? path.resolve(projectConfig.projectDir, projectConfig.config.prefabsPath)
640
+ : projectConfig.projectDir;
641
+ const prefabs = await scanPrefabFiles(prefabsDir, '');
642
+ json(res, { prefabs });
643
+ return;
644
+ }
645
+ // GET /api/prefab/* — read a .stowprefab file
646
+ if (pathname.startsWith('/api/prefab/') && req.method === 'GET') {
647
+ if (!projectConfig) {
648
+ json(res, { error: 'No project open' }, 400);
649
+ return;
650
+ }
651
+ const relPath = decodeURIComponent(pathname.slice('/api/prefab/'.length));
652
+ const prefabsDir = projectConfig.config.prefabsPath
653
+ ? path.resolve(projectConfig.projectDir, projectConfig.config.prefabsPath)
654
+ : projectConfig.projectDir;
655
+ const fullPath = path.join(prefabsDir, relPath);
656
+ try {
657
+ const data = await fs.readFile(fullPath, 'utf-8');
658
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
659
+ res.end(data);
660
+ }
661
+ catch {
662
+ res.writeHead(404);
663
+ res.end('Not found');
664
+ }
665
+ return;
666
+ }
667
+ // PUT /api/prefab/* — write a .stowprefab file
668
+ if (pathname.startsWith('/api/prefab/') && req.method === 'PUT') {
669
+ if (!projectConfig) {
670
+ json(res, { error: 'No project open' }, 400);
671
+ return;
672
+ }
673
+ const relPath = decodeURIComponent(pathname.slice('/api/prefab/'.length));
674
+ const prefabsDir = projectConfig.config.prefabsPath
675
+ ? path.resolve(projectConfig.projectDir, projectConfig.config.prefabsPath)
676
+ : projectConfig.projectDir;
677
+ const fullPath = path.join(prefabsDir, relPath);
678
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
679
+ const body = await readBody(req);
680
+ await fs.writeFile(fullPath, body);
681
+ json(res, { ok: true });
682
+ return;
683
+ }
684
+ // DELETE /api/prefab/* — delete a .stowprefab file
685
+ if (pathname.startsWith('/api/prefab/') && req.method === 'DELETE') {
686
+ if (!projectConfig) {
687
+ json(res, { error: 'No project open' }, 400);
688
+ return;
689
+ }
690
+ const relPath = decodeURIComponent(pathname.slice('/api/prefab/'.length));
691
+ const prefabsDir = projectConfig.config.prefabsPath
692
+ ? path.resolve(projectConfig.projectDir, projectConfig.config.prefabsPath)
693
+ : projectConfig.projectDir;
694
+ const fullPath = path.join(prefabsDir, relPath);
695
+ try {
696
+ await fs.unlink(fullPath);
697
+ }
698
+ catch { /* ignore */ }
699
+ json(res, { ok: true });
700
+ return;
701
+ }
702
+ // GET /api/pack/* — serve a .stow pack file from cdn-assets
703
+ if (pathname.startsWith('/api/pack/') && req.method === 'GET') {
704
+ if (!projectConfig) {
705
+ json(res, { error: 'No project open' }, 400);
706
+ return;
707
+ }
708
+ const relPath = decodeURIComponent(pathname.slice('/api/pack/'.length));
709
+ const cdnDir = path.resolve(projectConfig.projectDir, projectConfig.config.cdnAssetsPath ?? 'public/cdn-assets');
710
+ const fullPath = path.join(cdnDir, relPath);
711
+ try {
712
+ const data = await fs.readFile(fullPath);
713
+ res.writeHead(200, {
714
+ 'Content-Type': 'application/octet-stream',
715
+ 'Content-Length': data.length,
716
+ 'Access-Control-Allow-Origin': '*',
717
+ });
718
+ res.end(data);
719
+ }
720
+ catch {
721
+ res.writeHead(404);
722
+ res.end('Not found');
723
+ }
724
+ return;
725
+ }
726
+ // POST /api/scan — re-scan and detect new assets
727
+ if (pathname === '/api/scan' && req.method === 'POST') {
728
+ if (!projectConfig) {
729
+ json(res, { error: 'No project open' }, 400);
730
+ return;
731
+ }
732
+ await openProject(projectConfig.projectDir);
733
+ broadcast({ type: 'project-reloaded' });
734
+ json(res, { ok: true, assetCount: assets.length });
735
+ return;
736
+ }
737
+ // Static file serving for GUI apps (packer, editor)
738
+ // Sort: longer prefixes first so /packer matches before /
739
+ const mimeTypes = {
740
+ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
741
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
742
+ '.svg': 'image/svg+xml', '.wasm': 'application/wasm', '.ico': 'image/x-icon',
743
+ };
744
+ const sortedApps = Object.entries(staticApps).sort((a, b) => b[0].length - a[0].length);
745
+ for (const [prefix, dir] of sortedApps) {
746
+ const isRoot = prefix === '/';
747
+ if (!isRoot && !pathname.startsWith(prefix))
748
+ continue;
749
+ if (isRoot) {
750
+ // Don't match root for paths that belong to other apps
751
+ const belongsToOther = sortedApps.some(([p]) => p !== '/' && pathname.startsWith(p));
752
+ if (belongsToOther)
753
+ continue;
754
+ }
755
+ const stripped = isRoot ? pathname : pathname.slice(prefix.length) || '/';
756
+ const filePath = stripped === '/' ? '/index.html' : stripped;
757
+ const fullPath = path.join(dir, filePath);
758
+ try {
759
+ const fstat = await fs.stat(fullPath);
760
+ if (fstat.isFile()) {
761
+ let data = await fs.readFile(fullPath);
762
+ const ext = path.extname(fullPath).toLowerCase();
763
+ // Rewrite asset paths in HTML when serving under a prefix
764
+ if (ext === '.html' && !isRoot) {
765
+ let html = data.toString('utf-8');
766
+ html = html.replace(/(src|href)="\/(?!\/)/g, `$1="${prefix}/`);
767
+ data = html;
768
+ }
769
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream' });
770
+ res.end(data);
771
+ return;
772
+ }
773
+ }
774
+ catch { /* fall through */ }
775
+ // SPA fallback
776
+ try {
777
+ let html = await fs.readFile(path.join(dir, 'index.html'), 'utf-8');
778
+ if (!isRoot) {
779
+ html = html.replace(/(src|href)="\/(?!\/)/g, `$1="${prefix}/`);
780
+ }
781
+ res.writeHead(200, { 'Content-Type': 'text/html' });
782
+ res.end(html);
783
+ return;
784
+ }
785
+ catch { /* no index.html */ }
786
+ }
787
+ // 404
788
+ res.writeHead(404);
789
+ res.end('Not found');
790
+ }
791
+ export async function startServer(opts = {}) {
792
+ const port = opts.port ?? 3210;
793
+ const apps = opts.staticApps ?? {};
794
+ const server = http.createServer(async (req, res) => {
795
+ try {
796
+ await handleRequest(req, res, apps);
797
+ }
798
+ catch (err) {
799
+ console.error('[server] Request error:', err);
800
+ if (!res.headersSent) {
801
+ json(res, { error: err.message }, 500);
802
+ }
803
+ }
804
+ });
805
+ const wss = new WebSocketServer({ server, path: '/api/ws' });
806
+ wss.on('connection', (ws) => {
807
+ wsClients.add(ws);
808
+ // Send current state on connect
809
+ ws.send(JSON.stringify({
810
+ type: 'init',
811
+ project: projectConfig ? {
812
+ projectName: projectConfig.projectName,
813
+ config: projectConfig.config,
814
+ } : null,
815
+ assets,
816
+ folders,
817
+ encodersReady,
818
+ }));
819
+ ws.on('close', () => wsClients.delete(ws));
820
+ });
821
+ // Initialize encoders in background
822
+ initEncoders(opts.wasmDir).then(ctx => {
823
+ processingCtx = ctx;
824
+ }).catch(err => {
825
+ console.error('[server] Encoder init failed:', err);
826
+ });
827
+ // Open project if specified
828
+ if (opts.projectDir) {
829
+ await openProject(opts.projectDir);
830
+ // Auto-process pending assets once encoders are ready
831
+ const waitForEncoders = async () => {
832
+ while (!processingCtx)
833
+ await new Promise(r => setTimeout(r, 100));
834
+ const pending = assets.filter(a => a.status === 'pending' && a.type !== AssetType.MaterialSchema);
835
+ if (pending.length > 0) {
836
+ console.log(`[server] Auto-processing ${pending.length} pending assets...`);
837
+ const queue = pending.map(a => a.id);
838
+ async function drain() {
839
+ while (queue.length > 0) {
840
+ const id = queue.shift();
841
+ await processOneAsset(id);
842
+ }
843
+ }
844
+ const workers = [];
845
+ for (let i = 0; i < Math.min(8, pending.length); i++)
846
+ workers.push(drain());
847
+ await Promise.all(workers);
848
+ broadcast({ type: 'processing-complete' });
849
+ }
850
+ };
851
+ waitForEncoders();
852
+ }
853
+ return new Promise((resolve) => {
854
+ server.listen(port, () => {
855
+ console.log(`[stowkit] API server listening on http://localhost:${port}`);
856
+ resolve(server);
857
+ });
858
+ });
859
+ }