@series-inc/stowkit-cli 0.6.39 → 0.6.41

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.
@@ -1,4 +1,14 @@
1
1
  import { AssetType } from './core/types.js';
2
+ export interface SpriteSheetDetection {
3
+ isSpriteSheet: boolean;
4
+ rows?: number;
5
+ columns?: number;
6
+ }
7
+ /**
8
+ * Use Gemini Vision to detect whether an image is a sprite sheet.
9
+ * If it is, returns the detected row/column grid dimensions.
10
+ */
11
+ export declare function detectSpriteSheet(apiKey: string, imageData: Buffer, mimeType: string): Promise<SpriteSheetDetection>;
2
12
  /**
3
13
  * Verify that a Gemini API key is valid by making a minimal request.
4
14
  */
package/dist/ai-tagger.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { GoogleGenAI } from '@google/genai';
2
2
  import { AssetType } from './core/types.js';
3
- const MODEL = 'gemini-3.1-flash-lite-preview';
3
+ const MODEL_LITE = 'gemini-3.1-flash-lite-preview';
4
+ const MODEL_FLASH = 'gemini-3.1-pro-preview';
4
5
  const ASSET_TYPE_LABELS = {
5
6
  [AssetType.Texture2D]: 'texture',
6
7
  [AssetType.StaticMesh]: '3D mesh',
@@ -26,6 +27,65 @@ Rules:
26
27
  - no generic tags like asset, file, media, content
27
28
  - only include tags directly supported by the input`;
28
29
  }
30
+ /**
31
+ * Use Gemini Vision to detect whether an image is a sprite sheet.
32
+ * If it is, returns the detected row/column grid dimensions.
33
+ */
34
+ export async function detectSpriteSheet(apiKey, imageData, mimeType) {
35
+ const client = new GoogleGenAI({ apiKey });
36
+ const response = await client.models.generateContent({
37
+ model: MODEL_FLASH,
38
+ contents: [{
39
+ role: 'user',
40
+ parts: [
41
+ {
42
+ text: `Analyze this image and determine if it is a sprite sheet: a grid of multiple animation frames or game sprites arranged in regular rows and columns.
43
+
44
+ If it IS a sprite sheet, count the rows and columns of the grid.
45
+ If it is NOT a sprite sheet (e.g. a single image, photo, icon, or seamless texture), respond accordingly.
46
+
47
+ Respond with ONLY a JSON object:
48
+ - Sprite sheet: { "isSpriteSheet": true, "rows": <number>, "columns": <number> }
49
+ - Not a sprite sheet: { "isSpriteSheet": false }`,
50
+ },
51
+ {
52
+ inlineData: {
53
+ data: imageData.toString('base64'),
54
+ mimeType,
55
+ },
56
+ },
57
+ ],
58
+ }],
59
+ config: {
60
+ temperature: 0.1,
61
+ maxOutputTokens: 64,
62
+ responseMimeType: 'application/json',
63
+ responseSchema: {
64
+ type: 'OBJECT',
65
+ properties: {
66
+ isSpriteSheet: { type: 'BOOLEAN' },
67
+ rows: { type: 'INTEGER' },
68
+ columns: { type: 'INTEGER' },
69
+ },
70
+ required: ['isSpriteSheet'],
71
+ },
72
+ safetySettings: [
73
+ { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
74
+ { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
75
+ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
76
+ { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
77
+ ],
78
+ },
79
+ });
80
+ const text = response.text ?? '{}';
81
+ const parsed = JSON.parse(text);
82
+ return {
83
+ isSpriteSheet: !!parsed.isSpriteSheet,
84
+ rows: typeof parsed.rows === 'number' && parsed.rows > 0 ? parsed.rows : undefined,
85
+ columns: typeof parsed.columns === 'number' && parsed.columns > 0 ? parsed.columns : undefined,
86
+ };
87
+ }
88
+ // ─── API Key Verification ─────────────────────────────────────────────────────
29
89
  /**
30
90
  * Verify that a Gemini API key is valid by making a minimal request.
31
91
  */
@@ -33,7 +93,7 @@ export async function verifyApiKey(apiKey) {
33
93
  try {
34
94
  const client = new GoogleGenAI({ apiKey });
35
95
  const response = await client.models.generateContent({
36
- model: MODEL,
96
+ model: MODEL_LITE,
37
97
  contents: [{ role: 'user', parts: [{ text: 'Respond with the word "ok"' }] }],
38
98
  config: { maxOutputTokens: 10 },
39
99
  });
@@ -51,7 +111,7 @@ export async function verifyApiKey(apiKey) {
51
111
  export async function tagAsset(apiKey, assetType, fileName, inputData, mimeType) {
52
112
  const client = new GoogleGenAI({ apiKey });
53
113
  const response = await client.models.generateContent({
54
- model: MODEL,
114
+ model: MODEL_LITE,
55
115
  contents: [{
56
116
  role: 'user',
57
117
  parts: [
@@ -80,6 +80,9 @@ export interface StowMetaAnimationClip extends StowMetaBase {
80
80
  export interface StowMetaMaterialSchema extends StowMetaBase {
81
81
  type: 'materialSchema';
82
82
  }
83
+ export interface StowMetaSpriteSheet extends StowMetaBase {
84
+ type: 'spriteSheet';
85
+ }
83
86
  export interface StowMetaGlbChild {
84
87
  name: string;
85
88
  childType: string;
@@ -108,7 +111,7 @@ export interface StowMetaGlbContainer extends StowMetaBase {
108
111
  preserveHierarchy?: boolean;
109
112
  children: StowMetaGlbChild[];
110
113
  }
111
- export type StowMeta = StowMetaTexture | StowMetaAudio | StowMetaStaticMesh | StowMetaSkinnedMesh | StowMetaAnimationClip | StowMetaMaterialSchema | StowMetaGlbContainer;
114
+ export type StowMeta = StowMetaTexture | StowMetaAudio | StowMetaStaticMesh | StowMetaSkinnedMesh | StowMetaAnimationClip | StowMetaMaterialSchema | StowMetaSpriteSheet | StowMetaGlbContainer;
112
115
  export interface StowMatProperty {
113
116
  fieldName: string;
114
117
  fieldType: string;
@@ -121,6 +124,14 @@ export interface StowMat {
121
124
  schemaName: string;
122
125
  properties: StowMatProperty[];
123
126
  }
127
+ export interface StowSpriteSheet {
128
+ version: 1;
129
+ textureAsset: string | null;
130
+ rows: number;
131
+ columns: number;
132
+ frameCount: number;
133
+ frameRate: number;
134
+ }
124
135
  export declare const KTX2_QUALITY_STRINGS: Record<string, KTX2Quality>;
125
136
  export declare const KTX2_QUALITY_TO_STRING: Record<number, string>;
126
137
  export declare const TEXTURE_RESIZE_STRINGS: Record<string, TextureResize>;
@@ -1,4 +1,4 @@
1
- import type { AssetType, TextureMetadata, AudioMetadata, MeshMetadata, AnimationClipMetadata, SkinnedMeshMetadata, KTX2Quality, TextureResize, TextureFilterMode, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
1
+ import type { AssetType, TextureMetadata, AudioMetadata, MeshMetadata, AnimationClipMetadata, SkinnedMeshMetadata, SpriteSheetMetadata, KTX2Quality, TextureResize, TextureFilterMode, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
2
2
  export interface MaterialProperty {
3
3
  fieldName: string;
4
4
  fieldType: MaterialFieldType;
@@ -10,6 +10,13 @@ export interface MaterialConfig {
10
10
  schemaId: string;
11
11
  properties: MaterialProperty[];
12
12
  }
13
+ export interface SpriteSheetConfig {
14
+ textureAssetId: string | null;
15
+ rows: number;
16
+ columns: number;
17
+ frameCount: number;
18
+ frameRate: number;
19
+ }
13
20
  export interface AssetSettings {
14
21
  quality: KTX2Quality;
15
22
  resize: TextureResize;
@@ -21,6 +28,7 @@ export interface AssetSettings {
21
28
  audioSampleRate: AudioSampleRate;
22
29
  targetMeshId: string | null;
23
30
  materialConfig: MaterialConfig;
31
+ spritesheetConfig: SpriteSheetConfig;
24
32
  materialOverrides: Record<number, string | null>;
25
33
  pack: string;
26
34
  excluded: boolean;
@@ -35,7 +43,7 @@ export interface ProjectAsset {
35
43
  status: 'loading' | 'pending' | 'processing' | 'ready' | 'error';
36
44
  error?: string;
37
45
  settings: AssetSettings;
38
- metadata?: TextureMetadata | AudioMetadata | MeshMetadata | AnimationClipMetadata | SkinnedMeshMetadata;
46
+ metadata?: TextureMetadata | AudioMetadata | MeshMetadata | AnimationClipMetadata | SkinnedMeshMetadata | SpriteSheetMetadata;
39
47
  sourceSize: number;
40
48
  processedSize: number;
41
49
  parentId?: string;
package/dist/app/state.js CHANGED
@@ -11,6 +11,7 @@ export function defaultAssetSettings() {
11
11
  audioSampleRate: ASR.Auto,
12
12
  targetMeshId: null,
13
13
  materialConfig: { schemaId: '', properties: [] },
14
+ spritesheetConfig: { textureAssetId: null, rows: 1, columns: 1, frameCount: 1, frameRate: 12 },
14
15
  materialOverrides: {},
15
16
  pack: 'default',
16
17
  excluded: false,
@@ -114,6 +114,11 @@ export function stowmetaToAssetSettings(meta) {
114
114
  type: AssetType.MaterialSchema,
115
115
  settings: base,
116
116
  };
117
+ case 'spriteSheet':
118
+ return {
119
+ type: AssetType.SpriteSheet,
120
+ settings: base,
121
+ };
117
122
  case 'glbContainer':
118
123
  return {
119
124
  type: AssetType.GlbContainer,
@@ -185,6 +190,8 @@ export function assetSettingsToStowmeta(asset) {
185
190
  };
186
191
  case AssetType.MaterialSchema:
187
192
  return { ...base, type: 'materialSchema' };
193
+ case AssetType.SpriteSheet:
194
+ return { ...base, type: 'spriteSheet' };
188
195
  case AssetType.GlbContainer:
189
196
  return { ...base, type: 'glbContainer', preserveHierarchy: asset.settings.preserveHierarchy || undefined, children: [] };
190
197
  default:
@@ -250,6 +257,8 @@ export function generateDefaultStowmeta(relativePath, type, projectDefaults, ima
250
257
  return { ...base, type: 'animationClip', targetMeshId: null };
251
258
  case AssetType.MaterialSchema:
252
259
  return { ...base, type: 'materialSchema' };
260
+ case AssetType.SpriteSheet:
261
+ return { ...base, type: 'spriteSheet' };
253
262
  case AssetType.GlbContainer:
254
263
  return { ...base, type: 'glbContainer', children: [] };
255
264
  default:
@@ -0,0 +1,6 @@
1
+ import type { SpriteSheetConfig } from './state.js';
2
+ import type { StowSpriteSheet } from './disk-project.js';
3
+ export declare function readStowSpriteSheet(srcArtDir: string, relativePath: string): Promise<StowSpriteSheet | null>;
4
+ export declare function writeStowSpriteSheet(srcArtDir: string, relativePath: string, sheet: StowSpriteSheet): Promise<void>;
5
+ export declare function spritesheetConfigToStowSpriteSheet(config: SpriteSheetConfig): StowSpriteSheet;
6
+ export declare function stowSpriteSheetToConfig(sheet: StowSpriteSheet): SpriteSheetConfig;
@@ -0,0 +1,40 @@
1
+ import { readTextFile, writeFile } from '../node-fs.js';
2
+ export async function readStowSpriteSheet(srcArtDir, relativePath) {
3
+ const text = await readTextFile(srcArtDir, relativePath);
4
+ if (!text)
5
+ return null;
6
+ try {
7
+ return JSON.parse(text);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ export async function writeStowSpriteSheet(srcArtDir, relativePath, sheet) {
14
+ try {
15
+ const json = JSON.stringify(sheet, null, 2);
16
+ await writeFile(srcArtDir, relativePath, json);
17
+ }
18
+ catch (err) {
19
+ console.warn(`[stowspritesheet] Failed to write ${relativePath}:`, err);
20
+ }
21
+ }
22
+ export function spritesheetConfigToStowSpriteSheet(config) {
23
+ return {
24
+ version: 1,
25
+ textureAsset: config.textureAssetId,
26
+ rows: config.rows,
27
+ columns: config.columns,
28
+ frameCount: config.frameCount,
29
+ frameRate: config.frameRate,
30
+ };
31
+ }
32
+ export function stowSpriteSheetToConfig(sheet) {
33
+ return {
34
+ textureAssetId: sheet.textureAsset ?? null,
35
+ rows: sheet.rows ?? 1,
36
+ columns: sheet.columns ?? 1,
37
+ frameCount: sheet.frameCount ?? 1,
38
+ frameRate: sheet.frameRate ?? 12,
39
+ };
40
+ }
@@ -36,7 +36,7 @@ export async function writeManifest(srcArtDir, manifest) {
36
36
  export function computeSettingsHash(settings) {
37
37
  // Only include settings that affect visual output
38
38
  const relevant = {};
39
- for (const key of ['quality', 'resize', 'generateMipmaps', 'dracoQuality', 'materialConfig', 'targetMeshId', 'materialOverrides']) {
39
+ for (const key of ['quality', 'resize', 'generateMipmaps', 'dracoQuality', 'materialConfig', 'spritesheetConfig', 'targetMeshId', 'materialOverrides']) {
40
40
  if (settings[key] !== undefined)
41
41
  relevant[key] = settings[key];
42
42
  }
package/dist/cleanup.js CHANGED
@@ -9,11 +9,12 @@ export async function cleanupProject(projectDir, opts) {
9
9
  // Build set of source files that exist
10
10
  const sourceFiles = new Set(scan.sourceFiles.map(f => f.relativePath));
11
11
  const matFiles = new Set(scan.matFiles.map(f => f.relativePath));
12
+ const spritesheetFiles = new Set(scan.spritesheetFiles.map(f => f.relativePath));
12
13
  let deletedCaches = 0;
13
14
  let deletedMetas = 0;
14
15
  let freedBytes = 0;
15
16
  // Walk all files looking for orphaned .stowcache and .stowmeta
16
- await walkForOrphans(config.srcArtDir, '', sourceFiles, matFiles, async (orphanPath, type, size) => {
17
+ await walkForOrphans(config.srcArtDir, '', sourceFiles, matFiles, spritesheetFiles, async (orphanPath, type, size) => {
17
18
  const fullPath = path.join(config.srcArtDir, orphanPath);
18
19
  await fs.unlink(fullPath);
19
20
  freedBytes += size;
@@ -43,7 +44,7 @@ export async function cleanupProject(projectDir, opts) {
43
44
  console.log(`Cleaned up ${deletedCaches} cache(s) and ${deletedMetas} meta(s)${thumbNote} (${(freedBytes / 1024).toFixed(0)} KB freed)`);
44
45
  }
45
46
  }
46
- async function walkForOrphans(basePath, prefix, sourceFiles, matFiles, onOrphan) {
47
+ async function walkForOrphans(basePath, prefix, sourceFiles, matFiles, spritesheetFiles, onOrphan) {
47
48
  const dirPath = prefix ? path.join(basePath, prefix) : basePath;
48
49
  let entries;
49
50
  try {
@@ -59,21 +60,21 @@ async function walkForOrphans(basePath, prefix, sourceFiles, matFiles, onOrphan)
59
60
  continue;
60
61
  if (entry.name.endsWith('.children'))
61
62
  continue;
62
- await walkForOrphans(basePath, relativePath, sourceFiles, matFiles, onOrphan);
63
+ await walkForOrphans(basePath, relativePath, sourceFiles, matFiles, spritesheetFiles, onOrphan);
63
64
  }
64
65
  else if (entry.isFile()) {
65
66
  if (entry.name.endsWith('.stowcache')) {
66
67
  // Cache orphan: source file doesn't exist
67
68
  const sourceId = relativePath.replace(/\.stowcache$/, '');
68
- if (!sourceFiles.has(sourceId) && !matFiles.has(sourceId)) {
69
+ if (!sourceFiles.has(sourceId) && !matFiles.has(sourceId) && !spritesheetFiles.has(sourceId)) {
69
70
  const stat = await fs.stat(path.join(basePath, relativePath));
70
71
  await onOrphan(relativePath, 'cache', stat.size);
71
72
  }
72
73
  }
73
74
  else if (entry.name.endsWith('.stowmeta')) {
74
- // Meta orphan: neither source file nor mat file exists
75
+ // Meta orphan: neither source file, mat file, nor spritesheet file exists
75
76
  const sourceId = relativePath.replace(/\.stowmeta$/, '');
76
- if (!sourceFiles.has(sourceId) && !matFiles.has(sourceId)) {
77
+ if (!sourceFiles.has(sourceId) && !matFiles.has(sourceId) && !spritesheetFiles.has(sourceId)) {
77
78
  const stat = await fs.stat(path.join(basePath, relativePath));
78
79
  await onOrphan(relativePath, 'meta', stat.size);
79
80
  }
package/dist/cli.js CHANGED
@@ -78,6 +78,7 @@ Usage:
78
78
  stowkit init [dir] Initialize a StowKit project (interactive menu)
79
79
  stowkit init --with-engine Initialize with 3D engine included
80
80
  stowkit init --no-engine Initialize without 3D engine (skip prompt)
81
+ stowkit init --with-publish Initialize with assets-package.json for publishing
81
82
  stowkit init --update [dir] Update AI skill files to match installed CLI version
82
83
  stowkit build [dir] Full build: scan + process + pack
83
84
  stowkit scan [dir] Detect new assets, generate .stowmeta defaults
@@ -175,6 +176,7 @@ async function main() {
175
176
  update: args.includes('--update'),
176
177
  withEngine: args.includes('--with-engine'),
177
178
  noEngine: args.includes('--no-engine'),
179
+ withPublish: args.includes('--with-publish'),
178
180
  });
179
181
  break;
180
182
  case 'update': {
@@ -69,3 +69,5 @@ export declare const ANIMATION_CLIP_METADATA_FIXED_SIZE = 268;
69
69
  export declare const TRACK_NAME_SIZE = 64;
70
70
  /** Metadata version discriminator for v2 animation clips */
71
71
  export declare const ANIMATION_METADATA_VERSION = 2;
72
+ /** Size of SpriteSheetMetadata on disk (bytes) */
73
+ export declare const SPRITESHEET_METADATA_SIZE = 272;
@@ -70,3 +70,5 @@ export const ANIMATION_CLIP_METADATA_FIXED_SIZE = 268;
70
70
  export const TRACK_NAME_SIZE = 64;
71
71
  /** Metadata version discriminator for v2 animation clips */
72
72
  export const ANIMATION_METADATA_VERSION = 2;
73
+ /** Size of SpriteSheetMetadata on disk (bytes) */
74
+ export const SPRITESHEET_METADATA_SIZE = 272;
@@ -6,7 +6,8 @@ export declare enum AssetType {
6
6
  MaterialSchema = 4,
7
7
  SkinnedMesh = 5,
8
8
  AnimationClip = 6,
9
- GlbContainer = 7
9
+ GlbContainer = 7,
10
+ SpriteSheet = 8
10
11
  }
11
12
  export declare enum KTX2Quality {
12
13
  Fastest = 0,
@@ -142,6 +143,14 @@ export interface MaterialSchemaMetadata {
142
143
  fieldCount: number;
143
144
  fields: MaterialSchemaField[];
144
145
  }
146
+ export interface SpriteSheetMetadata {
147
+ stringId: string;
148
+ textureId: string;
149
+ rows: number;
150
+ columns: number;
151
+ frameCount: number;
152
+ frameRate: number;
153
+ }
145
154
  export interface MeshMetadata {
146
155
  meshGeometryCount: number;
147
156
  materialCount: number;
@@ -9,6 +9,7 @@ export var AssetType;
9
9
  AssetType[AssetType["SkinnedMesh"] = 5] = "SkinnedMesh";
10
10
  AssetType[AssetType["AnimationClip"] = 6] = "AnimationClip";
11
11
  AssetType[AssetType["GlbContainer"] = 7] = "GlbContainer";
12
+ AssetType[AssetType["SpriteSheet"] = 8] = "SpriteSheet";
12
13
  })(AssetType || (AssetType = {}));
13
14
  // ─── Texture Enums ──────────────────────────────────────────────────────────
14
15
  export var KTX2Quality;
@@ -1,4 +1,4 @@
1
- import type { TextureMetadata, AudioMetadata, MeshMetadata, MaterialSchemaMetadata, AnimationClipMetadata, SkinnedMeshMetadata } from '../core/types.js';
1
+ import type { TextureMetadata, AudioMetadata, MeshMetadata, MaterialSchemaMetadata, AnimationClipMetadata, SkinnedMeshMetadata, SpriteSheetMetadata } from '../core/types.js';
2
2
  export declare function serializeTextureMetadata(meta: TextureMetadata): Uint8Array;
3
3
  export declare function deserializeTextureMetadata(data: Uint8Array): TextureMetadata;
4
4
  export declare function serializeAudioMetadata(meta: AudioMetadata): Uint8Array;
@@ -11,6 +11,8 @@ export declare function serializeAnimationClipMetadata(meta: AnimationClipMetada
11
11
  export declare function deserializeAnimationClipMetadata(data: Uint8Array): AnimationClipMetadata;
12
12
  export declare function serializeSkinnedMeshMetadata(meta: SkinnedMeshMetadata): Uint8Array;
13
13
  export declare function deserializeSkinnedMeshMetadata(data: Uint8Array): SkinnedMeshMetadata;
14
+ export declare function serializeSpriteSheetMetadata(meta: SpriteSheetMetadata): Uint8Array;
15
+ export declare function deserializeSpriteSheetMetadata(data: Uint8Array): SpriteSheetMetadata;
14
16
  export declare function wrapMetadata(assetMetadata: Uint8Array, tags: string[]): Uint8Array;
15
17
  export declare function unwrapMetadata(wrapped: Uint8Array): {
16
18
  tags: string[];
@@ -1,5 +1,5 @@
1
1
  import { BinaryReader, BinaryWriter } from '../core/binary.js';
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';
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, SPRITESHEET_METADATA_SIZE, } from '../core/constants.js';
3
3
  import { TextureFilterMode } from '../core/types.js';
4
4
  // ─── Texture Metadata ───────────────────────────────────────────────────────
5
5
  export function serializeTextureMetadata(meta) {
@@ -361,6 +361,28 @@ export function deserializeSkinnedMeshMetadata(data) {
361
361
  bones.push(deserializeBone(r));
362
362
  return { meshGeometryCount, materialCount, nodeCount, boneCount, stringId, geometries, materials, nodes, meshIndices, bones };
363
363
  }
364
+ // ─── SpriteSheet Metadata ────────────────────────────────────────────────────
365
+ export function serializeSpriteSheetMetadata(meta) {
366
+ const w = new BinaryWriter(SPRITESHEET_METADATA_SIZE);
367
+ w.writeFixedString(meta.stringId, STRING_ID_SIZE);
368
+ w.writeFixedString(meta.textureId, STRING_ID_SIZE);
369
+ w.writeUint32(meta.rows);
370
+ w.writeUint32(meta.columns);
371
+ w.writeUint32(meta.frameCount);
372
+ w.writeFloat32(meta.frameRate);
373
+ return w.getUint8Array();
374
+ }
375
+ export function deserializeSpriteSheetMetadata(data) {
376
+ const r = new BinaryReader(data);
377
+ return {
378
+ stringId: r.readFixedString(STRING_ID_SIZE),
379
+ textureId: r.readFixedString(STRING_ID_SIZE),
380
+ rows: r.readUint32(),
381
+ columns: r.readUint32(),
382
+ frameCount: r.readUint32(),
383
+ frameRate: r.readFloat32(),
384
+ };
385
+ }
364
386
  // ─── Metadata Wrapping ──────────────────────────────────────────────────────
365
387
  export function wrapMetadata(assetMetadata, tags) {
366
388
  const tagCsv = tags.length > 0 ? tags.join(',') + '\0' : '\0';
package/dist/init.d.ts CHANGED
@@ -2,5 +2,6 @@ export interface InitOptions {
2
2
  update?: boolean;
3
3
  withEngine?: boolean;
4
4
  noEngine?: boolean;
5
+ withPublish?: boolean;
5
6
  }
6
7
  export declare function initProject(projectDir: string, opts?: InitOptions): Promise<void>;
package/dist/init.js CHANGED
@@ -153,6 +153,12 @@ export async function initProject(projectDir, opts) {
153
153
  if (withEngine) {
154
154
  await installEngine(absDir);
155
155
  }
156
+ // Create assets-package.json for publishing
157
+ if (opts?.withPublish) {
158
+ const { initAssetsPackage } = await import('./assets-package.js');
159
+ await initAssetsPackage(absDir, { name: path.basename(absDir) });
160
+ console.log(` Publish config: assets-package.json`);
161
+ }
156
162
  console.log('');
157
163
  if (withEngine) {
158
164
  console.log('Ready to go! Run:');
package/dist/node-fs.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface ScanResult {
14
14
  sourceFiles: FileSnapshot[];
15
15
  metaFiles: FileSnapshot[];
16
16
  matFiles: FileSnapshot[];
17
+ spritesheetFiles: FileSnapshot[];
17
18
  folders: string[];
18
19
  }
19
20
  export declare function scanDirectory(basePath: string): Promise<ScanResult>;
package/dist/node-fs.js CHANGED
@@ -98,15 +98,19 @@ function isMetaFile(name) {
98
98
  function isMatFile(name) {
99
99
  return name.endsWith('.stowmat');
100
100
  }
101
+ function isSpritesheetFile(name) {
102
+ return name.endsWith('.stowspritesheet');
103
+ }
101
104
  export async function scanDirectory(basePath) {
102
105
  const sourceFiles = [];
103
106
  const metaFiles = [];
104
107
  const matFiles = [];
108
+ const spritesheetFiles = [];
105
109
  const folders = [];
106
- await walkDirectory(basePath, '', sourceFiles, metaFiles, matFiles, folders);
107
- return { sourceFiles, metaFiles, matFiles, folders };
110
+ await walkDirectory(basePath, '', sourceFiles, metaFiles, matFiles, spritesheetFiles, folders);
111
+ return { sourceFiles, metaFiles, matFiles, spritesheetFiles, folders };
108
112
  }
109
- async function walkDirectory(basePath, prefix, sourceFiles, metaFiles, matFiles, folders) {
113
+ async function walkDirectory(basePath, prefix, sourceFiles, metaFiles, matFiles, spritesheetFiles, folders) {
110
114
  const dirPath = prefix ? path.join(basePath, prefix) : basePath;
111
115
  let entries;
112
116
  try {
@@ -123,7 +127,7 @@ async function walkDirectory(basePath, prefix, sourceFiles, metaFiles, matFiles,
123
127
  if (entry.name.endsWith('.children'))
124
128
  continue;
125
129
  folders.push(relativePath);
126
- await walkDirectory(basePath, relativePath, sourceFiles, metaFiles, matFiles, folders);
130
+ await walkDirectory(basePath, relativePath, sourceFiles, metaFiles, matFiles, spritesheetFiles, folders);
127
131
  }
128
132
  else if (entry.isFile()) {
129
133
  const fullPath = path.join(basePath, relativePath);
@@ -145,6 +149,9 @@ async function walkDirectory(basePath, prefix, sourceFiles, metaFiles, matFiles,
145
149
  else if (isMatFile(entry.name)) {
146
150
  matFiles.push(snapshot);
147
151
  }
152
+ else if (isSpritesheetFile(entry.name)) {
153
+ spritesheetFiles.push(snapshot);
154
+ }
148
155
  else if (isSourceFile(entry.name)) {
149
156
  sourceFiles.push(snapshot);
150
157
  }
@@ -10,6 +10,7 @@ export interface ScanReport {
10
10
  sourceFiles: number;
11
11
  metaFiles: number;
12
12
  matFiles: number;
13
+ spritesheetFiles: number;
13
14
  newFiles: string[];
14
15
  totalAssets: number;
15
16
  }
@@ -8,6 +8,7 @@ import { readProjectConfig, scanDirectory, readFile, getFileSnapshot, probeImage
8
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
+ import { readStowSpriteSheet, stowSpriteSheetToConfig } from './app/stowspritesheet-io.js';
11
12
  import { readCacheBlobs, writeCacheBlobs, buildCacheStamp, isCacheValid, } from './app/process-cache.js';
12
13
  import { buildPack, processExtractedAnimations, validatePackDependencies } from './pipeline.js';
13
14
  import { WorkerPool } from './workers/worker-pool.js';
@@ -37,8 +38,9 @@ export async function scanProject(projectDir, opts) {
37
38
  sourceFiles: scan.sourceFiles.length,
38
39
  metaFiles: scan.metaFiles.length,
39
40
  matFiles: scan.matFiles.length,
41
+ spritesheetFiles: scan.spritesheetFiles.length,
40
42
  newFiles,
41
- totalAssets: scan.sourceFiles.length + scan.matFiles.length,
43
+ totalAssets: scan.sourceFiles.length + scan.matFiles.length + scan.spritesheetFiles.length,
42
44
  };
43
45
  }
44
46
  // ─── Full Build ──────────────────────────────────────────────────────────────
@@ -56,7 +58,7 @@ export async function fullBuild(projectDir, opts) {
56
58
  // 1. Scan
57
59
  const scan = await scanDirectory(config.srcArtDir);
58
60
  if (verbose)
59
- console.log(`Found ${scan.sourceFiles.length} source files, ${scan.matFiles.length} materials`);
61
+ console.log(`Found ${scan.sourceFiles.length} source files, ${scan.matFiles.length} materials, ${scan.spritesheetFiles.length} spritesheets`);
60
62
  // 2. Build asset list
61
63
  const assets = [];
62
64
  const assetsById = new Map();
@@ -139,6 +141,33 @@ export async function fullBuild(projectDir, opts) {
139
141
  assets.push(asset);
140
142
  assetsById.set(id, asset);
141
143
  }
144
+ // SpriteSheets from .stowspritesheet files
145
+ for (const ssFile of scan.spritesheetFiles) {
146
+ const id = ssFile.relativePath;
147
+ const sheet = await readStowSpriteSheet(config.srcArtDir, id);
148
+ if (!sheet)
149
+ continue;
150
+ const spritesheetConfig = stowSpriteSheetToConfig(sheet);
151
+ const fileName = id.split('/').pop() ?? id;
152
+ const baseName = fileName.replace(/\.[^.]+$/, '');
153
+ let meta = await readStowmeta(config.srcArtDir, id);
154
+ if (!meta) {
155
+ meta = generateDefaultStowmeta(id, AssetType.SpriteSheet, config.config.defaults);
156
+ await writeStowmeta(config.srcArtDir, id, meta);
157
+ }
158
+ const asset = {
159
+ id,
160
+ fileName,
161
+ stringId: meta.stringId || baseName,
162
+ type: AssetType.SpriteSheet,
163
+ status: 'ready',
164
+ settings: { ...defaultAssetSettings(), spritesheetConfig, pack: meta.pack ?? 'default', tags: meta.tags ?? [] },
165
+ sourceSize: ssFile.size,
166
+ processedSize: 0,
167
+ };
168
+ assets.push(asset);
169
+ assetsById.set(id, asset);
170
+ }
142
171
  // 2b. Extract all GLB containers (always re-parse so preserveHierarchy etc. are reflected)
143
172
  const glbExtracts = new Map();
144
173
  const glbContainers = assets.filter(a => a.type === AssetType.GlbContainer);
@@ -480,6 +509,7 @@ export async function showStatus(projectDir) {
480
509
  console.log(`Source dir: ${config.srcArtDir}`);
481
510
  console.log(`Source files: ${scan.sourceFiles.length}`);
482
511
  console.log(`Material files: ${scan.matFiles.length}`);
512
+ console.log(`SpriteSheet files: ${scan.spritesheetFiles.length}`);
483
513
  console.log(`Meta files: ${scan.metaFiles.length}`);
484
514
  const packs = config.config.packs ?? [{ name: 'default' }];
485
515
  console.log(`Packs: ${packs.map(p => p.name).join(', ')}`);
package/dist/pipeline.js CHANGED
@@ -2,7 +2,7 @@ import { AssetType, TextureChannelFormat, TextureResize, AudioSampleRate } from
2
2
  import { parseGlb } from './encoders/glb-loader.js';
3
3
  import { dracoPresetToSettings } from './encoders/draco-encoder.js';
4
4
  import { buildSkinnedMeshData } from './encoders/skinned-mesh-builder.js';
5
- import { serializeTextureMetadata, serializeAudioMetadata, serializeMeshMetadata, serializeAnimationClipMetadata, serializeSkinnedMeshMetadata, serializeMaterialSchemaMetadata, deserializeSkinnedMeshMetadata, deserializeAnimationClipMetadata, } from './format/metadata.js';
5
+ import { serializeTextureMetadata, serializeAudioMetadata, serializeMeshMetadata, serializeAnimationClipMetadata, serializeSkinnedMeshMetadata, serializeMaterialSchemaMetadata, serializeSpriteSheetMetadata, deserializeSkinnedMeshMetadata, deserializeAnimationClipMetadata, } from './format/metadata.js';
6
6
  import { StowPacker } from './format/packer.js';
7
7
  import { BlobStore } from './app/blob-store.js';
8
8
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -267,6 +267,22 @@ export function validatePackDependencies(packAssets, allAssetsById) {
267
267
  }
268
268
  }
269
269
  }
270
+ // SpriteSheet: check texture reference
271
+ if (asset.type === AssetType.SpriteSheet) {
272
+ const texId = asset.settings.spritesheetConfig.textureAssetId;
273
+ if (texId) {
274
+ const tex = allAssetsById.get(texId);
275
+ if (!tex) {
276
+ errors.push({ assetId: asset.id, message: `SpriteSheet "${asset.stringId}" references missing texture "${texId}"` });
277
+ }
278
+ else if (tex.settings.excluded) {
279
+ errors.push({ assetId: asset.id, message: `SpriteSheet "${asset.stringId}" references excluded texture "${tex.stringId}"` });
280
+ }
281
+ else if (!packIds.has(tex.id)) {
282
+ errors.push({ assetId: asset.id, message: `SpriteSheet "${asset.stringId}" references texture "${tex.stringId}" which is not in the same pack` });
283
+ }
284
+ }
285
+ }
270
286
  // AnimationClip: check target mesh reference
271
287
  if (asset.type === AssetType.AnimationClip && asset.settings.targetMeshId) {
272
288
  const target = allAssetsById.get(asset.settings.targetMeshId);
@@ -298,6 +314,12 @@ export function buildPack(assets, assetsById) {
298
314
  packer.addAsset(asset.stringId, AssetType.MaterialSchema, new Uint8Array(0), schemaBytes, asset.settings.tags);
299
315
  continue;
300
316
  }
317
+ if (asset.type === AssetType.SpriteSheet) {
318
+ const ssMeta = buildSpriteSheetMetadata(asset, assetsById);
319
+ const ssBytes = serializeSpriteSheetMetadata(ssMeta);
320
+ packer.addAsset(asset.stringId, AssetType.SpriteSheet, new Uint8Array(0), ssBytes, asset.settings.tags);
321
+ continue;
322
+ }
301
323
  const processedData = BlobStore.getProcessed(asset.id);
302
324
  if (!processedData) {
303
325
  console.warn(`[buildPack] Skipping ${asset.id} (type=${asset.type}): no processed data in BlobStore`);
@@ -433,6 +455,20 @@ function buildMaterialSchemaMetadata(asset, assetsById) {
433
455
  fields,
434
456
  };
435
457
  }
458
+ function buildSpriteSheetMetadata(asset, assetsById) {
459
+ const config = asset.settings.spritesheetConfig;
460
+ const textureAsset = config.textureAssetId
461
+ ? assetsById.get(config.textureAssetId)
462
+ : undefined;
463
+ return {
464
+ stringId: asset.stringId,
465
+ textureId: textureAsset?.stringId ?? '',
466
+ rows: config.rows,
467
+ columns: config.columns,
468
+ frameCount: config.frameCount,
469
+ frameRate: config.frameRate,
470
+ };
471
+ }
436
472
  // ─── Animation data blob builder (v2) ────────────────────────────────────────
437
473
  export function buildAnimationDataBlobsV2(imported) {
438
474
  const results = [];
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as http from 'node:http';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
+ import { exec } from 'node:child_process';
4
5
  import { WebSocketServer, WebSocket } from 'ws';
5
6
  import { AssetType } from './core/types.js';
6
7
  import { defaultAssetSettings } from './app/state.js';
@@ -8,6 +9,7 @@ import { BlobStore } from './app/blob-store.js';
8
9
  import { readProjectConfig, scanDirectory, readFile, writeFile, renameFile, deleteFile, getFileSnapshot, probeImageDimensions, } from './node-fs.js';
9
10
  import { detectAssetType, readStowmeta, writeStowmeta, stowmetaToAssetSettings, assetSettingsToStowmeta, generateDefaultStowmeta, glbChildToAssetSettings, generateDefaultGlbChild, writeGlbChildSettings, } from './app/stowmeta-io.js';
10
11
  import { readStowmat, writeStowmat, stowmatToMaterialConfig, materialConfigToStowmat } from './app/stowmat-io.js';
12
+ import { readStowSpriteSheet, writeStowSpriteSheet, stowSpriteSheetToConfig, spritesheetConfigToStowSpriteSheet } from './app/stowspritesheet-io.js';
11
13
  import { readCacheBlobs, writeCacheBlobs, buildCacheStamp, isCacheValid, deleteCacheFile, } from './app/process-cache.js';
12
14
  import { buildPack, validatePackDependencies, processExtractedAnimations } from './pipeline.js';
13
15
  import { syncRuntimeAssets } from './sync-runtime-assets.js';
@@ -563,6 +565,68 @@ async function readBinaryBody(req) {
563
565
  chunks.push(chunk);
564
566
  return Buffer.concat(chunks);
565
567
  }
568
+ // ─── SpriteSheet grid detection ──────────────────────────────────────────────
569
+ const COMMON_TILE_SIZES = [16, 32, 48, 64, 128];
570
+ function detectSpriteGrid(width, height) {
571
+ const result = detectGridDimensions(width, height);
572
+ if (result)
573
+ return { ...result, frameCount: result.columns * result.rows };
574
+ // Absolute fallback: single frame
575
+ return { columns: 1, rows: 1, frameCount: 1 };
576
+ }
577
+ function detectGridDimensions(width, height) {
578
+ // 1. Try common square tile sizes (covers most character/monster/item sheets)
579
+ for (const tile of COMMON_TILE_SIZES) {
580
+ if (width % tile === 0 && height % tile === 0) {
581
+ return { columns: width / tile, rows: height / tile };
582
+ }
583
+ }
584
+ // 2. Square frames where frame_size = height (single-row FX sheets)
585
+ if (height < width && width % height === 0) {
586
+ return { columns: width / height, rows: 1 };
587
+ }
588
+ // 3. Near-square single-row: find frame width closest to height among width's divisors
589
+ if (height < 64 && width > height * 1.5) {
590
+ const match = findBestSingleRowCols(width, height);
591
+ if (match)
592
+ return { columns: match, rows: 1 };
593
+ }
594
+ // 4. Non-square common tile combos
595
+ for (const th of COMMON_TILE_SIZES) {
596
+ if (height % th === 0) {
597
+ const rows = height / th;
598
+ for (const tw of COMMON_TILE_SIZES) {
599
+ if (width % tw === 0)
600
+ return { columns: width / tw, rows };
601
+ }
602
+ }
603
+ }
604
+ // 5. frame_width = 16 with non-standard height
605
+ if (width % 16 === 0)
606
+ return { columns: width / 16, rows: 1 };
607
+ // 6. Near-square fallback for any size
608
+ const cols = findBestSingleRowCols(width, height);
609
+ if (cols)
610
+ return { columns: cols, rows: 1 };
611
+ return null;
612
+ }
613
+ function findBestSingleRowCols(width, height) {
614
+ let bestCloseness = Infinity;
615
+ let bestCols = null;
616
+ for (let cols = 1; cols <= width; cols++) {
617
+ if (width % cols !== 0)
618
+ continue;
619
+ const fw = width / cols;
620
+ if (fw < height * 0.4 || fw > height * 2.5)
621
+ continue;
622
+ const closeness = Math.abs(fw / height - 1.0);
623
+ if (closeness < bestCloseness) {
624
+ bestCloseness = closeness;
625
+ bestCols = cols;
626
+ }
627
+ }
628
+ return bestCloseness < 0.6 ? bestCols : null;
629
+ }
566
630
  function json(res, data, status = 200) {
567
631
  res.writeHead(status, {
568
632
  'Content-Type': 'application/json',
@@ -688,6 +752,33 @@ async function openProject(projectDir) {
688
752
  processedSize: 0,
689
753
  });
690
754
  }
755
+ // SpriteSheets from .stowspritesheet files
756
+ for (const ssFile of scan.spritesheetFiles) {
757
+ const id = ssFile.relativePath;
758
+ const sheet = await readStowSpriteSheet(projectConfig.srcArtDir, id);
759
+ const fileName = id.split('/').pop() ?? id;
760
+ const baseName = fileName.replace(/\.[^.]+$/, '');
761
+ let meta = await readStowmeta(projectConfig.srcArtDir, id);
762
+ if (!meta) {
763
+ meta = generateDefaultStowmeta(id, AssetType.SpriteSheet, projectConfig.config.defaults);
764
+ await writeStowmeta(projectConfig.srcArtDir, id, meta);
765
+ }
766
+ const spritesheetConfig = sheet ? stowSpriteSheetToConfig(sheet) : { textureAssetId: null, rows: 1, columns: 1, frameCount: 1, frameRate: 12 };
767
+ const settings = defaultAssetSettings();
768
+ settings.spritesheetConfig = spritesheetConfig;
769
+ settings.pack = meta.pack ?? 'default';
770
+ settings.tags = meta.tags ?? [];
771
+ assets.push({
772
+ id,
773
+ fileName: baseName,
774
+ stringId: meta.stringId || baseName,
775
+ type: AssetType.SpriteSheet,
776
+ status: 'ready',
777
+ settings,
778
+ sourceSize: ssFile.size,
779
+ processedSize: 0,
780
+ });
781
+ }
691
782
  console.log(`[server] Opened: ${projectConfig.projectName} (${assets.length} assets, ${folders.length} folders)`);
692
783
  }
693
784
  // ─── Process single asset ─────────────────────────────────────────────────
@@ -953,6 +1044,7 @@ async function handleRequest(req, res, staticApps) {
953
1044
  const diskFiles = new Map([
954
1045
  ...scan.sourceFiles.map(f => [f.relativePath, f]),
955
1046
  ...scan.matFiles.map(f => [f.relativePath, f]),
1047
+ ...scan.spritesheetFiles.map(f => [f.relativePath, f]),
956
1048
  ]);
957
1049
  const diskIds = new Set(diskFiles.keys());
958
1050
  // Check for added/removed files
@@ -964,7 +1056,7 @@ async function handleRequest(req, res, staticApps) {
964
1056
  const modifiedIds = [];
965
1057
  if (!changed) {
966
1058
  for (const asset of currentAssets) {
967
- if (asset.type === AssetType.MaterialSchema)
1059
+ if (asset.type === AssetType.MaterialSchema || asset.type === AssetType.SpriteSheet)
968
1060
  continue;
969
1061
  const diskFile = diskFiles.get(asset.id);
970
1062
  if (diskFile && asset.sourceSize > 0 && diskFile.size !== asset.sourceSize) {
@@ -1154,6 +1246,10 @@ async function handleRequest(req, res, staticApps) {
1154
1246
  const stowmat = materialConfigToStowmat(merged.materialConfig);
1155
1247
  await writeStowmat(projectConfig.srcArtDir, id, stowmat);
1156
1248
  }
1249
+ if (asset.type === AssetType.SpriteSheet && body.settings.spritesheetConfig) {
1250
+ const sheet = spritesheetConfigToStowSpriteSheet(merged.spritesheetConfig);
1251
+ await writeStowSpriteSheet(projectConfig.srcArtDir, id, sheet);
1252
+ }
1157
1253
  }
1158
1254
  }
1159
1255
  // Check if needs reprocessing
@@ -1423,6 +1519,34 @@ async function handleRequest(req, res, staticApps) {
1423
1519
  json(res, { ok: true, removedAssets: removedIds.length });
1424
1520
  return;
1425
1521
  }
1522
+ // POST /api/reveal — open file or folder in OS file explorer
1523
+ if (pathname === '/api/reveal' && req.method === 'POST') {
1524
+ if (!projectConfig) {
1525
+ json(res, { error: 'No project open' }, 400);
1526
+ return;
1527
+ }
1528
+ const body = JSON.parse(await readBody(req));
1529
+ const relativePath = body.path;
1530
+ if (!relativePath) {
1531
+ json(res, { error: 'Missing path' }, 400);
1532
+ return;
1533
+ }
1534
+ const fullPath = path.resolve(projectConfig.srcArtDir, relativePath);
1535
+ // Ensure the path is inside the project
1536
+ if (!fullPath.startsWith(path.resolve(projectConfig.srcArtDir))) {
1537
+ json(res, { error: 'Path outside project' }, 403);
1538
+ return;
1539
+ }
1540
+ const stat = await fs.stat(fullPath).catch(() => null);
1541
+ if (stat?.isDirectory()) {
1542
+ exec(`explorer "${fullPath}"`);
1543
+ }
1544
+ else {
1545
+ exec(`explorer /select,"${fullPath}"`);
1546
+ }
1547
+ json(res, { ok: true });
1548
+ return;
1549
+ }
1426
1550
  // GET /api/asset/:id/source — serve source file for preview
1427
1551
  if (pathname.startsWith('/api/asset/') && pathname.endsWith('/source') && req.method === 'GET') {
1428
1552
  const id = decodeURIComponent(pathname.slice('/api/asset/'.length, -'/source'.length));
@@ -1559,6 +1683,210 @@ async function handleRequest(req, res, staticApps) {
1559
1683
  json(res, { ok: true, asset });
1560
1684
  return;
1561
1685
  }
1686
+ // POST /api/create-spritesheet
1687
+ if (pathname === '/api/create-spritesheet' && req.method === 'POST') {
1688
+ if (!projectConfig) {
1689
+ json(res, { error: 'No project open' }, 400);
1690
+ return;
1691
+ }
1692
+ const body = JSON.parse(await readBody(req));
1693
+ const targetFolder = body.targetFolder ?? '';
1694
+ const name = body.name?.trim() || `SpriteSheet ${assets.filter(a => a.type === AssetType.SpriteSheet).length + 1}`;
1695
+ const baseName = `${name.replace(/\s+/g, '_')}.stowspritesheet`;
1696
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
1697
+ const settings = defaultAssetSettings();
1698
+ settings.spritesheetConfig = {
1699
+ textureAssetId: null,
1700
+ rows: 1,
1701
+ columns: 1,
1702
+ frameCount: 1,
1703
+ frameRate: 12,
1704
+ };
1705
+ const asset = {
1706
+ id: fileName,
1707
+ fileName: name,
1708
+ stringId: name,
1709
+ type: AssetType.SpriteSheet,
1710
+ status: 'ready',
1711
+ settings,
1712
+ sourceSize: 0,
1713
+ processedSize: 0,
1714
+ };
1715
+ assets.push(asset);
1716
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
1717
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
1718
+ const meta = assetSettingsToStowmeta(asset);
1719
+ await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
1720
+ json(res, { ok: true, asset });
1721
+ return;
1722
+ }
1723
+ // POST /api/convert-to-spritesheet — auto-detect grid from a texture and create a .stowspritesheet
1724
+ if (pathname === '/api/convert-to-spritesheet' && req.method === 'POST') {
1725
+ if (!projectConfig) {
1726
+ json(res, { error: 'No project open' }, 400);
1727
+ return;
1728
+ }
1729
+ const body = JSON.parse(await readBody(req));
1730
+ const textureId = body.textureId;
1731
+ if (!textureId) {
1732
+ json(res, { error: 'Missing textureId' }, 400);
1733
+ return;
1734
+ }
1735
+ const textureAsset = assets.find(a => a.id === textureId);
1736
+ if (!textureAsset || textureAsset.type !== AssetType.Texture2D) {
1737
+ json(res, { error: 'Asset is not a texture' }, 400);
1738
+ return;
1739
+ }
1740
+ // Use caller-supplied grid override, otherwise heuristic-detect from image dimensions
1741
+ let rows;
1742
+ let columns;
1743
+ if (typeof body.rows === 'number' && body.rows > 0 && typeof body.columns === 'number' && body.columns > 0) {
1744
+ rows = body.rows;
1745
+ columns = body.columns;
1746
+ }
1747
+ else {
1748
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, textureId);
1749
+ if (!dims) {
1750
+ json(res, { error: 'Could not read image dimensions' }, 500);
1751
+ return;
1752
+ }
1753
+ ({ rows, columns } = detectSpriteGrid(dims.width, dims.height));
1754
+ }
1755
+ const frameCount = rows * columns;
1756
+ // Build name: foldername_imagename_animation
1757
+ const parts = textureId.replace(/\\/g, '/').split('/');
1758
+ const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
1759
+ const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
1760
+ const safeName = [folder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
1761
+ const sheetName = `${safeName}_animation`;
1762
+ const targetFolder = parts.slice(0, -1).join('/');
1763
+ const baseName = `${sheetName}.stowspritesheet`;
1764
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
1765
+ // Check if already exists
1766
+ if (assets.find(a => a.id === fileName)) {
1767
+ json(res, { error: `Spritesheet already exists: ${fileName}` }, 409);
1768
+ return;
1769
+ }
1770
+ const settings = defaultAssetSettings();
1771
+ settings.spritesheetConfig = {
1772
+ textureAssetId: textureId,
1773
+ rows,
1774
+ columns,
1775
+ frameCount,
1776
+ frameRate: 12,
1777
+ };
1778
+ const asset = {
1779
+ id: fileName,
1780
+ fileName: sheetName,
1781
+ stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
1782
+ type: AssetType.SpriteSheet,
1783
+ status: 'ready',
1784
+ settings,
1785
+ sourceSize: 0,
1786
+ processedSize: 0,
1787
+ };
1788
+ assets.push(asset);
1789
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
1790
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
1791
+ const meta = assetSettingsToStowmeta(asset);
1792
+ await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
1793
+ broadcast({ type: 'refresh' });
1794
+ json(res, { ok: true, asset });
1795
+ return;
1796
+ }
1797
+ // POST /api/batch-convert-folder-name — find all folders matching a name and convert all textures under them to sprite sheets
1798
+ if (pathname === '/api/batch-convert-folder-name' && req.method === 'POST') {
1799
+ if (!projectConfig) {
1800
+ json(res, { error: 'No project open' }, 400);
1801
+ return;
1802
+ }
1803
+ const body = JSON.parse(await readBody(req));
1804
+ const folderName = body.folderName;
1805
+ if (!folderName) {
1806
+ json(res, { error: 'Missing folderName' }, 400);
1807
+ return;
1808
+ }
1809
+ // Find all folders that match the given name (case-insensitive)
1810
+ const matchingFolders = folders.filter(f => {
1811
+ const parts = f.replace(/\\/g, '/').split('/');
1812
+ return parts[parts.length - 1].toLowerCase() === folderName.toLowerCase();
1813
+ });
1814
+ if (matchingFolders.length === 0) {
1815
+ json(res, { error: `No folders found matching "${folderName}"` }, 404);
1816
+ return;
1817
+ }
1818
+ // Find all Texture2D assets under any of the matching folders
1819
+ const textureIds = [];
1820
+ for (const folder of matchingFolders) {
1821
+ const prefix = folder + '/';
1822
+ for (const asset of assets) {
1823
+ if (asset.type === AssetType.Texture2D && (asset.id.startsWith(prefix) || asset.id.replace(/\\/g, '/').startsWith(prefix))) {
1824
+ textureIds.push(asset.id);
1825
+ }
1826
+ }
1827
+ }
1828
+ if (textureIds.length === 0) {
1829
+ json(res, { error: `No textures found under folders matching "${folderName}"` }, 404);
1830
+ return;
1831
+ }
1832
+ // Convert each texture to a sprite sheet (reuse the same logic as convert-to-spritesheet)
1833
+ let succeeded = 0;
1834
+ let failed = 0;
1835
+ const created = [];
1836
+ for (const textureId of textureIds) {
1837
+ const textureAsset = assets.find(a => a.id === textureId);
1838
+ if (!textureAsset) {
1839
+ failed++;
1840
+ continue;
1841
+ }
1842
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, textureId);
1843
+ if (!dims) {
1844
+ failed++;
1845
+ continue;
1846
+ }
1847
+ const { columns, rows, frameCount } = detectSpriteGrid(dims.width, dims.height);
1848
+ const parts = textureId.replace(/\\/g, '/').split('/');
1849
+ const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
1850
+ const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
1851
+ const safeName = [folder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
1852
+ const sheetName = `${safeName}_animation`;
1853
+ const targetFolder = parts.slice(0, -1).join('/');
1854
+ const baseName = `${sheetName}.stowspritesheet`;
1855
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
1856
+ if (assets.find(a => a.id === fileName)) {
1857
+ failed++;
1858
+ continue;
1859
+ }
1860
+ const settings = defaultAssetSettings();
1861
+ settings.spritesheetConfig = {
1862
+ textureAssetId: textureId,
1863
+ rows,
1864
+ columns,
1865
+ frameCount,
1866
+ frameRate: 12,
1867
+ };
1868
+ const asset = {
1869
+ id: fileName,
1870
+ fileName: sheetName,
1871
+ stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
1872
+ type: AssetType.SpriteSheet,
1873
+ status: 'ready',
1874
+ settings,
1875
+ sourceSize: 0,
1876
+ processedSize: 0,
1877
+ };
1878
+ assets.push(asset);
1879
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
1880
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
1881
+ const meta = assetSettingsToStowmeta(asset);
1882
+ await writeStowmeta(projectConfig.srcArtDir, fileName, meta);
1883
+ created.push(asset);
1884
+ succeeded++;
1885
+ }
1886
+ broadcast({ type: 'refresh' });
1887
+ json(res, { ok: true, succeeded, failed, matchingFolders, assets: created });
1888
+ return;
1889
+ }
1562
1890
  // POST /api/create-folder
1563
1891
  if (pathname === '/api/create-folder' && req.method === 'POST') {
1564
1892
  if (!projectConfig) {
@@ -1973,6 +2301,31 @@ async function handleRequest(req, res, staticApps) {
1973
2301
  }
1974
2302
  return;
1975
2303
  }
2304
+ // POST /api/asset-store/package/:name/delist — remove package from registry (keeps GCS files)
2305
+ if (pathname.startsWith('/api/asset-store/package/') && pathname.endsWith('/delist') && req.method === 'POST') {
2306
+ if (!projectConfig) {
2307
+ json(res, { error: 'No project open' }, 400);
2308
+ return;
2309
+ }
2310
+ try {
2311
+ const packageName = decodeURIComponent(pathname.slice('/api/asset-store/package/'.length, -'/delist'.length));
2312
+ const { createFirestoreClient } = await import('./firestore.js');
2313
+ const firestore = await createFirestoreClient(projectConfig.projectDir);
2314
+ const pkg = await firestore.getPackage(packageName);
2315
+ if (!pkg) {
2316
+ json(res, { error: `Package "${packageName}" not found` }, 404);
2317
+ return;
2318
+ }
2319
+ await firestore.deletePackage(packageName);
2320
+ const { clearFirestoreCache } = await import('./firestore.js');
2321
+ clearFirestoreCache();
2322
+ json(res, { ok: true, packageName });
2323
+ }
2324
+ catch (err) {
2325
+ json(res, { error: err.message }, 500);
2326
+ }
2327
+ return;
2328
+ }
1976
2329
  // POST /api/publish — publish assets to GCS (SSE stream with progress)
1977
2330
  if (pathname === '/api/publish' && req.method === 'POST') {
1978
2331
  if (!projectConfig) {
@@ -2300,6 +2653,159 @@ async function handleRequest(req, res, staticApps) {
2300
2653
  }
2301
2654
  return;
2302
2655
  }
2656
+ // POST /api/ai/guess-spritesheet-grid/:id — ask Gemini to count rows/columns on a texture without creating anything
2657
+ if (pathname.startsWith('/api/ai/guess-spritesheet-grid/') && req.method === 'POST') {
2658
+ if (!projectConfig) {
2659
+ json(res, { error: 'No project open' }, 400);
2660
+ return;
2661
+ }
2662
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2663
+ if (!apiKey) {
2664
+ json(res, { error: 'Gemini API key not configured' }, 400);
2665
+ return;
2666
+ }
2667
+ const id = decodeURIComponent(pathname.slice('/api/ai/guess-spritesheet-grid/'.length));
2668
+ const asset = assets.find(a => a.id === id);
2669
+ if (!asset || asset.type !== AssetType.Texture2D) {
2670
+ json(res, { error: 'Asset is not a texture' }, 404);
2671
+ return;
2672
+ }
2673
+ try {
2674
+ const raw = await readFile(projectConfig.srcArtDir, id);
2675
+ if (!raw) {
2676
+ json(res, { error: 'Source file not found' }, 404);
2677
+ return;
2678
+ }
2679
+ const ext = id.split('.').pop()?.toLowerCase() ?? 'png';
2680
+ const mimeMap = {
2681
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
2682
+ };
2683
+ const mimeType = mimeMap[ext] ?? 'image/png';
2684
+ const { detectSpriteSheet: aiDetectSpriteSheet } = await import('./ai-tagger.js');
2685
+ const detection = await aiDetectSpriteSheet(apiKey, Buffer.from(raw), mimeType);
2686
+ if (detection.isSpriteSheet && detection.rows && detection.columns) {
2687
+ json(res, { rows: detection.rows, columns: detection.columns });
2688
+ }
2689
+ else {
2690
+ // Fall back to heuristic
2691
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, id);
2692
+ const grid = dims ? detectSpriteGrid(dims.width, dims.height) : { rows: 1, columns: 1 };
2693
+ json(res, { rows: grid.rows, columns: grid.columns });
2694
+ }
2695
+ }
2696
+ catch (err) {
2697
+ json(res, { error: err.message }, 500);
2698
+ }
2699
+ return;
2700
+ }
2701
+ // POST /api/ai/detect-spritesheet-batch — use Gemini to detect sprite sheets across multiple textures concurrently
2702
+ if (pathname === '/api/ai/detect-spritesheet-batch' && req.method === 'POST') {
2703
+ if (!projectConfig) {
2704
+ json(res, { error: 'No project open' }, 400);
2705
+ return;
2706
+ }
2707
+ const apiKey = projectConfig.config.ai?.geminiApiKey;
2708
+ if (!apiKey) {
2709
+ json(res, { error: 'Gemini API key not configured' }, 400);
2710
+ return;
2711
+ }
2712
+ try {
2713
+ const body = JSON.parse(await readBody(req));
2714
+ const ids = body.ids;
2715
+ if (!Array.isArray(ids) || ids.length === 0) {
2716
+ json(res, { error: 'ids must be a non-empty array' }, 400);
2717
+ return;
2718
+ }
2719
+ const { detectSpriteSheet: aiDetectSpriteSheet } = await import('./ai-tagger.js');
2720
+ const mimeMap = {
2721
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif',
2722
+ };
2723
+ let created = 0;
2724
+ let notSpriteSheet = 0;
2725
+ let alreadyExists = 0;
2726
+ const errors = [];
2727
+ const results = await Promise.allSettled(ids.map(async (id) => {
2728
+ const asset = assets.find(a => a.id === id);
2729
+ if (!asset || asset.type !== AssetType.Texture2D)
2730
+ throw new Error('Not a texture');
2731
+ const raw = await readFile(projectConfig.srcArtDir, id);
2732
+ if (!raw)
2733
+ throw new Error(`Source file not found: ${id}`);
2734
+ const ext = id.split('.').pop()?.toLowerCase() ?? 'png';
2735
+ const mimeType = mimeMap[ext] ?? 'image/png';
2736
+ const detection = await aiDetectSpriteSheet(apiKey, Buffer.from(raw), mimeType);
2737
+ if (!detection.isSpriteSheet)
2738
+ return 'notSpriteSheet';
2739
+ // Use AI-detected grid, falling back to heuristic
2740
+ let rows;
2741
+ let columns;
2742
+ if (detection.rows && detection.columns) {
2743
+ rows = detection.rows;
2744
+ columns = detection.columns;
2745
+ }
2746
+ else {
2747
+ const dims = await probeImageDimensions(projectConfig.srcArtDir, id);
2748
+ if (!dims)
2749
+ throw new Error('Could not read image dimensions');
2750
+ const grid = detectSpriteGrid(dims.width, dims.height);
2751
+ rows = grid.rows;
2752
+ columns = grid.columns;
2753
+ }
2754
+ const frameCount = rows * columns;
2755
+ const parts = id.replace(/\\/g, '/').split('/');
2756
+ const stem = parts[parts.length - 1].replace(/\.[^.]+$/, '').replace(/^SpriteSheet/, '').replace(/^spritesheet/i, '');
2757
+ const folder = parts.length >= 2 ? parts[parts.length - 2] : '';
2758
+ const safeName = [folder, stem].filter(Boolean).join('_').replace(/\s+/g, '_').toLowerCase();
2759
+ const sheetName = `${safeName}_animation`;
2760
+ const targetFolder = parts.slice(0, -1).join('/');
2761
+ const baseName = `${sheetName}.stowspritesheet`;
2762
+ const fileName = targetFolder ? `${targetFolder}/${baseName}` : baseName;
2763
+ if (assets.find(a => a.id === fileName))
2764
+ return 'alreadyExists';
2765
+ const settings = defaultAssetSettings();
2766
+ settings.spritesheetConfig = { textureAssetId: id, rows, columns, frameCount, frameRate: 12 };
2767
+ const newAsset = {
2768
+ id: fileName,
2769
+ fileName: sheetName,
2770
+ stringId: fileName.replace(/\.[^.]+$/, '').toLowerCase(),
2771
+ type: AssetType.SpriteSheet,
2772
+ status: 'ready',
2773
+ settings,
2774
+ sourceSize: 0,
2775
+ processedSize: 0,
2776
+ };
2777
+ assets.push(newAsset);
2778
+ const stowSpriteSheet = spritesheetConfigToStowSpriteSheet(settings.spritesheetConfig);
2779
+ await writeStowSpriteSheet(projectConfig.srcArtDir, fileName, stowSpriteSheet);
2780
+ const newMeta = assetSettingsToStowmeta(newAsset);
2781
+ await writeStowmeta(projectConfig.srcArtDir, fileName, newMeta);
2782
+ return 'created';
2783
+ }));
2784
+ for (let i = 0; i < results.length; i++) {
2785
+ const r = results[i];
2786
+ if (r.status === 'fulfilled') {
2787
+ if (r.value === 'created')
2788
+ created++;
2789
+ else if (r.value === 'notSpriteSheet')
2790
+ notSpriteSheet++;
2791
+ else if (r.value === 'alreadyExists')
2792
+ alreadyExists++;
2793
+ }
2794
+ else {
2795
+ const errMsg = r.reason?.message ?? String(r.reason);
2796
+ console.error(`[ai-detect-spritesheet] ${ids[i]}: ${errMsg}`);
2797
+ errors.push({ id: ids[i], error: errMsg });
2798
+ }
2799
+ }
2800
+ if (created > 0)
2801
+ broadcast({ type: 'refresh' });
2802
+ json(res, { created, notSpriteSheet, alreadyExists, errors });
2803
+ }
2804
+ catch (err) {
2805
+ json(res, { error: err.message }, 500);
2806
+ }
2807
+ return;
2808
+ }
2303
2809
  // GET /api/thumbnails/cached — return which thumbnails are still valid in the cache
2304
2810
  if (pathname === '/api/thumbnails/cached' && req.method === 'GET') {
2305
2811
  if (!projectConfig) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.6.39",
3
+ "version": "0.6.41",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@google/genai": "^1.46.0",
22
22
  "@series-inc/stowkit-editor": "^0.1.8",
23
- "@series-inc/stowkit-packer-gui": "^0.1.26",
23
+ "@series-inc/stowkit-packer-gui": "^0.1.30",
24
24
  "@strangeape/ffmpeg-audio-wasm": "^0.1.0",
25
25
  "draco3d": "^1.5.7",
26
26
  "fbx-parser": "^2.1.3",