@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.
package/dist/cli.js CHANGED
@@ -10,6 +10,8 @@ import { cleanupProject } from './cleanup.js';
10
10
  import { createMaterial } from './create-material.js';
11
11
  import { renameAsset, moveAsset, deleteAsset, setStringId } from './asset-commands.js';
12
12
  import { inspectPack } from './inspect.js';
13
+ import { publishPackage } from './publish.js';
14
+ import { storeSearch, storeList, storeInfo } from './store.js';
13
15
  const args = process.argv.slice(2);
14
16
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
15
17
  const STOWKIT_PACKAGES = [
@@ -89,6 +91,10 @@ Usage:
89
91
  stowkit delete <path> Delete an asset and its sidecar files
90
92
  stowkit set-id <path> <id> Change an asset's stringId
91
93
  stowkit inspect <file.stow> Show manifest of a built .stow pack
94
+ stowkit publish [dir] Publish asset packs to GCS bucket
95
+ stowkit store search <query> Search the asset store
96
+ stowkit store list List all packages in the store
97
+ stowkit store info <package> Show package details and assets
92
98
  stowkit update Update CLI to latest version and refresh skill files
93
99
  stowkit version Show installed version
94
100
  stowkit packer [dir] Open the packer GUI
@@ -100,6 +106,10 @@ Options:
100
106
  --verbose Detailed output
101
107
  --port Server port (default 3210)
102
108
  --schema Material schema template: pbr (default), unlit, or custom name
109
+ --bucket GCS bucket for publish/store (overrides default)
110
+ --dry-run Show what would be published without uploading
111
+ --json Output store results as JSON (for AI agents)
112
+ --type Filter store search by asset type
103
113
  --help Show this help message
104
114
  `.trim());
105
115
  }
@@ -128,6 +138,18 @@ function openBrowser(url) {
128
138
  exec(`${cmd} ${url}`);
129
139
  });
130
140
  }
141
+ async function isStowKitRunning(port) {
142
+ try {
143
+ const controller = new AbortController();
144
+ const timeout = setTimeout(() => controller.abort(), 1000);
145
+ const res = await fetch(`http://localhost:${port}/api/project`, { signal: controller.signal });
146
+ clearTimeout(timeout);
147
+ return res.ok;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
131
153
  async function main() {
132
154
  if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
133
155
  printUsage();
@@ -139,6 +161,12 @@ async function main() {
139
161
  const verbose = args.includes('--verbose') || args.includes('-v');
140
162
  const portIdx = args.indexOf('--port');
141
163
  const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : 3210;
164
+ const dryRun = args.includes('--dry-run');
165
+ const jsonOutput = args.includes('--json');
166
+ const bucketIdx = args.indexOf('--bucket');
167
+ const bucket = bucketIdx >= 0 ? args[bucketIdx + 1] : undefined;
168
+ const typeIdx = args.indexOf('--type');
169
+ const typeFilter = typeIdx >= 0 ? args[typeIdx + 1] : undefined;
142
170
  const opts = { force, verbose };
143
171
  try {
144
172
  switch (command) {
@@ -268,7 +296,43 @@ async function main() {
268
296
  await inspectPack(stowPath, { verbose });
269
297
  break;
270
298
  }
299
+ case 'publish':
300
+ await publishPackage(projectDir, { force, dryRun, bucket, verbose });
301
+ break;
302
+ case 'store': {
303
+ const subCmd = args[1];
304
+ const storeOpts = { json: jsonOutput, bucket };
305
+ if (subCmd === 'search') {
306
+ const query = args.filter(a => !a.startsWith('-') && a !== 'store' && a !== 'search').join(' ');
307
+ if (!query) {
308
+ console.error('Usage: stowkit store search <query> [--type <type>] [--json]');
309
+ process.exit(1);
310
+ }
311
+ await storeSearch(query, { ...storeOpts, type: typeFilter });
312
+ }
313
+ else if (subCmd === 'list') {
314
+ await storeList(storeOpts);
315
+ }
316
+ else if (subCmd === 'info') {
317
+ const pkgName = args.find(a => !a.startsWith('-') && a !== 'store' && a !== 'info');
318
+ if (!pkgName) {
319
+ console.error('Usage: stowkit store info <package-name> [--json]');
320
+ process.exit(1);
321
+ }
322
+ await storeInfo(pkgName, storeOpts);
323
+ }
324
+ else {
325
+ console.error('Usage: stowkit store <search|list|info> [args]');
326
+ process.exit(1);
327
+ }
328
+ break;
329
+ }
271
330
  case 'packer': {
331
+ if (await isStowKitRunning(port)) {
332
+ console.log(`\n Packer already running: http://localhost:${port}\n`);
333
+ openBrowser(`http://localhost:${port}`);
334
+ break;
335
+ }
272
336
  const packerDir = resolveAppDir('@series-inc/stowkit-packer-gui', 'stowkit-packer-gui');
273
337
  if (!packerDir) {
274
338
  console.error('Packer GUI not found. Install it: npm install @series-inc/stowkit-packer-gui');
@@ -281,6 +345,11 @@ async function main() {
281
345
  break;
282
346
  }
283
347
  case 'editor': {
348
+ if (await isStowKitRunning(port)) {
349
+ console.log(`\n Editor already running: http://localhost:${port}\n`);
350
+ openBrowser(`http://localhost:${port}`);
351
+ break;
352
+ }
284
353
  const editorDir = resolveAppDir('@series-inc/stowkit-editor', 'stowkit-editor');
285
354
  if (!editorDir) {
286
355
  console.error('Editor not found. Install it: npm install @series-inc/stowkit-editor');
@@ -12,8 +12,10 @@ export declare const DATA_ALIGNMENT = 16;
12
12
  export declare const MAX_PATH_LENGTH = 512;
13
13
  /** Size of the string_id field in metadata structs (bytes) */
14
14
  export declare const STRING_ID_SIZE = 128;
15
- /** Size of TextureMetadata on disk (bytes) */
16
- export declare const TEXTURE_METADATA_SIZE = 144;
15
+ /** Size of TextureMetadata on disk (bytes) — v1.1: appended filtering uint32 */
16
+ export declare const TEXTURE_METADATA_SIZE = 148;
17
+ /** Size of legacy TextureMetadata without filtering field (bytes) */
18
+ export declare const LEGACY_TEXTURE_METADATA_SIZE = 144;
17
19
  /** Size of AudioMetadata on disk (bytes) */
18
20
  export declare const AUDIO_METADATA_SIZE = 140;
19
21
  /** Size of MeshGeometryInfo on disk (bytes) */
@@ -12,8 +12,10 @@ export const DATA_ALIGNMENT = 16;
12
12
  export const MAX_PATH_LENGTH = 512;
13
13
  /** Size of the string_id field in metadata structs (bytes) */
14
14
  export const STRING_ID_SIZE = 128;
15
- /** Size of TextureMetadata on disk (bytes) */
16
- export const TEXTURE_METADATA_SIZE = 144;
15
+ /** Size of TextureMetadata on disk (bytes) — v1.1: appended filtering uint32 */
16
+ export const TEXTURE_METADATA_SIZE = 148;
17
+ /** Size of legacy TextureMetadata without filtering field (bytes) */
18
+ export const LEGACY_TEXTURE_METADATA_SIZE = 144;
17
19
  /** Size of AudioMetadata on disk (bytes) */
18
20
  export const AUDIO_METADATA_SIZE = 140;
19
21
  /** Size of MeshGeometryInfo on disk (bytes) */
@@ -26,6 +26,10 @@ export declare enum TextureResize {
26
26
  Quarter = 2,
27
27
  Eighth = 3
28
28
  }
29
+ export declare enum TextureFilterMode {
30
+ Linear = 0,
31
+ Nearest = 1
32
+ }
29
33
  export declare enum MaterialFieldType {
30
34
  Texture = 0,
31
35
  Color = 1,
@@ -86,6 +90,7 @@ export interface TextureMetadata {
86
90
  channels: number;
87
91
  channelFormat: TextureChannelFormat;
88
92
  stringId: string;
93
+ filtering: TextureFilterMode;
89
94
  }
90
95
  export interface AudioMetadata {
91
96
  stringId: string;
@@ -32,6 +32,11 @@ export var TextureResize;
32
32
  TextureResize[TextureResize["Quarter"] = 2] = "Quarter";
33
33
  TextureResize[TextureResize["Eighth"] = 3] = "Eighth";
34
34
  })(TextureResize || (TextureResize = {}));
35
+ export var TextureFilterMode;
36
+ (function (TextureFilterMode) {
37
+ TextureFilterMode[TextureFilterMode["Linear"] = 0] = "Linear";
38
+ TextureFilterMode[TextureFilterMode["Nearest"] = 1] = "Nearest";
39
+ })(TextureFilterMode || (TextureFilterMode = {}));
35
40
  // ─── Material Enums ─────────────────────────────────────────────────────────
36
41
  export var MaterialFieldType;
37
42
  (function (MaterialFieldType) {
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { createRequire } from 'node:module';
5
- import { KTX2Quality, TextureChannelFormat } from '../core/types.js';
5
+ import { KTX2Quality, TextureChannelFormat, TextureFilterMode } from '../core/types.js';
6
6
  const QUALITY_TO_LEVEL = {
7
7
  [KTX2Quality.Fastest]: 1,
8
8
  [KTX2Quality.Fast]: 64,
@@ -106,6 +106,7 @@ export class NodeBasisEncoder {
106
106
  channels: useUastc ? 4 : 3,
107
107
  channelFormat: useUastc ? TextureChannelFormat.RGBA : TextureChannelFormat.RGB,
108
108
  stringId: '',
109
+ filtering: TextureFilterMode.Linear,
109
110
  };
110
111
  return { data: ktx2Data, metadata };
111
112
  }
@@ -1,5 +1,6 @@
1
1
  import { BinaryReader, BinaryWriter } from '../core/binary.js';
2
2
  import { TEXTURE_METADATA_SIZE, AUDIO_METADATA_SIZE, STRING_ID_SIZE, MESH_GEOMETRY_INFO_SIZE, SCENE_NODE_SIZE, MATERIAL_DATA_FIXED_SIZE, MATERIAL_PROPERTY_VALUE_SIZE, MESH_METADATA_FIXED_SIZE, NODE_NAME_SIZE, MATERIAL_NAME_SIZE, MATERIAL_SCHEMA_ID_SIZE, MATERIAL_FIELD_NAME_SIZE, MATERIAL_SCHEMA_NAME_SIZE, MATERIAL_SCHEMA_DEFAULT_TEXTURE_ID_SIZE, MATERIAL_SCHEMA_METADATA_FIXED_SIZE, MATERIAL_SCHEMA_FIELD_SIZE, BONE_NAME_SIZE, SKINNED_MESH_GEOMETRY_INFO_SIZE, SKINNED_MESH_METADATA_FIXED_SIZE, BONE_SIZE, ANIMATION_TRACK_DESCRIPTOR_SIZE, ANIMATION_CLIP_METADATA_FIXED_SIZE, TRACK_NAME_SIZE, ANIMATION_METADATA_VERSION, } from '../core/constants.js';
3
+ import { TextureFilterMode } from '../core/types.js';
3
4
  // ─── Texture Metadata ───────────────────────────────────────────────────────
4
5
  export function serializeTextureMetadata(meta) {
5
6
  const w = new BinaryWriter(TEXTURE_METADATA_SIZE);
@@ -8,17 +9,21 @@ export function serializeTextureMetadata(meta) {
8
9
  w.writeUint32(meta.channels);
9
10
  w.writeUint32(meta.channelFormat);
10
11
  w.writeFixedString(meta.stringId, STRING_ID_SIZE);
12
+ w.writeUint32(meta.filtering);
11
13
  return w.getUint8Array();
12
14
  }
13
15
  export function deserializeTextureMetadata(data) {
14
16
  const r = new BinaryReader(data);
15
- return {
16
- width: r.readUint32(),
17
- height: r.readUint32(),
18
- channels: r.readUint32(),
19
- channelFormat: r.readUint32(),
20
- stringId: r.readFixedString(STRING_ID_SIZE),
21
- };
17
+ const width = r.readUint32();
18
+ const height = r.readUint32();
19
+ const channels = r.readUint32();
20
+ const channelFormat = r.readUint32();
21
+ const stringId = r.readFixedString(STRING_ID_SIZE);
22
+ // Old packs have 144-byte texture metadata (no filtering field)
23
+ const filtering = data.length >= TEXTURE_METADATA_SIZE
24
+ ? r.readUint32()
25
+ : TextureFilterMode.Linear;
26
+ return { width, height, channels, channelFormat, stringId, filtering };
22
27
  }
23
28
  // ─── Audio Metadata ─────────────────────────────────────────────────────────
24
29
  export function serializeAudioMetadata(meta) {
package/dist/gcs.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export interface GCSClient {
2
+ upload(objectPath: string, data: Uint8Array | string, contentType?: string): Promise<void>;
3
+ download(objectPath: string): Promise<string | null>;
4
+ downloadWithGeneration(objectPath: string): Promise<{
5
+ data: string;
6
+ generation: string;
7
+ } | null>;
8
+ uploadWithGeneration(objectPath: string, data: string, generation: string | null, contentType?: string): Promise<void>;
9
+ }
10
+ export declare function createGCSClient(projectDir: string, bucketUri: string): Promise<GCSClient>;
package/dist/gcs.js ADDED
@@ -0,0 +1,158 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as crypto from 'node:crypto';
4
+ // ─── JWT Auth ────────────────────────────────────────────────────────────────
5
+ function base64url(data) {
6
+ const buf = typeof data === 'string' ? Buffer.from(data) : data;
7
+ return buf.toString('base64url');
8
+ }
9
+ function createJWT(sa) {
10
+ const now = Math.floor(Date.now() / 1000);
11
+ const header = { alg: 'RS256', typ: 'JWT' };
12
+ const payload = {
13
+ iss: sa.client_email,
14
+ scope: 'https://www.googleapis.com/auth/devstorage.read_write',
15
+ aud: 'https://oauth2.googleapis.com/token',
16
+ iat: now,
17
+ exp: now + 3600,
18
+ };
19
+ const segments = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
20
+ const sign = crypto.createSign('RSA-SHA256');
21
+ sign.update(segments);
22
+ const signature = sign.sign(sa.private_key);
23
+ return `${segments}.${base64url(signature)}`;
24
+ }
25
+ async function getAccessToken(sa) {
26
+ const jwt = createJWT(sa);
27
+ const res = await fetch('https://oauth2.googleapis.com/token', {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
30
+ body: `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`,
31
+ });
32
+ if (!res.ok) {
33
+ const text = await res.text();
34
+ throw new Error(`GCS auth failed (${res.status}): ${text}`);
35
+ }
36
+ const data = await res.json();
37
+ return data.access_token;
38
+ }
39
+ // ─── Credential Resolution ───────────────────────────────────────────────────
40
+ async function loadServiceAccount(projectDir) {
41
+ // Search order: project dir, cwd, GOOGLE_APPLICATION_CREDENTIALS env
42
+ const candidates = [
43
+ path.join(projectDir, 'service_account.json'),
44
+ path.join(process.cwd(), 'service_account.json'),
45
+ ];
46
+ for (const candidate of candidates) {
47
+ try {
48
+ const text = await fs.readFile(candidate, 'utf-8');
49
+ return JSON.parse(text);
50
+ }
51
+ catch { /* not found */ }
52
+ }
53
+ // Fall back to GOOGLE_APPLICATION_CREDENTIALS
54
+ const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
55
+ if (envPath) {
56
+ try {
57
+ const text = await fs.readFile(envPath, 'utf-8');
58
+ return JSON.parse(text);
59
+ }
60
+ catch {
61
+ throw new Error(`Could not read service account from GOOGLE_APPLICATION_CREDENTIALS: ${envPath}`);
62
+ }
63
+ }
64
+ throw new Error('No GCS credentials found. Place service_account.json in project root, ' +
65
+ 'current directory, or set GOOGLE_APPLICATION_CREDENTIALS environment variable.');
66
+ }
67
+ // ─── Bucket Name Parsing ─────────────────────────────────────────────────────
68
+ function parseBucket(bucketUri) {
69
+ // Accept "gs://bucket-name" or just "bucket-name"
70
+ if (bucketUri.startsWith('gs://'))
71
+ return bucketUri.slice(5).replace(/\/$/, '');
72
+ return bucketUri.replace(/\/$/, '');
73
+ }
74
+ // ─── GCS Client Factory ─────────────────────────────────────────────────────
75
+ export async function createGCSClient(projectDir, bucketUri) {
76
+ const sa = await loadServiceAccount(projectDir);
77
+ const token = await getAccessToken(sa);
78
+ const bucket = parseBucket(bucketUri);
79
+ const apiBase = `https://storage.googleapis.com`;
80
+ return {
81
+ async upload(objectPath, data, contentType = 'application/octet-stream') {
82
+ const encoded = encodeURIComponent(objectPath);
83
+ const url = `${apiBase}/upload/storage/v1/b/${bucket}/o?uploadType=media&name=${encoded}`;
84
+ const body = typeof data === 'string' ? data : Buffer.from(data);
85
+ const res = await fetch(url, {
86
+ method: 'POST',
87
+ headers: {
88
+ Authorization: `Bearer ${token}`,
89
+ 'Content-Type': contentType,
90
+ },
91
+ body,
92
+ });
93
+ if (!res.ok) {
94
+ const text = await res.text();
95
+ throw new Error(`GCS upload failed for ${objectPath} (${res.status}): ${text}`);
96
+ }
97
+ },
98
+ async download(objectPath) {
99
+ const encoded = encodeURIComponent(objectPath);
100
+ const url = `${apiBase}/storage/v1/b/${bucket}/o/${encoded}?alt=media`;
101
+ const res = await fetch(url, {
102
+ headers: { Authorization: `Bearer ${token}` },
103
+ });
104
+ if (res.status === 404)
105
+ return null;
106
+ if (!res.ok) {
107
+ const text = await res.text();
108
+ throw new Error(`GCS download failed for ${objectPath} (${res.status}): ${text}`);
109
+ }
110
+ return res.text();
111
+ },
112
+ async downloadWithGeneration(objectPath) {
113
+ const encoded = encodeURIComponent(objectPath);
114
+ const url = `${apiBase}/storage/v1/b/${bucket}/o/${encoded}?alt=media`;
115
+ const res = await fetch(url, {
116
+ headers: { Authorization: `Bearer ${token}` },
117
+ });
118
+ if (res.status === 404)
119
+ return null;
120
+ if (!res.ok) {
121
+ const text = await res.text();
122
+ throw new Error(`GCS download failed for ${objectPath} (${res.status}): ${text}`);
123
+ }
124
+ const data = await res.text();
125
+ const generation = res.headers.get('x-goog-generation') ?? '0';
126
+ return { data, generation };
127
+ },
128
+ async uploadWithGeneration(objectPath, data, generation, contentType = 'application/json') {
129
+ const encoded = encodeURIComponent(objectPath);
130
+ let url = `${apiBase}/upload/storage/v1/b/${bucket}/o?uploadType=media&name=${encoded}`;
131
+ const headers = {
132
+ Authorization: `Bearer ${token}`,
133
+ 'Content-Type': contentType,
134
+ };
135
+ // Optimistic concurrency: if we have a generation, require it to match
136
+ if (generation) {
137
+ headers['x-goog-if-generation-match'] = generation;
138
+ }
139
+ else {
140
+ // Object should not exist yet
141
+ headers['x-goog-if-generation-match'] = '0';
142
+ }
143
+ const res = await fetch(url, {
144
+ method: 'POST',
145
+ headers,
146
+ body: data,
147
+ });
148
+ if (res.status === 412) {
149
+ throw new Error(`Registry was modified by another publish while uploading. ` +
150
+ `Please retry the publish command.`);
151
+ }
152
+ if (!res.ok) {
153
+ const text = await res.text();
154
+ throw new Error(`GCS upload failed for ${objectPath} (${res.status}): ${text}`);
155
+ }
156
+ },
157
+ };
158
+ }
package/dist/index.d.ts CHANGED
@@ -27,3 +27,8 @@ export type { ServerOptions } from './server.js';
27
27
  export { initProject } from './init.js';
28
28
  export { cleanupProject } from './cleanup.js';
29
29
  export { syncRuntimeAssets } from './sync-runtime-assets.js';
30
+ export { publishPackage } from './publish.js';
31
+ export type { PublishOptions, PublishResult } from './publish.js';
32
+ export * from './assets-package.js';
33
+ export { fetchRegistry, searchAssets, listPackages, resolveAssetDeps } from './store.js';
34
+ export type { SearchResult, PackageInfo } from './store.js';
package/dist/index.js CHANGED
@@ -35,3 +35,8 @@ export { initProject } from './init.js';
35
35
  export { cleanupProject } from './cleanup.js';
36
36
  // Runtime asset sync
37
37
  export { syncRuntimeAssets } from './sync-runtime-assets.js';
38
+ // Publish
39
+ export { publishPackage } from './publish.js';
40
+ export * from './assets-package.js';
41
+ // Store
42
+ export { fetchRegistry, searchAssets, listPackages, resolveAssetDeps } from './store.js';
package/dist/init.js CHANGED
@@ -96,6 +96,8 @@ export async function initProject(projectDir, opts) {
96
96
  '# StowKit',
97
97
  '*.stowcache',
98
98
  'public/cdn-assets/',
99
+ 'Open Packer.bat',
100
+ 'open-packer.sh',
99
101
  ].join('\n');
100
102
  try {
101
103
  const existing = await fs.readFile(gitignorePath, 'utf-8');
@@ -108,11 +110,14 @@ export async function initProject(projectDir, opts) {
108
110
  }
109
111
  // Copy AI skill/rule files
110
112
  await copySkillFiles(absDir);
113
+ // Create double-clickable launcher scripts
114
+ await createLauncherScripts(absDir);
111
115
  console.log(`Initialized StowKit project at ${absDir}`);
112
116
  console.log(` Source art dir: ${srcArtDir}/`);
113
117
  console.log(` Output dir: public/cdn-assets/`);
114
118
  console.log(` Config: .felicityproject`);
115
119
  console.log(` AI skills: .claude/skills/stowkit/SKILL.md, .cursor/rules/stowkit.mdc`);
120
+ console.log(` Launcher: Open Packer.bat (Windows) / open-packer.sh (macOS/Linux)`);
116
121
  // Install engine if selected
117
122
  if (withEngine) {
118
123
  await installEngine(absDir);
@@ -120,6 +125,7 @@ export async function initProject(projectDir, opts) {
120
125
  console.log('');
121
126
  console.log('Drop your assets (PNG, JPG, FBX, WAV, etc.) into assets/');
122
127
  console.log('Then run: stowkit build');
128
+ console.log('Or double-click "Open Packer.bat" to launch the packer GUI.');
123
129
  }
124
130
  async function installEngine(absDir) {
125
131
  console.log('');
@@ -159,6 +165,20 @@ async function copySkillFiles(absDir) {
159
165
  // Skill file not found in package — skip silently
160
166
  }
161
167
  }
168
+ async function createLauncherScripts(absDir) {
169
+ const batContent = [
170
+ '@echo off',
171
+ 'npx @series-inc/stowkit-cli packer .',
172
+ 'if %errorlevel% neq 0 pause',
173
+ ].join('\r\n');
174
+ const shContent = [
175
+ '#!/bin/sh',
176
+ 'cd "$(dirname "$0")"',
177
+ 'npx @series-inc/stowkit-cli packer .',
178
+ ].join('\n');
179
+ await fs.writeFile(path.join(absDir, 'Open Packer.bat'), batContent);
180
+ await fs.writeFile(path.join(absDir, 'open-packer.sh'), shContent, { mode: 0o755 });
181
+ }
162
182
  async function copyEngineSkillFiles(absDir) {
163
183
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
164
184
  const candidates = [
package/dist/node-fs.d.ts CHANGED
@@ -6,6 +6,10 @@ export declare function renameFile(basePath: string, oldRelative: string, newRel
6
6
  export declare function deleteFile(basePath: string, relativePath: string): Promise<void>;
7
7
  export declare function fileExists(basePath: string, relativePath: string): Promise<boolean>;
8
8
  export declare function getFileSnapshot(basePath: string, relativePath: string): Promise<FileSnapshot | null>;
9
+ export declare function probeImageDimensions(srcArtDir: string, relativePath: string): Promise<{
10
+ width: number;
11
+ height: number;
12
+ } | null>;
9
13
  export interface ScanResult {
10
14
  sourceFiles: FileSnapshot[];
11
15
  metaFiles: FileSnapshot[];
package/dist/node-fs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
+ import sharp from 'sharp';
3
4
  // ─── File I/O ────────────────────────────────────────────────────────────────
4
5
  export async function readFile(basePath, relativePath) {
5
6
  try {
@@ -68,6 +69,19 @@ export async function getFileSnapshot(basePath, relativePath) {
68
69
  return null;
69
70
  }
70
71
  }
72
+ export async function probeImageDimensions(srcArtDir, relativePath) {
73
+ try {
74
+ const absPath = path.join(srcArtDir, relativePath);
75
+ const metadata = await sharp(absPath).metadata();
76
+ if (metadata.width && metadata.height) {
77
+ return { width: metadata.width, height: metadata.height };
78
+ }
79
+ return null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
71
85
  // ─── Directory Scanning ──────────────────────────────────────────────────────
72
86
  const SOURCE_EXTENSIONS = new Set([
73
87
  'png', 'jpg', 'jpeg', 'bmp', 'tga', 'webp', 'gif',
@@ -4,8 +4,8 @@ import { AssetType } from './core/types.js';
4
4
  import { cleanupProject } from './cleanup.js';
5
5
  import { defaultAssetSettings } from './app/state.js';
6
6
  import { BlobStore } from './app/blob-store.js';
7
- import { readProjectConfig, scanDirectory, readFile, getFileSnapshot, } from './node-fs.js';
8
- import { readStowmeta, writeStowmeta, stowmetaToAssetSettings, generateDefaultStowmeta, glbChildToAssetSettings, generateDefaultGlbChild, } from './app/stowmeta-io.js';
7
+ import { readProjectConfig, scanDirectory, readFile, getFileSnapshot, probeImageDimensions, } from './node-fs.js';
8
+ import { detectAssetType, readStowmeta, writeStowmeta, stowmetaToAssetSettings, generateDefaultStowmeta, glbChildToAssetSettings, generateDefaultGlbChild, } from './app/stowmeta-io.js';
9
9
  import { parseGlb, pbrToMaterialConfig } from './encoders/glb-loader.js';
10
10
  import { readStowmat, stowmatToMaterialConfig } from './app/stowmat-io.js';
11
11
  import { readCacheBlobs, writeCacheBlobs, buildCacheStamp, isCacheValid, } from './app/process-cache.js';
@@ -21,7 +21,11 @@ export async function scanProject(projectDir, opts) {
21
21
  for (const file of scan.sourceFiles) {
22
22
  if (!existingMeta.has(file.relativePath)) {
23
23
  newFiles.push(file.relativePath);
24
- const meta = generateDefaultStowmeta(file.relativePath);
24
+ const type = detectAssetType(file.relativePath);
25
+ const imageDimensions = type === AssetType.Texture2D
26
+ ? await probeImageDimensions(config.srcArtDir, file.relativePath)
27
+ : null;
28
+ const meta = generateDefaultStowmeta(file.relativePath, type, config.config.defaults, imageDimensions);
25
29
  await writeStowmeta(config.srcArtDir, file.relativePath, meta);
26
30
  if (verbose)
27
31
  console.log(` [new] ${file.relativePath} → .stowmeta (${meta.type})`);
@@ -60,7 +64,11 @@ export async function fullBuild(projectDir, opts) {
60
64
  const id = file.relativePath;
61
65
  let meta = await readStowmeta(config.srcArtDir, id);
62
66
  if (!meta) {
63
- meta = generateDefaultStowmeta(id);
67
+ const type = detectAssetType(id);
68
+ const imageDimensions = type === AssetType.Texture2D
69
+ ? await probeImageDimensions(config.srcArtDir, id)
70
+ : null;
71
+ meta = generateDefaultStowmeta(id, type, config.config.defaults, imageDimensions);
64
72
  await writeStowmeta(config.srcArtDir, id, meta);
65
73
  }
66
74
  const { type, settings } = stowmetaToAssetSettings(meta);
@@ -115,7 +123,7 @@ export async function fullBuild(projectDir, opts) {
115
123
  const baseName = fileName.replace(/\.[^.]+$/, '');
116
124
  let meta = await readStowmeta(config.srcArtDir, id);
117
125
  if (!meta) {
118
- meta = generateDefaultStowmeta(id, AssetType.MaterialSchema);
126
+ meta = generateDefaultStowmeta(id, AssetType.MaterialSchema, config.config.defaults);
119
127
  await writeStowmeta(config.srcArtDir, id, meta);
120
128
  }
121
129
  const asset = {
@@ -147,12 +155,12 @@ export async function fullBuild(projectDir, opts) {
147
155
  const existingChildren = new Map((containerMeta.children ?? []).map(c => [c.name, c]));
148
156
  const childrenManifest = [];
149
157
  for (const tex of extract.textures) {
150
- childrenManifest.push(existingChildren.get(tex.name) ?? generateDefaultGlbChild(tex.name, 'texture'));
158
+ childrenManifest.push(existingChildren.get(tex.name) ?? generateDefaultGlbChild(tex.name, 'texture', config.config.defaults));
151
159
  BlobStore.setSource(`${container.id}/${tex.name}`, tex.data);
152
160
  }
153
161
  for (const mesh of extract.meshes) {
154
162
  const typeName = mesh.hasSkeleton ? 'skinnedMesh' : 'staticMesh';
155
- const meshChild = existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName);
163
+ const meshChild = existingChildren.get(mesh.name) ?? generateDefaultGlbChild(mesh.name, typeName, config.config.defaults);
156
164
  // Store scene node names so AI agents can see the hierarchy in the stowmeta
157
165
  if (mesh.imported.nodes.length > 1) {
158
166
  meshChild.sceneNodeNames = mesh.imported.nodes.map(n => n.name);
@@ -169,7 +177,7 @@ export async function fullBuild(projectDir, opts) {
169
177
  childrenManifest.push(existing);
170
178
  }
171
179
  else {
172
- const child = generateDefaultGlbChild(matName, 'materialSchema');
180
+ const child = generateDefaultGlbChild(matName, 'materialSchema', config.config.defaults);
173
181
  const matConfig = pbrToMaterialConfig(mat.pbrConfig, container.id);
174
182
  child.materialConfig = {
175
183
  schemaId: matConfig.schemaId,
@@ -189,7 +197,7 @@ export async function fullBuild(projectDir, opts) {
189
197
  }
190
198
  }
191
199
  for (const anim of extract.animations) {
192
- childrenManifest.push(existingChildren.get(anim.name) ?? generateDefaultGlbChild(anim.name, 'animationClip'));
200
+ childrenManifest.push(existingChildren.get(anim.name) ?? generateDefaultGlbChild(anim.name, 'animationClip', config.config.defaults));
193
201
  }
194
202
  // Update stowmeta with inline children
195
203
  containerMeta.children = childrenManifest;
@@ -408,7 +416,26 @@ export async function fullBuild(projectDir, opts) {
408
416
  }
409
417
  // 4. Clean orphaned caches/metas
410
418
  await cleanupProject(config.projectDir, { verbose });
411
- // 5. Build packs
419
+ // 5. Validate unique stringIds
420
+ const idCounts = new Map();
421
+ for (const a of assets) {
422
+ if (a.status !== 'ready' || a.type === AssetType.GlbContainer || a.settings.excluded)
423
+ continue;
424
+ const files = idCounts.get(a.stringId) ?? [];
425
+ files.push(a.id);
426
+ idCounts.set(a.stringId, files);
427
+ }
428
+ const duplicates = [...idCounts.entries()].filter(([, files]) => files.length > 1);
429
+ if (duplicates.length > 0) {
430
+ console.error('Build failed: duplicate stringIds found. Each asset must have a unique stringId.');
431
+ for (const [id, files] of duplicates) {
432
+ console.error(` "${id}" used by:`);
433
+ for (const f of files)
434
+ console.error(` - ${f}`);
435
+ }
436
+ throw new Error(`Cannot build: ${duplicates.length} duplicate stringId(s) found.`);
437
+ }
438
+ // 6. Build packs
412
439
  const packs = config.config.packs ?? [{ name: 'default' }];
413
440
  const cdnDir = path.resolve(config.projectDir, config.config.cdnAssetsPath ?? 'public/cdn-assets');
414
441
  await fs.mkdir(cdnDir, { recursive: true });
package/dist/pipeline.js CHANGED
@@ -54,6 +54,7 @@ export async function processAsset(id, sourceData, type, stringId, settings, ctx
54
54
  log('encoding KTX2...');
55
55
  const result = await ctx.textureEncoder.encode(pixels, width, height, 4, settings.quality, TextureChannelFormat.RGBA, settings.generateMipmaps);
56
56
  result.metadata.stringId = stringId;
57
+ result.metadata.filtering = settings.filtering;
57
58
  BlobStore.setProcessed(id, result.data);
58
59
  log(`KTX2 encoded (${(result.data.length / 1024).toFixed(0)} KB)`);
59
60
  return { metadata: result.metadata, processedSize: result.data.length };
@@ -0,0 +1,27 @@
1
+ export interface PublishProgress {
2
+ phase: 'auth' | 'files' | 'thumbnails' | 'registry';
3
+ done: number;
4
+ total: number;
5
+ message: string;
6
+ }
7
+ export interface PublishOptions {
8
+ force?: boolean;
9
+ dryRun?: boolean;
10
+ bucket?: string;
11
+ verbose?: boolean;
12
+ /** Thumbnails keyed by stringId (from packer GUI) */
13
+ thumbnails?: Record<string, {
14
+ data: string;
15
+ format: 'png' | 'webp' | 'webm';
16
+ }>;
17
+ onProgress?: (progress: PublishProgress) => void;
18
+ }
19
+ export interface PublishResult {
20
+ ok: boolean;
21
+ packageName: string;
22
+ version: string;
23
+ assetCount: number;
24
+ fileCount: number;
25
+ thumbnailCount: number;
26
+ }
27
+ export declare function publishPackage(projectDir: string, opts?: PublishOptions): Promise<PublishResult>;