@playcraft/cli 0.0.28 → 0.0.29

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,5 +1,5 @@
1
1
  import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
2
- import { basename, dirname } from 'path';
2
+ import { basename, dirname, join } from 'path';
3
3
  import sharp from 'sharp';
4
4
  import { AgentApiClient } from '../utils/agent-api-client.js';
5
5
  /** 单文件输入上限(字节),防止误处理巨型文件导致 OOM */
@@ -228,10 +228,75 @@ function parseResizeFitOption(raw) {
228
228
  console.error('Error: --fit must be contain|cover|fill|inside|outside');
229
229
  process.exit(1);
230
230
  }
231
+ /** 不规则子图文件名(避免路径分隔符与非法字符) */
232
+ function safeElementFileBase(name, fallback) {
233
+ const s = name.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^\.+/, '').slice(0, 120);
234
+ const base = (s || fallback).replace(/\.png$/i, '');
235
+ return base || fallback;
236
+ }
237
+ /**
238
+ * 按分析结果将精灵图裁成逐帧 PNG,并写入 split-meta.json(供 sprite-sheet 重组参考)
239
+ */
240
+ async function splitSpriteSheetToDir(inputPath, outputDir, analysis) {
241
+ mkdirSync(outputDir, { recursive: true });
242
+ // 与后端 analyze-sprite-sheet 一致:按 EXIF 摆正后再按 bounds 裁剪
243
+ const sheetBuf = await sharp(inputPath).rotate().toBuffer();
244
+ const sheetMeta = await sharp(sheetBuf).metadata();
245
+ const iw = sheetMeta.width ?? 0;
246
+ const ih = sheetMeta.height ?? 0;
247
+ const frames = [];
248
+ let written = 0;
249
+ if (analysis.gridType === 'irregular' && analysis.elements?.length) {
250
+ for (let i = 0; i < analysis.elements.length; i++) {
251
+ const el = analysis.elements[i];
252
+ const fname = `${safeElementFileBase(el.name, `frame_${i}`)}.png`;
253
+ const outPath = join(outputDir, fname);
254
+ const { x, y, w, h } = el.bounds;
255
+ await sharp(sheetBuf).extract({ left: x, top: y, width: w, height: h }).png().toFile(outPath);
256
+ frames.push({ path: fname, name: el.name, x, y, w, h });
257
+ written++;
258
+ }
259
+ }
260
+ else {
261
+ const { rows, columns, frameWidth, frameHeight, padding } = analysis;
262
+ for (let r = 0; r < rows; r++) {
263
+ for (let c = 0; c < columns; c++) {
264
+ const x = c * (frameWidth + padding);
265
+ const y = r * (frameHeight + padding);
266
+ if (x + frameWidth > iw || y + frameHeight > ih)
267
+ continue;
268
+ const fname = `frame_r${r}_c${c}.png`;
269
+ const outPath = join(outputDir, fname);
270
+ await sharp(sheetBuf)
271
+ .extract({ left: x, top: y, width: frameWidth, height: frameHeight })
272
+ .png()
273
+ .toFile(outPath);
274
+ frames.push({ path: fname, row: r, col: c, x, y, w: frameWidth, h: frameHeight });
275
+ written++;
276
+ }
277
+ }
278
+ }
279
+ const splitMeta = {
280
+ source: inputPath,
281
+ imageSize: { w: iw, h: ih },
282
+ gridType: analysis.gridType,
283
+ rows: analysis.rows,
284
+ columns: analysis.columns,
285
+ frameWidth: analysis.frameWidth,
286
+ frameHeight: analysis.frameHeight,
287
+ padding: analysis.padding,
288
+ frameCount: analysis.frameCount,
289
+ description: analysis.description,
290
+ elements: analysis.elements,
291
+ frames,
292
+ };
293
+ writeFileSync(join(outputDir, 'split-meta.json'), JSON.stringify(splitMeta, null, 2), 'utf-8');
294
+ return written;
295
+ }
231
296
  export function registerImageCommands(program) {
232
297
  const img = program
233
298
  .command('image')
234
- .description('素材工具:本地图片处理(sharp);remove-background 调用后端 API');
299
+ .description('素材工具:本地图片处理(sharp);remove-background / sprite-split --auto-detect 可调用后端 API');
235
300
  // ─── resize ──────────────────────────────────────────────────
236
301
  img.command('resize')
237
302
  .description('缩放图片(按比例或指定宽高)')
@@ -563,6 +628,71 @@ export function registerImageCommands(program) {
563
628
  handleError(e);
564
629
  }
565
630
  });
