@series-inc/stowkit-cli 0.6.17 → 0.6.19

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,13 +1,32 @@
1
- import type { KTX2Quality, TextureResize, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
1
+ import type { KTX2Quality, TextureResize, TextureFilterMode, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
2
2
  export interface PackConfig {
3
3
  name: string;
4
4
  }
5
+ export interface TextureDefaults {
6
+ quality?: string;
7
+ resize?: string;
8
+ filtering?: string;
9
+ generateMipmaps?: boolean;
10
+ }
11
+ export interface AudioDefaults {
12
+ aacQuality?: string;
13
+ sampleRate?: string;
14
+ }
15
+ export interface MeshDefaults {
16
+ dracoQuality?: string;
17
+ }
18
+ export interface ProjectDefaults {
19
+ texture?: TextureDefaults;
20
+ audio?: AudioDefaults;
21
+ mesh?: MeshDefaults;
22
+ }
5
23
  export interface FelicityProject {
6
24
  srcArtDir: string;
7
25
  name?: string;
8
26
  cdnAssetsPath?: string;
9
27
  prefabsPath?: string;
10
28
  packs?: PackConfig[];
29
+ defaults?: ProjectDefaults;
11
30
  }
12
31
  export interface FileSnapshot {
13
32
  relativePath: string;
@@ -33,6 +52,7 @@ export interface StowMetaTexture extends StowMetaBase {
33
52
  type: 'texture';
34
53
  quality: string;
35
54
  resize: string;
55
+ filtering?: string;
36
56
  generateMipmaps: boolean;
37
57
  }
38
58
  export interface StowMetaAudio extends StowMetaBase {
@@ -67,6 +87,7 @@ export interface StowMetaGlbChild {
67
87
  cache?: StowMetaCache;
68
88
  quality?: string;
69
89
  resize?: string;
90
+ filtering?: string;
70
91
  generateMipmaps?: boolean;
71
92
  aacQuality?: string;
72
93
  sampleRate?: string;
@@ -101,6 +122,8 @@ export declare const KTX2_QUALITY_STRINGS: Record<string, KTX2Quality>;
101
122
  export declare const KTX2_QUALITY_TO_STRING: Record<number, string>;
102
123
  export declare const TEXTURE_RESIZE_STRINGS: Record<string, TextureResize>;
103
124
  export declare const TEXTURE_RESIZE_TO_STRING: Record<number, string>;
125
+ export declare const TEXTURE_FILTER_STRINGS: Record<string, TextureFilterMode>;
126
+ export declare const TEXTURE_FILTER_TO_STRING: Record<number, string>;
104
127
  export declare const DRACO_QUALITY_STRINGS: Record<string, DracoQualityPreset>;
105
128
  export declare const DRACO_QUALITY_TO_STRING: Record<number, string>;
106
129
  export declare const AAC_QUALITY_STRINGS: Record<string, AacQuality>;
@@ -18,6 +18,13 @@ export const TEXTURE_RESIZE_STRINGS = {
18
18
  export const TEXTURE_RESIZE_TO_STRING = {
19
19
  0: 'full', 1: 'half', 2: 'quarter', 3: 'eighth',
20
20
  };
21
+ export const TEXTURE_FILTER_STRINGS = {
22
+ linear: 0,
23
+ nearest: 1,
24
+ };
25
+ export const TEXTURE_FILTER_TO_STRING = {
26
+ 0: 'linear', 1: 'nearest',
27
+ };
21
28
  export const DRACO_QUALITY_STRINGS = {
22
29
  fast: 0,
23
30
  balanced: 1,
@@ -96,7 +96,7 @@ export function computeSettingsHash(type, settings) {
96
96
  const parts = [String(type)];
97
97
  switch (type) {
98
98
  case 2: // Texture2D
99
- parts.push(`q=${settings.quality}`, `r=${settings.resize}`, `m=${settings.generateMipmaps}`);
99
+ parts.push(`q=${settings.quality}`, `r=${settings.resize}`, `f=${settings.filtering}`, `m=${settings.generateMipmaps}`);
100
100
  break;
101
101
  case 3: // Audio
102
102
  parts.push(`aq=${settings.aacQuality}`, `sr=${settings.audioSampleRate}`);
@@ -1,4 +1,4 @@
1
- import type { AssetType, TextureMetadata, AudioMetadata, MeshMetadata, AnimationClipMetadata, SkinnedMeshMetadata, KTX2Quality, TextureResize, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
1
+ import type { AssetType, TextureMetadata, AudioMetadata, MeshMetadata, AnimationClipMetadata, SkinnedMeshMetadata, 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;
@@ -13,6 +13,7 @@ export interface MaterialConfig {
13
13
  export interface AssetSettings {
14
14
  quality: KTX2Quality;
15
15
  resize: TextureResize;
16
+ filtering: TextureFilterMode;
16
17
  generateMipmaps: boolean;
17
18
  tags: string[];
18
19
  dracoQuality: DracoQualityPreset;
package/dist/app/state.js CHANGED
@@ -1,8 +1,9 @@
1
- import { KTX2Quality as KQ, TextureResize as TR, DracoQualityPreset as DQ, AacQuality as AQ, AudioSampleRate as ASR, } from '../core/types.js';
1
+ import { KTX2Quality as KQ, TextureResize as TR, TextureFilterMode as TFM, DracoQualityPreset as DQ, AacQuality as AQ, AudioSampleRate as ASR, } from '../core/types.js';
2
2
  export function defaultAssetSettings() {
3
3
  return {
4
4
  quality: KQ.Fastest,
5
5
  resize: TR.Full,
6
+ filtering: TFM.Linear,
6
7
  generateMipmaps: false,
7
8
  tags: [],
8
9
  dracoQuality: DQ.Balanced,
@@ -1,7 +1,7 @@
1
1
  import type { AssetSettings } from './state.js';
2
2
  import { AssetType } from '../core/types.js';
3
3
  import type { ProjectAsset } from './state.js';
4
- import type { StowMeta, StowMetaGlbChild } from './disk-project.js';
4
+ import type { ProjectDefaults, StowMeta, StowMetaGlbChild } from './disk-project.js';
5
5
  export declare function detectAssetType(fileName: string): AssetType;
6
6
  export declare function stowmetaPath(relativePath: string): string;
7
7
  export declare function readStowmeta(srcArtDir: string, relativePath: string): Promise<StowMeta | null>;
@@ -11,9 +11,12 @@ export declare function stowmetaToAssetSettings(meta: StowMeta): {
11
11
  settings: AssetSettings;
12
12
  };
13
13
  export declare function assetSettingsToStowmeta(asset: ProjectAsset): StowMeta;
14
- export declare function generateDefaultStowmeta(relativePath: string, type?: AssetType): StowMeta;
14
+ export declare function generateDefaultStowmeta(relativePath: string, type?: AssetType, projectDefaults?: ProjectDefaults, imageDimensions?: {
15
+ width: number;
16
+ height: number;
17
+ } | null): StowMeta;
15
18
  /** Generate a default inline child entry for a GLB container. */
16
- export declare function generateDefaultGlbChild(name: string, childType: string): StowMetaGlbChild;
19
+ export declare function generateDefaultGlbChild(name: string, childType: string, projectDefaults?: ProjectDefaults): StowMetaGlbChild;
17
20
  /** Convert an inline child entry to { type, settings } for use in ProjectAsset. */
18
21
  export declare function glbChildToAssetSettings(child: StowMetaGlbChild): {
19
22
  type: AssetType;
@@ -1,6 +1,6 @@
1
1
  import { AssetType } from '../core/types.js';
2
- import { KTX2Quality, TextureResize, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
3
- import { KTX2_QUALITY_STRINGS, KTX2_QUALITY_TO_STRING, TEXTURE_RESIZE_STRINGS, TEXTURE_RESIZE_TO_STRING, DRACO_QUALITY_STRINGS, DRACO_QUALITY_TO_STRING, AAC_QUALITY_STRINGS, AAC_QUALITY_TO_STRING, AUDIO_SAMPLE_RATE_STRINGS, AUDIO_SAMPLE_RATE_TO_STRING, MATERIAL_FIELD_TYPE_STRINGS, MATERIAL_FIELD_TYPE_TO_STRING, PREVIEW_FLAG_STRINGS, PREVIEW_FLAG_TO_STRING, } from './disk-project.js';
2
+ import { KTX2Quality, TextureResize, TextureFilterMode, DracoQualityPreset, AacQuality, AudioSampleRate, MaterialFieldType, PreviewPropertyFlag } from '../core/types.js';
3
+ import { KTX2_QUALITY_STRINGS, KTX2_QUALITY_TO_STRING, TEXTURE_RESIZE_STRINGS, TEXTURE_RESIZE_TO_STRING, TEXTURE_FILTER_STRINGS, TEXTURE_FILTER_TO_STRING, DRACO_QUALITY_STRINGS, DRACO_QUALITY_TO_STRING, AAC_QUALITY_STRINGS, AAC_QUALITY_TO_STRING, AUDIO_SAMPLE_RATE_STRINGS, AUDIO_SAMPLE_RATE_TO_STRING, MATERIAL_FIELD_TYPE_STRINGS, MATERIAL_FIELD_TYPE_TO_STRING, PREVIEW_FLAG_STRINGS, PREVIEW_FLAG_TO_STRING, } from './disk-project.js';
4
4
  import { readTextFile, writeFile } from '../node-fs.js';
5
5
  import { defaultAssetSettings } from './state.js';
6
6
  // ─── Detect asset type from file extension ──────────────────────────────────
@@ -70,6 +70,7 @@ export function stowmetaToAssetSettings(meta) {
70
70
  ...base,
71
71
  quality: KTX2_QUALITY_STRINGS[meta.quality] ?? KTX2Quality.Fastest,
72
72
  resize: TEXTURE_RESIZE_STRINGS[meta.resize] ?? TextureResize.Full,
73
+ filtering: TEXTURE_FILTER_STRINGS[meta.filtering ?? 'linear'] ?? TextureFilterMode.Linear,
73
74
  generateMipmaps: meta.generateMipmaps ?? false,
74
75
  },
75
76
  };
@@ -152,6 +153,7 @@ export function assetSettingsToStowmeta(asset) {
152
153
  type: 'texture',
153
154
  quality: KTX2_QUALITY_TO_STRING[asset.settings.quality] ?? 'fastest',
154
155
  resize: TEXTURE_RESIZE_TO_STRING[asset.settings.resize] ?? 'full',
156
+ filtering: TEXTURE_FILTER_TO_STRING[asset.settings.filtering] ?? 'linear',
155
157
  generateMipmaps: asset.settings.generateMipmaps,
156
158
  };
157
159
  case AssetType.Audio:
@@ -196,11 +198,12 @@ function serializeMaterialOverrides(overrides) {
196
198
  return result;
197
199
  }
198
200
  // ─── Generate default .stowmeta ─────────────────────────────────────────────
199
- export function generateDefaultStowmeta(relativePath, type) {
201
+ export function generateDefaultStowmeta(relativePath, type, projectDefaults, imageDimensions) {
200
202
  const fileName = relativePath.split('/').pop() ?? relativePath;
201
203
  const resolvedType = type ?? detectAssetType(fileName);
202
- const baseName = fileName.replace(/\.[^.]+$/, '');
203
- const stringId = resolvedType === AssetType.MaterialSchema ? baseName : baseName.toLowerCase();
204
+ // Use full relative path (minus extension) as stringId to avoid collisions across folders
205
+ const pathWithoutExt = relativePath.replace(/\.[^.]+$/, '');
206
+ const stringId = resolvedType === AssetType.MaterialSchema ? pathWithoutExt : pathWithoutExt.toLowerCase();
204
207
  const base = {
205
208
  version: 1,
206
209
  stringId,
@@ -208,12 +211,39 @@ export function generateDefaultStowmeta(relativePath, type) {
208
211
  pack: 'default',
209
212
  };
210
213
  switch (resolvedType) {
211
- case AssetType.Texture2D:
212
- return { ...base, type: 'texture', quality: 'fastest', resize: 'quarter', generateMipmaps: false };
213
- case AssetType.Audio:
214
- return { ...base, type: 'audio', aacQuality: 'medium', sampleRate: 'auto' };
215
- case AssetType.StaticMesh:
216
- return { ...base, type: 'staticMesh', dracoQuality: 'balanced', materialOverrides: {} };
214
+ case AssetType.Texture2D: {
215
+ const textureDefaults = projectDefaults?.texture;
216
+ const isSmall = imageDimensions != null
217
+ && imageDimensions.width <= 256
218
+ && imageDimensions.height <= 256;
219
+ const defaultResize = isSmall ? 'full' : 'quarter';
220
+ return {
221
+ ...base,
222
+ type: 'texture',
223
+ quality: textureDefaults?.quality ?? 'fastest',
224
+ resize: textureDefaults?.resize ?? defaultResize,
225
+ filtering: textureDefaults?.filtering ?? 'linear',
226
+ generateMipmaps: textureDefaults?.generateMipmaps ?? false,
227
+ };
228
+ }
229
+ case AssetType.Audio: {
230
+ const audioDefaults = projectDefaults?.audio;
231
+ return {
232
+ ...base,
233
+ type: 'audio',
234
+ aacQuality: audioDefaults?.aacQuality ?? 'medium',
235
+ sampleRate: audioDefaults?.sampleRate ?? 'auto',
236
+ };
237
+ }
238
+ case AssetType.StaticMesh: {
239
+ const meshDefaults = projectDefaults?.mesh;
240
+ return {
241
+ ...base,
242
+ type: 'staticMesh',
243
+ dracoQuality: meshDefaults?.dracoQuality ?? 'balanced',
244
+ materialOverrides: {},
245
+ };
246
+ }
217
247
  case AssetType.SkinnedMesh:
218
248
  return { ...base, type: 'skinnedMesh', materialOverrides: {} };
219
249
  case AssetType.AnimationClip:
@@ -228,23 +258,32 @@ export function generateDefaultStowmeta(relativePath, type) {
228
258
  }
229
259
  // ─── GLB Inline Child Helpers ───────────────────────────────────────────────
230
260
  /** Generate a default inline child entry for a GLB container. */
231
- export function generateDefaultGlbChild(name, childType) {
261
+ export function generateDefaultGlbChild(name, childType, projectDefaults) {
232
262
  const baseName = name.replace(/\.[^.]+$/, '');
233
263
  const stringId = childType === 'materialSchema' ? baseName : baseName.toLowerCase();
234
264
  const child = { name, childType, stringId };
235
265
  switch (childType) {
236
- case 'texture':
237
- child.quality = 'fastest';
238
- child.resize = 'quarter';
239
- child.generateMipmaps = false;
266
+ case 'texture': {
267
+ const textureDefaults = projectDefaults?.texture;
268
+ child.quality = textureDefaults?.quality ?? 'fastest';
269
+ child.resize = textureDefaults?.resize ?? 'quarter';
270
+ child.filtering = textureDefaults?.filtering ?? 'linear';
271
+ child.generateMipmaps = textureDefaults?.generateMipmaps ?? false;
240
272
  break;
241
- case 'audio':
242
- child.aacQuality = 'medium';
243
- child.sampleRate = 'auto';
273
+ }
274
+ case 'audio': {
275
+ const audioDefaults = projectDefaults?.audio;
276
+ child.aacQuality = audioDefaults?.aacQuality ?? 'medium';
277
+ child.sampleRate = audioDefaults?.sampleRate ?? 'auto';
244
278
  break;
245
- case 'staticMesh':
279
+ }
280
+ case 'staticMesh': {
281
+ const meshDefaults = projectDefaults?.mesh;
282
+ child.dracoQuality = meshDefaults?.dracoQuality ?? 'balanced';
283
+ child.materialOverrides = {};
284
+ break;
285
+ }
246
286
  case 'skinnedMesh':
247
- child.dracoQuality = 'balanced';
248
287
  child.materialOverrides = {};
249
288
  break;
250
289
  case 'animationClip':
@@ -269,6 +308,7 @@ export function glbChildToAssetSettings(child) {
269
308
  ...base,
270
309
  quality: KTX2_QUALITY_STRINGS[child.quality ?? 'fastest'] ?? KTX2Quality.Fastest,
271
310
  resize: TEXTURE_RESIZE_STRINGS[child.resize ?? 'full'] ?? TextureResize.Full,
311
+ filtering: TEXTURE_FILTER_STRINGS[child.filtering ?? 'linear'] ?? TextureFilterMode.Linear,
272
312
  generateMipmaps: child.generateMipmaps ?? false,
273
313
  },
274
314
  };
@@ -342,6 +382,7 @@ export function assetSettingsToGlbChild(name, childType, stringId, settings, exi
342
382
  case 'texture':
343
383
  child.quality = KTX2_QUALITY_TO_STRING[settings.quality] ?? 'fastest';
344
384
  child.resize = TEXTURE_RESIZE_TO_STRING[settings.resize] ?? 'full';
385
+ child.filtering = TEXTURE_FILTER_TO_STRING[settings.filtering] ?? 'linear';
345
386
  child.generateMipmaps = settings.generateMipmaps;
346
387
  break;
347
388
  case 'audio':
@@ -0,0 +1,29 @@
1
+ export interface ThumbnailManifestEntry {
2
+ format: 'png' | 'webp' | 'webm';
3
+ sourceSize: number;
4
+ settingsHash: string;
5
+ materialDepsHash: string;
6
+ }
7
+ export type ThumbnailManifest = Record<string, ThumbnailManifestEntry>;
8
+ export declare function readManifest(srcArtDir: string): Promise<ThumbnailManifest>;
9
+ export declare function writeManifest(srcArtDir: string, manifest: ThumbnailManifest): Promise<void>;
10
+ /** Hash the settings fields that affect thumbnail appearance. */
11
+ export declare function computeSettingsHash(settings: Record<string, unknown>): string;
12
+ /**
13
+ * Compute a hash over material dependencies for mesh/animation assets.
14
+ * Covers materialOverrides → referenced material asset settings + their texture dependencies.
15
+ */
16
+ export declare function computeMaterialDepsHash(settings: Record<string, unknown>, allAssets: Array<{
17
+ id: string;
18
+ stringId: string;
19
+ sourceSize: number;
20
+ settings: Record<string, unknown>;
21
+ }>): string;
22
+ export declare function isThumbnailCacheValid(entry: ThumbnailManifestEntry | undefined, sourceSize: number, settingsHash: string, materialDepsHash: string): boolean;
23
+ export declare function writeThumbnailFile(srcArtDir: string, stringId: string, format: 'png' | 'webp' | 'webm', data: Buffer): Promise<void>;
24
+ export declare function readThumbnailFile(srcArtDir: string, stringId: string, format: 'png' | 'webp' | 'webm'): Promise<Buffer | null>;
25
+ /** Clean up thumbnail cache directory. */
26
+ export declare function cleanThumbnailCache(srcArtDir: string): Promise<{
27
+ deleted: number;
28
+ freedBytes: number;
29
+ }>;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Server-side thumbnail cache.
3
+ * Stores captured thumbnails in `.stow-thumbnails/` to skip re-capture for unchanged assets.
4
+ */
5
+ import * as fs from 'node:fs/promises';
6
+ import * as path from 'node:path';
7
+ import * as crypto from 'node:crypto';
8
+ const CACHE_DIR = '.stow-thumbnails';
9
+ const MANIFEST_FILE = 'manifest.json';
10
+ function cacheDir(srcArtDir) {
11
+ return path.join(srcArtDir, CACHE_DIR);
12
+ }
13
+ function manifestPath(srcArtDir) {
14
+ return path.join(cacheDir(srcArtDir), MANIFEST_FILE);
15
+ }
16
+ function thumbnailFileName(stringId, format) {
17
+ // Use a hash of stringId to avoid filesystem issues with slashes/special chars
18
+ const hash = crypto.createHash('sha256').update(stringId).digest('hex').slice(0, 16);
19
+ return `${hash}.${format}`;
20
+ }
21
+ export async function readManifest(srcArtDir) {
22
+ try {
23
+ const raw = await fs.readFile(manifestPath(srcArtDir), 'utf-8');
24
+ return JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ export async function writeManifest(srcArtDir, manifest) {
31
+ const dir = cacheDir(srcArtDir);
32
+ await fs.mkdir(dir, { recursive: true });
33
+ await fs.writeFile(manifestPath(srcArtDir), JSON.stringify(manifest, null, 2));
34
+ }
35
+ /** Hash the settings fields that affect thumbnail appearance. */
36
+ export function computeSettingsHash(settings) {
37
+ // Only include settings that affect visual output
38
+ const relevant = {};
39
+ for (const key of ['quality', 'resize', 'generateMipmaps', 'dracoQuality', 'materialConfig', 'targetMeshId', 'materialOverrides']) {
40
+ if (settings[key] !== undefined)
41
+ relevant[key] = settings[key];
42
+ }
43
+ return crypto.createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16);
44
+ }
45
+ /**
46
+ * Compute a hash over material dependencies for mesh/animation assets.
47
+ * Covers materialOverrides → referenced material asset settings + their texture dependencies.
48
+ */
49
+ export function computeMaterialDepsHash(settings, allAssets) {
50
+ const overrides = settings.materialOverrides;
51
+ if (!overrides || Object.keys(overrides).length === 0)
52
+ return '';
53
+ const parts = [];
54
+ for (const [idx, matId] of Object.entries(overrides)) {
55
+ if (!matId)
56
+ continue;
57
+ const matAsset = allAssets.find(a => a.id === matId);
58
+ if (!matAsset)
59
+ continue;
60
+ // Include material's settings and sourceSize
61
+ parts.push(`${idx}:${matId}:${matAsset.sourceSize}:${JSON.stringify(matAsset.settings.materialConfig ?? {})}`);
62
+ // Also hash texture assets referenced by material properties
63
+ const matConfig = matAsset.settings.materialConfig;
64
+ if (matConfig?.properties) {
65
+ for (const prop of matConfig.properties) {
66
+ if (prop.type === 'texture' && prop.value) {
67
+ const texAsset = allAssets.find(a => a.id === prop.value);
68
+ if (texAsset)
69
+ parts.push(`tex:${prop.value}:${texAsset.sourceSize}`);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ // For animation clips, also include the target mesh
75
+ const targetMeshId = settings.targetMeshId;
76
+ if (targetMeshId) {
77
+ const meshAsset = allAssets.find(a => a.id === targetMeshId);
78
+ if (meshAsset) {
79
+ parts.push(`mesh:${targetMeshId}:${meshAsset.sourceSize}:${computeSettingsHash(meshAsset.settings)}`);
80
+ // Recurse: include the mesh's own material deps
81
+ const meshDeps = computeMaterialDepsHash(meshAsset.settings, allAssets);
82
+ if (meshDeps)
83
+ parts.push(`meshDeps:${meshDeps}`);
84
+ }
85
+ }
86
+ if (parts.length === 0)
87
+ return '';
88
+ return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
89
+ }
90
+ export function isThumbnailCacheValid(entry, sourceSize, settingsHash, materialDepsHash) {
91
+ if (!entry)
92
+ return false;
93
+ return entry.sourceSize === sourceSize &&
94
+ entry.settingsHash === settingsHash &&
95
+ entry.materialDepsHash === materialDepsHash;
96
+ }
97
+ export async function writeThumbnailFile(srcArtDir, stringId, format, data) {
98
+ const dir = cacheDir(srcArtDir);
99
+ await fs.mkdir(dir, { recursive: true });
100
+ await fs.writeFile(path.join(dir, thumbnailFileName(stringId, format)), data);
101
+ }
102
+ export async function readThumbnailFile(srcArtDir, stringId, format) {
103
+ try {
104
+ return await fs.readFile(path.join(cacheDir(srcArtDir), thumbnailFileName(stringId, format)));
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }
110
+ /** Clean up thumbnail cache directory. */
111
+ export async function cleanThumbnailCache(srcArtDir) {
112
+ const dir = cacheDir(srcArtDir);
113
+ let deleted = 0;
114
+ let freedBytes = 0;
115
+ try {
116
+ const manifest = await readManifest(srcArtDir);
117
+ const validFiles = new Set();
118
+ for (const [stringId, entry] of Object.entries(manifest)) {
119
+ validFiles.add(thumbnailFileName(stringId, entry.format));
120
+ }
121
+ validFiles.add(MANIFEST_FILE);
122
+ const entries = await fs.readdir(dir);
123
+ for (const name of entries) {
124
+ if (!validFiles.has(name)) {
125
+ const fullPath = path.join(dir, name);
126
+ const stat = await fs.stat(fullPath);
127
+ await fs.unlink(fullPath);
128
+ deleted++;
129
+ freedBytes += stat.size;
130
+ }
131
+ }
132
+ }
133
+ catch {
134
+ // Directory doesn't exist yet — nothing to clean
135
+ }
136
+ return { deleted, freedBytes };
137
+ }
@@ -0,0 +1,66 @@
1
+ export interface AssetsPackage {
2
+ name: string;
3
+ version: string;
4
+ author: string;
5
+ description: string;
6
+ tags: string[];
7
+ bucket: string;
8
+ }
9
+ export interface RegistryAsset {
10
+ /** Unique string identifier for this asset */
11
+ stringId: string;
12
+ /** Asset type: texture, staticMesh, skinnedMesh, audio, materialSchema, animationClip, glbContainer */
13
+ type: string;
14
+ /** Relative file path within the version directory */
15
+ file: string;
16
+ /** All files needed for this asset (source + stowmeta + stowmat) */
17
+ files: string[];
18
+ /** Source file size in bytes */
19
+ size: number;
20
+ /** User-defined tags from stowmeta */
21
+ tags: string[];
22
+ /** Direct dependency stringIds (mesh→material, material→texture, anim→mesh) */
23
+ dependencies: string[];
24
+ /** Whether a thumbnail was uploaded for this asset */
25
+ thumbnail?: boolean;
26
+ /** Thumbnail format: 'png' (default) or 'webm' for animated previews */
27
+ thumbnailFormat?: 'png' | 'webp' | 'webm';
28
+ /** Texture filtering mode — 'nearest' for pixel art, 'linear' (default) for smooth */
29
+ filtering?: 'linear' | 'nearest';
30
+ }
31
+ export interface RegistryVersion {
32
+ publishedAt: string;
33
+ fileCount: number;
34
+ totalSize: number;
35
+ assets: RegistryAsset[];
36
+ }
37
+ export interface RegistryPackage {
38
+ description: string;
39
+ author: string;
40
+ tags: string[];
41
+ latest: string;
42
+ versions: Record<string, RegistryVersion>;
43
+ /** Pack-level thumbnail filename (e.g. "thumbnail.webp"), uploaded during publish */
44
+ thumbnail?: string;
45
+ }
46
+ export interface Registry {
47
+ schemaVersion: number;
48
+ packages: Record<string, RegistryPackage>;
49
+ }
50
+ export declare function readAssetsPackage(dir: string): Promise<AssetsPackage | null>;
51
+ export declare function writeAssetsPackage(dir: string, pkg: AssetsPackage): Promise<void>;
52
+ export declare function initAssetsPackage(dir: string, config: {
53
+ name: string;
54
+ }): Promise<AssetsPackage>;
55
+ export declare function createEmptyRegistry(): Registry;
56
+ /**
57
+ * Given a set of requested stringIds and a version's asset list,
58
+ * returns the full set of stringIds needed (transitive closure).
59
+ * Deduplicates automatically — shared deps are only included once.
60
+ */
61
+ export declare function resolveTransitiveDeps(requestedIds: string[], assets: RegistryAsset[]): string[];
62
+ /**
63
+ * Given resolved stringIds and a version's asset list,
64
+ * returns the deduplicated set of files needed to download.
65
+ */
66
+ export declare function resolveFiles(stringIds: string[], assets: RegistryAsset[]): string[];
@@ -0,0 +1,80 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ // ─── Constants ───────────────────────────────────────────────────────────────
4
+ const ASSETS_PACKAGE_FILE = 'assets-package.json';
5
+ const DEFAULT_BUCKET = 'venus-shared-assets-test';
6
+ // ─── Read / Write / Init ─────────────────────────────────────────────────────
7
+ export async function readAssetsPackage(dir) {
8
+ try {
9
+ const filePath = path.join(dir, ASSETS_PACKAGE_FILE);
10
+ const text = await fs.readFile(filePath, 'utf-8');
11
+ return JSON.parse(text);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ export async function writeAssetsPackage(dir, pkg) {
18
+ const filePath = path.join(dir, ASSETS_PACKAGE_FILE);
19
+ await fs.writeFile(filePath, JSON.stringify(pkg, null, 2) + '\n');
20
+ }
21
+ export async function initAssetsPackage(dir, config) {
22
+ const pkg = {
23
+ name: config.name,
24
+ version: '1.0.0',
25
+ author: '',
26
+ description: '',
27
+ tags: [],
28
+ bucket: `gs://${DEFAULT_BUCKET}`,
29
+ };
30
+ await writeAssetsPackage(dir, pkg);
31
+ return pkg;
32
+ }
33
+ export function createEmptyRegistry() {
34
+ return { schemaVersion: 1, packages: {} };
35
+ }
36
+ // ─── Dependency Resolution ───────────────────────────────────────────────────
37
+ /**
38
+ * Given a set of requested stringIds and a version's asset list,
39
+ * returns the full set of stringIds needed (transitive closure).
40
+ * Deduplicates automatically — shared deps are only included once.
41
+ */
42
+ export function resolveTransitiveDeps(requestedIds, assets) {
43
+ const assetMap = new Map();
44
+ for (const a of assets)
45
+ assetMap.set(a.stringId, a);
46
+ const resolved = new Set();
47
+ const queue = [...requestedIds];
48
+ while (queue.length > 0) {
49
+ const id = queue.pop();
50
+ if (resolved.has(id))
51
+ continue;
52
+ resolved.add(id);
53
+ const asset = assetMap.get(id);
54
+ if (!asset)
55
+ continue;
56
+ for (const dep of asset.dependencies) {
57
+ if (!resolved.has(dep))
58
+ queue.push(dep);
59
+ }
60
+ }
61
+ return [...resolved];
62
+ }
63
+ /**
64
+ * Given resolved stringIds and a version's asset list,
65
+ * returns the deduplicated set of files needed to download.
66
+ */
67
+ export function resolveFiles(stringIds, assets) {
68
+ const assetMap = new Map();
69
+ for (const a of assets)
70
+ assetMap.set(a.stringId, a);
71
+ const files = new Set();
72
+ for (const id of stringIds) {
73
+ const asset = assetMap.get(id);
74
+ if (!asset)
75
+ continue;
76
+ for (const f of asset.files)
77
+ files.add(f);
78
+ }
79
+ return [...files];
80
+ }
package/dist/cleanup.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import { readProjectConfig, scanDirectory } from './node-fs.js';
4
+ import { cleanThumbnailCache } from './app/thumbnail-cache.js';
4
5
  export async function cleanupProject(projectDir, opts) {
5
6
  const verbose = opts?.verbose ?? false;
6
7
  const config = await readProjectConfig(projectDir);
@@ -27,11 +28,19 @@ export async function cleanupProject(projectDir, opts) {
27
28
  console.log(` [delete] ${orphanPath} (orphaned meta)`);
28
29
  }
29
30
  });
30
- if (deletedCaches + deletedMetas === 0) {
31
+ // Clean orphaned thumbnail cache files
32
+ const thumbResult = await cleanThumbnailCache(config.srcArtDir);
33
+ if (thumbResult.deleted > 0) {
34
+ freedBytes += thumbResult.freedBytes;
35
+ if (verbose)
36
+ console.log(` [delete] ${thumbResult.deleted} orphaned thumbnail cache file(s)`);
37
+ }
38
+ if (deletedCaches + deletedMetas + thumbResult.deleted === 0) {
31
39
  console.log('No orphaned files found.');
32
40
  }
33
41
  else {
34
- console.log(`Cleaned up ${deletedCaches} cache(s) and ${deletedMetas} meta(s) (${(freedBytes / 1024).toFixed(0)} KB freed)`);
42
+ const thumbNote = thumbResult.deleted > 0 ? `, ${thumbResult.deleted} thumbnail(s)` : '';
43
+ console.log(`Cleaned up ${deletedCaches} cache(s) and ${deletedMetas} meta(s)${thumbNote} (${(freedBytes / 1024).toFixed(0)} KB freed)`);
35
44
  }
36
45
  }
37
46
  async function walkForOrphans(basePath, prefix, sourceFiles, matFiles, onOrphan) {