@playcraft/cli 0.1.84-beta → 0.1.86
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.
- package/dist/commands/tools-generation.js +46 -102
- package/dist/commands/tools-request.js +24 -0
- package/dist/commands/tools-utils.js +17 -15
- package/dist/commands/tools.js +2 -0
- package/dist/utils/agent-api-client.js +57 -18
- package/dist/utils/image-asset-context.js +42 -92
- package/dist/utils/tool-operation-journal.js +3 -3
- package/package.json +3 -3
|
@@ -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
|
|
7
|
-
import { assertImageAssetOutputPaths, readImageAssetContext
|
|
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,8 +73,7 @@ async function verifyMasterCompositeStripSize(imgBuf, target) {
|
|
|
73
73
|
}
|
|
74
74
|
export function registerGenerationCommands(tools) {
|
|
75
75
|
// ─── Image Models ────────────────────────────────────────────
|
|
76
|
-
tools
|
|
77
|
-
.command('list-image-models')
|
|
76
|
+
tools.command('list-image-models')
|
|
78
77
|
.description('列出后端已配置的可用生图模型,按模型名分组展示所有 provider(多个 provider 时按优先级自动 fallback)')
|
|
79
78
|
.option('--json', '以 JSON 格式输出(默认为人类可读表格)')
|
|
80
79
|
.action(async (opts) => {
|
|
@@ -151,8 +150,7 @@ export function registerGenerationCommands(tools) {
|
|
|
151
150
|
}
|
|
152
151
|
});
|
|
153
152
|
// ─── Generation ─────────────────────────────────────────────
|
|
154
|
-
tools
|
|
155
|
-
.command('generate-image')
|
|
153
|
+
tools.command('generate-image')
|
|
156
154
|
.description('AI 生成图片(支持多张参考图图生图;gpt-image-2 支持 --count N 批量出图)')
|
|
157
155
|
.requiredOption('--prompt <text>', '图片描述')
|
|
158
156
|
.option('--aspect-ratio <ratio>', '宽高比 (1:1|45:16|16:9|9:16|3:4|4:3|2:3|3:2|21:9)', '1:1')
|
|
@@ -176,12 +174,8 @@ export function registerGenerationCommands(tools) {
|
|
|
176
174
|
.option('--count <n>', '一次 API 调用生成多少张图片(默认 1,最多 10;仅 gpt-image-2 实际支持批量)', (v) => parseInt(v, 10), 1)
|
|
177
175
|
.action(async (opts) => {
|
|
178
176
|
try {
|
|
179
|
-
const explicitBackground = opts.background === undefined
|
|
180
|
-
|
|
181
|
-
: opts.background === 'transparent';
|
|
182
|
-
if (opts.background !== undefined &&
|
|
183
|
-
opts.removeBackground !== undefined &&
|
|
184
|
-
opts.removeBackground !== explicitBackground) {
|
|
177
|
+
const explicitBackground = opts.background === undefined ? opts.removeBackground : opts.background === 'transparent';
|
|
178
|
+
if (opts.background !== undefined && opts.removeBackground !== undefined && opts.removeBackground !== explicitBackground) {
|
|
185
179
|
throw new Error('Conflicting background options. Use --background transparent or --background opaque; no image request was sent.');
|
|
186
180
|
}
|
|
187
181
|
opts = { ...opts, removeBackground: explicitBackground };
|
|
@@ -190,46 +184,28 @@ export function registerGenerationCommands(tools) {
|
|
|
190
184
|
}
|
|
191
185
|
const explicitOutputFormat = opts.outputFormat;
|
|
192
186
|
const { dir: outputDir, name: outputName, ext: outputExt } = parse(opts.output);
|
|
193
|
-
const outputCandidates = [
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
...(opts.targetFieldPath ? { targetFieldPath: opts.targetFieldPath } : {}),
|
|
202
|
-
outputPaths: outputCandidates,
|
|
203
|
-
prompt: opts.prompt,
|
|
204
|
-
removeBackground: opts.removeBackground,
|
|
205
|
-
})
|
|
206
|
-
: undefined;
|
|
207
|
-
opts = {
|
|
208
|
-
...opts,
|
|
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,
|
|
209
195
|
aspectRatio: assetContext?.aspectRatio ?? opts.aspectRatio,
|
|
210
|
-
width: assetContext?.width ?? opts.width,
|
|
211
|
-
|
|
212
|
-
outputFormat: assetContext?.outputFormatFromSchema
|
|
213
|
-
? assetContext.outputFormat
|
|
214
|
-
: (opts.outputFormat ?? assetContext?.outputFormat),
|
|
196
|
+
width: assetContext?.width ?? opts.width, height: assetContext?.height ?? opts.height,
|
|
197
|
+
outputFormat: assetContext?.outputFormatFromSchema ? assetContext.outputFormat : opts.outputFormat ?? assetContext?.outputFormat,
|
|
215
198
|
fitMode: assetContext?.fitMode ?? opts.fitMode,
|
|
216
|
-
removeBackground: resolveImageBackgroundRequirement({
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
? assetContext.removeBackground
|
|
220
|
-
: undefined,
|
|
221
|
-
removeBackground: opts.removeBackground,
|
|
222
|
-
fallbackRemoveBackground: assetContext?.removeBackground,
|
|
223
|
-
}),
|
|
199
|
+
removeBackground: resolveImageBackgroundRequirement({ prompt: opts.prompt,
|
|
200
|
+
schemaRemoveBackground: assetContext?.removeBackgroundFromSchema ? assetContext.removeBackground : undefined,
|
|
201
|
+
removeBackground: opts.removeBackground, fallbackRemoveBackground: assetContext?.removeBackground }),
|
|
224
202
|
paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
|
|
225
203
|
imageModel: opts.imageModel || assetContext?.imageModelRef,
|
|
226
204
|
};
|
|
227
205
|
const invalidDelivery = validateImageDeliveryOptions(opts);
|
|
228
206
|
if (invalidDelivery)
|
|
229
207
|
throw new Error(invalidDelivery);
|
|
230
|
-
const paths = opts.referenceImage?.length
|
|
231
|
-
? opts.referenceImage
|
|
232
|
-
: (assetContext?.referenceImagePaths ?? []);
|
|
208
|
+
const paths = opts.referenceImage?.length ? opts.referenceImage : assetContext?.referenceImagePaths ?? [];
|
|
233
209
|
if (paths.length > MAX_REFERENCE_IMAGES) {
|
|
234
210
|
console.error(`Error: Too many reference images: ${paths.length} paths given; maximum is ${MAX_REFERENCE_IMAGES}.`);
|
|
235
211
|
process.exit(1);
|
|
@@ -254,26 +230,14 @@ export function registerGenerationCommands(tools) {
|
|
|
254
230
|
width: assetContext?.width ?? opts.width,
|
|
255
231
|
height: assetContext?.height ?? opts.height,
|
|
256
232
|
outputFormat: opts.outputFormat,
|
|
257
|
-
outputFormatSource: assetContext?.outputFormatFromSchema
|
|
258
|
-
? '
|
|
259
|
-
: explicitOutputFormat
|
|
260
|
-
? 'explicit'
|
|
261
|
-
: assetContext
|
|
262
|
-
? 'inferred'
|
|
263
|
-
: undefined,
|
|
233
|
+
outputFormatSource: assetContext?.outputFormatFromSchema ? 'schema'
|
|
234
|
+
: explicitOutputFormat ? 'explicit' : assetContext ? 'inferred' : undefined,
|
|
264
235
|
fitMode: assetContext?.fitMode ?? opts.fitMode,
|
|
265
236
|
paddingColor: assetContext?.paddingColor ?? opts.paddingColor,
|
|
266
|
-
background: opts.background === undefined
|
|
267
|
-
? undefined
|
|
268
|
-
: opts.removeBackground
|
|
269
|
-
? 'transparent'
|
|
270
|
-
: 'opaque',
|
|
237
|
+
background: opts.background === undefined ? undefined : opts.removeBackground ? 'transparent' : 'opaque',
|
|
271
238
|
removeBackground: opts.removeBackground,
|
|
272
|
-
backgroundRequirementSource: assetContext?.removeBackgroundFromSchema
|
|
273
|
-
? '
|
|
274
|
-
: explicitBackground !== undefined
|
|
275
|
-
? 'explicit'
|
|
276
|
-
: 'inferred',
|
|
239
|
+
backgroundRequirementSource: assetContext?.removeBackgroundFromSchema ? 'schema'
|
|
240
|
+
: explicitBackground !== undefined ? 'explicit' : 'inferred',
|
|
277
241
|
referenceImages,
|
|
278
242
|
...(modelRef ? { imageModelRef: modelRef } : {}),
|
|
279
243
|
...(imageCount > 1 ? { imageCount } : {}),
|
|
@@ -301,8 +265,7 @@ export function registerGenerationCommands(tools) {
|
|
|
301
265
|
lines.push(' → Try --image-size 1K to reduce generation time');
|
|
302
266
|
lines.push(' → Or increase timeout with --timeout <seconds> (current default: 1800s)');
|
|
303
267
|
}
|
|
304
|
-
else if (hasReferenceImages &&
|
|
305
|
-
(msg.includes('reference') || msg.includes('image input'))) {
|
|
268
|
+
else if (hasReferenceImages && (msg.includes('reference') || msg.includes('image input'))) {
|
|
306
269
|
lines.push('');
|
|
307
270
|
lines.push('Hint: Reference image error — the selected model may not support --reference-image.');
|
|
308
271
|
lines.push(' → Run: playcraft tools list-image-models');
|
|
@@ -350,11 +313,7 @@ export function registerGenerationCommands(tools) {
|
|
|
350
313
|
}
|
|
351
314
|
// 收集所有待保存图片(第 1 张 + 批量额外图)
|
|
352
315
|
const allImages = [
|
|
353
|
-
{
|
|
354
|
-
imageBase64: result.imageBase64,
|
|
355
|
-
mimeType: result.mimeType,
|
|
356
|
-
previewUrl: result.previewUrl,
|
|
357
|
-
},
|
|
316
|
+
{ imageBase64: result.imageBase64, mimeType: result.mimeType, previewUrl: result.previewUrl },
|
|
358
317
|
...(result.additionalImages ?? []),
|
|
359
318
|
];
|
|
360
319
|
const isBatch = allImages.length > 1;
|
|
@@ -365,14 +324,12 @@ export function registerGenerationCommands(tools) {
|
|
|
365
324
|
const savedPaths = [];
|
|
366
325
|
if (result.transparencySource)
|
|
367
326
|
console.log(`Transparency source: ${result.transparencySource}`);
|
|
368
|
-
const stripTarget = opts.targetAsset || result.transparentBackground
|
|
369
|
-
|
|
370
|
-
:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
imageSize: opts.imageSize,
|
|
375
|
-
});
|
|
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
|
+
});
|
|
376
333
|
for (let i = 0; i < allImages.length; i++) {
|
|
377
334
|
const img = allImages[i];
|
|
378
335
|
let imgBuf = Buffer.from(img.imageBase64, 'base64');
|
|
@@ -390,7 +347,8 @@ export function registerGenerationCommands(tools) {
|
|
|
390
347
|
generatedHeight = metaBefore.height;
|
|
391
348
|
if (metaBefore.width != null &&
|
|
392
349
|
metaBefore.height != null &&
|
|
393
|
-
(metaBefore.width !== stripTarget.width ||
|
|
350
|
+
(metaBefore.width !== stripTarget.width ||
|
|
351
|
+
metaBefore.height !== stripTarget.height)) {
|
|
394
352
|
imgBuf = Buffer.from(await letterboxToTarget(imgBuf, stripTarget.width, stripTarget.height));
|
|
395
353
|
console.log(`↑ Letterbox ${generatedWidth}×${generatedHeight} → ${stripTarget.width}×${stripTarget.height}(等比 + 白边,不拉伸)`);
|
|
396
354
|
}
|
|
@@ -446,11 +404,10 @@ export function registerGenerationCommands(tools) {
|
|
|
446
404
|
referenceImageStatus: result.referenceImageStatus ?? null,
|
|
447
405
|
generatedAt: new Date().toISOString(),
|
|
448
406
|
};
|
|
449
|
-
const sidecarPath =
|
|
407
|
+
const sidecarPath = resolvedPath.replace(/\.[^.]+$/, '.json');
|
|
450
408
|
writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2));
|
|
451
409
|
await captureGeneratedArtifact(resolvedPath, 'generate-image', {
|
|
452
|
-
...(actualWidth ? { width: actualWidth } : {}),
|
|
453
|
-
...(actualHeight ? { height: actualHeight } : {}),
|
|
410
|
+
...(actualWidth ? { width: actualWidth } : {}), ...(actualHeight ? { height: actualHeight } : {}),
|
|
454
411
|
});
|
|
455
412
|
console.log(` └─ Generation metadata: ${sidecarPath}`);
|
|
456
413
|
}
|
|
@@ -474,8 +431,7 @@ export function registerGenerationCommands(tools) {
|
|
|
474
431
|
handleError(e);
|
|
475
432
|
}
|
|
476
433
|
});
|
|
477
|
-
tools
|
|
478
|
-
.command('generate-sfx')
|
|
434
|
+
tools.command('generate-sfx')
|
|
479
435
|
.description('AI 生成音效(SFX),使用 ElevenLabs Sound Effects')
|
|
480
436
|
.requiredOption('--prompt <text>', '音效描述,仅英文,如 "crisp UI click, short tail, no voice"')
|
|
481
437
|
.option('--duration <seconds>', '时长(秒,0.5-22;省略时由供应商自动决定)', parseFloat)
|
|
@@ -495,14 +451,9 @@ export function registerGenerationCommands(tools) {
|
|
|
495
451
|
const sizeKB = Math.round(buf.length / 1024);
|
|
496
452
|
console.log(`SFX saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
|
|
497
453
|
const sfxSidecar = {
|
|
498
|
-
type: 'sfx',
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
loop: opts.loop ?? false,
|
|
502
|
-
provider: result.provider,
|
|
503
|
-
actualDuration: result.duration,
|
|
504
|
-
output: opts.output,
|
|
505
|
-
generatedAt: new Date().toISOString(),
|
|
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(),
|
|
506
457
|
};
|
|
507
458
|
writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(sfxSidecar, null, 2));
|
|
508
459
|
await captureGeneratedArtifact(opts.output, 'generate-sfx', { duration: result.duration });
|
|
@@ -512,8 +463,7 @@ export function registerGenerationCommands(tools) {
|
|
|
512
463
|
handleError(e);
|
|
513
464
|
}
|
|
514
465
|
});
|
|
515
|
-
tools
|
|
516
|
-
.command('generate-bgm')
|
|
466
|
+
tools.command('generate-bgm')
|
|
517
467
|
.description('AI 生成 BGM(30s 循环),使用 Google Lyria 3')
|
|
518
468
|
.requiredOption('--prompt <text>', 'BGM 描述,如 "轻快休闲游戏配乐"')
|
|
519
469
|
.option('--style <style>', '音乐风格,如 casual / epic / sci-fi / retro')
|
|
@@ -534,14 +484,9 @@ export function registerGenerationCommands(tools) {
|
|
|
534
484
|
const sizeKB = Math.round(buf.length / 1024);
|
|
535
485
|
console.log(`BGM saved to ${opts.output} (${sizeKB}KB, ${result.duration.toFixed(2)}s, provider=${result.provider})`);
|
|
536
486
|
const bgmSidecar = {
|
|
537
|
-
type: 'bgm',
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
bpm: opts.bpm ?? null,
|
|
541
|
-
provider: result.provider,
|
|
542
|
-
actualDuration: result.duration,
|
|
543
|
-
output: opts.output,
|
|
544
|
-
generatedAt: new Date().toISOString(),
|
|
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(),
|
|
545
490
|
};
|
|
546
491
|
writeFileSync(opts.output.replace(/\.[^.]+$/, '.json'), JSON.stringify(bgmSidecar, null, 2));
|
|
547
492
|
await captureGeneratedArtifact(opts.output, 'generate-bgm', { duration: result.duration });
|
|
@@ -572,8 +517,7 @@ export function registerGenerationCommands(tools) {
|
|
|
572
517
|
}
|
|
573
518
|
});
|
|
574
519
|
// ─── Search Image ──────────────────────────────────────────
|
|
575
|
-
tools
|
|
576
|
-
.command('search-image')
|
|
520
|
+
tools.command('search-image')
|
|
577
521
|
.description('搜索参考图片(Unsplash / Pexels),结果 URL 可直接用于 --reference-image')
|
|
578
522
|
.requiredOption('--query <query>', '搜索关键词(建议英文)')
|
|
579
523
|
.option('--source <source>', '图源:unsplash | pexels | auto(默认 auto)', 'auto')
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { AgentApiClient } from '../utils/agent-api-client.js';
|
|
4
|
+
export async function toolRequestCommand(operation, serializedBody) {
|
|
5
|
+
if (!['generate-image', 'generate-bgm', 'generate-sfx'].includes(operation))
|
|
6
|
+
throw new Error('Unsupported generation operation');
|
|
7
|
+
const body = JSON.parse(serializedBody);
|
|
8
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
9
|
+
throw new Error('Generation request must be a JSON object');
|
|
10
|
+
return new AgentApiClient({
|
|
11
|
+
configFile: process.env.PLAYCRAFT_SANDBOX_CONFIG_PATH || join(process.cwd(), '.playcraft.json'),
|
|
12
|
+
operationNamespace: 'opencode',
|
|
13
|
+
}).post(`/${operation}`, body);
|
|
14
|
+
}
|
|
15
|
+
/** Static sandbox plugins cannot import command-line modules; use this client for transport. */
|
|
16
|
+
export function registerToolRequestCommand(tools) {
|
|
17
|
+
tools
|
|
18
|
+
.command('request <operation>', { hidden: true })
|
|
19
|
+
.description('Send a generation request from JSON on stdin and print its durable result as JSON')
|
|
20
|
+
.action(async (operation) => {
|
|
21
|
+
const result = await toolRequestCommand(operation, readFileSync(0, 'utf8'));
|
|
22
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
|
|
2
|
-
import { join, parse
|
|
2
|
+
import { join, parse } from 'path';
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { InvalidArgumentError } from 'commander';
|
|
5
5
|
export const TMP_DIR = join(tmpdir(), 'playcraft');
|
|
@@ -9,12 +9,8 @@ export function parseImageTargetFieldPath(value) {
|
|
|
9
9
|
try {
|
|
10
10
|
parsed = JSON.parse(value);
|
|
11
11
|
}
|
|
12
|
-
catch {
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
if (!Array.isArray(parsed) ||
|
|
16
|
-
!parsed.length ||
|
|
17
|
-
parsed.some((key) => typeof key !== 'string' || !key.length)) {
|
|
12
|
+
catch { /* Report the expected shape below. */ }
|
|
13
|
+
if (!Array.isArray(parsed) || !parsed.length || parsed.some(key => typeof key !== 'string' || !key.length)) {
|
|
18
14
|
throw new InvalidArgumentError('Expected an array of field names, e.g. ["endUI","playButton"].');
|
|
19
15
|
}
|
|
20
16
|
return parsed;
|
|
@@ -78,7 +74,11 @@ export function extensionForImageMime(mimeType) {
|
|
|
78
74
|
* Detect container from decoded bytes (magic). Prefer this over MIME alone.
|
|
79
75
|
*/
|
|
80
76
|
export function sniffImageExtension(buf) {
|
|
81
|
-
if (buf.length >= 8 &&
|
|
77
|
+
if (buf.length >= 8 &&
|
|
78
|
+
buf[0] === 0x89 &&
|
|
79
|
+
buf[1] === 0x50 &&
|
|
80
|
+
buf[2] === 0x4e &&
|
|
81
|
+
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,14 +133,12 @@ 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';
|
|
138
136
|
if (ext === 'jpg' || ext === 'jpeg')
|
|
139
137
|
return 'image/jpeg';
|
|
140
138
|
if (ext === undefined || ext === '') {
|
|
141
139
|
throw new Error(`Reference image path has no file extension: ${filePath}. Use .png, .webp, .jpg, or .jpeg.`);
|
|
142
140
|
}
|
|
143
|
-
throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg,
|
|
141
|
+
throw new Error(`Unsupported reference image extension ".${ext}" in ${filePath}. Only png, webp, jpg, and jpeg are allowed.`);
|
|
144
142
|
}
|
|
145
143
|
async function downloadImageFromUrl(url) {
|
|
146
144
|
let res;
|
|
@@ -157,8 +155,7 @@ async function downloadImageFromUrl(url) {
|
|
|
157
155
|
const contentType = res.headers.get('content-type') ?? '';
|
|
158
156
|
const mimeType = contentType.split(';')[0].trim() || 'image/png';
|
|
159
157
|
const allowed = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
|
|
160
|
-
if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) ||
|
|
161
|
-
!mimeType.startsWith('image/')) {
|
|
158
|
+
if (!allowed.some((t) => mimeType.startsWith(t.split('/')[0]) && mimeType.includes('/')) || !mimeType.startsWith('image/')) {
|
|
162
159
|
throw new Error(`参考图 URL 返回的 Content-Type 不是图片(${mimeType}):${url}\n 只支持 PNG / JPG / WEBP / GIF。`);
|
|
163
160
|
}
|
|
164
161
|
const buf = await res.arrayBuffer();
|
|
@@ -181,13 +178,18 @@ export async function collectReferenceImagePayloads(paths) {
|
|
|
181
178
|
}
|
|
182
179
|
else {
|
|
183
180
|
const mimeType = mimeTypeForImagePath(p);
|
|
184
|
-
out.push({ base64: readFileSync(
|
|
181
|
+
out.push({ base64: readFileSync(p).toString('base64'), mimeType });
|
|
185
182
|
}
|
|
186
183
|
}
|
|
187
184
|
catch (e) {
|
|
188
185
|
const detail = e instanceof Error ? e.message : String(e);
|
|
189
|
-
|
|
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}`);
|
|
190
189
|
}
|
|
191
190
|
}
|
|
191
|
+
if (paths.length > 0 && out.length === 0) {
|
|
192
|
+
console.warn('All reference images failed to load; continuing with text-only generation.');
|
|
193
|
+
}
|
|
192
194
|
return out.length > 0 ? out : undefined;
|
|
193
195
|
}
|
package/dist/commands/tools.js
CHANGED
|
@@ -6,11 +6,13 @@ import { registerDetectCommand } from './tools-detect.js';
|
|
|
6
6
|
import { registerVideoReviewCommand } from './tools-video-review.js';
|
|
7
7
|
import { registerImageContextCommand } from './tools-image-context.js';
|
|
8
8
|
import { registerContextCommands } from './tools-context.js';
|
|
9
|
+
import { registerToolRequestCommand } from './tools-request.js';
|
|
9
10
|
export function registerToolsCommands(program) {
|
|
10
11
|
const tools = program
|
|
11
12
|
.command('tools')
|
|
12
13
|
.description('后端 /api/agent/tools;运行 playcraft login 后即可直接使用,也支持 .playcraft.json 或 PLAYCRAFT_SANDBOX_TOKEN 环境变量');
|
|
13
14
|
registerGenerationCommands(tools);
|
|
15
|
+
registerToolRequestCommand(tools);
|
|
14
16
|
registerImageContextCommand(tools);
|
|
15
17
|
registerProjectCommands(tools);
|
|
16
18
|
registerBatchCommands(tools);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs';
|
|
2
2
|
import { createWriteStream } from 'node:fs';
|
|
3
3
|
import { link, mkdir, stat, unlink } from 'node:fs/promises';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
|
-
import { dirname, join } from 'path';
|
|
7
|
+
import { dirname, join, resolve } from 'path';
|
|
8
8
|
import { homedir } from 'os';
|
|
9
9
|
import { loadGlobalConfig } from '../config.js';
|
|
10
10
|
import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
@@ -41,20 +41,28 @@ export class AgentApiClient {
|
|
|
41
41
|
credentialFile;
|
|
42
42
|
playcraftEnv;
|
|
43
43
|
cloudRuntimeCredential;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
explicitConfig;
|
|
45
|
+
operationDirectory;
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
const localConfig = options.configFile
|
|
48
|
+
? AgentApiClient.requireLocalConfig(options.configFile)
|
|
49
|
+
: AgentApiClient.loadLocalConfig();
|
|
50
|
+
const globalConfig = options.configFile ? {} : loadGlobalConfig();
|
|
47
51
|
// URL: 环境变量 → 本地 .playcraft.json → 全局 login 配置 → 公有云默认地址
|
|
48
|
-
const apiUrl = (
|
|
49
|
-
localConfig.apiUrl
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const apiUrl = (options.configFile
|
|
53
|
+
? localConfig.apiUrl
|
|
54
|
+
: process.env.PLAYCRAFT_API_URL ||
|
|
55
|
+
localConfig.apiUrl ||
|
|
56
|
+
globalConfig.backendUrl ||
|
|
57
|
+
globalConfig.url ||
|
|
58
|
+
DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
|
|
53
59
|
// Token: 环境变量 → 沙箱全局配置 → 项目配置 → 用户登录配置
|
|
54
|
-
const token =
|
|
55
|
-
localConfig.token
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
const token = options.configFile
|
|
61
|
+
? localConfig.token
|
|
62
|
+
: process.env.PLAYCRAFT_SANDBOX_TOKEN ||
|
|
63
|
+
localConfig.token ||
|
|
64
|
+
globalConfig.token ||
|
|
65
|
+
'';
|
|
58
66
|
if (!token) {
|
|
59
67
|
throw new Error('Missing PlayCraft API credentials. ' +
|
|
60
68
|
'Run `playcraft login` to authenticate, or set PLAYCRAFT_SANDBOX_TOKEN / create a .playcraft.json file.');
|
|
@@ -62,14 +70,29 @@ export class AgentApiClient {
|
|
|
62
70
|
this.apiUrl = apiUrl;
|
|
63
71
|
this.token = token;
|
|
64
72
|
this.credentialFile = localConfig.credentialFile;
|
|
65
|
-
this.
|
|
73
|
+
this.explicitConfig = Boolean(options.configFile);
|
|
74
|
+
this.playcraftEnv = (options.configFile
|
|
75
|
+
? localConfig.env || process.env.PLAYCRAFT_ENV || ''
|
|
76
|
+
: process.env.PLAYCRAFT_ENV || localConfig.env || '').trim();
|
|
66
77
|
this.cloudRuntimeCredential = cloudRuntimeCredentialHint(token, token === localConfig.token ? localConfig : undefined);
|
|
78
|
+
if (options.operationNamespace) {
|
|
79
|
+
if (!options.configFile)
|
|
80
|
+
throw new Error('Operation namespace requires an explicit sandbox configuration');
|
|
81
|
+
const scope = JSON.stringify([
|
|
82
|
+
resolve(options.configFile),
|
|
83
|
+
apiUrl,
|
|
84
|
+
this.playcraftEnv,
|
|
85
|
+
localConfig.projectId,
|
|
86
|
+
localConfig.branch,
|
|
87
|
+
]);
|
|
88
|
+
this.operationDirectory = join(process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests'), options.operationNamespace, createHash('sha256').update(scope).digest('hex'));
|
|
89
|
+
}
|
|
67
90
|
}
|
|
68
91
|
/** 根据 token 类型构造鉴权 headers */
|
|
69
92
|
authHeaders() {
|
|
70
93
|
let token = this.token;
|
|
71
94
|
// Polling commands can outlive a credential: read the atomically refreshed file per request.
|
|
72
|
-
if (this.credentialFile && !process.env.PLAYCRAFT_SANDBOX_TOKEN) {
|
|
95
|
+
if (this.credentialFile && (this.explicitConfig || !process.env.PLAYCRAFT_SANDBOX_TOKEN)) {
|
|
73
96
|
try {
|
|
74
97
|
token = JSON.parse(readFileSync(this.credentialFile, 'utf8')).token || token;
|
|
75
98
|
}
|
|
@@ -96,12 +119,12 @@ export class AgentApiClient {
|
|
|
96
119
|
const recoverId = options?.requestId ?? process.env.PLAYCRAFT_TOOL_REQUEST_ID;
|
|
97
120
|
const newRequest = options?.newRequest ?? process.env.PLAYCRAFT_TOOL_NEW_REQUEST === '1';
|
|
98
121
|
const originalReceipt = recoverId !== undefined && !newRequest && isWorkspaceGenerationPath(path)
|
|
99
|
-
? findToolOperation(url.pathname, originalBody, recoverId) : undefined;
|
|
122
|
+
? findToolOperation(url.pathname, originalBody, recoverId, this.operationDirectory) : undefined;
|
|
100
123
|
let serializedBody = originalReceipt ? originalBody : JSON.stringify(await withGenerationWorkspaceScope(path, body, this.cloudRuntimeCredential));
|
|
101
124
|
if (serializedBody === undefined)
|
|
102
125
|
throw new Error('Tool request body is required');
|
|
103
126
|
if (serializedBody !== originalBody && !newRequest) {
|
|
104
|
-
const previous = findToolOperation(url.pathname, originalBody, recoverId);
|
|
127
|
+
const previous = findToolOperation(url.pathname, originalBody, recoverId, this.operationDirectory);
|
|
105
128
|
if (previous && recoverId !== undefined)
|
|
106
129
|
serializedBody = originalBody;
|
|
107
130
|
else if (previous)
|
|
@@ -110,6 +133,7 @@ export class AgentApiClient {
|
|
|
110
133
|
}
|
|
111
134
|
const operation = beginToolOperation(url.pathname, serializedBody, recoverId, {
|
|
112
135
|
newRequest,
|
|
136
|
+
directory: this.operationDirectory,
|
|
113
137
|
});
|
|
114
138
|
const unknownOutcome = `Tool request ${operation.requestId}: the outcome is unknown. Rerun the same command to ` +
|
|
115
139
|
`continue this request: it returns the result if the request finished, or finishes it if ` +
|
|
@@ -384,6 +408,21 @@ export class AgentApiClient {
|
|
|
384
408
|
}
|
|
385
409
|
return { apiUrl: '', token: '' };
|
|
386
410
|
}
|
|
411
|
+
/** A static plugin's slot must never fall back to another workspace or a global login. */
|
|
412
|
+
static requireLocalConfig(file) {
|
|
413
|
+
try {
|
|
414
|
+
const config = JSON.parse(readFileSync(file, 'utf8'));
|
|
415
|
+
if (typeof config?.apiUrl !== 'string' ||
|
|
416
|
+
!config.apiUrl.trim() ||
|
|
417
|
+
typeof config.token !== 'string' ||
|
|
418
|
+
!config.token.trim())
|
|
419
|
+
throw new Error('invalid');
|
|
420
|
+
return { ...config, credentialFile: file };
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
throw new Error('A valid project .playcraft.json with apiUrl and token is required; no generation request was sent');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
387
426
|
}
|
|
388
427
|
/** A failure whose meaning is settled; no recovery hint applies. */
|
|
389
428
|
class ToolRequestError extends Error {
|
|
@@ -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,
|
|
5
|
+
import { extractAssetFields, getByPath, resolveAiImageTargetConstraints, resolveImageBackgroundRequirement, 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,29 +38,20 @@ 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 = [
|
|
42
|
-
root,
|
|
43
|
-
...allowedOutputRoots.map((outputRoot) => path.resolve(root, outputRoot)),
|
|
44
|
-
];
|
|
41
|
+
const outputRoots = [root, ...allowedOutputRoots.map(outputRoot => path.resolve(root, outputRoot))];
|
|
45
42
|
for (const output of outputPaths) {
|
|
46
43
|
const mapped = output.startsWith('/project/') ? output.slice('/project/'.length) : output;
|
|
47
44
|
const { dir, name } = path.parse(mapped);
|
|
48
45
|
// The response encoding can change the requested extension.
|
|
49
|
-
const candidates = [
|
|
50
|
-
mapped,
|
|
51
|
-
...['.png', '.webp', '.jpg', '.gif', '.bmp'].map((ext) => path.join(dir, `${name}${ext}`)),
|
|
52
|
-
];
|
|
46
|
+
const candidates = [mapped, ...['.png', '.webp', '.jpg', '.gif', '.bmp'].map(ext => path.join(dir, `${name}${ext}`))];
|
|
53
47
|
for (const candidate of candidates) {
|
|
54
48
|
const candidatePath = path.resolve(root, candidate);
|
|
55
|
-
const outputRoot = outputRoots.find(
|
|
49
|
+
const outputRoot = outputRoots.find(allowed => isWithinDirectory(allowed, candidatePath));
|
|
56
50
|
if (!outputRoot)
|
|
57
51
|
throw new Error(`Image output path is outside the project and allowed output roots: ${output}`);
|
|
58
52
|
const file = canonicalProjectPath(outputRoot, candidatePath);
|
|
59
53
|
const fileStat = existsSync(file) ? statSync(file) : undefined;
|
|
60
|
-
if (!path.relative(target, file) ||
|
|
61
|
-
(targetStat.ino !== 0 &&
|
|
62
|
-
fileStat?.ino === targetStat.ino &&
|
|
63
|
-
fileStat.dev === targetStat.dev)) {
|
|
54
|
+
if (!path.relative(target, file) || (targetStat.ino !== 0 && fileStat?.ino === targetStat.ino && fileStat.dev === targetStat.dev)) {
|
|
64
55
|
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.`);
|
|
65
56
|
}
|
|
66
57
|
}
|
|
@@ -80,10 +71,8 @@ function projectFile(root, input) {
|
|
|
80
71
|
/** Read the same per-field constraints used by the asset image dialog. */
|
|
81
72
|
export async function readImageAssetContext(assetPath, projectDir = process.cwd(), options = {}) {
|
|
82
73
|
const targetFieldPath = options.targetFieldPath;
|
|
83
|
-
if (targetFieldPath !== undefined &&
|
|
84
|
-
(!
|
|
85
|
-
!targetFieldPath.length ||
|
|
86
|
-
targetFieldPath.some((key) => typeof key !== 'string' || !key.length))) {
|
|
74
|
+
if (targetFieldPath !== undefined && (!Array.isArray(targetFieldPath)
|
|
75
|
+
|| !targetFieldPath.length || targetFieldPath.some(key => typeof key !== 'string' || !key.length))) {
|
|
87
76
|
throw new Error('targetFieldPath must be a non-empty array of non-empty field names.');
|
|
88
77
|
}
|
|
89
78
|
const root = path.resolve(projectDir);
|
|
@@ -92,36 +81,24 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
92
81
|
const targetStat = statSync(target);
|
|
93
82
|
const relativeTarget = path.relative(root, target).replace(/\\/g, '/');
|
|
94
83
|
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);
|
|
100
84
|
let fields = [];
|
|
101
85
|
const schemaFile = path.join(root, THEME_SCHEMA_PATH);
|
|
102
|
-
if (
|
|
86
|
+
if (themeMatch && existsSync(schemaFile)) {
|
|
103
87
|
const schema = JSON5.parse(readFileSync(projectFile(root, THEME_SCHEMA_PATH), 'utf8'));
|
|
104
|
-
const dataPath = themeDataPath(
|
|
88
|
+
const dataPath = themeDataPath(themeMatch[1]);
|
|
105
89
|
const data = existsSync(path.join(root, dataPath))
|
|
106
|
-
? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8'))
|
|
107
|
-
: {};
|
|
90
|
+
? JSON5.parse(readFileSync(projectFile(root, dataPath), 'utf8')) : {};
|
|
108
91
|
const themeRoot = path.dirname(path.join(root, dataPath));
|
|
109
|
-
const matches = extractAssetFields(schema, data).filter(
|
|
92
|
+
const matches = extractAssetFields(schema, data).filter(candidate => {
|
|
110
93
|
let configured = getByPath(data, candidate.path);
|
|
111
94
|
if (configured === undefined && ['gameplay', 'gamePlay'].includes(candidate.path[0])) {
|
|
112
|
-
configured = getByPath(data, [
|
|
113
|
-
candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay',
|
|
114
|
-
...candidate.path.slice(1),
|
|
115
|
-
]);
|
|
95
|
+
configured = getByPath(data, [candidate.path[0] === 'gameplay' ? 'gamePlay' : 'gameplay', ...candidate.path.slice(1)]);
|
|
116
96
|
}
|
|
117
97
|
const value = typeof configured === 'string' ? configured : candidate.schema.default;
|
|
118
98
|
if (typeof value !== 'string' || /^https?:\/\//i.test(value))
|
|
119
99
|
return false;
|
|
120
|
-
const configuredPath = value.startsWith('/project/')
|
|
121
|
-
|
|
122
|
-
: /^\/?src\//.test(value)
|
|
123
|
-
? path.resolve(root, value.replace(/^\//, ''))
|
|
124
|
-
: path.resolve(themeRoot, value);
|
|
100
|
+
const configuredPath = value.startsWith('/project/') ? path.resolve(root, value.slice('/project/'.length))
|
|
101
|
+
: path.resolve(themeRoot, value);
|
|
125
102
|
if (configuredPath === target)
|
|
126
103
|
return true;
|
|
127
104
|
try {
|
|
@@ -129,22 +106,19 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
129
106
|
if (!path.relative(realTarget, realpathSync(candidateFile)))
|
|
130
107
|
return true;
|
|
131
108
|
const candidateStat = statSync(candidateFile);
|
|
132
|
-
return
|
|
133
|
-
candidateStat.ino === targetStat.ino &&
|
|
134
|
-
candidateStat.dev === targetStat.dev);
|
|
109
|
+
return targetStat.ino !== 0 && candidateStat.ino === targetStat.ino && candidateStat.dev === targetStat.dev;
|
|
135
110
|
}
|
|
136
111
|
catch {
|
|
137
112
|
// Other fields may reference missing files or paths outside this project.
|
|
138
113
|
return false;
|
|
139
114
|
}
|
|
140
115
|
});
|
|
141
|
-
if (!matches.length
|
|
116
|
+
if (!matches.length)
|
|
142
117
|
throw new Error(`No image schema field matches ${relativeTarget}; check theme.data.json5 before generating.`);
|
|
143
118
|
fields = matches;
|
|
144
|
-
if (targetFieldPath &&
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
throw new Error(`targetFieldPath ${JSON.stringify(targetFieldPath)} does not reference ${relativeTarget}. Matching fields: ${matches.map((candidate) => JSON.stringify(candidate.path)).join(', ')}.`);
|
|
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(', ')}.`);
|
|
148
122
|
}
|
|
149
123
|
}
|
|
150
124
|
else if (targetFieldPath) {
|
|
@@ -157,35 +131,22 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
157
131
|
const ext = xp?.ext;
|
|
158
132
|
// Schema references follow the dialog's root/theme conventions; explicit tool paths do not change.
|
|
159
133
|
const schemaReferences = Array.isArray(ext?.reference_image)
|
|
160
|
-
? ext.reference_image
|
|
161
|
-
.filter((ref) => typeof ref === 'string' && ref.trim())
|
|
162
|
-
.map((ref) => {
|
|
134
|
+
? ext.reference_image.filter(ref => typeof ref === 'string' && ref.trim()).map(ref => {
|
|
163
135
|
const raw = ref.trim().replace(/\\/g, '/');
|
|
164
|
-
const normalized = raw
|
|
165
|
-
.replace(/^\.\/+/, '')
|
|
166
|
-
.replace(/^\/+/, '')
|
|
167
|
-
.replace(/\/{2,}/g, '/');
|
|
136
|
+
const normalized = raw.replace(/^\.\/+/, '').replace(/^\/+/, '').replace(/\/{2,}/g, '/');
|
|
168
137
|
if (/^\/?(?:assets|src|public)\//.test(raw) || normalized.startsWith('src/'))
|
|
169
138
|
return normalized;
|
|
170
|
-
if (!
|
|
139
|
+
if (!themeMatch || path.isAbsolute(raw) || /^https?:\/\//i.test(raw))
|
|
171
140
|
return raw;
|
|
172
|
-
return path.posix.join('src/theme',
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
: options.useTargetReference === false
|
|
178
|
-
? []
|
|
179
|
-
: schemaReferences.length
|
|
180
|
-
? schemaReferences
|
|
181
|
-
: [relativeTarget];
|
|
182
|
-
const referenceImagePaths = references.map((ref) => /^https?:\/\//i.test(ref)
|
|
183
|
-
? ref
|
|
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
|
|
184
146
|
: path.relative(realpathSync(root), canonicalProjectPath(root, ref)).replace(/\\/g, '/'));
|
|
185
147
|
const hasSchemaBackground = typeof ext?.remove_background === 'boolean';
|
|
186
148
|
if (!hasSchemaBackground && targetHasTransparency === undefined) {
|
|
187
|
-
targetHasTransparency =
|
|
188
|
-
!!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
|
|
149
|
+
targetHasTransparency = !!targetMeta.hasAlpha && (await sharp(target).stats()).channels.at(-1).min < 250;
|
|
189
150
|
}
|
|
190
151
|
const removeBackground = resolveImageBackgroundRequirement({
|
|
191
152
|
prompt: options.prompt ?? '',
|
|
@@ -210,16 +171,12 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
210
171
|
targetAssetPath: relativeTarget,
|
|
211
172
|
fieldPath: field?.path,
|
|
212
173
|
referenceImagePaths,
|
|
213
|
-
width: delivery.width,
|
|
214
|
-
height: delivery.height,
|
|
174
|
+
width: delivery.width, height: delivery.height,
|
|
215
175
|
aspectRatio: typeof ext?.ratio === 'string' ? ext.ratio : `${delivery.width}:${delivery.height}`,
|
|
216
176
|
outputFormat: (removeBackground && delivery.format === 'jpeg' && !delivery.fromSchemaMimeType
|
|
217
|
-
? 'png'
|
|
218
|
-
: delivery.format),
|
|
177
|
+
? 'png' : delivery.format),
|
|
219
178
|
outputFormatFromSchema: delivery.fromSchemaMimeType,
|
|
220
|
-
fitMode: delivery.fitMode,
|
|
221
|
-
removeBackground: delivery.removeBackground,
|
|
222
|
-
paddingColor: delivery.paddingColor,
|
|
179
|
+
fitMode: delivery.fitMode, removeBackground: delivery.removeBackground, paddingColor: delivery.paddingColor,
|
|
223
180
|
removeBackgroundFromSchema: hasSchemaBackground,
|
|
224
181
|
imageModelRef: typeof ext?.imageModelRef === 'string' ? ext.imageModelRef : undefined,
|
|
225
182
|
};
|
|
@@ -231,40 +188,33 @@ export async function readImageAssetContext(assetPath, projectDir = process.cwd(
|
|
|
231
188
|
contexts.push(await contextForField(field));
|
|
232
189
|
}
|
|
233
190
|
catch (error) {
|
|
234
|
-
const selected = field?.path.length === targetFieldPath?.length
|
|
235
|
-
field?.path.every((key, index) => key === targetFieldPath?.[index]);
|
|
191
|
+
const selected = field?.path.length === targetFieldPath?.length
|
|
192
|
+
&& field?.path.every((key, index) => key === targetFieldPath?.[index]);
|
|
236
193
|
if (!targetFieldPath || selected)
|
|
237
194
|
throw error;
|
|
238
195
|
// Invalid sibling requirements still protect the original shared file.
|
|
239
196
|
unresolvedSharedField = true;
|
|
240
197
|
}
|
|
241
198
|
}
|
|
242
|
-
const context = targetFieldPath
|
|
243
|
-
|
|
244
|
-
candidate.fieldPath.every((key, index) => key === targetFieldPath[index]))
|
|
245
|
-
: contexts[0];
|
|
199
|
+
const context = targetFieldPath ? contexts.find(candidate => candidate.fieldPath?.length === targetFieldPath.length
|
|
200
|
+
&& candidate.fieldPath.every((key, index) => key === targetFieldPath[index])) : contexts[0];
|
|
246
201
|
const deliveryKey = ({ fieldPath: _fieldPath, outputFormatFromSchema: _formatSource, removeBackgroundFromSchema: _backgroundSource, aspectRatio, ...delivery }) => {
|
|
247
202
|
const ratio = aspectRatio.match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
|
|
248
|
-
return JSON.stringify({
|
|
249
|
-
...delivery,
|
|
250
|
-
aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio,
|
|
251
|
-
});
|
|
203
|
+
return JSON.stringify({ ...delivery, aspectRatio: ratio && Number(ratio[2]) > 0 ? Number(ratio[1]) / Number(ratio[2]) : aspectRatio });
|
|
252
204
|
};
|
|
253
|
-
const conflicting = unresolvedSharedField ||
|
|
254
|
-
contexts.some((candidate) => deliveryKey(candidate) !== deliveryKey(context));
|
|
205
|
+
const conflicting = unresolvedSharedField || contexts.some(candidate => deliveryKey(candidate) !== deliveryKey(context));
|
|
255
206
|
if (conflicting && !targetFieldPath) {
|
|
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(
|
|
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(', ')}.`);
|
|
257
208
|
}
|
|
258
209
|
if (targetFieldPath) {
|
|
259
210
|
// A separate output retains exactly the selected field's requirements.
|
|
260
|
-
if (conflicting ||
|
|
261
|
-
|
|
262
|
-
(candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
|
|
211
|
+
if (conflicting || contexts.some(candidate => (candidate.outputFormatFromSchema && !context.outputFormatFromSchema)
|
|
212
|
+
|| (candidate.removeBackgroundFromSchema && !context.removeBackgroundFromSchema)))
|
|
263
213
|
context.requiresSeparateOutput = true;
|
|
264
214
|
}
|
|
265
215
|
else {
|
|
266
|
-
context.outputFormatFromSchema = contexts.some(
|
|
267
|
-
context.removeBackgroundFromSchema = contexts.some(
|
|
216
|
+
context.outputFormatFromSchema = contexts.some(candidate => candidate.outputFormatFromSchema);
|
|
217
|
+
context.removeBackgroundFromSchema = contexts.some(candidate => candidate.removeBackgroundFromSchema);
|
|
268
218
|
}
|
|
269
219
|
assertImageAssetOutputPaths(context, options.outputPaths ?? [], root, options.allowedOutputRoots);
|
|
270
220
|
if (context.referenceImagePaths.length > 8)
|
|
@@ -29,7 +29,7 @@ function holderAlive(holder) {
|
|
|
29
29
|
}
|
|
30
30
|
/** Persist an identity before sending. Store neither credentials nor request/response bodies. */
|
|
31
31
|
export function beginToolOperation(path, serializedBody, recoverId, options = {}) {
|
|
32
|
-
const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
32
|
+
const directory = options.directory ?? process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
33
33
|
if (!isAbsolute(directory))
|
|
34
34
|
throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
|
|
35
35
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
@@ -98,8 +98,8 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
|
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
/** Read an existing identity without creating an intent or changing its receipt state. */
|
|
101
|
-
export function findToolOperation(path, serializedBody, requestId) {
|
|
102
|
-
const directory = process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
101
|
+
export function findToolOperation(path, serializedBody, requestId, directoryOverride) {
|
|
102
|
+
const directory = directoryOverride ?? process.env.PLAYCRAFT_TOOL_REQUEST_DIR ?? join(homedir(), '.playcraft', 'tool-requests');
|
|
103
103
|
if (!isAbsolute(directory))
|
|
104
104
|
throw new Error('PLAYCRAFT_TOOL_REQUEST_DIR must be an absolute path');
|
|
105
105
|
const inputHash = createHash('sha256').update(serializedBody).digest('hex');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.86",
|
|
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.
|
|
61
|
-
"@playcraft/common": "0.1.
|
|
60
|
+
"@playcraft/build": "0.1.86",
|
|
61
|
+
"@playcraft/common": "0.1.86",
|
|
62
62
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
63
63
|
"chokidar": "^4.0.3",
|
|
64
64
|
"commander": "^13.1.0",
|