@playcraft/cli 0.0.40 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +66 -3
  2. package/dist/atom-plan/validate-atom-plan.js +298 -0
  3. package/dist/cli-root-help.js +1 -1
  4. package/dist/commands/3d.js +363 -0
  5. package/dist/commands/create.js +337 -0
  6. package/dist/commands/image.js +1337 -43
  7. package/dist/commands/recommend.js +1 -1
  8. package/dist/commands/remix.js +213 -0
  9. package/dist/commands/skills.js +1379 -0
  10. package/dist/commands/tools-3d.js +473 -0
  11. package/dist/commands/tools-generation.js +454 -0
  12. package/dist/commands/tools-project.js +400 -0
  13. package/dist/commands/tools-research.js +37 -0
  14. package/dist/commands/tools-research.test.js +216 -0
  15. package/dist/commands/tools-utils.js +164 -0
  16. package/dist/commands/tools.js +7 -616
  17. package/dist/config.js +2 -0
  18. package/dist/index.js +19 -1
  19. package/package.json +9 -3
  20. package/project-template/.claude/agents/designer.md +116 -0
  21. package/project-template/.claude/agents/developer.md +133 -0
  22. package/project-template/.claude/agents/pm.md +164 -0
  23. package/project-template/.claude/agents/refs/README.md +67 -0
  24. package/project-template/.claude/agents/refs/designer-art-style-catalog.md +533 -0
  25. package/project-template/.claude/agents/refs/designer-color-audio-recipes.md +153 -0
  26. package/project-template/.claude/agents/refs/designer-deliverable-spec.md +167 -0
  27. package/project-template/.claude/agents/refs/designer-dimension-axis.md +27 -0
  28. package/project-template/.claude/agents/refs/designer-handoff-v2-checklist.md +68 -0
  29. package/project-template/.claude/agents/refs/designer-master-composite-recipes.md +216 -0
  30. package/project-template/.claude/agents/refs/designer-style-exploration-flow.md +37 -0
  31. package/project-template/.claude/agents/refs/developer-dev-handoff.md +109 -0
  32. package/project-template/.claude/agents/refs/developer-impl-cookbook.md +134 -0
  33. package/project-template/.claude/agents/refs/developer-phase1-flow.md +211 -0
  34. package/project-template/.claude/agents/refs/pm-workflow-detail.md +545 -0
  35. package/project-template/.claude/agents/refs/reviewer-six-dimension-eval.md +286 -0
  36. package/project-template/.claude/agents/refs/ta-3d-flip-recipe.md +85 -0
  37. package/project-template/.claude/agents/refs/ta-atlas-deliverable-standard.md +46 -0
  38. package/project-template/.claude/agents/refs/ta-batch-pipeline-recipes.md +120 -0
  39. package/project-template/.claude/agents/refs/ta-image-generation-detail.md +356 -0
  40. package/project-template/.claude/agents/refs/ta-image-ops-reference.md +495 -0
  41. package/project-template/.claude/agents/refs/ta-pipeline-cookbook.md +699 -0
  42. package/project-template/.claude/agents/refs/ta-tools-reference.md +111 -0
  43. package/project-template/.claude/agents/refs/ta-vfx-preset-catalog.md +365 -0
  44. package/project-template/.claude/agents/reviewer.md +103 -0
  45. package/project-template/.claude/agents/technical-artist.md +111 -0
  46. package/project-template/.claude/hooks/README.md +36 -0
  47. package/project-template/.claude/hooks/validate-atom-plan.mjs +224 -0
  48. package/project-template/.claude/hooks/validate-workflow-stop.mjs +258 -0
  49. package/project-template/.claude/settings.json +32 -0
  50. package/project-template/.claude/settings.local.json +4 -0
  51. package/project-template/.claude/skills/playcraft-ad-psychology/SKILL.md +182 -0
  52. package/project-template/.claude/skills/playcraft-art-style-guide/SKILL.md +123 -0
  53. package/project-template/.claude/skills/playcraft-asset-state-sheet/SKILL.md +141 -0
  54. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +280 -0
  55. package/project-template/.claude/skills/playcraft-batch-pipeline/SKILL.md +184 -0
  56. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +306 -0
  57. package/project-template/.claude/skills/playcraft-image-generation/SKILL.md +229 -0
  58. package/project-template/.claude/skills/playcraft-image-generation/reference/build-sprite-sheet.template.mjs +123 -0
  59. package/project-template/.claude/skills/playcraft-image-generation/reference/compare-style.template.mjs +254 -0
  60. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch-sprite.template.mjs +235 -0
  61. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-batch.template.mjs +97 -0
  62. package/project-template/.claude/skills/playcraft-image-generation/reference/gen-edit-variants.template.mjs +118 -0
  63. package/project-template/.claude/skills/playcraft-image-generation/reference/process-batch.template.mjs +137 -0
  64. package/project-template/.claude/skills/playcraft-image-generation/reference/prompt-cookbook.md +397 -0
  65. package/project-template/.claude/skills/playcraft-image-generation/reference/validate-sprite-sheet.template.mjs +296 -0
  66. package/project-template/.claude/skills/playcraft-image-ops/SKILL.md +122 -0
  67. package/project-template/.claude/skills/playcraft-masking/SKILL.md +373 -0
  68. package/project-template/.claude/skills/playcraft-research/SKILL.md +212 -0
  69. package/project-template/.claude/skills/playcraft-sprite-generation/SKILL.md +423 -0
  70. package/project-template/.claude/skills/playcraft-storyboard/SKILL.md +148 -0
  71. package/project-template/.claude/skills/playcraft-style-qa/SKILL.md +270 -0
  72. package/project-template/.claude/skills/playcraft-text-rendering/SKILL.md +236 -0
  73. package/project-template/.claude/skills/playcraft-vfx-animation/SKILL.md +130 -0
  74. package/project-template/.claude/skills/playcraft-workflow/SKILL.md +396 -0
  75. package/project-template/.cursor/hooks.json +17 -0
  76. package/project-template/.cursor/rules/playcraft-orchestrator.mdc +87 -0
  77. package/project-template/.cursor/rules/playcraft-subagent-boundary.mdc +18 -0
  78. package/project-template/CLAUDE.md +240 -0
  79. package/project-template/assets/audio/bgm/.gitkeep +0 -0
  80. package/project-template/assets/audio/sfx/.gitkeep +0 -0
  81. package/project-template/assets/bundles/.gitkeep +0 -0
  82. package/project-template/assets/images/bg/.gitkeep +0 -0
  83. package/project-template/assets/images/reference/.gitkeep +0 -0
  84. package/project-template/assets/images/storyboard/.gitkeep +0 -0
  85. package/project-template/assets/images/tiles/.gitkeep +0 -0
  86. package/project-template/assets/images/ui/.gitkeep +0 -0
  87. package/project-template/assets/images/vfx/.gitkeep +0 -0
  88. package/project-template/assets/models/.gitkeep +0 -0
  89. package/project-template/docs/team/agent-conduct.md +105 -0
  90. package/project-template/docs/team/agent-runtime-matrix.md +62 -0
  91. package/project-template/docs/team/atom-plan-format.md +74 -0
  92. package/project-template/docs/team/collaboration.md +288 -0
  93. package/project-template/docs/team/core-model.md +50 -0
  94. package/project-template/docs/team/platform-capabilities.md +15 -0
  95. package/project-template/docs/team/workflow-changelog.md +51 -0
  96. package/project-template/docs/team/workflow-consistency-checklist.md +128 -0
  97. package/project-template/game/config/.gitkeep +0 -0
  98. package/project-template/game/gameplay/.gitkeep +0 -0
  99. package/project-template/game/scenes/.gitkeep +0 -0
  100. package/project-template/logs/.gitkeep +0 -0
  101. package/project-template/ta-workspace/logs/.gitkeep +0 -0
  102. package/project-template/ta-workspace/scripts/.gitkeep +0 -0
  103. package/project-template/ta-workspace/tmp/.gitkeep +0 -0
  104. package/project-template/templates/atom-plan.template.json +26 -0
  105. package/project-template/templates/atom-plan.template.md +76 -0
  106. package/project-template/templates/design-brief.template.md +195 -0
  107. package/project-template/templates/design-lens-checklist.reference.md +117 -0
  108. package/project-template/templates/design-methodology.md +99 -0
  109. package/project-template/templates/designer-log.template.md +98 -0
  110. package/project-template/templates/developer-log.template.md +140 -0
  111. package/project-template/templates/five-axis-framework.md +186 -0
  112. package/project-template/templates/intent-clarifications.template.md +58 -0
  113. package/project-template/templates/layout-spec.template.md +132 -0
  114. package/project-template/templates/project-state.template.md +219 -0
  115. package/project-template/templates/review-report.template.md +166 -0
  116. package/project-template/templates/style-exploration.template.md +93 -0
  117. package/project-template/templates/ta-log.template.md +205 -0