631
+ // ─── sprite-split ──────────────────────────────────────────────
632
+ img.command('sprite-split')
633
+ .description('拆分精灵图为逐帧 PNG + split-meta.json;--auto-detect 调用后端多模态分析网格(需 token)')
634
+ .requiredOption('--input <path>', '精灵图路径')
635
+ .requiredOption('--output-dir <dir>', '输出目录(将自动创建)')
636
+ .option('--auto-detect', '由后端 Vision 推断行列、帧尺寸与间距')
637
+ .option('--hint <text>', '传给 Vision 的上下文(如:麻将牌面,等大小网格)')
638
+ .option('--model <id>', '覆盖 Vision 所用 Google GenAI 模型 ID(仅与 --auto-detect 一起使用)')
639
+ .option('--rows <n>', '行数(手动模式必填,除非使用 --auto-detect)', cliParseInt)
640
+ .option('--columns <n>', '列数(手动模式必填)', cliParseInt)
641
+ .option('--frame-width <n>', '单帧宽度(像素,手动模式必填)', cliParseInt)
642
+ .option('--frame-height <n>', '单帧高度(像素,手动模式必填)', cliParseInt)
643
+ .option('--padding <n>', '帧间距(像素,默认 0)', cliParseInt, 0)
644
+ .action(async (opts) => {
645
+ try {
646
+ assertInputWithinLimit(opts.input);
647
+ let analysis;
648
+ if (opts.autoDetect) {
649
+ const imageBase64 = readFileSync(opts.input).toString('base64');
650
+ const client = new AgentApiClient();
651
+ analysis = await client.post('/analyze-sprite-sheet', {
652
+ imageBase64,
653
+ hint: typeof opts.hint === 'string' ? opts.hint : undefined,
654
+ model: typeof opts.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined,
655
+ });
656
+ }
657
+ else {
658
+ const rows = opts.rows;
659
+ const columns = opts.columns;
660
+ const frameWidth = opts.frameWidth;
661
+ const frameHeight = opts.frameHeight;
662
+ if (rows === undefined ||
663
+ columns === undefined ||
664
+ frameWidth === undefined ||
665
+ frameHeight === undefined) {
666
+ console.error('Error: manual mode requires --rows, --columns, --frame-width, and --frame-height (or use --auto-detect)');
667
+ process.exit(1);
668
+ }
669
+ if (rows < 1 || columns < 1 || frameWidth < 1 || frameHeight < 1) {
670
+ console.error('Error: rows, columns, frame-width, frame-height must be positive integers');
671
+ process.exit(1);
672
+ }
673
+ const padding = opts.padding ?? 0;
674
+ analysis = {
675
+ gridType: 'regular',
676
+ rows,
677
+ columns,
678
+ frameWidth,
679
+ frameHeight,
680
+ padding,
681
+ frameCount: rows * columns,
682
+ description: 'Manual grid (playcraft image sprite-split)',
683
+ };
684
+ }
685
+ const written = await splitSpriteSheetToDir(opts.input, opts.outputDir, analysis);
686
+ if (written === 0) {
687
+ console.error('Error: no frames extracted (check grid vs image size)');
688
+ process.exit(1);
689
+ }
690
+ console.log(`Sprite split: ${written} frame(s) → ${opts.outputDir} (split-meta.json); grid=${analysis.gridType} ${analysis.gridType === 'regular' ? `${analysis.rows}x${analysis.columns}` : `${analysis.elements?.length ?? 0} elements`}`);
691
+ }
692
+ catch (e) {
693
+ handleError(e);
694
+ }
695
+ });
566
696
  // ─── sprite-sheet ────────────────────────────────────────────
