@series-inc/stowkit-cli 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,16 +5,12 @@ import { cleanupProject } from './cleanup.js';
5
5
  import { defaultAssetSettings } from './app/state.js';
6
6
  import { BlobStore } from './app/blob-store.js';
7
7
  import { readProjectConfig, scanDirectory, readFile, getFileSnapshot, } from './node-fs.js';
8
- import { readStowmeta, writeStowmeta, stowmetaToAssetSettings, generateDefaultStowmeta, } from './app/stowmeta-io.js';
8
+ import { readStowmeta, writeStowmeta, stowmetaToAssetSettings, generateDefaultStowmeta, glbChildToAssetSettings, generateDefaultGlbChild, } from './app/stowmeta-io.js';
9
+ import { parseGlb, pbrToMaterialConfig } from './encoders/glb-loader.js';
9
10
  import { readStowmat, stowmatToMaterialConfig } from './app/stowmat-io.js';
10
11
  import { readCacheBlobs, writeCacheBlobs, buildCacheStamp, isCacheValid, } from './app/process-cache.js';
11
- import { processAsset, buildPack } from './pipeline.js';
12
- // ─── Encoder initialization ────────────────────────────────────────────────
13
- import { NodeBasisEncoder } from './encoders/basis-encoder.js';
14
- import { NodeDracoEncoder } from './encoders/draco-encoder.js';
15
- import { NodeAacEncoder, NodeAudioDecoder } from './encoders/aac-encoder.js';
16
- import { NodeFbxImporter } from './encoders/fbx-loader.js';
17
- import { SharpImageDecoder } from './encoders/image-decoder.js';
12
+ import { buildPack, processExtractedAnimations, validatePackDependencies } from './pipeline.js';
13
+ import { WorkerPool } from './workers/worker-pool.js';
18
14
  export async function scanProject(projectDir, opts) {
19
15
  const config = await readProjectConfig(projectDir);
20
16
  const scan = await scanDirectory(config.srcArtDir);
@@ -102,6 +98,58 @@ export async function fullBuild(projectDir, opts) {
102
98
  }
103
99
  assets.push(asset);
104
100
  assetsById.set(id, asset);
101
+ // Expand GlbContainer children
102
+ if (type === AssetType.GlbContainer && meta.type === 'glbContainer') {
103
+ const glbMeta = meta;
104
+ if (glbMeta.children && glbMeta.children.length > 0) {
105
+ asset.status = 'ready'; // Container itself is always "ready"
106
+ for (const child of glbMeta.children) {
107
+ const childId = `${id}/${child.name}`;
108
+ const baseName = child.name.replace(/\.[^.]+$/, '');
109
+ // Read settings from inline child entry
110
+ const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
111
+ const childAsset = {
112
+ id: childId,
113
+ fileName: child.name,
114
+ stringId: child.stringId || baseName,
115
+ type: cType,
116
+ status: 'pending',
117
+ settings: cSettings,
118
+ sourceSize: 0,
119
+ processedSize: 0,
120
+ parentId: id,
121
+ locked: true,
122
+ };
123
+ // Check cache
124
+ if (!force && child.cache) {
125
+ const cached = await readCacheBlobs(config.srcArtDir, childId);
126
+ if (cached) {
127
+ for (const [key, data] of cached) {
128
+ if (key === `${childId}:__metadata__`) {
129
+ try {
130
+ childAsset.metadata = JSON.parse(new TextDecoder().decode(data));
131
+ }
132
+ catch { /* skip */ }
133
+ }
134
+ else {
135
+ BlobStore.setProcessed(key, data);
136
+ }
137
+ }
138
+ childAsset.status = 'ready';
139
+ childAsset.processedSize = BlobStore.getProcessed(childId)?.length ?? 0;
140
+ if (verbose)
141
+ console.log(` [cached] ${childId}`);
142
+ }
143
+ }
144
+ assets.push(childAsset);
145
+ assetsById.set(childId, childAsset);
146
+ }
147
+ }
148
+ else {
149
+ // No children manifest yet — need to parse GLB to discover them
150
+ // This will be handled during processing
151
+ }
152
+ }
105
153
  }