@@ -0,0 +1,454 @@
1
+ import { writeFileSync, mkdirSync } from 'fs';
2
+ import { dirname, parse, join } from 'path';
3
+ import sharp from 'sharp';
4
+ import { AgentApiClient } from '../utils/agent-api-client.js';
5
+ import { MAX_REFERENCE_IMAGES, collectReferenceImagePaths, collectReferenceImagePayloads, sniffImageExtension, extensionForImageMime, resolveImageOutputPath, handleError, } from './tools-utils.js';
6
+ function isMasterCompositeStripRequest(opts) {
7
+ if (opts.aspectRatio === '45:16')
8
+ return true;
9
+ if (opts.width && opts.height) {
10
+ const r = opts.width / opts.height;
11
+ return r >= 2.5 && r <= 3.2;
12
+ }
13
+ return false;
14
+ }
15
+ const STRIP_TARGET_BY_PRESET = {
16
+ '1K': { width: 2048, height: 728 },
17
+ '2K': { width: 3600, height: 1280 },
18
+ '4K': { width: 4096, height: 1455 },
19
+ };
20
+ function resolveStripTargetDimensions(opts) {
21
+ if (!isMasterCompositeStripRequest(opts))
22
+ return null;
23
+ if (opts.width && opts.height) {
24
+ return { width: opts.width, height: opts.height };
25
+ }
26
+ const preset = opts.imageSize ?? '2K';
27
+ return STRIP_TARGET_BY_PRESET[preset] ?? STRIP_TARGET_BY_PRESET['2K'];
28
+ }
29
+ /** 等比缩放 + 白底 letterbox,禁止非等比拉伸 */
30
+ async function letterboxToTarget(imgBuf, targetW, targetH) {
31
+ return sharp(imgBuf)
32
+ .resize(targetW, targetH, {
33
+ fit: 'contain',
34
+ background: { r: 255, g: 255, b: 255, alpha: 1 },
35
+ })
36
+ .png()
37
+ .toBuffer();
38
+ }
39
+ /** Best-effort dimensions for sidecar; corrupt/truncated payloads must not fail save. */
40
+ async function readImageDimensions(imgBuf) {
41
+ try {
42
+ const meta = await sharp(imgBuf).metadata();
43
+ return { width: meta.width ?? null, height: meta.height ?? null };
44
+ }
45
+ catch {
46
+ return { width: null, height: null };
47
+ }
48
+ }
49
+ /** 故事板横条:letterbox 后验收总比例 45:16 + 单格 9:16 */
50
+ async function verifyMasterCompositeStripSize(imgBuf, target) {
51
+ const meta = await sharp(imgBuf).metadata();
52
+ const w = meta.width ?? 0;
53
+ const h = meta.height ?? 0;
54
+ const totalRatio = w / h;
55
+ const cellRatio = w / 5 / h;
56
+ const wantTotal = target.width / target.height;
57
+ const wantCell = 9 / 16;
58
+ const totalOk = Math.abs(totalRatio - wantTotal) / wantTotal <= 0.05;
59
+ const cellOk = Math.abs(cellRatio - wantCell) / wantCell <= 0.05;
60
+ if (!totalOk || !cellOk) {
61
+ console.warn(`\n⚠ Master Composite 尺寸仍偏离目标: ${w}×${h}`);
62
+ console.warn(` 总比例 ${totalRatio.toFixed(3)}(期望 ≈${wantTotal.toFixed(3)}),单格 ${cellRatio.toFixed(3)}(期望 ≈${wantCell.toFixed(3)})`);
63
+ }
64
+ else {
65
+ console.log(`✓ 尺寸合格: ${w}×${h}(总比例 ${totalRatio.toFixed(2)},单格 ${cellRatio.toFixed(3)} ≈ 9:16)`);
66
+ }
67
+ return { width: w, height: h };
68
+ }
69
+ export function registerGenerationCommands(tools) {
70
+ // ─── Image Models ────────────────────────────────────────────
71
+ tools.command('list-image-models')
72
+ .description('列出后端已配置的可用生图模型,按模型名分组展示所有 provider(多个 provider 时按优先级自动 fallback)')
73
+ .option('--json', '以 JSON 格式输出(默认为人类可读表格)')
74
+ .action(async (opts) => {
75
+ try {
76
+ const client = new AgentApiClient();
77
+ const rawModels = await client.get('/image-models');
78
+ if (!rawModels.length) {
79
+ console.log('暂无已配置的生图模型。请前往 Admin > AI Settings 添加 modelKind=image 的配置。');
80
+ return;
81
+ }
82
+ // 按模型名分组(同一模型名下可能有多个 provider,裸模型名时按优先级 fallback)
83
+ const grouped = new Map();
84
+ for (const m of rawModels) {
85
+ const key = m.model;
86
+ if (!grouped.has(key))
87
+ grouped.set(key, []);
88
+ grouped.get(key).push(m);
89
+ }
90
+ // 每组取第一个 provider 的 capability/alpha(同名模型各 provider 能力相同)
91
+ const models = Array.from(grouped.entries()).map(([modelName, entries]) => ({
92
+ model: modelName,
93
+ providers: entries.map((e) => e.provider),
94
+ capability: entries[0].capability,
95
+ alpha: entries[0].alpha ?? false,
96
+ isDefault: entries.some((e) => e.isDefault),
97
+ }));
98
+ if (opts.json) {
99
+ console.log(JSON.stringify(models, null, 2));
100
+ return;
101
+ }
102
+ const capLabel = (cap) => {
103
+ if (cap === 'text-only')
104
+ return 'text→image';
105
+ if (cap === 'image-only')
106
+ return 'image→image';
107
+ return 'text+image';
108
+ };
109
+ const alphaLabel = (alpha) => (alpha ? 'yes' : 'no');
110
+ const MODEL_COL = 'MODEL';
111
+ const CAP_COL = 'CAPABILITY';
112
+ const ALPHA_COL = 'ALPHA';
113
+ const PROVIDERS_COL = 'PROVIDERS';
114
+ const maxModelLen = Math.max(MODEL_COL.length, ...models.map((m) => m.model.length));
115
+ const capColWidth = Math.max(CAP_COL.length, ...models.map((m) => capLabel(m.capability).length));
116
+ console.log('');
117
+ console.log(`${MODEL_COL.padEnd(maxModelLen)} ${CAP_COL.padEnd(capColWidth)} ${ALPHA_COL.padEnd(5)} ${PROVIDERS_COL}`);
118
+ console.log(`${'-'.repeat(maxModelLen)} ${'-'.repeat(capColWidth)} ----- --------------------------------`);
119
+ for (const m of models) {
120
+ const providerSummary = m.providers.length === 1
121
+ ? m.providers[0]
122
+ : `${m.providers.length} (${m.providers.join(', ')})`;
123
+ const defaultMark = m.isDefault ? ' *' : '';
124
+ console.log(`${m.model.padEnd(maxModelLen)}${defaultMark.padEnd(3 - defaultMark.length)} ${capLabel(m.capability).padEnd(capColWidth)} ${alphaLabel(m.alpha).padEnd(5)} ${providerSummary}`);
125
+ }
126
+ console.log('');
127
+ console.log('提示:传入模型名(如 gpt-image-2),后端按优先级依次 fallback 各 provider(gpt-image-2:iegg-litellm → mulerouter → 302)。');
128
+ console.log(' 也可传入 provider/model(如 mulerouter/gpt-image-2)直连指定 provider。');
129
+ console.log('');
130
+ console.log('CAPABILITY 说明:');
131
+ console.log(' text→image 仅支持文生图(不可传 --reference-image)');
132
+ console.log(' image→image 仅支持图生图(必须传 --reference-image)');
133
+ console.log(' text+image 两者均支持(有无 --reference-image 均可)');
134
+ console.log('');
135
+ console.log('ALPHA 说明:');
136
+ console.log(' yes 模型原生支持透明 PNG 输出(background: transparent)');
137
+ console.log(' no 输出 opaque PNG/JPEG;需透明素材时请使用绿幕策略');
138
+ console.log(' (prompt 加 "on solid bright green #00FF00 background" → remove-background)');
139
+ console.log('');
140
+ console.log(`使用方式:playcraft tools generate-image --prompt "..." --output out.png --image-model <MODEL>`);
141
+ }
142
+ catch (err) {
143
+ console.error('获取生图模型列表失败:', err instanceof Error ? err.message : String(err));
144
+ process.exit(1);
145
+ }
146
+ });
147
+ // ─── Generation ─────────────────────────────────────────────
148
+ tools.command('generate-image')
149
+ .description('AI 生成图片(支持多张参考图图生图;gpt-image-2 支持 --count N 批量出图)')
150
+ .requiredOption('--prompt <text>', '图片描述')
151
+ .option('--aspect-ratio <ratio>', '宽高比 (1:1|45:16|16:9|9:16|3:4|4:3|2:3|3:2|21:9)', '1:1')
152
+ .requiredOption('--output <path>', '保存路径(--count>1 时自动追加 _1/_2 等后缀)')
153
+ .option('--image-size <size>', '图片尺寸 (1K|2K|4K)')
154
+ .option('--width <n>', '精确宽度(像素;MC 横排 5×9:16 推荐 3600 或 4096)', (v) => parseInt(v, 10))
155
+ .option('--height <n>', '精确高度(像素;MC 推荐 1280 或 1455)', (v) => parseInt(v, 10))
156
+ .option('--reference-image <path>', '参考图路径(可重复多次,最多 8 张),支持 PNG/JPG/WEBP', collectReferenceImagePaths, [])
157
+ .option('--image-model <model>', '模型名(如 gpt-image-2)或 provider/model(如 mulerouter/gpt-image-2)。传模型名时后端按优先级自动 fallback;传 provider/model 则直连指定 provider')
158
+ .option('--retry <n>', '全部 provider 都失败时的重试轮数(默认 1;超时不计为可重试错误)', (v) => parseInt(v, 10), 1)
159
+ .option('--timeout <seconds>', '请求超时秒数(默认 1800,即 30 分钟;异步生图模型耗时较长,仅在后端明确报错时提前终止)', (v) => parseInt(v, 10), 1800)
160
+ .option('--count <n>', '一次 API 调用生成多少张图片(默认 1,最多 10;仅 gpt-image-2 实际支持批量)', (v) => parseInt(v, 10), 1)
161
+ .action(async (opts) => {
162
+ const paths = opts.referenceImage ?? [];
163
+ if (paths.length > MAX_REFERENCE_IMAGES) {
164
+ console.error(`Error: Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
165
+ process.exit(1);
166
+ return;
167
+ }
168
+ const hasReferenceImages = paths.length > 0;
169
+ const attemptGenerate = async (modelRef, attempt) => {
170
+ const label = modelRef ? `[${modelRef}]` : '[default model]';
171
+ if (attempt > 0) {
172
+ console.log(`Retry ${attempt}/${opts.retry} ${label}...`);
173
+ }
174
+ else {
175
+ console.log(`Generating image ${label}...`);
176
+ }
177
+ const referenceImages = await collectReferenceImagePayloads(paths);
178
+ const client = new AgentApiClient();
179
+ const timeoutMs = opts.timeout * 1000;
180
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timed out after ${opts.timeout}s. The backend task may still be running — check admin panel before re-running.`)), timeoutMs));
181
+ const imageCount = Math.min(Math.max(1, opts.count ?? 1), 10);
182
+ const requestPromise = client.post('/generate-image', {
183
+ prompt: opts.prompt,
184
+ aspectRatio: opts.aspectRatio,
185
+ imageSize: opts.imageSize,
186
+ ...(opts.width && opts.height ? { width: opts.width, height: opts.height } : {}),
187
+ referenceImages,
188
+ ...(modelRef ? { imageModelRef: modelRef } : {}),
189
+ ...(imageCount > 1 ? { imageCount } : {}),
190
+ });
191
+ return Promise.race([requestPromise, timeoutPromise]);
192
+ };
193
+ const enrichError = (err, modelRef) => {
194
+ const msg = err instanceof Error ? err.message : String(err);
195
+ const lines = [`Error: ${msg}`];
196
+ if (msg.includes('500') || msg.includes('Internal server error')) {
197
+ lines.push('');
198
+ lines.push('Hint: API 500 error — the model may be temporarily unavailable.');
199
+ lines.push(' → If using a bare model name (e.g. gpt-image-2), all providers failed — try a different model');
200
+ if (modelRef?.includes('/')) {
201
+ lines.push(' → Or try without provider prefix to auto-fallback providers: --image-model gpt-image-2');
202
+ }
203
+ }
204
+ else if (msg.includes('timed out')) {
205
+ lines.push('');
206
+ lines.push('Hint: Request timed out. The backend task may still be running — retries were skipped to avoid creating duplicate tasks.');
207
+ lines.push(' → Check the backend/admin panel for task status before re-running');
208
+ lines.push(' → Try --image-size 1K to reduce generation time');
209
+ lines.push(' → Or increase timeout with --timeout <seconds> (current default: 1800s)');
210
+ }
211
+ else if (hasReferenceImages && (msg.includes('reference') || msg.includes('image input'))) {
212
+ lines.push('');
213
+ lines.push('Hint: Reference image error — the selected model may not support --reference-image.');
214
+ lines.push(' → Run: playcraft tools list-image-models');
215
+ lines.push(' → Models with CAPABILITY=text+image support --reference-image');
216
+ }
217
+ return lines.join('\n');
218
+ };
219
+ try {
220
+ let result = null;
221
+ let lastError = null;
222
+ // 重试逻辑:超时不重试(后端任务可能仍在运行,重试会导致重复提交)
223
+ const maxAttempts = 1 + (opts.retry ?? 1);
224
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
225
+ try {
226
+ result = await attemptGenerate(opts.imageModel, attempt);
227
+ break;
228
+ }
229
+ catch (e) {
230
+ lastError = e;
231
+ const msg = e instanceof Error ? e.message : String(e);
232
+ const isTimeout = msg.includes('timed out');
233
+ if (isTimeout) {
234
+ console.error(`Attempt ${attempt + 1} timed out — not retrying (backend task may still be running).`);
235
+ break;
236
+ }
237
+ if (attempt < maxAttempts - 1) {
238
+ console.warn(`Attempt ${attempt + 1} failed: ${msg}`);
239
+ }
240
+ }
241
+ }
242
+ if (!result) {
243
+ console.error(enrichError(lastError, opts.imageModel));
244
+ process.exit(1);
245
+ return;
246
+ }
247
+ const buf = Buffer.from(result.imageBase64, 'base64');
248
+ const sniffed = sniffImageExtension(buf);
249
+ const fromMime = extensionForImageMime(result.mimeType);
250
+ const wantExt = sniffed ?? fromMime;
251
+ if (!sniffed) {
252
+ console.warn('Could not detect image format from file signature; using Content-Type from API for extension.');
253
+ }
254
+ else if (sniffed !== fromMime) {
255
+ console.warn(`Image bytes look like ${sniffed.slice(1).toUpperCase()} but API reported ${result.mimeType}; extension follows file signature.`);
256
+ }
257
+ // 收集所有待保存图片(第 1 张 + 批量额外图)
258
+ const allImages = [
259
+ { imageBase64: result.imageBase64, mimeType: result.mimeType },
260
+ ...(result.additionalImages ?? []),
261
+ ];
262
+ const isBatch = allImages.length > 1;
263
+ const modelPart = opts.imageModel ? `model: ${opts.imageModel}` : '';
264
+ const providerPart = result.provider ? `provider: ${result.provider}` : '';
265
+ const labelParts = [modelPart, providerPart].filter(Boolean);
266
+ const modelLabel = labelParts.length > 0 ? ` (${labelParts.join(', ')})` : '';
267
+ const savedPaths = [];
268
+ const stripTarget = resolveStripTargetDimensions({
269
+ aspectRatio: opts.aspectRatio,
270
+ width: opts.width,
271
+ height: opts.height,
272
+ imageSize: opts.imageSize,
273
+ });
274
+ for (let i = 0; i < allImages.length; i++) {
275
+ const img = allImages[i];
276
+ let imgBuf = Buffer.from(img.imageBase64, 'base64');
277
+ let resolvedPath = resolveImageOutputPath(opts.output, wantExt);
278
+ let generatedWidth = null;
279
+ let generatedHeight = null;
280
+ // 批量模式:在扩展名前插入 _1, _2, ... 后缀
281
+ if (isBatch) {
282
+ const { dir, name } = parse(resolvedPath);
283
+ resolvedPath = join(dir, `${name}_${i + 1}${wantExt}`);
284
+ }
285
+ if (stripTarget && i === 0) {
286
+ const metaBefore = await readImageDimensions(imgBuf);
287
+ generatedWidth = metaBefore.width;
288
+ generatedHeight = metaBefore.height;
289
+ if (metaBefore.width != null &&
290
+ metaBefore.height != null &&
291
+ (metaBefore.width !== stripTarget.width ||
292
+ metaBefore.height !== stripTarget.height)) {
293
+ imgBuf = Buffer.from(await letterboxToTarget(imgBuf, stripTarget.width, stripTarget.height));
294
+ console.log(`↑ Letterbox ${generatedWidth}×${generatedHeight} → ${stripTarget.width}×${stripTarget.height}(等比 + 白边,不拉伸)`);
295
+ }
296
+ }
297
+ mkdirSync(dirname(resolvedPath), { recursive: true });
298
+ writeFileSync(resolvedPath, imgBuf);
299
+ let actualWidth = null;
300
+ let actualHeight = null;
301
+ if (stripTarget && i === 0) {
302
+ const dims = await verifyMasterCompositeStripSize(imgBuf, stripTarget);
303
+ actualWidth = dims.width;
304
+ actualHeight = dims.height;
305
+ }
306
+ else {
307
+ const meta = await readImageDimensions(imgBuf);
308
+ actualWidth = meta.width;
309
+ actualHeight = meta.height;
310
+ }
311
+ const sizeKB = Math.round(imgBuf.length / 1024);
312
+ console.log(`[${i + 1}/${allImages.length}] Image saved to ${resolvedPath} (${sizeKB}KB)${modelLabel}`);
313
+ savedPaths.push(resolvedPath);
314
+ const sidecar = {
315
+ prompt: opts.prompt,
316
+ model: opts.imageModel ?? 'default',
317
+ provider: result.provider ?? null,
318
+ aspectRatio: opts.aspectRatio,
319
+ imageSize: opts.imageSize ?? null,
320
+ requestedWidth: opts.width ?? stripTarget?.width ?? null,
321
+ requestedHeight: opts.height ?? stripTarget?.height ?? null,
322
+ generatedWidth: i === 0 ? generatedWidth : null,
323
+ generatedHeight: i === 0 ? generatedHeight : null,
324
+ actualWidth,
325
+ actualHeight,
326
+ referenceImages: paths.length > 0 ? paths : null,
327
+ count: opts.count ?? 1,
328
+ batchIndex: isBatch ? i + 1 : null,
329
+ output: resolvedPath,
330
+ referenceImageStatus: result.referenceImageStatus ?? null,
331
+ generatedAt: new Date().toISOString(),
332
+ };
333
+ const sidecarPath = resolvedPath.replace(/\.[^.]+$/, '.json');
334
+ writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2));
335
+ console.log(` └─ Generation metadata: ${sidecarPath}`);
336
+ }
337
+ if (!isBatch && opts.output !== savedPaths[0] && savedPaths[0]) {
338
+ // 单图但路径被调整时给出提示
339
+ console.log(`Output path adjusted to ${wantExt} payload: ${savedPaths[0]}`);
340
+ }
341
+ if (isBatch) {
342
+ console.log(`\nBatch complete: ${allImages.length} images saved.`);
343
+ }
344
+ if (result.referenceImageStatus === 'effective') {
345
+ console.log('Reference image: effective (image-to-image mode)');
346
+ }
347
+ else if (result.referenceImageStatus === 'ignored') {
348
+ console.warn('Warning: reference image was ignored by the provider (text-to-image mode used instead).');
349
+ console.warn(' → Check model CAPABILITY with: playcraft tools list-image-models');
350
+ console.warn(' → Use a model with CAPABILITY=text+image for reference image support');
351
+ }
352
+ }
353
+ catch (e) {
354
+ handleError(e);
355
+ }
356
+ });
357
+ tools.command('generate-sfx')
358
+ .description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
359
+ .requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
360
+ .option('--duration <seconds>', '时长(秒,0.5-30)', parseFloat)
361
+ .option('--loop', '生成可循环的音效', false)
362
+ .requiredOption('--output <path>', '保存路径,如 ./assets/audio/click.mp3')
363
+ .action(async (opts) => {
364
+ try {
365
+ const client = new AgentApiClient();
366
+ const result = await client.post('/generate-sfx', {
367
+ prompt: opts.prompt,
368
+ duration: opts.duration,
369
+ loop: opts.loop,
370
+ });
371
+ mkdirSync(dirname(opts.output), { recursive: true });
372
+ const buf = Buffer.from(result.audioBase64, 'base64');
373
+ writeFileSync(opts.output, buf);
374
+ const sizeKB = Math.round(buf.length / 1024);
375
+ console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
376
+ const sfxSidecar = {
377
+ type: 'sfx', prompt: opts.prompt, duration: opts.duration ?? null,
378
+ loop: opts.loop ?? false, provider: result.provider, actualDuration: result.duration,
379
+ output: opts.output, generatedAt: new Date().toISOString(),
380
+ };
381
+ writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(sfxSidecar, null, 2));
382
+ console.log(` └─ Generation metadata: ${opts.output.replace(/\.[^.]+$/, '.json')}`);
383
+ }
384
+ catch (e) {
385
+ handleError(e);
386
+ }
387
+ });
388
+ tools.command('generate-bgm')
389
+ .description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
390
+ .requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
391
+ .option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
392
+ .option('--bpm <bpm>', '目标 BPM', parseInt)
393
+ .requiredOption('--output <path>', '保存路径,如 ./assets/audio/bgm.mp3')
394
+ .action(async (opts) => {
395
+ try {
396
+ const client = new AgentApiClient();
397
+ const result = await client.post('/generate-bgm', {
398
+ prompt: opts.prompt,
399
+ style: opts.style,
400
+ bpm: opts.bpm,
401
+ });
402
+ mkdirSync(dirname(opts.output), { recursive: true });
403
+ const buf = Buffer.from(result.audioBase64, 'base64');
404
+ writeFileSync(opts.output, buf);
405
+ const sizeKB = Math.round(buf.length / 1024);
406
+ console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
407
+ const bgmSidecar = {
408
+ type: 'bgm', prompt: opts.prompt, style: opts.style ?? null,
409
+ bpm: opts.bpm ?? null, provider: result.provider, actualDuration: result.duration,
410
+ output: opts.output, generatedAt: new Date().toISOString(),
411
+ };
412
+ writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(bgmSidecar, null, 2));
413
+ console.log(` └─ Generation metadata: ${opts.output.replace(/\.[^.]+$/, '.json')}`);
414
+ }
415
+ catch (e) {
416
+ handleError(e);
417
+ }
418
+ });
419
+ // ─── Search Image ──────────────────────────────────────────
420
+ tools.command('search-image')
421
+ .description('搜索参考图片(Unsplash / Pexels),结果 URL 可直接用于 --reference-image')
422
+ .requiredOption('--query <query>', '搜索关键词(建议英文)')
423
+ .option('--source <source>', '图源:unsplash | pexels | auto(默认 auto)', 'auto')
424
+ .option('--count <count>', '返回数量(默认 6,最多 30)', '6')
425
+ .option('--orientation <orientation>', '方向:landscape | portrait | square')
426
+ .option('--json', '输出 JSON 格式(供 Agent 消费)')
427
+ .action(async (opts) => {
428
+ try {
429
+ const client = new AgentApiClient();
430
+ const result = await client.post('/search-image', {
431
+ query: opts.query,
432
+ source: opts.source,
433
+ count: parseInt(opts.count, 10),
434
+ orientation: opts.orientation,
435
+ });
436
+ if (opts.json) {
437
+ console.log(JSON.stringify(result, null, 2));
438
+ }
439
+ else {
440
+ console.log(`\nFound ${result.total} images from ${result.source} (showing ${result.results.length})\n`);
441
+ result.results.forEach((img, i) => {
442
+ console.log(`[${i + 1}] ${img.description.slice(0, 60)}`);
443
+ console.log(` Size: ${img.width}x${img.height}`);
444
+ console.log(` URL: ${img.downloadUrl}`);
445
+ console.log(` ${img.attribution}`);
446
+ console.log();
447
+ });
448
+ }
449
+ }
450
+ catch (e) {
451
+ handleError(e);
452
+ }
453
+ });
454
+ }