567
697
  img.command('sprite-sheet')
568
698
  .description('将多张图合并为精灵图,并输出帧坐标 JSON(完整本地实现)')
@@ -2,7 +2,7 @@ import { inspect } from 'node:util';
2
2
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import JSON5 from 'json5';
5
- import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } from '@playcraft/common/prefab';
5
+ import { THEME_SCHEMA_PATH, THEME_INDEX_PATH, THEME_DIR, DEFAULT_GAME_PATH, MANIFEST_PATH, ASSETS_JSON_PATH, LITE_CREATOR_EXTENSIONS_DIR, themeDataPath, scenePath, defaultValueFromJsonSchemaProperty, buildThemeIndexTs, parseThemeIndexImportPath, parseThemePrefabs, mergeThemeDataKey, deleteThemeDataKey, parseExtensionMetaData, parseGameConfig, buildExtensionPrefabs, updateGameConfigValue, updateGameConfigExtensions, findConfigKeyCaseInsensitive, parseConfigKey, listScenesFromManifest, listScenesWithGameConfig, resolveGameConfigPath, validateThemeValue, validateConfigValue, describeJsonSchemaFields, describeConfigSchemaFields, getPrefabFieldDescriptors, getPrefabFieldDiffs, prefabHasSchemaDiff, } from '@playcraft/common/prefab';
6
6
  const DEFAULT_PAGE_LIMIT = 50;
7
7
  /** 无法识别 External / PlayCanvas 项目类型(供测试在 mock process.exit 后通过 instanceof 识别)。 */
8
8
  export class ProjectDetectError extends Error {
@@ -266,22 +266,58 @@ function scanExtensionMetaData(projectDir) {
266
266
  }
267
267
  return map;
268
268
  }
269
+ /** Write a single field's value into MetaData.json configSchema.{field}.default */
270
+ function writeMetaDataDefault(projectDir, extensionName, field, value) {
271
+ const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
272
+ const raw = readFileSync(metaPath, 'utf-8');
273
+ const metaData = JSON.parse(raw);
274
+ const configSchema = metaData.configSchema;
275
+ if (!configSchema?.[field]) {
276
+ throw new Error(`MetaData.json for "${extensionName}" has no configSchema.${field}`);
277
+ }
278
+ configSchema[field].default = value;
279
+ writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
280
+ }
281
+ /** Write multiple fields' values into MetaData.json configSchema.*.default */
282
+ function writeMetaDataDefaults(projectDir, extensionName, values) {
283
+ const metaPath = join(projectDir, LITE_CREATOR_EXTENSIONS_DIR, extensionName, 'MetaData.json');
284
+ const raw = readFileSync(metaPath, 'utf-8');
285
+ const metaData = JSON.parse(raw);
286
+ const configSchema = metaData.configSchema;
287
+ if (!configSchema) {
288
+ throw new Error(`MetaData.json for "${extensionName}" has no configSchema`);
289
+ }
290
+ for (const [field, value] of Object.entries(values)) {
291
+ if (configSchema[field]) {
292
+ configSchema[field].default = value;
293
+ }
294
+ }
295
+ writeFileSync(metaPath, JSON.stringify(metaData, null, 2), 'utf-8');
296
+ }
269
297
  function resolveGameConfigPathForVariant(projectDir, variantId) {
270
298
  if (!variantId)
271
- return DEFAULT_GAME_PATH;
299
+ return null;
272
300
  if (!fileExists(projectDir, scenePath(variantId)))
273
- return DEFAULT_GAME_PATH;
301
+ return null;
274
302
  const sceneJson = JSON.parse(readLocalFile(projectDir, scenePath(variantId)));
275
303
  if (!fileExists(projectDir, ASSETS_JSON_PATH))
276
- return DEFAULT_GAME_PATH;
304
+ return null;
277
305
  const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
278
- const resolved = resolveGameConfigPath(sceneJson, assetsJson);
279
- return resolved ?? DEFAULT_GAME_PATH;
306
+ return resolveGameConfigPath(sceneJson, assetsJson);
280
307
  }