106
154
  // Materials from .stowmat files
107
155
  for (const matFile of scan.matFiles) {
@@ -130,37 +178,209 @@ export async function fullBuild(projectDir, opts) {
130
178
  assets.push(asset);
131
179
  assetsById.set(id, asset);
132
180
  }
133
- // 3. Process stale assets
134
- const pending = assets.filter(a => a.status === 'pending');
135
- if (pending.length === 0) {
181
+ // 2b. Process GLB containers that need extraction (no children yet)
182
+ // Store extract results so mesh/animation children can be processed after encoder init
183
+ const glbExtracts = new Map();
184
+ const glbContainers = assets.filter(a => a.type === AssetType.GlbContainer && a.status === 'pending');
185
+ for (const container of glbContainers) {
186
+ try {
187
+ const sourceData = await readFile(config.srcArtDir, container.id);
188
+ if (!sourceData)
189
+ throw new Error(`Could not read source file: ${container.id}`);
190
+ // Build inline children with full settings
191
+ const containerMeta = (await readStowmeta(config.srcArtDir, container.id));
192
+ const preserveHierarchy = containerMeta.preserveHierarchy ?? false;
193
+ const extract = await parseGlb(sourceData, { preserveHierarchy });
194
+ glbExtracts.set(container.id, extract);
195
+ const existingChildren = new Map((containerMeta.children ?? []).map(c => [c.name, c]));
196
+ const childrenManifest = [];
197
+ for (const tex of extract.textures) {
198
+ childrenManifest.push(existingChildren.get(tex.name) ?? generateDefaultGlbChild(tex.name, 'texture'));
199
+ BlobStore.setSource(`${container.id}/${tex.name}`, tex.data);
200
+ }
201
+ for (const mesh of extract.meshes) {
202
+ const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
203
+ childrenManifest.push(existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName));
204
+ }
205
+ for (const mat of extract.materials) {
206
+ const matName = `${mat.name}.stowmat`;
207
+ const existing = existingChildren.get(matName);
208
+ if (existing) {
209
+ childrenManifest.push(existing);
210
+ }
211
+ else {
212
+ const child = generateDefaultGlbChild(matName, 'materialSchema');
213
+ const matConfig = pbrToMaterialConfig(mat.pbrConfig, container.id);
214
+ child.materialConfig = {
215
+ schemaId: matConfig.schemaId,
216
+ properties: matConfig.properties.map(p => ({
217
+ fieldName: p.fieldName,
218
+ fieldType: typeof p.fieldType === 'number'
219
+ ? (['texture', 'color', 'float', 'vec2', 'vec3', 'vec4', 'int'][p.fieldType] ?? 'color')
220
+ : p.fieldType,
221
+ previewFlag: typeof p.previewFlag === 'number'
222
+ ? (['none', 'mainTex', 'tint', 'alphaTest'][p.previewFlag] ?? 'none')
223
+ : p.previewFlag,
224
+ value: [...p.value],
225
+ textureAsset: p.textureAssetId,
226
+ })),
227
+ };
228
+ childrenManifest.push(child);
229
+ }
230
+ }
231
+ for (const anim of extract.animations) {
232
+ childrenManifest.push(existingChildren.get(anim.name) ?? generateDefaultGlbChild(anim.name, 'animationClip'));
233
+ }
234
+ // Update stowmeta with inline children
235
+ containerMeta.children = childrenManifest;
236
+ await writeStowmeta(config.srcArtDir, container.id, containerMeta);
237
+ // Create child assets from inline entries
238
+ for (const child of childrenManifest) {
239
+ const childId = `${container.id}/${child.name}`;
240
+ if (assetsById.has(childId))
241
+ continue;
242
+ const baseName = child.name.replace(/\.[^.]+$/, '');
243
+ const { type: cType, settings: cSettings } = glbChildToAssetSettings(child);
244
+ const childAsset = {
245
+ id: childId,
246
+ fileName: child.name,
247
+ stringId: child.stringId || baseName,
248
+ type: cType,
249
+ status: 'pending',
250
+ settings: cSettings,
251
+ sourceSize: 0,
252
+ processedSize: 0,
253
+ parentId: container.id,
254
+ locked: true,
255
+ };
256
+ // Set up material configs for extracted materials
257
+ if (cType === AssetType.MaterialSchema) {
258
+ const matName = child.name.replace(/\.stowmat$/, '');
259
+ const mat = extract.materials.find(m => m.name === matName);
260
+ if (mat) {
261
+ if (!childAsset.settings.materialConfig?.properties?.length) {
262
+ childAsset.settings.materialConfig = pbrToMaterialConfig(mat.pbrConfig, container.id);
263
+ }
264
+ childAsset.status = 'ready';
265
+ }
266
+ }
267
+ // Process animation children directly (no encoder needed)
268
+ if (cType === AssetType.AnimationClip) {
269
+ const anim = extract.animations.find(a => a.name === child.name);
270
+ if (anim) {
271
+ const result = processExtractedAnimations(childId, anim.clips, childAsset.stringId);
272
+ childAsset.status = 'ready';
273
+ childAsset.metadata = result.metadata;
274
+ childAsset.processedSize = result.processedSize;
275
+ }
276
+ }
277
+ assets.push(childAsset);
278
+ assetsById.set(childId, childAsset);
279
+ }
280
+ // Auto-assign materialOverrides on mesh children from GLB sub-mesh→material mapping
281
+ for (const mesh of extract.meshes) {
282
+ const meshChildId = `${container.id}/${mesh.name}`;
283
+ const meshAsset = assetsById.get(meshChildId);
284
+ if (!meshAsset || Object.keys(meshAsset.settings.materialOverrides).length > 0)
285
+ continue;
286
+ const overrides = {};
287
+ for (let si = 0; si < mesh.imported.subMeshes.length; si++) {
288
+ const matIdx = mesh.imported.subMeshes[si].materialIndex;
289
+ const matName = mesh.imported.materials[matIdx]?.name;
290
+ if (matName) {
291
+ const matChildId = `${container.id}/${matName}.stowmat`;
292
+ if (assetsById.has(matChildId)) {
293
+ overrides[si] = matChildId;
294
+ }
295
+ }
296
+ }
297
+ if (Object.keys(overrides).length > 0) {
298
+ meshAsset.settings.materialOverrides = overrides;
299
+ const inlineChild = childrenManifest.find(c => c.name === mesh.name);
300
+ if (inlineChild) {
301
+ inlineChild.materialOverrides = {};
302
+ for (const [k, v] of Object.entries(overrides)) {
303
+ inlineChild.materialOverrides[k] = v;
304
+ }
305
+ }
306
+ }
307
+ }
308
+ // Persist updated stowmeta with auto-assigned materialOverrides
309
+ containerMeta.children = childrenManifest;
310
+ await writeStowmeta(config.srcArtDir, container.id, containerMeta);
311
+ container.status = 'ready';
312
+ if (verbose)
313
+ console.log(` [glb] ${container.id}: ${childrenManifest.length} children`);
314
+ }
315
+ catch (err) {
316
+ container.status = 'error';
317
+ container.error = err instanceof Error ? err.message : String(err);
318
+ console.error(` [glb] ${container.id} FAILED: ${container.error}`);
319
+ }
320
+ }
321
+ // 2c. Process GLB mesh children + stale assets via worker pool
322
+ const glbMeshChildren = assets.filter(a => a.status === 'pending' && a.parentId && (a.type === AssetType.StaticMesh || a.type === AssetType.SkinnedMesh));
323
+ const pending = assets.filter(a => a.status === 'pending' && !a.parentId);
324
+ const totalWork = glbMeshChildren.length + pending.length;
325
+ if (totalWork === 0) {
136
326
  if (verbose)
137
327
  console.log('All assets cached, nothing to process.');
138
328
  }
139
329
  else {
140
- console.log(`Processing ${pending.length} asset(s)...`);
141
- // Initialize encoders
142
- const ctx = await initializeEncoders(opts?.wasmDir);
143
- // Process with concurrency limit
330
+ console.log(`Processing ${totalWork} asset(s)...`);
331
+ const pool = new WorkerPool({ wasmDir: opts?.wasmDir });
144
332
  let processed = 0;
333
+ // Process GLB mesh children
334
+ for (const child of glbMeshChildren) {
335
+ const extract = glbExtracts.get(child.parentId);
336
+ if (!extract)
337
+ continue;
338
+ const mesh = extract.meshes.find(m => `${child.parentId}/${m.name}` === child.id);
339
+ if (!mesh)
340
+ continue;
341
+ try {
342
+ const { result, blobs } = await pool.processExtractedMesh({ childId: child.id, imported: mesh.imported, hasSkeleton: mesh.hasSkeleton, stringId: child.stringId, settings: child.settings });
343
+ for (const [key, data] of blobs)
344
+ BlobStore.setProcessed(key, data);
345
+ child.status = 'ready';
346
+ child.metadata = result.metadata;
347
+ child.processedSize = result.processedSize;
348
+ processed++;
349
+ if (verbose)
350
+ console.log(` [${processed}/${totalWork}] ${child.id} (glb-mesh)`);
351
+ }
352
+ catch (err) {
353
+ child.status = 'error';
354
+ child.error = err instanceof Error ? err.message : String(err);
355
+ processed++;
356
+ }
357
+ }
358
+ // Process remaining pending assets with concurrency limit
145
359
  const queue = [...pending];
146
360
  async function processNext() {
147
361
  while (queue.length > 0) {
148
362
  const asset = queue.shift();
149
363
  const id = asset.id;
150
364
  try {
151
- // Load source
152
- const sourceData = await readFile(config.srcArtDir, id);
153
- if (!sourceData)
154
- throw new Error(`Could not read source file: ${id}`);
365
+ // Load source (GLB texture children have source in BlobStore already)
366
+ let sourceData = BlobStore.getSource(id);
367
+ if (!sourceData) {
368
+ const data = await readFile(config.srcArtDir, id);
369
+ if (!data)
370
+ throw new Error(`Could not read source file: ${id}`);
371
+ sourceData = data;
372
+ }
155
373
  BlobStore.setSource(id, sourceData);
156
374
  const t0 = performance.now();
157
- const result = await processAsset(id, sourceData, asset.type, asset.stringId, asset.settings, ctx);
375
+ const { result, blobs } = await pool.processAsset({ id, sourceData, type: asset.type, stringId: asset.stringId, settings: asset.settings });
376
+ for (const [key, data] of blobs)
377
+ BlobStore.setProcessed(key, data);
158
378
  const elapsed = (performance.now() - t0).toFixed(0);
159
379
  asset.status = 'ready';
160
380
  asset.metadata = result.metadata;
161
381
  asset.processedSize = result.processedSize;
162
382
  processed++;
163
- console.log(` [${processed}/${pending.length}] ${id} (${elapsed}ms)`);
383
+ console.log(` [${processed}/${totalWork}] ${id} (${elapsed}ms)`);
164
384
  // Write cache
165
385
  const cacheEntries = new Map();
166
386
  const processedBlob = BlobStore.getProcessed(id);
@@ -202,7 +422,7 @@ export async function fullBuild(projectDir, opts) {
202
422
  asset.status = 'error';
203
423
  asset.error = err instanceof Error ? err.message : String(err);
204
424
  processed++;
205
- console.error(` [${processed}/${pending.length}] ${id} FAILED: ${asset.error}`);
425
+ console.error(` [${processed}/${totalWork}] ${id} FAILED: ${asset.error}`);
206
426
  }
207
427
  }
208
428
  }
@@ -211,6 +431,7 @@ export async function fullBuild(projectDir, opts) {
211
431
  workers.push(processNext());
212
432
  }
213
433
  await Promise.all(workers);
434
+ await pool.terminate();
214
435
  }
215
436
  // 4. Clean orphaned caches/metas
216
437
  await cleanupProject(config.projectDir, { verbose });
@@ -220,12 +441,22 @@ export async function fullBuild(projectDir, opts) {
220
441
  await fs.mkdir(cdnDir, { recursive: true });
221
442
  for (const pack of packs) {
222
443
  const packAssets = assets.filter(a => a.status === 'ready' &&
444
+ a.type !== AssetType.GlbContainer &&
445
+ !a.settings.excluded &&
223
446
  (a.settings.pack === pack.name || (!a.settings.pack && pack.name === 'default')));
224
447
  if (packAssets.length === 0) {
225
448
  if (verbose)
226
449
  console.log(`Pack "${pack.name}": no assets, skipping`);
227
450
  continue;
228
451
  }
452
+ // Validate dependencies before building
453
+ const depErrors = validatePackDependencies(packAssets, assetsById);
454
+ if (depErrors.length > 0) {
455
+ console.error(`Pack "${pack.name}" has dependency errors:`);
456
+ for (const err of depErrors)
457
+ console.error(` ${err.assetId}: ${err.message}`);
458
+ continue;
459
+ }
229
460
  console.log(`Building pack "${pack.name}" (${packAssets.length} assets)...`);
230
461
  const packData = buildPack(packAssets, assetsById);
231
462
  const outPath = path.join(cdnDir, `${pack.name}.stow`);
@@ -271,31 +502,3 @@ export async function showStatus(projectDir) {
271
502
  }
272
503
  console.log(`Cached: ${cached}, Needs processing: ${stale}`);
273
504
  }
274
- // ─── Initialize Encoders ────────────────────────────────────────────────────
275
- async function initializeEncoders(wasmDir) {
276
- const textureEncoder = new NodeBasisEncoder(wasmDir);
277
- const meshEncoder = new NodeDracoEncoder();
278
- const aacEncoder = new NodeAacEncoder();
279
- const audioDecoder = new NodeAudioDecoder();
280
- const meshImporter = new NodeFbxImporter();
281
- const imageDecoder = new SharpImageDecoder();
282
- await Promise.all([
283
- textureEncoder.initialize(),
284
- meshEncoder.initialize(),
285
- aacEncoder.initialize(),
286
- audioDecoder.initialize(),
287
- ]);
288
- return {
289
- textureEncoder,
290
- meshEncoder,
291
- meshImporter,
292
- imageDecoder,
293
- audioDecoder,
294
- aacEncoder,
295
- onProgress: (id, msg) => {
296
- // Simple console progress
297
- const name = id.split('/').pop() ?? id;
298
- process.stdout.write(` [${name}] ${msg}\r`);
299
- },
300
- };
301
- }
@@ -1,7 +1,8 @@
1
1
  import { AssetType } from './core/types.js';
2
2
  import type { AnimationClipMetadata } from './core/types.js';
3
3
  import type { AssetSettings, ProjectAsset } from './app/state.js';
4
- import type { ITextureEncoder, IMeshEncoder, IMeshImporter, IImageDecoder, IAudioDecoder, IAacEncoder, ImportedMesh } from './encoders/interfaces.js';
4
+ import type { ITextureEncoder, IMeshEncoder, IMeshImporter, IImageDecoder, IAudioDecoder, IAacEncoder, ImportedMesh, ImportedAnimation } from './encoders/interfaces.js';
5
+ import type { GlbExtractResult } from './encoders/glb-loader.js';
5
6
  export interface ProcessingContext {
6
7
  textureEncoder: ITextureEncoder;
7
8
  meshEncoder: IMeshEncoder;
@@ -11,11 +12,29 @@ export interface ProcessingContext {
11
12
  aacEncoder: IAacEncoder;
12
13
  onProgress?: (id: string, msg: string) => void;
13
14
  }
15
+ export interface GlbChildDescriptor {
16
+ name: string;
17
+ childType: string;
18
+ assetType: AssetType;
19
+ }
14
20
  export interface ProcessResult {
15
21
  metadata?: ProjectAsset['metadata'];
16
22
  processedSize: number;
23
+ glbChildren?: GlbChildDescriptor[];
24
+ glbExtract?: GlbExtractResult;
17
25
  }
18
26
  export declare function processAsset(id: string, sourceData: Uint8Array, type: AssetType, stringId: string, settings: AssetSettings, ctx: ProcessingContext): Promise<ProcessResult>;
27
+ export declare function processExtractedMesh(childId: string, imported: ImportedMesh, hasSkeleton: boolean, stringId: string, settings: AssetSettings, ctx: ProcessingContext): Promise<ProcessResult>;
28
+ export declare function processExtractedAnimations(childId: string, clips: ImportedAnimation[], stringId: string): ProcessResult;
29
+ export interface DependencyError {
30
+ assetId: string;
31
+ message: string;
32
+ }
33
+ /**
34
+ * Validate dependencies for a set of pack assets before building.
35
+ * Returns errors for missing/excluded/cross-pack texture and mesh references.
36
+ */
37
+ export declare function validatePackDependencies(packAssets: ProjectAsset[], allAssetsById: Map<string, ProjectAsset>): DependencyError[];
19
38
  export declare function buildPack(assets: ProjectAsset[], assetsById: Map<string, ProjectAsset>): Uint8Array;
20
39
  export declare function buildAnimationDataBlobsV2(imported: ImportedMesh): {
21
40
  data: Uint8Array;
package/dist/pipeline.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AssetType, TextureChannelFormat, TextureResize, AudioSampleRate } from './core/types.js';
2
+ import { parseGlb } from './encoders/glb-loader.js';
2
3
  import { dracoPresetToSettings } from './encoders/draco-encoder.js';
3
4
  import { buildSkinnedMeshData } from './encoders/skinned-mesh-builder.js';
4
5
  import { serializeTextureMetadata, serializeAudioMetadata, serializeMeshMetadata, serializeAnimationClipMetadata, serializeSkinnedMeshMetadata, serializeMaterialSchemaMetadata, deserializeSkinnedMeshMetadata, deserializeAnimationClipMetadata, } from './format/metadata.js';
@@ -59,7 +60,7 @@ export async function processAsset(id, sourceData, type, stringId, settings, ctx
59
60
  }
60
61
  if (type === AssetType.StaticMesh) {
61
62
  log('importing FBX...');
62
- const imported = await ctx.meshImporter.import(sourceData, id);
63
+ const imported = await ctx.meshImporter.import(sourceData, id, { preserveHierarchy: settings.preserveHierarchy });
63
64
  const verts = imported.subMeshes.reduce((s, m) => s + m.positions.length / 3, 0);
64
65
  log(`imported (${verts} verts, ${imported.subMeshes.length} submeshes)`);
65
66
  const meshSettings = dracoPresetToSettings(settings.dracoQuality);
@@ -141,9 +142,140 @@ export async function processAsset(id, sourceData, type, stringId, settings, ctx
141
142
  log(`AAC encoded (${(aacData.length / 1024).toFixed(0)} KB)`);
142
143
  return { metadata, processedSize: aacData.length };
143
144
  }
145
+ if (type === AssetType.GlbContainer) {
146
+ log('parsing GLB...');
147
+ const extract = await parseGlb(sourceData, { preserveHierarchy: settings.preserveHierarchy });
148
+ const children = [];
149
+ for (const tex of extract.textures) {
150
+ children.push({ name: tex.name, childType: 'texture', assetType: AssetType.Texture2D });
151
+ }
152
+ for (const mesh of extract.meshes) {
153
+ const meshType = mesh.hasSkeleton ? AssetType.SkinnedMesh : AssetType.StaticMesh;
154
+ const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
155
+ children.push({ name: mesh.name, childType: typeName, assetType: meshType });
156
+ }
157
+ for (const mat of extract.materials) {
158
+ children.push({ name: `${mat.name}.stowmat`, childType: 'materialSchema', assetType: AssetType.MaterialSchema });
159
+ }
160
+ for (const anim of extract.animations) {
161
+ children.push({ name: anim.name, childType: 'animationClip', assetType: AssetType.AnimationClip });
162
+ }
163
+ log(`extracted ${extract.textures.length} textures, ${extract.meshes.length} meshes, ${extract.materials.length} materials, ${extract.animations.length} animations`);
164
+ return { processedSize: 0, glbChildren: children, glbExtract: extract };
165
+ }
144
166
  return { processedSize: 0 };
145
167
  }
146
- // ─── Build Pack ──────────────────────────────────────────────────────────────
168
+ // ─── Process pre-extracted GLB child data ────────────────────────────────────
169
+ export async function processExtractedMesh(childId, imported, hasSkeleton, stringId, settings, ctx) {
170
+ if (hasSkeleton) {
171
+ const skinnedResult = buildSkinnedMeshData(imported, 0.01);
172
+ skinnedResult.metadata.stringId = stringId;
173
+ BlobStore.setProcessed(childId, skinnedResult.data);
174
+ BlobStore.setProcessed(`${childId}:skinnedMeta`, serializeSkinnedMeshMetadata(skinnedResult.metadata));
175
+ const animCount = imported.animations?.length ?? 0;
176
+ if (animCount > 0 && imported.animations) {
177
+ const animBlobs = buildAnimationDataBlobsV2(imported);
178
+ for (let ci = 0; ci < animBlobs.length; ci++) {
179
+ const ab = animBlobs[ci];
180
+ ab.metadata.stringId = animBlobs.length === 1 ? `${stringId}_anim` : `${stringId}_anim_${ci}`;
181
+ ab.metadata.targetMeshId = stringId;
182
+ BlobStore.setProcessed(`${childId}:anim:${ci}`, ab.data);
183
+ BlobStore.setProcessed(`${childId}:animMeta:${ci}`, serializeAnimationClipMetadata(ab.metadata));
184
+ }
185
+ BlobStore.setProcessed(`${childId}:animCount`, new Uint8Array([animBlobs.length]));
186
+ }
187
+ return { metadata: skinnedResult.metadata, processedSize: skinnedResult.data.length };
188
+ }
189
+ // Static mesh — encode with Draco
190
+ const meshSettings = dracoPresetToSettings(settings.dracoQuality);
191
+ const result = await ctx.meshEncoder.encode(imported, meshSettings);
192
+ result.metadata.stringId = stringId;
193
+ BlobStore.setProcessed(childId, result.data);
194
+ return { metadata: result.metadata, processedSize: result.data.length };
195
+ }
196
+ export function processExtractedAnimations(childId, clips, stringId) {
197
+ // Build a minimal ImportedMesh with just the animations
198
+ const dummyImported = {
199
+ subMeshes: [],
200
+ materials: [],
201
+ nodes: [],
202
+ hasSkeleton: false,
203
+ bones: [],
204
+ animations: clips,
205
+ };
206
+ const animBlobs = buildAnimationDataBlobsV2(dummyImported);
207
+ if (animBlobs.length === 0)
208
+ return { processedSize: 0 };
209
+ const primary = animBlobs[0];
210
+ primary.metadata.stringId = stringId;
211
+ BlobStore.setProcessed(childId, primary.data);
212
+ BlobStore.setProcessed(`${childId}:animMeta`, serializeAnimationClipMetadata(primary.metadata));
213
+ for (let ci = 1; ci < animBlobs.length; ci++) {
214
+ const ab = animBlobs[ci];
215
+ ab.metadata.stringId = `${stringId}_${ci}`;
216
+ BlobStore.setProcessed(`${childId}:anim:${ci}`, ab.data);
217
+ BlobStore.setProcessed(`${childId}:animMeta:${ci}`, serializeAnimationClipMetadata(ab.metadata));
218
+ }
219
+ BlobStore.setProcessed(`${childId}:animCount`, new Uint8Array([animBlobs.length]));
220
+ const totalBytes = animBlobs.reduce((s, a) => s + a.data.length, 0);
221
+ return { metadata: primary.metadata, processedSize: totalBytes };
222
+ }
223
+ /**
224
+ * Validate dependencies for a set of pack assets before building.
225
+ * Returns errors for missing/excluded/cross-pack texture and mesh references.
226
+ */
227
+ export function validatePackDependencies(packAssets, allAssetsById) {
228
+ const errors = [];
229
+ const packIds = new Set(packAssets.filter(a => !a.settings.excluded).map(a => a.id));
230
+ for (const asset of packAssets) {
231
+ if (asset.settings.excluded)
232
+ continue;
233
+ if (asset.type === AssetType.GlbContainer)
234
+ continue;
235
+ // MaterialSchema: check texture references
236
+ if (asset.type === AssetType.MaterialSchema) {
237
+ for (const prop of asset.settings.materialConfig.properties) {
238
+ if (!prop.textureAssetId)
239
+ continue;
240
+ const tex = allAssetsById.get(prop.textureAssetId);
241
+ if (!tex) {
242
+ errors.push({ assetId: asset.id, message: `Material "${asset.stringId}" references missing texture "${prop.textureAssetId}"` });
243
+ }
244
+ else if (tex.settings.excluded) {
245
+ errors.push({ assetId: asset.id, message: `Material "${asset.stringId}" references excluded texture "${tex.stringId}"` });
246
+ }
247
+ else if (!packIds.has(tex.id)) {
248
+ errors.push({ assetId: asset.id, message: `Material "${asset.stringId}" references texture "${tex.stringId}" which is not in the same pack` });
249
+ }
250
+ }
251
+ }
252
+ // Mesh: check material override references
253
+ if (asset.type === AssetType.StaticMesh || asset.type === AssetType.SkinnedMesh) {
254
+ for (const [idx, matId] of Object.entries(asset.settings.materialOverrides)) {
255
+ if (!matId)
256
+ continue;
257
+ const mat = allAssetsById.get(matId);
258
+ if (!mat) {
259
+ errors.push({ assetId: asset.id, message: `Mesh "${asset.stringId}" sub-mesh ${idx} references missing material "${matId}"` });
260
+ }
261
+ else if (mat.settings.excluded) {
262
+ errors.push({ assetId: asset.id, message: `Mesh "${asset.stringId}" sub-mesh ${idx} references excluded material "${mat.stringId}"` });
263
+ }
264
+ }
265
+ }
266
+ // AnimationClip: check target mesh reference
267
+ if (asset.type === AssetType.AnimationClip && asset.settings.targetMeshId) {
268
+ const target = allAssetsById.get(asset.settings.targetMeshId);
269
+ if (!target) {
270
+ errors.push({ assetId: asset.id, message: `Animation "${asset.stringId}" references missing target mesh "${asset.settings.targetMeshId}"` });
271
+ }
272
+ else if (target.settings.excluded) {
273
+ errors.push({ assetId: asset.id, message: `Animation "${asset.stringId}" references excluded target mesh "${target.stringId}"` });
274
+ }
275
+ }
276
+ }
277
+ return errors;
278
+ }
147
279
  export function buildPack(assets, assetsById) {
148
280
  const packer = new StowPacker();
149
281
  const materialsByStringId = new Map(assets
@@ -152,6 +284,10 @@ export function buildPack(assets, assetsById) {
152
284
  for (const asset of assets) {
153
285
  if (asset.status !== 'ready')
154
286
  continue;
287
+ if (asset.type === AssetType.GlbContainer)
288
+ continue;
289
+ if (asset.settings.excluded)
290
+ continue;
155
291
  if (asset.type === AssetType.MaterialSchema) {
156
292
  const schemaMeta = buildMaterialSchemaMetadata(asset, assetsById);
157
293
  const schemaBytes = serializeMaterialSchemaMetadata(schemaMeta);