@playcraft/cli 0.1.82 → 0.1.84-beta

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.
@@ -16,7 +16,8 @@ export function registerReferenceCommands(program) {
16
16
  ...(options.tag?.length ? { tagCategory: options.tagCategory ?? 'Gameplay', tagValues: options.tag }
17
17
  : options.tagCategory === undefined ? {} : { tagCategory: options.tagCategory }),
18
18
  page: Number(options.page), limit: Number(options.limit) } : {}),
19
- ...(action === 'import' ? { projectUuid: options.project, branchName: options.branch } : {}) });
19
+ ...(action === 'import' ? { projectUuid: options.project, branchName: options.branch } : {}),
20
+ ...(action === 'prepare-starter-template' ? { templateId: options.template } : {}) });
20
21
  const output = { schemaVersion: 'playcraft.references.v1', command: `references.${action}`, ...result };
21
22
  if (options.json)
22
23
  console.log(JSON.stringify(output));
@@ -45,7 +46,8 @@ export function registerReferenceCommands(program) {
45
46
  const scope = (command) => command.option('--workspace <id>', message('workspace'))
46
47
  .option('--directory <name>', message('directory')).option('--json', message('json'));
47
48
  scope(references.command('list').description(message('list'))).action(options => run('list', options));
48
- scope(references.command('prepare-starter-template').description(message('prepareStarterTemplate'))).action(options => run('prepare-starter-template', options));
49
+ scope(references.command('prepare-starter-template').description(message('prepareStarterTemplate'))
50
+ .option('--template <id>', message('template'))).action(options => run('prepare-starter-template', options));
49
51
  scope(references.command('search [query]').description(message('search'))
50
52
  .option('--source <source>', message('source'), 'recommended')
51
53
  .option('--tag <value>', message('tag'), (value, previous) => [...previous, value], [])
@@ -3,8 +3,8 @@ import { writeFileSync, mkdirSync } from 'fs';
3
3
  import { dirname, parse, join } from 'path';
4
4
  import sharp from 'sharp';
5
5
  import { captureGeneratedArtifact } from '../workspace-runtime/workspaces/artifact-receipts.js';
6
- import { resolveImageBackgroundRequirement, validateImageDeliveryOptions } from '@playcraft/common/prefab';
7
- import { assertImageAssetOutputPaths, readImageAssetContext } from '../utils/image-asset-context.js';
6
+ import { resolveImageBackgroundRequirement, validateImageDeliveryOptions, } from '@playcraft/common/prefab';
7
+ import { assertImageAssetOutputPaths, readImageAssetContext, } from '../utils/image-asset-context.js';
8
8
  import { AgentApiClient } from '../utils/agent-api-client.js';
9
9
  import { isUnsafeImageRetryError } from '../utils/image-generation-retry.js';
10
10
  import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, parseImageTargetFieldPath, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, raceWithTimeout, } from './tools-utils.js';
@@ -73,7 +73,8 @@ async function verifyMasterCompositeStripSize(imgBuf, target) {
73
73
  }
74
74
  export function registerGenerationCommands(tools) {
75
75
  // ─── Image Models ────────────────────────────────────────────
76
- tools.command('list-image-models')
76
+ tools
77
+ .command('list-image-models')
77
78
  .description('列出后端已配置的可用生图模型,按模型名分组展示所有 provider(多个 provider 时按优先级自动 fallback)')
78
79
  .option('--json', '以 JSON 格式输出(默认为人类可读表格)')
79
80
  .action(async (opts) => {
@@ -150,7 +151,8 @@ export function registerGenerationCommands(tools) {
150
151
  }
151
152
  });
152
153
  // ─── Generation ─────────────────────────────────────────────
153
- tools.command('generate-image')
154
+ tools
155
+ .command('generate-image')
154
156
  .description('AI 生成图片(支持多张参考图图生图;gpt-image-2 支持 --count N 批量出图)')
155
157
  .requiredOption('--prompt <text>', '图片描述')
156
158
  .option('--aspect-ratio <ratio>', '宽高比 (1:1|45:16|16:9|9:16|3:4|4:3|2:3|3:2|21:9)', '1:1')
@@ -174,8 +176,12 @@ export function registerGenerationCommands(tools) {
174
176
  .option('--count <n>', '一次 API 调用生成多少张图片(默认 1,最多 10;仅 gpt-image-2 实际支持批量)', (v) => parseInt(v, 10), 1)
175
177
  .action(async (opts) => {
176
178
  try {
177
- const explicitBackground = opts.background === undefined ? opts.removeBackground : opts.background === 'transparent';
178
- if (opts.background !== undefined && opts.removeBackground !== undefined && opts.removeBackground !== explicitBackground) {
179
+ const explicitBackground = opts.background === undefined
180
+ ? opts.removeBackground
181
+ : opts.background === 'transparent';
182
+ if (opts.background !== undefined &&
183
+ opts.removeBackground !== undefined &&
184
+ opts.removeBackground !== explicitBackground) {
179
185
  throw new Error('Conflicting background options. Use --background transparent or --background opaque; no image request was sent.');
180
186
  }
181
187
  opts = { ...opts, removeBackground: explicitBackground };
@@ -184,28 +190,46 @@ export function registerGenerationCommands(tools) {
184
190
  }
185
191
  const explicitOutputFormat = opts.outputFormat;
186
192
  const { dir: outputDir, name: outputName, ext: outputExt } = parse(opts.output);
187
- const outputCandidates = [opts.output, ...Array.from({ length: opts.count > 1 ? Math.min(opts.count, 10) : 0 }, (_, i) => join(outputDir, `${outputName}_${i + 1}${outputExt}`))];
188
- const assetContext = opts.targetAsset ? await readImageAssetContext(opts.targetAsset, process.cwd(), {
189
- referenceImagePaths: opts.referenceImage, useTargetReference: opts.targetReference,
190
- ...(opts.targetFieldPath ? { targetFieldPath: opts.targetFieldPath } : {}),
191
- outputPaths: outputCandidates,
192
- prompt: opts.prompt, removeBackground: opts.removeBackground,
193
- }) : undefined;
194
- opts = { ...opts,
193
+ const outputCandidates = [
194
+ opts.output,
195
+ ...Array.from({ length: opts.count > 1 ? Math.min(opts.count, 10) : 0 }, (_, i) => join(outputDir, `${outputName}_${i + 1}${outputExt}`)),
196
+ ];
197
+ const assetContext = opts.targetAsset
198
+ ? await readImageAssetContext(opts.targetAsset, process.cwd(), {
199
+ referenceImagePaths: opts.referenceImage,
200
+ useTargetReference: opts.targetReference,
201
+ ...(opts.targetFieldPath ? { targetFieldPath: opts.targetFieldPath } : {}),
202
+ outputPaths: outputCandidates,
203
+ prompt: opts.prompt,
204
+ removeBackground: opts.removeBackground,
205
+ })
206
+ : undefined;
207
+ opts = {
208
+ ...opts,
195
209
  aspectRatio: assetContext?.aspectRatio ?? opts.aspectRatio,
196
- width: assetContext?.width ?? opts.width, height: assetContext?.height ?? opts.height,
197
- outputFormat: assetContext?.outputFormatFromSchema ? assetContext.outputFormat : opts.outputFormat ?? assetContext?.outputFormat,
210
+ width: assetContext?.width ?? opts.width,
211
+ height: assetContext?.height ?? opts.height,
212
+ outputFormat: assetContext?.outputFormatFromSchema
213
+ ? assetContext.outputFormat
214
+ : (opts.outputFormat ?? assetContext?.outputFormat),
198
215
  fitMode: assetContext?.fitMode ?? opts.fitMode,
199
- removeBackground: resolveImageBackgroundRequirement({ prompt: opts.prompt,
200
- schemaRemoveBackground: assetContext?.removeBackgroundFromSchema ? assetContext.removeBackground : undefined,
201
- removeBackground: opts.removeBackground, fallbackRemoveBackground: assetContext?.removeBackground }),
216
+ removeBackground: resolveImageBackgroundRequirement({
217
+ prompt: opts.prompt,
218
+ schemaRemoveBackground: assetContext?.removeBackgroundFromSchema
219
+ ? assetContext.removeBackground
220
+ : undefined,
221
+ removeBackground: opts.removeBackground,
222
+ fallbackRemoveBackground: assetContext?.removeBackground,
223
+ }),
202
224
  paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
203
225
  imageModel: opts.imageModel || assetContext?.imageModelRef,
204
226
  };
205
227
  const invalidDelivery = validateImageDeliveryOptions(opts);
206
228
  if (invalidDelivery)
207
229
  throw new Error(invalidDelivery);
208
- const paths = opts.referenceImage?.length ? opts.referenceImage : assetContext?.referenceImagePaths ?? [];
230
+ const paths = opts.referenceImage?.length
231
+ ? opts.referenceImage
232
+ : (assetContext?.referenceImagePaths ?? []);
209
233
  if (paths.length > MAX_REFERENCE_IMAGES) {
210
234
  console.error(`Error: Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
211
235
  process.exit(1);
@@ -230,14 +254,26 @@ export function registerGenerationCommands(tools) {
230
254
  width: assetContext?.width ?? opts.width,
231
255
  height: assetContext?.height ?? opts.height,
232
256
  outputFormat: opts.outputFormat,
233
- outputFormatSource: assetContext?.outputFormatFromSchema ? 'schema'
234
- : explicitOutputFormat ? 'explicit' : assetContext ? 'inferred' : undefined,
257
+ outputFormatSource: assetContext?.outputFormatFromSchema
258
+ ? 'schema'
259
+ : explicitOutputFormat
260
+ ? 'explicit'
261
+ : assetContext
262
+ ? 'inferred'
263
+ : undefined,
235
264
  fitMode: assetContext?.fitMode ?? opts.fitMode,
236
265
  paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
237
- background: opts.background === undefined ? undefined : opts.removeBackground ? 'transparent' : 'opaque',
266
+ background: opts.background === undefined
267
+ ? undefined
268
+ : opts.removeBackground
269
+ ? 'transparent'
270
+ : 'opaque',
238
271
  removeBackground: opts.removeBackground,
239
- backgroundRequirementSource: assetContext?.removeBackgroundFromSchema ? 'schema'
240
- : explicitBackground !== undefined ? 'explicit' : 'inferred',
272
+ backgroundRequirementSource: assetContext?.removeBackgroundFromSchema
273
+ ? 'schema'
274
+ : explicitBackground !== undefined
275
+ ? 'explicit'
276
+ : 'inferred',
241
277
  referenceImages,
242
278
  ...(modelRef ? { imageModelRef: modelRef } : {}),
243
279
  ...(imageCount > 1 ? { imageCount } : {}),
@@ -265,7 +301,8 @@ export function registerGenerationCommands(tools) {
265
301
  lines.push(' → Try --image-size 1K to reduce generation time');
266
302
  lines.push(' → Or increase timeout with --timeout <seconds> (current default: 1800s)');
267
303
  }
268
- else if (hasReferenceImages && (msg.includes('reference') || msg.includes('image input'))) {
304
+ else if (hasReferenceImages &&
305
+ (msg.includes('reference') || msg.includes('image input'))) {
269
306
  lines.push('');
270
307
  lines.push('Hint: Reference image error — the selected model may not support --reference-image.');
271
308
  lines.push(' → Run: playcraft tools list-image-models');
@@ -313,7 +350,11 @@ export function registerGenerationCommands(tools) {
313
350
  }
314
351
  // 收集所有待保存图片(第 1 张 + 批量额外图)
315
352
  const allImages = [
316
- { imageBase64: result.imageBase64, mimeType: result.mimeType, previewUrl: result.previewUrl },
353
+ {
354
+ imageBase64: result.imageBase64,
355
+ mimeType: result.mimeType,
356
+ previewUrl: result.previewUrl,
357
+ },
317
358
  ...(result.additionalImages ?? []),
318
359
  ];
319
360
  const isBatch = allImages.length > 1;
@@ -324,12 +365,14 @@ export function registerGenerationCommands(tools) {
324
365
  const savedPaths = [];
325
366
  if (result.transparencySource)
326
367
  console.log(`Transparency source: ${result.transparencySource}`);
327
- const stripTarget = opts.targetAsset || result.transparentBackground ? null : resolveStripTargetDimensions({
328
- aspectRatio: opts.aspectRatio,
329
- width: opts.width,
330
- height: opts.height,
331
- imageSize: opts.imageSize,
332
- });
368
+ const stripTarget = opts.targetAsset || result.transparentBackground
369
+ ? null
370
+ : resolveStripTargetDimensions({
371
+ aspectRatio: opts.aspectRatio,
372
+ width: opts.width,
373
+ height: opts.height,
374
+ imageSize: opts.imageSize,
375
+ });
333
376
  for (let i = 0; i < allImages.length; i++) {
334
377
  const img = allImages[i];
335
378
  let imgBuf = Buffer.from(img.imageBase64, 'base64');
@@ -347,8 +390,7 @@ export function registerGenerationCommands(tools) {
347
390
  generatedHeight = metaBefore.height;
348
391
  if (metaBefore.width != null &&
349
392
  metaBefore.height != null &&
350
- (metaBefore.width !== stripTarget.width ||
351
- metaBefore.height !== stripTarget.height)) {
393
+ (metaBefore.width !== stripTarget.width || metaBefore.height !== stripTarget.height)) {
352
394
  imgBuf = Buffer.from(await letterboxToTarget(imgBuf, stripTarget.width, stripTarget.height));
353
395
  console.log(`↑ Letterbox ${generatedWidth}×${generatedHeight} → ${stripTarget.width}×${stripTarget.height}(等比 + 白边,不拉伸)`);
354
396
  }
@@ -404,10 +446,11 @@ export function registerGenerationCommands(tools) {
404
446
  referenceImageStatus: result.referenceImageStatus ?? null,
405
447
  generatedAt: new Date().toISOString(),
406
448
  };
407
- const sidecarPath = resolvedPath.replace(/\.[^.]+$/, '.json');
449
+ const sidecarPath = `${resolvedPath}.generation.json`;
408
450
  writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2));
409
451
  await captureGeneratedArtifact(resolvedPath, 'generate-image', {
410
- ...(actualWidth ? { width: actualWidth } : {}), ...(actualHeight ? { height: actualHeight } : {}),
452
+ ...(actualWidth ? { width: actualWidth } : {}),
453
+ ...(actualHeight ? { height: actualHeight } : {}),
411
454
  });
412
455
  console.log(` └─ Generation metadata: ${sidecarPath}`);
413
456
  }
@@ -431,7 +474,8 @@ export function registerGenerationCommands(tools) {
431
474
  handleError(e);
432
475
  }
433
476
  });
434
- tools.command('generate-sfx')
477
+ tools
478
+ .command('generate-sfx')
435
479
  .description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
436
480
  .requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
437
481
  .option('--duration <seconds>', '时长(秒,0.5-22;省略时由供应商自动决定)', parseFloat)
@@ -451,9 +495,14 @@ export function registerGenerationCommands(tools) {
451
495
  const sizeKB = Math.round(buf.length / 1024);
452
496
  console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
453
497
  const sfxSidecar = {
454
- type: 'sfx', prompt: opts.prompt, duration: opts.duration ?? null,
455
- loop: opts.loop ?? false, provider: result.provider, actualDuration: result.duration,
456
- output: opts.output, generatedAt: new Date().toISOString(),
498
+ type: 'sfx',
499
+ prompt: opts.prompt,
500
+ duration: opts.duration ?? null,
501
+ loop: opts.loop ?? false,
502
+ provider: result.provider,
503
+ actualDuration: result.duration,
504
+ output: opts.output,
505
+ generatedAt: new Date().toISOString(),
457
506
  };
458
507
  writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(sfxSidecar, null, 2));
459
508
  await captureGeneratedArtifact(opts.output, 'generate-sfx', { duration: result.duration });
@@ -463,7 +512,8 @@ export function registerGenerationCommands(tools) {
463
512
  handleError(e);
464
513
  }
465
514
  });
466
- tools.command('generate-bgm')
515
+ tools
516
+ .command('generate-bgm')
467
517
  .description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
468
518
  .requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
469
519
  .option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
@@ -484,9 +534,14 @@ export function registerGenerationCommands(tools) {
484
534
  const sizeKB = Math.round(buf.length / 1024);
485
535
  console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
486
536
  const bgmSidecar = {
487
- type: 'bgm', prompt: opts.prompt, style: opts.style ?? null,
488
- bpm: opts.bpm ?? null, provider: result.provider, actualDuration: result.duration,
489
- output: opts.output, generatedAt: new Date().toISOString(),
537
+ type: 'bgm',
538
+ prompt: opts.prompt,
539
+ style: opts.style ?? null,
540
+ bpm: opts.bpm ?? null,
541
+ provider: result.provider,
542
+ actualDuration: result.duration,
543
+ output: opts.output,
544
+ generatedAt: new Date().toISOString(),
490
545
  };
491
546
  writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(bgmSidecar, null, 2));
492
547
  await captureGeneratedArtifact(opts.output, 'generate-bgm', { duration: result.duration });
@@ -517,7 +572,8 @@ export function registerGenerationCommands(tools) {
517
572
  }
518
573
  });
519
574
  // ─── Search Image ──────────────────────────────────────────
520
- tools.command('search-image')
575
+ tools
576
+ .command('search-image')
521
577
  .description('搜索参考图片(Unsplash / Pexels),结果 URL 可直接用于 --reference-image')
522
578
  .requiredOption('--query <query>', '搜索关键词(建议英文)')
523
579
  .option('--source <source>', '图源:unsplash | pexels | auto(默认 auto)', 'auto')
@@ -1,5 +1,5 @@
1
1
  import { writeFileSync, mkdirSync, readFileSync } from 'fs';
2
- import { join, parse } from 'path';
2
+ import { join, parse, resolve } from 'path';
3
3
  import { tmpdir } from 'os';
4
4
  import { InvalidArgumentError } from 'commander';
5
5
  export const TMP_DIR = join(tmpdir(), 'playcraft');
@@ -9,8 +9,12 @@ export function parseImageTargetFieldPath(value) {
9
9
  try {
10
10
  parsed = JSON.parse(value);
11
11
  }
12
- catch { /* Report the expected shape below. */ }
13
- if (!Array.isArray(parsed) || !parsed.length || parsed.some(key => typeof key !== 'string' || !key.length)) {
12
+ catch {
13
+ /* Report the expected shape below. */
14
+ }
15
+ if (!Array.isArray(parsed) ||
16
+ !parsed.length ||
17
+ parsed.some((key) => typeof key !== 'string' || !key.length)) {
14
18
  throw new InvalidArgumentError('Expected an array of field names, e.g. ["endUI","playButton"].');
15
19
  }
16
20
  return parsed;
@@ -74,11 +78,7 @@ export function extensionForImageMime(mimeType) {
74
78
  * Detect container from decoded bytes (magic). Prefer this over MIME alone.
75
79
  */
76
80
  export function sniffImageExtension(buf) {
77
- if (buf.length >= 8 &&
78
- buf[0] === 0x89 &&
79
- buf[1] === 0x50 &&
80
- buf[2] === 0x4e &&
81
- buf[3] === 0x47) {
81
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
82
82
  return '.png';
83
83
  }
84
84
  if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
@@ -133,12 +133,14 @@ export function mimeTypeForImagePath(filePath) {
133
133
  return 'image/png';
134
134
  if (ext === 'webp')
135
135
  return 'image/webp';
136
+ if (ext === 'gif')
137
+ return 'image/gif';
136
138
  if (ext === 'jpg' || ext === 'jpeg')
137
139
  return 'image/jpeg';
138
140
  if (ext === undefined || ext === '') {
139
141
  throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
140
142
  }
141
- throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and jpeg are allowed.`);
143
+ throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, jpeg, and gif are allowed.`);
142
144
  }
143
145
  async function downloadImageFromUrl(url) {
144
146
  let res;
@@ -155,7 +157,8 @@ async function downloadImageFromUrl(url) {
155
157
  const contentType = res.headers.get('content-type') ?? '';
156
158
  const mimeType = contentType.split(';')[0].trim() || 'image/png';
157
159
  const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
158
- if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) || !mimeType.startsWith('image/')) {
160
+ if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) ||
161
+ !mimeType.startsWith('image/')) {
159
162
  throw new Error(`参考图 URL 返回的 Content-Type 不是图片(${mimeType}):${url}\n 只支持 PNG / JPG / WEBP / GIF。`);
160
163
  }
161
164
  const buf = await res.arrayBuffer();
@@ -178,18 +181,13 @@ export async function collectReferenceImagePayloads(paths) {
178
181
  }
179
182
  else {
180
183
  const mimeType = mimeTypeForImagePath(p);
181
- out.push({ base64: readFileSync(p).toString('base64'), mimeType });
184
+ out.push({ base64: readFileSync(resolve(process.cwd(), p)).toString('base64'), mimeType });
182
185
  }
183
186
  }
184
187
  catch (e) {
185
188
  const detail = e instanceof Error ? e.message : String(e);
186
- const code = !isUrl && e && typeof e === 'object' && 'code' in e ? String(e.code) : '';
187
- const hint = code ? ` (${code})` : '';
188
- console.warn(`Skipping reference image (load failed): ${p}${hint}\n ${detail}`);
189
+ throw new Error(`Could not load reference image "${p}": ${detail}. No image request was sent.`);
189
190
  }
190
191
  }
191
- if (paths.length > 0 && out.length === 0) {
192
- console.warn('All reference images failed to load; continuing with text-only generation.');
193
- }
194
192
  return out.length > 0 ? out : undefined;
195
193
  }
@@ -4,6 +4,7 @@ const messages = {
4
4
  list: 'List verified local Remix references and their paths', search: 'Search accessible recommended or community Remixes',
5
5
  import: 'Import a fixed Remix source snapshot into the current engineering directory',
6
6
  prepareStarterTemplate: 'Prepare a verified starter template in a temporary directory',
7
+ template: 'Starter template: cocos (default) or threejs',
7
8
  query: 'Search names, branches, tags, creators or templates', source: 'recommended or community', project: 'Source project identifier', branch: 'Exact source branch',
8
9
  tag: 'Match a tag value; repeat to match any of the values', tagCategory: 'Tag category; defaults to Gameplay when --tag is used',
9
10
  workspace: 'Current cloud workspace identifier (must match this conversation)', directory: 'Current engineering directory (must match this conversation)',
@@ -24,6 +25,7 @@ const messages = {
24
25
  description: '为当前云工作区发现并导入 Remix 源码参考',
25
26
  list: '列出已验证的本地 Remix 参考及路径', search: '搜索可访问的推荐或社区 Remix', import: '把固定版本的 Remix 源码参考导入当前工程',
26
27
  prepareStarterTemplate: '将经过校验的起始模板准备到临时目录',
28
+ template: '起始模板:cocos(默认)或 threejs',
27
29
  query: '搜索名称、分支、标签、创建者或模板', source: 'recommended(推荐)或 community(社区)', project: '来源项目标识', branch: '准确的来源分支名',
28
30
  tag: '匹配的标签值;可重复指定,匹配任意一个值', tagCategory: '标签分类;使用 --tag 时默认为 Gameplay',
29
31
  workspace: '当前云工作区标识(必须与本对话一致)', directory: '当前工程目录(必须与本对话一致)',
@@ -1,5 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { z } from 'zod';
3
+ import { WORKSPACE_STARTER_TEMPLATE_IDS } from '@playcraft/common/cloud-workspace/runtime';
3
4
  const scope = {
4
5
  cwd: z.string().min(1).max(4096),
5
6
  workspaceId: z.string().uuid().optional(),
@@ -11,7 +12,9 @@ const scope = {
11
12
  export const remixReferenceToolRequestSchema = z
12
13
  .discriminatedUnion('action', [
13
14
  z.object({ ...scope, action: z.literal('list') }).strict(),
14
- z.object({ ...scope, action: z.literal('prepare-starter-template') }).strict(),
15
+ z
16
+ .object({ ...scope, action: z.literal('prepare-starter-template'), templateId: z.enum(WORKSPACE_STARTER_TEMPLATE_IDS).optional() })
17
+ .strict(),
15
18
  z
16
19
  .object({
17
20
  ...scope,
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import JSON5 from 'json5';
4
4
  import sharp from 'sharp';
5
- import { extractAssetFields, getByPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, THEME_SCHEMA_PATH, themeDataPath, } from '@playcraft/common/prefab';
5
+ import { extractAssetFields, getByPath, parseThemeIndexImportPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, THEME_INDEX_PATH, THEME_SCHEMA_PATH, themeDataPath, } from '@playcraft/common/prefab';
6
6
  function isWithinDirectory(root, file) {
7
7
  const relative = path.relative(root, file);
8
8
  return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
@@ -38,20 +38,29 @@ export function assertImageAssetOutputPaths(context, outputPaths, projectDir = p
38
38
  const root = path.resolve(projectDir);
39
39
  const target = canonicalProjectPath(root, context.targetAssetPath);
40
40
  const targetStat = statSync(target);
41
- const outputRoots = [root, ...allowedOutputRoots.map(outputRoot => path.resolve(root, outputRoot))];
41
+ const outputRoots = [
42
+ root,
43
+ ...allowedOutputRoots.map((outputRoot) => path.resolve(root, outputRoot)),
44
+ ];
42
45
  for (const output of outputPaths) {
43
46
  const mapped = output.startsWith('/project/') ? output.slice('/project/'.length) : output;
44
47
  const { dir, name } = path.parse(mapped);
45
48
  // The response encoding can change the requested extension.
46
- const candidates = [mapped, ...['.png', '.webp', '.jpg', '.gif', '.bmp'].map(ext => path.join(dir, `${name}${ext}`))];
49
+ const candidates = [
50
+ mapped,
51
+ ...['.png', '.webp', '.jpg', '.gif', '.bmp'].map((ext) => path.join(dir, `${name}${ext}`)),
52
+ ];
47
53
  for (const candidate of candidates) {
48
54
  const candidatePath = path.resolve(root, candidate);
49
- const outputRoot = outputRoots.find(allowed => isWithinDirectory(allowed, candidatePath));
55
+ const outputRoot = outputRoots.find((allowed) => isWithinDirectory(allowed, candidatePath));
50
56
  if (!outputRoot)
51
57
  throw new Error(`Image output path is outside the project and allowed output roots: ${output}`);
52
58
  const file = canonicalProjectPath(outputRoot, candidatePath);
53
59
  const fileStat = existsSync(file) ? statSync(file) : undefined;
54
- if (!path.relative(target, file) || (targetStat.ino !== 0 && fileStat?.ino === targetStat.ino && fileStat.dev === targetStat.dev)) {
60
+ if (!path.relative(target, file) ||
61
+ (targetStat.ino !== 0 &&
62
+ fileStat?.ino === targetStat.ino &&
63
+ fileStat.dev === targetStat.dev)) {
55
64
  throw new Error(`Cannot overwrite shared image asset ${context.targetAssetPath}: other fields have different requirements. Use a separate outputPath, then explicitly update the selected field reference; no shared asset was changed.`);
56
65
  }
57
66
  }
@@ -71,8 +80,10 @@ function projectFile(root, input) {
71
80
  /** Read the same per-field constraints used by the asset image dialog. */
72
81
  export async function readImageAssetContext(assetPath, projectDir = process.cwd(), options = {}) {
73
82
  const targetFieldPath = options.targetFieldPath;
74
- if (targetFieldPath !== undefined && (!Array.isArray(targetFieldPath)
75
- || !targetFieldPath.length || targetFieldPath.some(key => typeof key !== 'string' || !key.length))) {
83
+ if (targetFieldPath !== undefined &&
84
+ (!Array.isArray(targetFieldPath) ||
85
+ !targetFieldPath.length ||
86
+ targetFieldPath.some((key) => typeof key !== 'string' || !key.length))) {
76
87
  throw new Error('targetFieldPath must be a non-empty array of non-empty field names.');
77
88
  }
78
89
  const root = path.resolve(projectDir);
@@ -81,24 +92,36 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
81
92
  const targetStat = statSync(target);
82
93
  const relativeTarget = path.relative(root, target).replace(/\\/g, '/');
83
94
  const themeMatch = relativeTarget.match(/^src\/theme\/([^/]+)\/assets\//);
95
+ const indexFile = path.join(root, THEME_INDEX_PATH);
96
+ const themeId = themeMatch?.[1] ??
97
+ (existsSync(indexFile)
98
+ ? parseThemeIndexImportPath(readFileSync(projectFile(root, THEME_INDEX_PATH), 'utf8'))
99
+ : null);
84
100
  let fields = [];
85
101
  const schemaFile = path.join(root, THEME_SCHEMA_PATH);
86
- if (themeMatch && existsSync(schemaFile)) {
102
+ if (themeId && existsSync(schemaFile)) {
87
103
  const schema = JSON5.parse(readFileSync(projectFile(root, THEME_SCHEMA_PATH), 'utf8'));
88
- const dataPath = themeDataPath(themeMatch[1]);
104
+ const dataPath = themeDataPath(themeId);
89
105
  const data = existsSync(path.join(root, dataPath))
90
- ? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8')) : {};
106
+ ? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8'))
107
+ : {};
91
108
  const themeRoot = path.dirname(path.join(root, dataPath));
92
- const matches = extractAssetFields(schema, data).filter(candidate => {
109
+ const matches = extractAssetFields(schema, data).filter((candidate) => {
93
110
  let configured = getByPath(data, candidate.path);
94
111
  if (configured === undefined && ['gameplay', 'gamePlay'].includes(candidate.path[0])) {
95
- configured = getByPath(data, [candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay', ...candidate.path.slice(1)]);
112
+ configured = getByPath(data, [
113
+ candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay',
114
+ ...candidate.path.slice(1),
115
+ ]);
96
116
  }
97
117
  const value = typeof configured === 'string' ? configured : candidate.schema.default;
98
118
  if (typeof value !== 'string' || /^https?:\/\//i.test(value))
99
119
  return false;
100
- const configuredPath = value.startsWith('/project/') ? path.resolve(root, value.slice('/project/'.length))
101
- : path.resolve(themeRoot, value);
120
+ const configuredPath = value.startsWith('/project/')
121
+ ? path.resolve(root, value.slice('/project/'.length))
122
+ : /^\/?src\//.test(value)
123
+ ? path.resolve(root, value.replace(/^\//, ''))
124
+ : path.resolve(themeRoot, value);
102
125
  if (configuredPath === target)
103
126
  return true;
104
127
  try {
@@ -106,19 +129,22 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
106
129
  if (!path.relative(realTarget, realpathSync(candidateFile)))
107
130
  return true;
108
131
  const candidateStat = statSync(candidateFile);
109
- return targetStat.ino !== 0 && candidateStat.ino === targetStat.ino && candidateStat.dev === targetStat.dev;
132
+ return (targetStat.ino !== 0 &&
133
+ candidateStat.ino === targetStat.ino &&
134
+ candidateStat.dev === targetStat.dev);
110
135
  }
111
136
  catch {
112
137
  // Other fields may reference missing files or paths outside this project.
113
138
  return false;
114
139
  }
115
140
  });
116
- if (!matches.length)
141
+ if (!matches.length && (themeMatch || targetFieldPath))
117
142
  throw new Error(`No image schema field matches ${relativeTarget}; check theme.data.json5 before generating.`);
118
143
  fields = matches;
119
- if (targetFieldPath && !fields.some(candidate => candidate.path.length === targetFieldPath.length
120
- && candidate.path.every((key, index) => key === targetFieldPath[index]))) {
121
- throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map(candidate => JSON.stringify(candidate.path)).join(', ')}.`);
144
+ if (targetFieldPath &&
145
+ !fields.some((candidate) => candidate.path.length === targetFieldPath.length &&
146
+ candidate.path.every((key, index) => key === targetFieldPath[index]))) {
147
+ throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map((candidate) => JSON.stringify(candidate.path)).join(', ')}.`);
122
148
  }
123
149
  }
124
150
  else if (targetFieldPath) {
@@ -131,22 +157,35 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
131
157
  const ext = xp?.ext;
132
158
  // Schema references follow the dialog's root/theme conventions; explicit tool paths do not change.
133
159
  const schemaReferences = Array.isArray(ext?.reference_image)
134
- ? ext.reference_image.filter(ref => typeof ref === 'string' && ref.trim()).map(ref => {
160
+ ? ext.reference_image
161
+ .filter((ref) => typeof ref === 'string' && ref.trim())
162
+ .map((ref) => {
135
163
  const raw = ref.trim().replace(/\\/g, '/');
136
- const normalized = raw.replace(/^\.\/+/, '').replace(/^\/+/, '').replace(/\/{2,}/g, '/');
164
+ const normalized = raw
165
+ .replace(/^\.\/+/, '')
166
+ .replace(/^\/+/, '')
167
+ .replace(/\/{2,}/g, '/');
137
168
  if (/^\/?(?:assets|src|public)\//.test(raw) || normalized.startsWith('src/'))
138
169
  return normalized;
139
- if (!themeMatch || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
170
+ if (!themeId || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
140
171
  return raw;
141
- return path.posix.join('src/theme', themeMatch[1], normalized);
142
- }) : [];
143
- const references = options.referenceImagePaths?.length ? options.referenceImagePaths
144
- : options.useTargetReference === false ? [] : schemaReferences.length ? schemaReferences : [relativeTarget];
145
- const referenceImagePaths = references.map(ref => /^https?:\/\//i.test(ref) ? ref
172
+ return path.posix.join('src/theme', themeId, normalized);
173
+ })
174
+ : [];
175
+ const references = options.referenceImagePaths?.length
176
+ ? options.referenceImagePaths
177
+ : options.useTargetReference === false
178
+ ? []
179
+ : schemaReferences.length
180
+ ? schemaReferences
181
+ : [relativeTarget];
182
+ const referenceImagePaths = references.map((ref) => /^https?:\/\//i.test(ref)
183
+ ? ref
146
184
  : path.relative(realpathSync(root), canonicalProjectPath(root, ref)).replace(/\\/g, '/'));
147
185
  const hasSchemaBackground = typeof ext?.remove_background === 'boolean';
148
186
  if (!hasSchemaBackground && targetHasTransparency === undefined) {
149
- targetHasTransparency = !!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
187
+ targetHasTransparency =
188
+ !!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
150
189
  }
151
190
  const removeBackground = resolveImageBackgroundRequirement({
152
191
  prompt: options.prompt ?? '',
@@ -171,12 +210,16 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
171
210
  targetAssetPath: relativeTarget,
172
211
  fieldPath: field?.path,
173
212
  referenceImagePaths,
174
- width: delivery.width, height: delivery.height,
213
+ width: delivery.width,
214
+ height: delivery.height,
175
215
  aspectRatio: typeof ext?.ratio === 'string' ? ext.ratio : `${delivery.width}:${delivery.height}`,
176
216
  outputFormat: (removeBackground && delivery.format === 'jpeg' && !delivery.fromSchemaMimeType
177
- ? 'png' : delivery.format),
217
+ ? 'png'
218
+ : delivery.format),
178
219
  outputFormatFromSchema: delivery.fromSchemaMimeType,
179
- fitMode: delivery.fitMode, removeBackground: delivery.removeBackground, paddingColor: delivery.paddingColor,
220
+ fitMode: delivery.fitMode,
221
+ removeBackground: delivery.removeBackground,
222
+ paddingColor: delivery.paddingColor,
180
223
  removeBackgroundFromSchema: hasSchemaBackground,
181
224
  imageModelRef: typeof ext?.imageModelRef === 'string' ? ext.imageModelRef : undefined,
182
225
  };
@@ -188,33 +231,40 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
188
231
  contexts.push(await contextForField(field));
189
232
  }
190
233
  catch (error) {
191
- const selected = field?.path.length === targetFieldPath?.length
192
- && field?.path.every((key, index) => key === targetFieldPath?.[index]);
234
+ const selected = field?.path.length === targetFieldPath?.length &&
235
+ field?.path.every((key, index) => key === targetFieldPath?.[index]);
193
236
  if (!targetFieldPath || selected)
194
237
  throw error;
195
238
  // Invalid sibling requirements still protect the original shared file.
196
239
  unresolvedSharedField = true;
197
240
  }
198
241
  }
199
- const context = targetFieldPath ? contexts.find(candidate => candidate.fieldPath?.length === targetFieldPath.length
200
- && candidate.fieldPath.every((key, index) => key === targetFieldPath[index])) : contexts[0];
242
+ const context = targetFieldPath
243
+ ? contexts.find((candidate) => candidate.fieldPath?.length === targetFieldPath.length &&
244
+ candidate.fieldPath.every((key, index) => key === targetFieldPath[index]))
245
+ : contexts[0];
201
246
  const deliveryKey = ({ fieldPath: _fieldPath, outputFormatFromSchema: _formatSource, removeBackgroundFromSchema: _backgroundSource, aspectRatio, ...delivery }) => {
202
247
  const ratio = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
203
- return JSON.stringify({ ...delivery, aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio });
248
+ return JSON.stringify({
249
+ ...delivery,
250
+ aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio,
251
+ });
204
252
  };
205
- const conflicting = unresolvedSharedField || contexts.some(candidate => deliveryKey(candidate) !== deliveryKey(context));
253
+ const conflicting = unresolvedSharedField ||
254
+ contexts.some((candidate) => deliveryKey(candidate) !== deliveryKey(context));
206
255
  if (conflicting && !targetFieldPath) {
207
- throw new Error(`Multiple image fields use ${relativeTarget} with different references or delivery constraints. Set targetFieldPath (or --target-field-path) to one of: ${fields.map(field => JSON.stringify(field.path)).join(', ')}.`);
256
+ throw new Error(`Multiple image fields use ${relativeTarget} with different references or delivery constraints. Set targetFieldPath (or --target-field-path) to one of: ${fields.map((field) => JSON.stringify(field.path)).join(', ')}.`);
208
257
  }
209
258
  if (targetFieldPath) {
210
259
  // A separate output retains exactly the selected field's requirements.
211
- if (conflicting || contexts.some(candidate => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema)
212
- || (candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
260
+ if (conflicting ||
261
+ contexts.some((candidate) => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema) ||
262
+ (candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
213
263
  context.requiresSeparateOutput = true;
214
264
  }
215
265
  else {
216
- context.outputFormatFromSchema = contexts.some(candidate => candidate.outputFormatFromSchema);
217
- context.removeBackgroundFromSchema = contexts.some(candidate => candidate.removeBackgroundFromSchema);
266
+ context.outputFormatFromSchema = contexts.some((candidate) => candidate.outputFormatFromSchema);
267
+ context.removeBackgroundFromSchema = contexts.some((candidate) => candidate.removeBackgroundFromSchema);
218
268
  }
219
269
  assertImageAssetOutputPaths(context, options.outputPaths ?? [], root, options.allowedOutputRoots);
220
270
  if (context.referenceImagePaths.length > 8)
@@ -33,7 +33,7 @@ function withModelShell(config, modelConfigId) {
33
33
  const ENGINEERING_BATCH_INSTRUCTIONS = [
34
34
  'For a single replica, work directly in the current repository and preserve the user-provided references. Do not create a batch for a single replica.',
35
35
  'If the repository is empty, initialize it using the default starter template before implementing the replica; preserve existing workspace metadata and files. If the template is unavailable, report the failure instead of substituting another template.',
36
- 'A repository is empty only when it has no user project files, including ignored files; workspace metadata, skills, references and attachments do not count as a project. For an empty repository, run playcraft references prepare-starter-template --json and merge the returned staged template into the current repository. Preserve existing files and workspace configuration, merge .gitignore without replacing it, preserve executable permissions, and report conflicts or preparation failures instead of overwriting files or creating a batch. Once finished, remove only the exact staging directory returned by the command.',
36
+ 'A repository is empty only when it has no user project files, including ignored files; workspace metadata, skills, references and attachments do not count as a project. For an empty repository, run playcraft references prepare-starter-template --template <cocos|threejs> --json and merge the returned staged template into the current repository. Always declare --template explicitly: use threejs when the user asks for a Three.js project, and cocos when the user asks for Cocos or does not specify an engine. Preserve existing files and workspace configuration, merge .gitignore without replacing it, preserve executable permissions, and report conflicts or preparation failures instead of overwriting files or creating a batch. Once finished, remove only the exact staging directory returned by the command.',
37
37
  'When the user requests multiple independent engineering variants, create a real platform batch using playcraft tools create-batch --prompt <the complete request, including constraints established in this conversation> --workspace-id <the trusted batch request workspaceId> --source-directory <the trusted batch request sourceDirectoryName> --client-request-id <the trusted batch request clientRequestId> --json.',
38
38
  'Distinguish independent variants from multiple assets, levels or themes within one engineering project. Use the current session directory supplied by the runtime as the batch source; do not infer it from a preview or another conversation.',
39
39
  'The platform freezes the current source and generates each planned variant in its own Remix directory. Submission authorizes automatic generation without another prompt approval. Keep the source unchanged for this batch; do not replace independent variants with theme switching in the source.',
@@ -183,7 +183,7 @@ export class WorkspaceRemixReferencesBroker {
183
183
  sessionId: context.sessionId, wait: false,
184
184
  }, async () => {
185
185
  await this.current(context, valid, true);
186
- const raw = await this.upstream(scope, { action: 'starter-template' }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
186
+ const raw = await this.upstream(scope, { action: 'starter-template', templateId: input.templateId }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
187
187
  await this.current(context, valid, true);
188
188
  return prepareStarterTemplate(context.cwd, raw, () => this.operation(context, valid, true));
189
189
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.82",
3
+ "version": "0.1.84-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -57,8 +57,8 @@
57
57
  "@gltf-transform/core": "^4.3.0",
58
58
  "@gltf-transform/extensions": "^4.3.0",
59
59
  "@gltf-transform/functions": "^4.3.0",
60
- "@playcraft/build": "0.1.82",
61
- "@playcraft/common": "0.1.82",
60
+ "@playcraft/build": "0.1.84-beta",
61
+ "@playcraft/common": "0.1.84-beta",
62
62
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
63
63
  "chokidar": "^4.0.3",
64
64
  "commander": "^13.1.0",