281
308
  function listLocalScenes(projectDir) {
282
309
  if (!fileExists(projectDir, MANIFEST_PATH))
283
310
  return [];
284
311
  const manifestJson = JSON.parse(readLocalFile(projectDir, MANIFEST_PATH));
312
+ if (fileExists(projectDir, ASSETS_JSON_PATH)) {
313
+ const assetsJson = JSON.parse(readLocalFile(projectDir, ASSETS_JSON_PATH));
314
+ return listScenesWithGameConfig(manifestJson, (sceneId) => {
315
+ const sp = scenePath(sceneId);
316
+ if (!fileExists(projectDir, sp))
317
+ return null;
318
+ return JSON.parse(readLocalFile(projectDir, sp));
319
+ }, assetsJson);
320
+ }
285
321
  return listScenesFromManifest(manifestJson);
286
322
  }
287
323
  function loadPrefabs(projectDir, systemType, opts) {
@@ -291,11 +327,22 @@ function loadPrefabs(projectDir, systemType, opts) {
291
327
  const data = loadThemeData(projectDir, themeId);
292
328
  return parseThemePrefabs(schema, data, themeId);
293
329
  }
294
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
295
- const gameConfigJson = loadGameConfig(projectDir, configPath);
296
- const { extensions, config } = parseGameConfig(gameConfigJson);
297
330
  const metaMap = scanExtensionMetaData(projectDir);
298
- return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
331
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
332
+ if (configPath) {
333
+ // 有场景级 GameConfig → 从中读取 extensions 和 config
334
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
335
+ const { extensions, config } = parseGameConfig(gameConfigJson);
336
+ return buildExtensionPrefabs(metaMap, { extensions, config }, configPath);
337
+ }
338
+ // 无场景级 GameConfig → 从 DefaultGame.json 只读 Extensions 列表(不读 Config 值)
339
+ if (fileExists(projectDir, DEFAULT_GAME_PATH)) {
340
+ const defaultGameJson = loadGameConfig(projectDir, DEFAULT_GAME_PATH);
341
+ const { extensions } = parseGameConfig(defaultGameJson);
342
+ return buildExtensionPrefabs(metaMap, { extensions, config: {} }, DEFAULT_GAME_PATH);
343
+ }
344
+ // 完全无 GameConfig → 仅根据 MetaData 构建(全部标记为未启用)
345
+ return buildExtensionPrefabs(metaMap, { extensions: [], config: {} }, '');
299
346
  }
300
347
  // ─── dotpath helper ──────────────────────────────────────────────────────────
301
348
  function getByDotPath(obj, path) {
@@ -818,18 +865,12 @@ export function registerPrefabCommands(program) {
818
865
  });
819
866
  }
820
867
  else {
821
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
822
- const gameConfigJson = loadGameConfig(projectDir, configPath);
823
- const { config } = parseGameConfig(gameConfigJson);
824
868
  const metaMap = scanExtensionMetaData(projectDir);
825
869
  const meta = metaMap.get(key);
826
870
  if (!meta) {
827
871
  console.error(`Error: extension "${key}" 不存在。`);
828
872
  process.exit(1);
829
873
  }
830
- const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
831
- const existingKey = findConfigKeyCaseInsensitive(config, configKey);
832
- const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
833
874
  const fieldDef = meta.configSchema?.[field];
834
875
  const fieldType = fieldDef?.type ?? 'string';
835
876
  const coerced = coerceValue(rawValue, fieldType);
@@ -840,9 +881,22 @@ export function registerPrefabCommands(program) {
840
881
  process.exit(1);
841
882
  }
842
883
  }
