@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.
- package/dist/app/blob-store.d.ts +9 -0
- package/dist/app/blob-store.js +42 -0
- package/dist/app/disk-project.d.ts +84 -0
- package/dist/app/disk-project.js +70 -0
- package/dist/app/process-cache.d.ts +10 -0
- package/dist/app/process-cache.js +126 -0
- package/dist/app/state.d.ts +38 -0
- package/dist/app/state.js +16 -0
- package/dist/app/stowmat-io.d.ts +6 -0
- package/dist/app/stowmat-io.js +48 -0
- package/dist/app/stowmeta-io.d.ts +14 -0
- package/dist/app/stowmeta-io.js +207 -0
- package/dist/cleanup.d.ts +3 -0
- package/dist/cleanup.js +72 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +148 -0
- package/dist/core/binary.d.ts +41 -0
- package/dist/core/binary.js +118 -0
- package/dist/core/constants.d.ts +64 -0
- package/dist/core/constants.js +65 -0
- package/dist/core/path.d.ts +3 -0
- package/dist/core/path.js +27 -0
- package/dist/core/types.d.ts +204 -0
- package/dist/core/types.js +76 -0
- package/dist/encoders/aac-encoder.d.ts +12 -0
- package/dist/encoders/aac-encoder.js +179 -0
- package/dist/encoders/basis-encoder.d.ts +15 -0
- package/dist/encoders/basis-encoder.js +116 -0
- package/dist/encoders/draco-encoder.d.ts +11 -0
- package/dist/encoders/draco-encoder.js +155 -0
- package/dist/encoders/fbx-loader.d.ts +4 -0
- package/dist/encoders/fbx-loader.js +540 -0
- package/dist/encoders/image-decoder.d.ts +13 -0
- package/dist/encoders/image-decoder.js +33 -0
- package/dist/encoders/interfaces.d.ts +105 -0
- package/dist/encoders/interfaces.js +1 -0
- package/dist/encoders/skinned-mesh-builder.d.ts +7 -0
- package/dist/encoders/skinned-mesh-builder.js +135 -0
- package/dist/format/metadata.d.ts +18 -0
- package/dist/format/metadata.js +381 -0
- package/dist/format/packer.d.ts +8 -0
- package/dist/format/packer.js +87 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +35 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +73 -0
- package/dist/node-fs.d.ts +22 -0
- package/dist/node-fs.js +148 -0
- package/dist/orchestrator.d.ts +20 -0
- package/dist/orchestrator.js +301 -0
- package/dist/pipeline.d.ts +23 -0
- package/dist/pipeline.js +354 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +859 -0
- package/package.json +35 -0
- 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
|
+
}
|