@series-inc/stowkit-cli 0.6.16 → 0.6.18

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.
@@ -0,0 +1,399 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { readProjectConfig, scanDirectory } from './node-fs.js';
4
+ import { readAssetsPackage, initAssetsPackage, createEmptyRegistry, } from './assets-package.js';
5
+ import { createGCSClient } from './gcs.js';
6
+ import { readStowmeta } from './app/stowmeta-io.js';
7
+ // ─── Build full dependency graph from srcArtDir ──────────────────────────────
8
+ async function scanAssets(srcArtDir, scan, verbose) {
9
+ const assets = [];
10
+ // Index source files by relative path for size lookup
11
+ const sourceByPath = new Map();
12
+ for (const f of scan.sourceFiles)
13
+ sourceByPath.set(f.relativePath, f);
14
+ // Pass 1: read all stowmeta files, collect assets with raw file-path deps
15
+ for (const metaFile of scan.metaFiles) {
16
+ const assetPath = metaFile.relativePath.replace(/\.stowmeta$/, '');
17
+ const meta = await readStowmeta(srcArtDir, assetPath);
18
+ if (!meta)
19
+ continue;
20
+ if (meta.type === 'glbContainer') {
21
+ const container = meta;
22
+ for (const child of container.children) {
23
+ if (child.excluded)
24
+ continue;
25
+ const childFiles = [assetPath, metaFile.relativePath];
26
+ const rawDeps = [];
27
+ if (child.childType === 'staticMesh' || child.childType === 'skinnedMesh') {
28
+ if (child.materialOverrides) {
29
+ for (const ref of Object.values(child.materialOverrides)) {
30
+ if (ref)
31
+ rawDeps.push(ref);
32
+ }
33
+ }
34
+ }
35
+ if (child.childType === 'materialSchema' && child.materialConfig) {
36
+ for (const prop of child.materialConfig.properties) {
37
+ if (prop.textureAsset)
38
+ rawDeps.push(prop.textureAsset);
39
+ }
40
+ }
41
+ if (child.childType === 'animationClip' && child.targetMeshId) {
42
+ rawDeps.push(child.targetMeshId);
43
+ }
44
+ assets.push({
45
+ stringId: child.stringId,
46
+ type: child.childType,
47
+ sourceFile: assetPath,
48
+ files: childFiles,
49
+ size: sourceByPath.get(assetPath)?.size ?? 0,
50
+ tags: child.tags ?? [],
51
+ dependencies: [], // resolved in pass 2
52
+ rawDeps,
53
+ });
54
+ }
55
+ continue;
56
+ }
57
+ if (meta.excluded)
58
+ continue;
59
+ const files = [assetPath, metaFile.relativePath];
60
+ const rawDeps = [];
61
+ // Mesh → material refs (file paths like "Materials/M_Sea_Floor.stowmat")
62
+ if (meta.type === 'staticMesh' || meta.type === 'skinnedMesh') {
63
+ for (const ref of Object.values(meta.materialOverrides)) {
64
+ if (ref)
65
+ rawDeps.push(ref);
66
+ }
67
+ }
68
+ // Animation → target mesh (stringId or file path)
69
+ if (meta.type === 'animationClip' && meta.targetMeshId) {
70
+ rawDeps.push(meta.targetMeshId);
71
+ }
72
+ // Material schema (.stowmat is the source file itself) → texture refs
73
+ // The source file IS the .stowmat, and it's already a source file in the scan.
74
+ // We need to read it to find texture dependencies.
75
+ if (meta.type === 'materialSchema' && assetPath.endsWith('.stowmat')) {
76
+ try {
77
+ const matText = await fs.readFile(path.join(srcArtDir, assetPath), 'utf-8');
78
+ const mat = JSON.parse(matText);
79
+ if (mat.properties) {
80
+ for (const prop of mat.properties) {
81
+ if (prop.textureAsset)
82
+ rawDeps.push(prop.textureAsset);
83
+ }
84
+ }
85
+ }
86
+ catch { /* not a valid stowmat */ }
87
+ }
88
+ assets.push({
89
+ stringId: meta.stringId,
90
+ type: meta.type,
91
+ sourceFile: assetPath,
92
+ files,
93
+ size: sourceByPath.get(assetPath)?.size ?? 0,
94
+ tags: meta.tags ?? [],
95
+ dependencies: [], // resolved in pass 2
96
+ rawDeps,
97
+ ...(meta.type === 'texture' && meta.filtering ? { filtering: meta.filtering } : {}),
98
+ });
99
+ }
100
+ // Pass 2: resolve raw file-path deps → stringIds
101
+ // Build lookup: source file path → stringId
102
+ const pathToStringId = new Map();
103
+ for (const asset of assets) {
104
+ pathToStringId.set(asset.sourceFile, asset.stringId);
105
+ }
106
+ for (const asset of assets) {
107
+ for (const ref of asset.rawDeps) {
108
+ // Try as file path first
109
+ const resolved = pathToStringId.get(ref);
110
+ if (resolved) {
111
+ asset.dependencies.push(resolved);
112
+ continue;
113
+ }
114
+ // Maybe it's already a stringId
115
+ const byStringId = assets.find(a => a.stringId === ref);
116
+ if (byStringId) {
117
+ asset.dependencies.push(ref);
118
+ continue;
119
+ }
120
+ // Unresolved — keep the raw ref so warning still fires
121
+ asset.dependencies.push(ref);
122
+ }
123
+ if (asset.dependencies.length > 0) {
124
+ verbose(`${asset.stringId} [${asset.type}] → ${asset.dependencies.join(', ')}`);
125
+ }
126
+ }
127
+ return assets;
128
+ }
129
+ // ─── Main Publish Function ───────────────────────────────────────────────────
130
+ export async function publishPackage(projectDir, opts = {}) {
131
+ const log = (msg) => console.log(msg);
132
+ const vlog = (msg) => { if (opts.verbose)
133
+ console.log(` ${msg}`); };
134
+ // Step 1: Read project config
135
+ const project = await readProjectConfig(projectDir);
136
+ log(`Project: ${project.projectName}`);
137
+ // Step 2: Read or auto-create assets-package.json
138
+ let pkg = await readAssetsPackage(projectDir);
139
+ if (!pkg) {
140
+ log('No assets-package.json found — creating default...');
141
+ pkg = await initAssetsPackage(projectDir, { name: project.projectName });
142
+ log(`Created assets-package.json (name: ${pkg.name}, version: ${pkg.version})`);
143
+ }
144
+ // Step 3: Resolve bucket
145
+ const bucketUri = opts.bucket
146
+ ?? pkg.bucket
147
+ ?? process.env.STOWKIT_BUCKET
148
+ ?? 'gs://venus-shared-assets-test';
149
+ log(`Bucket: ${bucketUri}`);
150
+ // Step 4: Scan srcArtDir
151
+ const scan = await scanDirectory(project.srcArtDir);
152
+ if (scan.sourceFiles.length === 0) {
153
+ throw new Error(`No source assets found in ${project.srcArtDir}. Nothing to publish.`);
154
+ }
155
+ // Step 5: Build asset list with full dependency graph
156
+ const scannedAssets = await scanAssets(project.srcArtDir, scan, vlog);
157
+ log(`Assets: ${scannedAssets.length}`);
158
+ // Validate: reject duplicate stringIds
159
+ const idCounts = new Map();
160
+ for (const asset of scannedAssets) {
161
+ const files = idCounts.get(asset.stringId) ?? [];
162
+ files.push(asset.sourceFile);
163
+ idCounts.set(asset.stringId, files);
164
+ }
165
+ const duplicates = [...idCounts.entries()].filter(([, files]) => files.length > 1);
166
+ if (duplicates.length > 0) {
167
+ const lines = duplicates.map(([id, files]) => ` "${id}" used by:\n${files.map(f => ` - ${f}`).join('\n')}`);
168
+ throw new Error(`Cannot publish: ${duplicates.length} duplicate stringId(s) found. Each asset must have a unique stringId.\n${lines.join('\n')}`);
169
+ }
170
+ // Validate: warn about unresolved dependencies
171
+ const knownIds = new Set(scannedAssets.map(a => a.stringId));
172
+ for (const asset of scannedAssets) {
173
+ for (const dep of asset.dependencies) {
174
+ if (!knownIds.has(dep)) {
175
+ console.warn(` Warning: ${asset.stringId} depends on "${dep}" which was not found`);
176
+ }
177
+ }
178
+ }
179
+ // Collect all unique files to upload
180
+ const allFiles = new Set();
181
+ for (const asset of scannedAssets) {
182
+ for (const f of asset.files)
183
+ allFiles.add(f);
184
+ }
185
+ for (const f of scan.sourceFiles)
186
+ allFiles.add(f.relativePath);
187
+ for (const f of scan.metaFiles)
188
+ allFiles.add(f.relativePath);
189
+ for (const f of scan.matFiles)
190
+ allFiles.add(f.relativePath);
191
+ const filesToUpload = [...allFiles].sort();
192
+ let totalSize = 0;
193
+ for (const rel of filesToUpload) {
194
+ const stat = await fs.stat(path.join(project.srcArtDir, rel)).catch(() => null);
195
+ totalSize += stat?.size ?? 0;
196
+ }
197
+ log(`Files to upload: ${filesToUpload.length} (${formatBytes(totalSize)})`);
198
+ log(` ${scan.sourceFiles.length} source, ${scan.metaFiles.length} stowmeta, ${scan.matFiles.length} stowmat`);
199
+ // Step 6: Build registry assets
200
+ const thumbMap = opts.thumbnails ?? {};
201
+ const registryAssets = scannedAssets.map(a => {
202
+ const thumb = thumbMap[a.stringId];
203
+ return {
204
+ stringId: a.stringId,
205
+ type: a.type,
206
+ file: a.sourceFile,
207
+ files: a.files,
208
+ size: a.size,
209
+ tags: a.tags,
210
+ dependencies: a.dependencies,
211
+ ...(thumb ? { thumbnail: true, thumbnailFormat: thumb.format } : {}),
212
+ ...(a.filtering && a.filtering !== 'linear' ? { filtering: a.filtering } : {}),
213
+ };
214
+ });
215
+ const versionEntry = {
216
+ publishedAt: new Date().toISOString(),
217
+ fileCount: filesToUpload.length,
218
+ totalSize,
219
+ assets: registryAssets,
220
+ };
221
+ // Dry run — print summary and exit
222
+ if (opts.dryRun) {
223
+ log('\n--- DRY RUN ---');
224
+ log(`Would publish ${pkg.name}@${pkg.version} to ${bucketUri}`);
225
+ log(`Would upload ${filesToUpload.length} files (${formatBytes(totalSize)})`);
226
+ log(`\nAssets (${registryAssets.length}):`);
227
+ for (const a of registryAssets) {
228
+ const deps = a.dependencies.length > 0 ? ` → [${a.dependencies.join(', ')}]` : '';
229
+ const tags = a.tags.length > 0 ? ` [${a.tags.join(', ')}]` : '';
230
+ log(` [${a.type}] ${a.stringId} (${a.file}, ${formatBytes(a.size)})${tags}${deps}`);
231
+ }
232
+ // Demo transitive resolution
233
+ const { resolveTransitiveDeps } = await import('./assets-package.js');
234
+ const example = registryAssets.find(a => a.dependencies.length > 0);
235
+ if (example) {
236
+ log(`\nExample: pulling "${example.stringId}" would also grab:`);
237
+ const resolved = resolveTransitiveDeps([example.stringId], registryAssets);
238
+ for (const id of resolved) {
239
+ const asset = registryAssets.find(a => a.stringId === id);
240
+ log(` ${id === example.stringId ? '→' : ' +'} ${id} [${asset?.type ?? '?'}]`);
241
+ }
242
+ }
243
+ log('\nNo files were uploaded.');
244
+ return {
245
+ ok: true,
246
+ packageName: pkg.name,
247
+ version: pkg.version,
248
+ assetCount: registryAssets.length,
249
+ fileCount: filesToUpload.length,
250
+ thumbnailCount: Object.keys(thumbMap).length,
251
+ };
252
+ }
253
+ // Step 7: Create GCS client and auth
254
+ const emitProgress = opts.onProgress ?? (() => { });
255
+ const totalUploads = filesToUpload.length + Object.keys(thumbMap).length + 2; // +2 for assets-package.json and registry.json
256
+ let totalDone = 0;
257
+ log('Authenticating with GCS...');
258
+ emitProgress({ phase: 'auth', done: 0, total: totalUploads, message: 'Authenticating with GCS...' });
259
+ const gcs = await createGCSClient(projectDir, bucketUri);
260
+ // Step 8: Download current registry
261
+ log('Fetching registry...');
262
+ const registryResult = await gcs.downloadWithGeneration('registry.json');
263
+ let registry;
264
+ let generation;
265
+ if (registryResult) {
266
+ registry = JSON.parse(registryResult.data);
267
+ generation = registryResult.generation;
268
+ vlog(`Registry loaded (generation: ${generation})`);
269
+ }
270
+ else {
271
+ registry = createEmptyRegistry();
272
+ generation = null;
273
+ vlog('No existing registry — will create new one');
274
+ }
275
+ // Step 9: Check version not already published
276
+ const existingPkg = registry.packages[pkg.name];
277
+ if (existingPkg?.versions[pkg.version] && !opts.force) {
278
+ throw new Error(`Version ${pkg.version} of "${pkg.name}" is already published. ` +
279
+ `Bump the version in assets-package.json or use --force to overwrite.`);
280
+ }
281
+ // Step 10: Upload all files (concurrent)
282
+ const uploadPrefix = `packages/${pkg.name}/${pkg.version}`;
283
+ let uploaded = 0;
284
+ const UPLOAD_CONCURRENCY = 10;
285
+ emitProgress({ phase: 'files', done: 0, total: totalUploads, message: `Uploading ${filesToUpload.length} files...` });
286
+ await runConcurrent(filesToUpload, UPLOAD_CONCURRENCY, async (relativePath) => {
287
+ const objectPath = `${uploadPrefix}/${relativePath}`;
288
+ const absPath = path.join(project.srcArtDir, relativePath);
289
+ const data = new Uint8Array(await fs.readFile(absPath));
290
+ await gcs.upload(objectPath, data, guessContentType(relativePath));
291
+ uploaded++;
292
+ totalDone++;
293
+ emitProgress({ phase: 'files', done: totalDone, total: totalUploads, message: `Uploading files: ${uploaded}/${filesToUpload.length}` });
294
+ if (opts.verbose) {
295
+ vlog(`[${uploaded}/${filesToUpload.length}] ${relativePath} (${formatBytes(data.length)})`);
296
+ }
297
+ else if (uploaded % 10 === 0 || uploaded === filesToUpload.length) {
298
+ log(` Uploaded ${uploaded}/${filesToUpload.length} files...`);
299
+ }
300
+ });
301
+ // Step 11: Upload thumbnails concurrently (if provided by packer GUI)
302
+ let thumbnailCount = 0;
303
+ const thumbEntries = opts.thumbnails ? Object.entries(opts.thumbnails) : [];
304
+ if (thumbEntries.length > 0) {
305
+ emitProgress({ phase: 'thumbnails', done: totalDone, total: totalUploads, message: `Uploading thumbnails...` });
306
+ }
307
+ await runConcurrent(thumbEntries, UPLOAD_CONCURRENCY, async ([stringId, thumb]) => {
308
+ const binaryData = Buffer.from(thumb.data, 'base64');
309
+ const ext = thumb.format === 'webm' ? 'webm' : thumb.format === 'webp' ? 'webp' : 'png';
310
+ const mime = thumb.format === 'webm' ? 'video/webm' : thumb.format === 'webp' ? 'image/webp' : 'image/png';
311
+ const objectPath = `${uploadPrefix}/thumbnails/${stringId}.${ext}`;
312
+ await gcs.upload(objectPath, new Uint8Array(binaryData), mime);
313
+ thumbnailCount++;
314
+ totalDone++;
315
+ emitProgress({ phase: 'thumbnails', done: totalDone, total: totalUploads, message: `Uploading thumbnails: ${thumbnailCount}/${thumbEntries.length}` });
316
+ });
317
+ if (thumbnailCount > 0)
318
+ log(` Uploaded ${thumbnailCount} thumbnails`);
319
+ // Step 12: Upload assets-package.json receipt copy
320
+ emitProgress({ phase: 'registry', done: totalDone, total: totalUploads, message: 'Uploading package manifest...' });
321
+ const receiptPath = `${uploadPrefix}/assets-package.json`;
322
+ await gcs.upload(receiptPath, JSON.stringify(pkg, null, 2), 'application/json');
323
+ totalDone++;
324
+ // Step 13: Update and upload registry
325
+ log('Updating registry...');
326
+ if (!registry.packages[pkg.name]) {
327
+ registry.packages[pkg.name] = {
328
+ description: pkg.description,
329
+ author: pkg.author,
330
+ tags: pkg.tags ?? [],
331
+ latest: pkg.version,
332
+ versions: {},
333
+ };
334
+ }
335
+ const regPkg = registry.packages[pkg.name];
336
+ regPkg.versions[pkg.version] = versionEntry;
337
+ regPkg.latest = pkg.version;
338
+ regPkg.description = pkg.description;
339
+ regPkg.author = pkg.author;
340
+ regPkg.tags = pkg.tags ?? [];
341
+ emitProgress({ phase: 'registry', done: totalDone, total: totalUploads, message: 'Updating registry...' });
342
+ await gcs.uploadWithGeneration('registry.json', JSON.stringify(registry, null, 2), generation);
343
+ totalDone++;
344
+ emitProgress({ phase: 'registry', done: totalDone, total: totalUploads, message: 'Done' });
345
+ log(`\nPublished ${pkg.name}@${pkg.version} successfully!`);
346
+ log(` ${filesToUpload.length} files, ${registryAssets.length} assets, ${formatBytes(totalSize)}`);
347
+ return {
348
+ ok: true,
349
+ packageName: pkg.name,
350
+ version: pkg.version,
351
+ assetCount: registryAssets.length,
352
+ fileCount: filesToUpload.length,
353
+ thumbnailCount,
354
+ };
355
+ }
356
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
357
+ function formatBytes(bytes) {
358
+ if (bytes < 1024)
359
+ return `${bytes} B`;
360
+ if (bytes < 1024 * 1024)
361
+ return `${(bytes / 1024).toFixed(1)} KB`;
362
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
363
+ }
364
+ const CONTENT_TYPES = {
365
+ png: 'image/png',
366
+ jpg: 'image/jpeg',
367
+ jpeg: 'image/jpeg',
368
+ webp: 'image/webp',
369
+ gif: 'image/gif',
370
+ bmp: 'image/bmp',
371
+ wav: 'audio/wav',
372
+ mp3: 'audio/mpeg',
373
+ ogg: 'audio/ogg',
374
+ flac: 'audio/flac',
375
+ aac: 'audio/aac',
376
+ m4a: 'audio/mp4',
377
+ fbx: 'application/octet-stream',
378
+ obj: 'text/plain',
379
+ glb: 'model/gltf-binary',
380
+ gltf: 'model/gltf+json',
381
+ json: 'application/json',
382
+ stowmeta: 'application/json',
383
+ stowmat: 'application/json',
384
+ };
385
+ function guessContentType(filePath) {
386
+ const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
387
+ return CONTENT_TYPES[ext] ?? 'application/octet-stream';
388
+ }
389
+ /** Run async tasks with a concurrency limit. */
390
+ async function runConcurrent(items, limit, fn) {
391
+ let i = 0;
392
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
393
+ while (i < items.length) {
394
+ const idx = i++;
395
+ await fn(items[idx]);
396
+ }
397
+ });
398
+ await Promise.all(workers);
399
+ }