843
- const newValues = { ...currentValues, [field]: coerced };
844
- const updated = updateGameConfigValue(gameConfigJson, configKey, newValues);
845
- writeGameConfig(projectDir, configPath, updated);
884
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
885
+ if (configPath) {
886
+ // 有场景级 GameConfig → 写入 GameConfig 文件
887
+ const gameConfigJson = loadGameConfig(projectDir, configPath);
888
+ const { config } = parseGameConfig(gameConfigJson);
889
+ const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
890
+ const existingKey = findConfigKeyCaseInsensitive(config, configKey);
891
+ const currentValues = (existingKey ? config[existingKey] : {}) ?? {};
892
+ const newValues = { ...currentValues, [field]: coerced };
893
+ const updated = updateGameConfigValue(gameConfigJson, configKey, newValues);
894
+ writeGameConfig(projectDir, configPath, updated);
895
+ }
896
+ else {
897
+ // 无场景级 GameConfig → 写 MetaData.json 的 configSchema.{field}.default
898
+ writeMetaDataDefault(projectDir, key, field, coerced);
899
+ }
846
900
  const payload = { success: true, key, field, value: coerced };
847
901
  outputResult(asJson, payload, () => {
848
902
  console.log(`已更新 ${key}.${field} = ${formatHumanValue(coerced)}`);
@@ -940,38 +994,67 @@ export function registerPrefabCommands(program) {
940
994
  }
941
995
  else {
942
996
  const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
943
- let gameConfigJson = loadGameConfig(projectDir, configPath);
944
997
  const metaMap = scanExtensionMetaData(projectDir);
945
- for (const [extKey, fieldMap] of Object.entries(batch)) {
946
- if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
947
- throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
948
- }
949
- const meta = metaMap.get(extKey);
950
- if (!meta) {
951
- throw new Error(`extension "${extKey}" 不存在`);
998
+ if (configPath) {
999
+ // 有场景级 GameConfig 写入 GameConfig 文件
1000
+ let gameConfigJson = loadGameConfig(projectDir, configPath);
1001
+ for (const [extKey, fieldMap] of Object.entries(batch)) {
1002
+ if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
1003
+ throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
1004
+ }
1005
+ const meta = metaMap.get(extKey);
1006
+ if (!meta) {
1007
+ throw new Error(`extension "${extKey}" 不存在`);
1008
+ }
1009
+ const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
1010
+ const { config } = parseGameConfig(gameConfigJson);
1011
+ const existingKey = findConfigKeyCaseInsensitive(config, configKey);
1012
+ let currentValues = (existingKey ? config[existingKey] : {}) ?? {};
1013
+ currentValues = typeof currentValues === 'object' && !Array.isArray(currentValues)
1014
+ ? { ...currentValues }
1015
+ : {};
1016
+ for (const [field, rawVal] of Object.entries(fieldMap)) {
1017
+ const fieldDef = meta.configSchema?.[field];
1018
+ const fieldType = fieldDef?.type ?? 'string';
1019
+ const coerced = coerceBatchLeaf(rawVal, fieldType);
1020
+ if (fieldDef) {
1021
+ const result = validateConfigValue(fieldDef, coerced);
1022
+ if (!result.valid) {
1023
+ throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
1024
+ }
1025
+ }
1026
+ currentValues = setByDotPath(currentValues, field, coerced);
1027
+ }
1028
+ gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
952
1029
  }
953
- const configKey = meta.configPath ? parseConfigKey(meta.configPath) : meta.name;
954
- const { config } = parseGameConfig(gameConfigJson);
955
- const existingKey = findConfigKeyCaseInsensitive(config, configKey);
956
- let currentValues = (existingKey ? config[existingKey] : {}) ?? {};
957
- currentValues = typeof currentValues === 'object' && !Array.isArray(currentValues)
958
- ? { ...currentValues }
959
- : {};
960
- for (const [field, rawVal] of Object.entries(fieldMap)) {
961
- const fieldDef = meta.configSchema?.[field];
962
- const fieldType = fieldDef?.type ?? 'string';
963
- const coerced = coerceBatchLeaf(rawVal, fieldType);
964
- if (fieldDef) {
965
- const result = validateConfigValue(fieldDef, coerced);
966
- if (!result.valid) {
967
- throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
1030
+ writeGameConfig(projectDir, configPath, gameConfigJson);
1031
+ }
1032
+ else {
1033
+ // 无场景级 GameConfig 逐个写 MetaData.json defaults
1034
+ for (const [extKey, fieldMap] of Object.entries(batch)) {
1035
+ if (!fieldMap || typeof fieldMap !== 'object' || Array.isArray(fieldMap)) {
1036
+ throw new Error(`extension "${extKey}" 的值必须是对象(字段 map)`);
1037
+ }
1038
+ const meta = metaMap.get(extKey);
1039
+ if (!meta) {
1040
+ throw new Error(`extension "${extKey}" 不存在`);
1041
+ }
1042
+ const coercedValues = {};
1043
+ for (const [field, rawVal] of Object.entries(fieldMap)) {
1044
+ const fieldDef = meta.configSchema?.[field];
1045
+ const fieldType = fieldDef?.type ?? 'string';
1046
+ const coerced = coerceBatchLeaf(rawVal, fieldType);
1047
+ if (fieldDef) {
1048
+ const result = validateConfigValue(fieldDef, coerced);
1049
+ if (!result.valid) {
1050
+ throw new Error(`${extKey}.${field}: ${result.errors.join('; ')}`);
1051
+ }
968
1052
  }
1053
+ coercedValues[field] = coerced;
969
1054
  }
970
- currentValues = setByDotPath(currentValues, field, coerced);
1055
+ writeMetaDataDefaults(projectDir, extKey, coercedValues);
971
1056
  }
972
- gameConfigJson = updateGameConfigValue(gameConfigJson, configKey, currentValues);
973
1057
  }
974
- writeGameConfig(projectDir, configPath, gameConfigJson);
975
1058
  }
976
1059
  const payload = { success: true, updatedKeys: Object.keys(batch) };
977
1060
  outputResult(asJson, payload, () => {
@@ -1011,7 +1094,7 @@ export function registerPrefabCommands(program) {
1011
1094
  });
1012
1095
  }
1013
1096
  else {
1014
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
1097
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
1015
1098
  const gameConfigJson = loadGameConfig(projectDir, configPath);
1016
1099
  const { extensions } = parseGameConfig(gameConfigJson);
1017
1100
  if (extensions.includes(key)) {
@@ -1055,7 +1138,7 @@ export function registerPrefabCommands(program) {
1055
1138
  });
1056
1139
  }
1057
1140
  else {
1058
- const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant);
1141
+ const configPath = resolveGameConfigPathForVariant(projectDir, opts.variant) ?? DEFAULT_GAME_PATH;
1059
1142
  const gameConfigJson = loadGameConfig(projectDir, configPath);
1060
1143
  const { extensions } = parseGameConfig(gameConfigJson);
1061
1144
  const filtered = extensions.filter((n) => n !== key);
@@ -3,6 +3,7 @@ import { dirname, join, parse } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { AgentApiClient } from '../utils/agent-api-client.js';
5
5
  const TMP_DIR = join(tmpdir(), 'playcraft');
6
+ const MAX_REFERENCE_IMAGES = 8;
6
7
  function ensureTmpDir() {
7
8
  mkdirSync(TMP_DIR, { recursive: true });
8
9
  }
@@ -85,11 +86,18 @@ function sniffImageExtension(buf) {
85
86
  }
86
87
  /** Replace output extension when it does not match the chosen format extension. */
87
88
  function resolveImageOutputPath(outputPath, wantExt) {
88
- const { dir, name, ext } = parse(outputPath);
89
- if (ext.toLowerCase() === wantExt) {
89
+ try {
90
+ const { dir, name, ext } = parse(outputPath);
91
+ if (ext.toLowerCase() === wantExt) {
92
+ return outputPath;
93
+ }
94
+ return join(dir, `${name}${wantExt}`);
95
+ }
96
+ catch (e) {
97
+ const detail = e instanceof Error ? e.message : String(e);
98
+ console.warn(`Could not adjust output path for extension ${wantExt} (${detail}); using path as given: ${outputPath}`);
90
99
  return outputPath;
91
100
  }
92
- return join(dir, `${name}${wantExt}`);
93
101
  }
94
102
  function collectReferenceImagePaths(value, previous) {
95
103
  return previous.concat([value]);
@@ -100,21 +108,37 @@ function mimeTypeForImagePath(filePath) {
100
108
  return 'image/png';
101
109
  if (ext === 'webp')
102
110
  return 'image/webp';
103
- return 'image/jpeg';
111
+ if (ext === 'jpg' || ext === 'jpeg')
112
+ return 'image/jpeg';
113
+ if (ext === undefined || ext === '') {
114
+ throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
115
+ }
116
+ throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and jpeg are allowed.`);
104
117
  }
105
- function readReferenceImagePayload(filePath) {
106
- try {
107
- return {
108
- base64: readFileSync(filePath).toString('base64'),
109
- mimeType: mimeTypeForImagePath(filePath),
110
- };
118
+ /** Load reference images for generate-image; skip unreadable paths after console.warn (same tolerance as output path resolve). */
119
+ function collectReferenceImagePayloads(paths) {
120
+ if (paths.length === 0)
121
+ return undefined;
122
+ const out = [];
123
+ for (const p of paths) {
124
+ try {
125
+ const mimeType = mimeTypeForImagePath(p);
126
+ out.push({
127
+ base64: readFileSync(p).toString('base64'),
128
+ mimeType,
129
+ });
130
+ }
131
+ catch (e) {
132
+ const detail = e instanceof Error ? e.message : String(e);
133
+ const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
134
+ const hint = code ? ` (${code})` : '';
135
+ console.warn(`Skipping reference image (read failed): ${p}${hint}\n ${detail}`);
136
+ }
111
137
  }
112
- catch (e) {
113
- const detail = e instanceof Error ? e.message : String(e);
114
- const code = e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
115
- const hint = code ? ` (${code})` : '';
116
- throw new Error(`Failed to read reference image: ${filePath}${hint}\n ${detail}`);
138
+ if (paths.length > 0 && out.length === 0) {
139
+ console.warn('All reference images failed to load; continuing with text-only generation.');
117
140
  }
141
+ return out.length > 0 ? out : undefined;
118
142
  }
119
143
  export function registerToolsCommands(program) {
120
144
  const tools = program
@@ -131,7 +155,10 @@ export function registerToolsCommands(program) {
131
155
  .action(async (opts) => {
132
156
  try {
133
157
  const paths = opts.referenceImage ?? [];
134
- const referenceImages = paths.length > 0 ? paths.map((p) => readReferenceImagePayload(p)) : undefined;
158
+ if (paths.length > MAX_REFERENCE_IMAGES) {
159
+ throw new Error(`Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
160
+ }
161
+ const referenceImages = collectReferenceImagePayloads(paths);
135
162
  const client = new AgentApiClient();
136
163
  const result = await client.post('/generate-image', {
137
164
  prompt: opts.prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,13 +17,14 @@
17
17
  "build": "tsc",
18
18
  "start": "node dist/index.js",
19
19
  "test": "vitest run",
20
+ "verify-sprite-overlay": "node scripts/verify-sprite-overlay.mjs",
20
21
  "link": "node scripts/bump-local.js && pnpm build && npm link",
21
22
  "unlink": "npm unlink -g @playcraft/cli",
22
23
  "release": "node scripts/release.js"
23
24
  },
24
25
  "dependencies": {
25
- "@playcraft/build": "^0.0.28",
26
- "@playcraft/common": "^0.0.17",
26
+ "@playcraft/build": "^0.0.29",
27
+ "@playcraft/common": "^0.0.18",
27
28
  "chokidar": "^4.0.3",
28
29
  "commander": "^13.1.0",
29
30
  "cors": "^2.8.6",