@goodandready/dsh-image-gen 0.10.15 → 0.10.17

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