@goodandready/dsh-image-gen 0.10.16 → 0.10.18

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.
@@ -0,0 +1,601 @@
1
+
2
+ /** Run an array of async task functions with a concurrency cap */
3
+ async function asyncPool(tasks, concurrency = 3) {
4
+ const results = new Array(tasks.length)
5
+ let nextIdx = 0
6
+ async function worker() {
7
+ while (nextIdx < tasks.length) {
8
+ const idx = nextIdx++
9
+ results[idx] = await tasks[idx]()
10
+ }
11
+ }
12
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
13
+ await Promise.all(workers)
14
+ return results
15
+ }
16
+
17
+ // generation — image-gen tools (generate_image, generate_image_pack). Extracted from apply() (#216).
18
+
19
+ import { defineTool } from '@deepseek-ai/dsh-tools'
20
+ import path from 'node:path'
21
+ import { mkdir, writeFile } from 'node:fs/promises'
22
+ import {
23
+ computeGenerationHash,
24
+ IMAGE_SIZES,
25
+ OUTPUT_FORMATS,
26
+ PROVIDER_KEYS,
27
+ buildSidecar,
28
+ makeProviders,
29
+ ASPECT_RATIOS,
30
+ pixelDiff,
31
+ normalizeCount,
32
+ tryGenerate,
33
+ fallbackOrder,
34
+ embedPngMetadata,
35
+ removeBackgroundFal,
36
+ upscaleImageFal,
37
+ traceToSvg,
38
+ estimateCost,
39
+ estimateSharpnessAndVariance,
40
+ applyStylePreset,
41
+ resolveStylePreset,
42
+ blendImagesFal,
43
+ toLosslessJson,
44
+ saveAttachmentSafe,
45
+ } from '../providers.js'
46
+ import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from '../resolve-image.js'
47
+ import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
48
+ import { executeWithQualityGate } from '../quality-gate.js'
49
+ import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
50
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
51
+ import { sanitizeErrorAndLogs } from '../security.js'
52
+ import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
53
+ import {
54
+ extractDesignTokens,
55
+ generateCssGradient,
56
+ checkWcagContrast,
57
+ optimizeSvgContent,
58
+ generatePwaIconSuite,
59
+ extractSampleColorsFromBuffer,
60
+ } from '../frontend-assets.js'
61
+
62
+
63
+ export function registerGenerationTools(ctx, deps) {
64
+ const {
65
+ config,
66
+ live,
67
+ saveAndAttachResult,
68
+ resolveSource,
69
+ slugify,
70
+ resolveApiKey,
71
+ } = deps
72
+ ctx.effect(() => {
73
+ ctx.tools.register(
74
+ defineTool({
75
+ name: 'generate_image',
76
+ description:
77
+ 'Generate an image with the configured image provider. '
78
+ + 'Saves the image to the session workspace and shows it in the conversation. '
79
+ + 'Depending on how the deployment is configured the result carries either the image itself or a link to it; '
80
+ + 'when you only get a link, answer from the prompt and the link rather than claiming to see the picture. '
81
+ + 'Use for any text-to-image request. Pass count (1-4) to generate several variations in one call; cost scales with count (default 1).',
82
+ parameters: {
83
+ prompt: {
84
+ type: 'string',
85
+ required: true,
86
+ description: 'Detailed description of the image to generate (subject, style, lighting, composition, colors).',
87
+ },
88
+ image_size: {
89
+ type: 'string',
90
+ description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
91
+ },
92
+ aspect_ratio: {
93
+ type: 'string',
94
+ enum: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
95
+ description: 'Optional aspect ratio; overrides image_size when both are given. Sent as width/height to providers that accept pixels.',
96
+ },
97
+ seed: {
98
+ type: 'integer',
99
+ description: 'Optional seed for reproducible output.',
100
+ },
101
+ output_format: {
102
+ type: 'string',
103
+ enum: OUTPUT_FORMATS,
104
+ description: `Output format. Default: ${config.defaultFormat}.`,
105
+ },
106
+ output_name: {
107
+ type: 'string',
108
+ description: 'Optional file name stem for the saved image (defaults to a slug of the prompt).',
109
+ },
110
+ output_dir: {
111
+ type: 'string',
112
+ description: 'Optional custom directory to write images into (relative to session cwd or absolute). Defaults to outputDir from settings.',
113
+ },
114
+ count: {
115
+ type: 'integer',
116
+ description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
117
+ },
118
+ prompts: {
119
+ type: 'array',
120
+ items: {
121
+ oneOf: [
122
+ { type: 'string' },
123
+ { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
124
+ ],
125
+ },
126
+ description: 'Optional list of prompts (strings or {text}); one image is generated per prompt. When set, overrides count and the single prompt.',
127
+ },
128
+ negative_prompt: {
129
+ type: 'string',
130
+ description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
131
+ },
132
+ guidance_scale: {
133
+ type: 'number',
134
+ description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
135
+ },
136
+ quality: {
137
+ type: 'string',
138
+ enum: ['auto', 'low', 'medium', 'high'],
139
+ description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
140
+ },
141
+ style: {
142
+ type: 'string',
143
+ description: 'Optional style preset for providers that support it (seedream, gemini). Ignored otherwise.',
144
+ },
145
+ source_image: {
146
+ type: 'string',
147
+ description: 'Optional path to an image file or an attachment id (sha256:...) to edit instead of drawing from scratch. Only providers that support image editing accept it; others refuse with a clear reason.',
148
+ },
149
+ mask: {
150
+ type: 'string',
151
+ description: 'Optional path or attachment id of a mask image (same size as source) to restrict the edit to a region. Only used when source_image is set and the provider supports masks.',
152
+ },
153
+ strength: {
154
+ type: 'number',
155
+ description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
156
+ },
157
+ quality_gate: {
158
+ type: 'boolean',
159
+ description: 'Optional override to enable/disable automated quality gate and defect re-rolling for this call.',
160
+ },
161
+ force: {
162
+ type: 'boolean',
163
+ description: 'Optional flag to bypass content-addressed cache and force a new API generation.',
164
+ },
165
+ },
166
+ output: {
167
+ schema: {
168
+ type: 'object',
169
+ additionalProperties: true,
170
+ properties: {
171
+ path: { type: 'string' },
172
+ url: { type: 'string' },
173
+ width: { type: 'integer' },
174
+ height: { type: 'integer' },
175
+ seed: { type: 'integer' },
176
+ prompt: { type: 'string' },
177
+ format: { type: 'string' },
178
+ attachment: {
179
+ type: 'object',
180
+ additionalProperties: false,
181
+ properties: {
182
+ attachmentId: { type: 'string' },
183
+ mediaType: { type: 'string' },
184
+ bytes: { type: 'integer' },
185
+ width: { type: 'integer' },
186
+ height: { type: 'integer' },
187
+ name: { type: 'string' },
188
+ },
189
+ },
190
+ images: {
191
+ type: 'array',
192
+ items: {
193
+ type: 'object',
194
+ additionalProperties: false,
195
+ properties: {
196
+ path: { type: 'string' },
197
+ url: { type: 'string' },
198
+ width: { type: 'integer' },
199
+ height: { type: 'integer' },
200
+ seed: { type: 'integer' },
201
+ prompt: { type: 'string' },
202
+ format: { type: 'string' },
203
+ },
204
+ },
205
+ },
206
+ },
207
+ },
208
+ render(args, value) {
209
+ const list = value.images || [value]
210
+ const lines = list.map((img) => `Generated image (${img.width}×${img.height}, ${img.format}, seed ${img.seed}) via ${value.provider || 'image-gen'}/${value.model || 'default'}: ${img.path}`)
211
+ const summary = lines.join('\n')
212
+ if (live().deliverAs !== 'image') {
213
+ const urls = list.map((img) => img.url).filter(Boolean)
214
+ return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
215
+ }
216
+ const blocks = [{ type: 'text', text: summary }]
217
+ for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
218
+ return blocks
219
+ },
220
+ },
221
+ isConcurrencySafe: () => false,
222
+ timeoutMs: config.timeoutMs + 30000,
223
+ async execute(args, exec) {
224
+ const cfg = live()
225
+
226
+ // #168: Fail-fast loop guard protecting against runaway agent loops
227
+ const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
228
+ if (cfg.loopGuardLimit > 0) {
229
+ trackAndAssertLoopGuard(sessionId, {
230
+ limit: cfg.loopGuardLimit,
231
+ prompt: args.prompt,
232
+ })
233
+ }
234
+ if (cfg.enabled === false) {
235
+ throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
236
+ }
237
+ const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
238
+ const deliverAs = cfg.deliverAs
239
+ const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
240
+ if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
241
+ throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
242
+ }
243
+ const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
244
+ const format = args.output_format ?? cfg.defaultFormat
245
+ if (!OUTPUT_FORMATS.includes(format)) {
246
+ throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
247
+ }
248
+ // Служба подписок необязательна: без неё эти два провайдера просто
249
+ // отказываются, а остальные работают как работали.
250
+ let subscriptionImages
251
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
252
+
253
+ const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
254
+ const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
255
+ const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
256
+ const effectiveStyle = args.style || cfg.stylePreset
257
+ const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
258
+ const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
259
+
260
+ // #165: Negative prompt sanitizer for diffusion models
261
+ let effectiveNegative = styleInfo.negativePrompt
262
+ if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
263
+ effectiveNegative = sanitizeNegativePrompt({
264
+ positivePrompt: effectivePrompt,
265
+ userNegative: effectiveNegative,
266
+ autoInjectDefects: true,
267
+ })
268
+ }
269
+ const effectiveGuidance = styleInfo.guidanceScale
270
+
271
+ // #167: Assert daily budget availability before calling API
272
+ const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
273
+ const estimatedCost = calculateGenerationCost({
274
+ provider,
275
+ model: cfg.model || cfg.customModel,
276
+ size,
277
+ count: totalCount,
278
+ })
279
+ assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
280
+ const providers = makeProviders(
281
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
282
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
283
+ )
284
+ // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
285
+ // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
286
+ // байтами, и пользователь получал битую карточку вместо внятного отказа.
287
+ const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
288
+ const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
289
+ const gen = await providers[providerKey](jobSeed, promptArg)
290
+ guard(gen)
291
+ const mediaType = gen.mediaType || 'image/png'
292
+ let bytes = gen.bytes
293
+ if (mediaType === 'image/png') {
294
+ bytes = embedPngMetadata(bytes, {
295
+ prompt: promptArg,
296
+ seed: gen.seed,
297
+ provider,
298
+ model: cfg.model || cfg.customModel,
299
+ negative_prompt: effectiveNegative,
300
+ guidance_scale: effectiveGuidance,
301
+ width: gen.width,
302
+ height: gen.height,
303
+ })
304
+ }
305
+ const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
306
+ const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
307
+ const name = `${stem}.${extension}`
308
+
309
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
310
+
311
+ const sessionCwd = exec.agent?.session?.header?.cwd
312
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
313
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
314
+ await mkdir(outDir, { recursive: true })
315
+ const filePath = path.join(outDir, name)
316
+ await writeFile(filePath, bytes)
317
+
318
+ // Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
319
+ // самодокументируемым (галерея/повтор/диагностика читают их без БД).
320
+ await writeFile(
321
+ path.join(outDir, `${stem}.json`),
322
+ JSON.stringify(buildSidecar({
323
+ prompt: promptArg,
324
+ size,
325
+ format,
326
+ seed: gen.seed,
327
+ provider,
328
+ deliverAs,
329
+ width: gen.width || attachment.width,
330
+ height: gen.height || attachment.height,
331
+ mediaType,
332
+ attachmentId: attachment.attachmentId,
333
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
334
+ cost: gen.cost,
335
+ }), null, 2),
336
+ )
337
+
338
+ const cacheHash = computeGenerationHash({
339
+ provider,
340
+ model: cfg.model || cfg.customModel,
341
+ prompt: promptArg,
342
+ seed: gen.seed,
343
+ size: args.image_size || cfg.defaultSize,
344
+ style: args.style || cfg.stylePreset,
345
+ })
346
+
347
+ // #170: Store in content-addressed disk cache
348
+ if (cfg.diskCache !== false) {
349
+ setCachedGeneration(cacheHash, {
350
+ bytes,
351
+ mediaType,
352
+ width: gen.width || attachment.width,
353
+ height: gen.height || attachment.height,
354
+ seed: gen.seed,
355
+ meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
356
+ })
357
+ }
358
+ const entry = {
359
+ path: filePath,
360
+ prompt: promptArg,
361
+ provider,
362
+ model: cfg.model,
363
+ size,
364
+ format,
365
+ seed: gen.seed,
366
+ cacheHash,
367
+ thumbnailUrl: localUrl,
368
+ width: gen.width || attachment.width,
369
+ height: gen.height || attachment.height,
370
+ mediaType,
371
+ bytes: attachment.bytes,
372
+ createdAt: new Date().toISOString(),
373
+ attachmentId: attachment.attachmentId,
374
+ }
375
+ let current = await readHistory()
376
+ current = await pruneHistory(current, cfg.pruneDays)
377
+ current.unshift(entry)
378
+ if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
379
+ await writeHistory(current)
380
+
381
+ // #167: Record spend in cost meter
382
+ recordSpend(gen.cost || (estimatedCost / totalCount), {
383
+ ctx,
384
+ meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
385
+ })
386
+
387
+ return {
388
+ path: filePath,
389
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
390
+ width: gen.width || attachment.width,
391
+ height: gen.height || attachment.height,
392
+ seed: gen.seed,
393
+ prompt: promptArg,
394
+ ...(enhanced.enhanced ? { originalPrompt: args.prompt } : {}),
395
+ cost: gen.cost,
396
+ format: mediaType.replace('image/', ''),
397
+ attachment: {
398
+ attachmentId: attachment.attachmentId,
399
+ mediaType: attachment.mediaType,
400
+ bytes: attachment.bytes,
401
+ width: attachment.width,
402
+ height: attachment.height,
403
+ name: attachment.name,
404
+ },
405
+ }
406
+ }
407
+
408
+ const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
409
+ // Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
410
+ // по порядку (fal → custom → codex → grok), собирая причины отказов.
411
+ const order = fallbackOrder(provider)
412
+ const qgEnabled = args.quality_gate ?? cfg.qualityGate
413
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
414
+ k,
415
+ async (s, p) => {
416
+ const { generated, qualityReport } = await executeWithQualityGate(
417
+ (seedVal) => one(seedVal, k, p),
418
+ {
419
+ initialSeed: s,
420
+ enabled: qgEnabled,
421
+ ctx,
422
+ prompt: p,
423
+ }
424
+ )
425
+ return { ...generated, qualityReport }
426
+ }
427
+ ]))
428
+ const images = []
429
+ const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
430
+ const checkCache = async (seedVal, promptVal) => {
431
+ const h = computeGenerationHash({
432
+ provider,
433
+ model: cfg.model || cfg.customModel,
434
+ prompt: promptVal,
435
+ seed: seedVal,
436
+ size: args.image_size || cfg.defaultSize,
437
+ style: args.style || cfg.stylePreset,
438
+ })
439
+
440
+ // #170: Content-addressed disk cache check (<50ms return)
441
+ if (cfg.diskCache !== false && !args.force) {
442
+ const diskHit = getCachedGeneration(h, { force: args.force })
443
+ if (diskHit) {
444
+ const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
445
+ const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
446
+ const name = `${stem}.${extension}`
447
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
448
+ const sessionCwd = exec.agent?.session?.header?.cwd
449
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
450
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
451
+ await mkdir(outDir, { recursive: true })
452
+ const filePath = path.join(outDir, name)
453
+ await writeFile(filePath, diskHit.bytes)
454
+
455
+ return {
456
+ path: filePath,
457
+ url: localUrl,
458
+ width: diskHit.width || attachment.width,
459
+ height: diskHit.height || attachment.height,
460
+ seed: seedVal,
461
+ prompt: promptVal,
462
+ cost: 0,
463
+ fromCache: true,
464
+ format: diskHit.mediaType.replace('image/', ''),
465
+ attachment: {
466
+ attachmentId: attachment.attachmentId,
467
+ mediaType: attachment.mediaType,
468
+ bytes: attachment.bytes,
469
+ width: attachment.width,
470
+ height: attachment.height,
471
+ name: attachment.name,
472
+ },
473
+ }
474
+ }
475
+ }
476
+
477
+ if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
478
+ const hPrompt = computeGenerationHash({
479
+ provider,
480
+ model: cfg.model,
481
+ prompt: promptVal,
482
+ seed: seedVal,
483
+ size: args.image_size || cfg.defaultSize,
484
+ style: args.style || cfg.stylePreset,
485
+ })
486
+ const found = findCachedGeneration(historyEntries, hPrompt)
487
+ if (found) return await cachedResult(found)
488
+ if (cfg.cacheBySeed) {
489
+ const bySeed = await findCached(historyEntries, seedVal, promptVal)
490
+ if (bySeed) return await cachedResult(bySeed)
491
+ }
492
+ if (cfg.cacheByPrompt) {
493
+ const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
494
+ if (byPrompt) return await cachedResult(byPrompt)
495
+ }
496
+ return undefined
497
+ }
498
+
499
+ const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
500
+ if (batchPrompts) {
501
+ const tasks = batchPrompts.map((item, i) => async () => {
502
+ const text = typeof item === 'string' ? item : (item && item.text) || ''
503
+ const cached = await checkCache(seedBase + i, text)
504
+ return cached || await tryGenerate(generators, order, seedBase + i, text)
505
+ })
506
+ const parallelResults = await asyncPool(tasks, 3)
507
+ images.push(...parallelResults)
508
+ } else {
509
+ const count = normalizeCount(args.count)
510
+ const tasks = Array.from({ length: count }, (_, i) => async () => {
511
+ const cached = await checkCache(seedBase + i, effectivePrompt)
512
+ return cached || await tryGenerate(generators, order, seedBase + i)
513
+ })
514
+ const parallelResults = await asyncPool(tasks, 3)
515
+ images.push(...parallelResults)
516
+ }
517
+ const first = images[0]
518
+ const dualOutputSummary = buildDualOutputMarkdown({
519
+ action: 'generated',
520
+ filePath: first.path,
521
+ width: first.width,
522
+ height: first.height,
523
+ mediaType: first.mediaType || ('image/' + (first.format || 'png')),
524
+ seed: first.seed,
525
+ provider,
526
+ model: cfg.model || cfg.customModel || 'default',
527
+ cost: first.cost,
528
+ attachmentId: first.attachment?.attachmentId || 'N/A',
529
+ })
530
+ return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, ...first, images })
531
+ },
532
+ }),
533
+ )
534
+ }, 'dsh-image-gen: tool generate_image')
535
+ ctx.effect(() => {
536
+ ctx.tools.register(
537
+ defineTool({
538
+ name: 'generate_image_pack',
539
+ description: 'Generate a multi-aspect ratio pack of the same image (e.g. 1:1, 16:9, 9:16) for different platforms.',
540
+ parameters: {
541
+ prompt: { type: 'string', required: true, description: 'Text prompt for image generation.' },
542
+ aspect_ratios: { type: 'array', items: { type: 'string' }, description: 'Target ratios (default: ["1:1", "16:9", "9:16"]).' },
543
+ output_name: { type: 'string', description: 'Base file name for the pack.' },
544
+ },
545
+ output: {
546
+ schema: {
547
+ type: 'object',
548
+ additionalProperties: true,
549
+ properties: {
550
+ images: { type: 'array', items: { type: 'object', additionalProperties: true } },
551
+ count: { type: 'number' },
552
+ warnings: { type: 'array', items: { type: 'string' } },
553
+ },
554
+ },
555
+ },
556
+ async execute(args, exec) {
557
+ const ratios = Array.isArray(args.aspect_ratios) && args.aspect_ratios.length ? args.aspect_ratios : ['1:1', '16:9', '9:16']
558
+ const cfg = live()
559
+ const generateTool = ctx.tools.get('generate_image')
560
+ const results = []
561
+ const warnings = []
562
+ const seedBase = Math.floor(Math.random() * 100000)
563
+
564
+ const packTasks = ratios.map((ratio) => async () => {
565
+ const sizeName = ratio === '16:9' ? 'landscape_16_9' : ratio === '9:16' ? 'portrait_16_9' : ratio === '4:3' ? 'landscape_4_3' : ratio === '3:4' ? 'portrait_4_3' : 'square_hd'
566
+ const name = `${slugify(args.output_name || 'pack')}-${ratio.replace(':', 'x')}`
567
+ try {
568
+ const genResult = await generateTool.execute({
569
+ prompt: args.prompt,
570
+ image_size: sizeName,
571
+ seed: seedBase,
572
+ output_name: name,
573
+ }, exec)
574
+ return { success: true, ratio, genResult }
575
+ } catch (err) {
576
+ return { success: false, ratio, error: err?.message || String(err) }
577
+ }
578
+ })
579
+ const settledPack = await Promise.all(packTasks.map((t) => t()))
580
+ for (const item of settledPack) {
581
+ if (item.success) {
582
+ results.push({ ratio: item.ratio, ...item.genResult })
583
+ } else {
584
+ warnings.push(`Ratio ${item.ratio} failed: ${item.error}`)
585
+ }
586
+ }
587
+
588
+ if (results.length === 0 && warnings.length > 0) {
589
+ throw new Error(`All aspect ratio generations failed in pack: ${warnings.join('; ')}`)
590
+ }
591
+
592
+ return toLosslessJson({
593
+ images: results,
594
+ count: results.length,
595
+ ...(warnings.length ? { warnings } : {}),
596
+ })
597
+ },
598
+ }),
599
+ )
600
+ }, 'dsh-image-gen: tool generate_image_pack')
601
+ }